From be95910ac2f2706589c83df80b1193ccc2b46539 Mon Sep 17 00:00:00 2001
From: null
Date: Sat, 6 Jun 2026 23:53:53 -0500
Subject: [PATCH] feat: admin UX cleanup, bills page reordering, subscriptions
page cadence sort + in-place edits, summary polish
---
.../components/admin/BackupManagementCard.jsx | 23 +-
client/components/admin/CleanupPanel.jsx | 10 +-
client/components/admin/LoginModeCard.jsx | 28 +-
client/components/admin/UsersTable.jsx | 37 ++-
client/pages/BillsPage.jsx | 102 ++++++-
client/pages/DataPage.jsx | 34 ++-
client/pages/SubscriptionsPage.jsx | 285 +++++++++++++-----
client/pages/SummaryPage.jsx | 36 ++-
8 files changed, 438 insertions(+), 117 deletions(-)
diff --git a/client/components/admin/BackupManagementCard.jsx b/client/components/admin/BackupManagementCard.jsx
index 8747e38..282c10b 100644
--- a/client/components/admin/BackupManagementCard.jsx
+++ b/client/components/admin/BackupManagementCard.jsx
@@ -16,6 +16,7 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { SectionHeading, Toggle, formatDateTime, BackupTypeBadge } from './adminShared';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
const DEFAULT_SETTINGS = {
enabled: false,
@@ -181,9 +182,16 @@ export default function BackupManagementCard() {
Admin-only SQLite backup, import, download, restore, and schedule controls.
-
@@ -304,7 +312,14 @@ export default function BackupManagementCard() {
setSchedule('time', e.target.value)} />
-
+
+
+
+
+
+ Number of scheduled backups to retain — older ones are auto-deleted
+
+
setSchedule('retention_count', e.target.value)} />
diff --git a/client/components/admin/CleanupPanel.jsx b/client/components/admin/CleanupPanel.jsx
index 68ba7dc..b9f4f97 100644
--- a/client/components/admin/CleanupPanel.jsx
+++ b/client/components/admin/CleanupPanel.jsx
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { FieldRow, Toggle, formatDateTime } from './adminShared';
export default function CleanupPanel() {
@@ -103,7 +104,14 @@ export default function CleanupPanel() {
- Auto
+
+
+
+ Auto
+
+ Cleanup runs automatically at 6:00 AM daily
+
+
diff --git a/client/components/admin/LoginModeCard.jsx b/client/components/admin/LoginModeCard.jsx
index 84f57ca..7156c2c 100644
--- a/client/components/admin/LoginModeCard.jsx
+++ b/client/components/admin/LoginModeCard.jsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
+import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
@@ -13,6 +14,7 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog';
import { LogIn, UserCheck, ShieldCheck } from 'lucide-react';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
export default function LoginModeCard({ users, onModeChange }) {
const [modeData, setModeData] = useState(null);
@@ -88,13 +90,25 @@ export default function LoginModeCard({ users, onModeChange }) {
Choose how users access this app.
-
- {currentMode === 'single' ? 'No Login' : 'Require Login'}
-
+
+
+
+
+ {currentMode === 'single' ? 'No Login' : 'Require Login'}
+
+
+
+ {currentMode === 'single'
+ ? 'Anyone who opens the app is automatically signed in'
+ : 'Users must authenticate to access the app'}
+
+
+
diff --git a/client/components/admin/UsersTable.jsx b/client/components/admin/UsersTable.jsx
index ab692fd..2c74c27 100644
--- a/client/components/admin/UsersTable.jsx
+++ b/client/components/admin/UsersTable.jsx
@@ -6,6 +6,7 @@ import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
@@ -107,12 +108,28 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
{user.username}
- {user.is_default_admin && default admin}
+ {user.is_default_admin && (
+
+
+
+ default admin
+
+ Initial admin account created during setup
+
+
+ )}
|
- {user.role}
+
+
+
+ {user.role}
+
+ {user.role === 'admin' ? 'Full access including admin panel' : 'Standard user — no admin access'}
+
+
|
- {user.must_change_password
- ? Temporary
- : Set
- }
+ {user.must_change_password ? (
+
+
+
+ Temporary
+
+ User must change their password on next login
+
+
+ ) : (
+ Set
+ )}
|
{form.open ? (
diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.jsx
index 2ae0de3..a4edbec 100644
--- a/client/pages/BillsPage.jsx
+++ b/client/pages/BillsPage.jsx
@@ -319,6 +319,51 @@ function FilterChip({ active, children, onClick }) {
);
}
+const BILLS_SORT_KEY = 'bills_sort_mode';
+const BILLS_CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other'];
+
+function normalizedBillCadence(bill) {
+ const raw = String(bill?.cycle_type || bill?.billing_cycle || '').toLowerCase();
+ if (raw.includes('week') && raw.includes('bi')) return 'biweekly';
+ if (raw === 'biweekly') return 'biweekly';
+ if (raw.includes('week')) return 'weekly';
+ if (raw.includes('quarter')) return 'quarterly';
+ if (raw.includes('annual') || raw.includes('year')) return 'annual';
+ if (raw.includes('month') || !raw) return 'monthly';
+ return 'other';
+}
+
+function billCadenceIndex(bill) {
+ const index = BILLS_CADENCE_ORDER.indexOf(normalizedBillCadence(bill));
+ return index >= 0 ? index : BILLS_CADENCE_ORDER.length - 1;
+}
+
+function sortBillsByCadence(items) {
+ return [...items].sort((a, b) => (
+ billCadenceIndex(a) - billCadenceIndex(b)
+ || (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
+ || String(a.name || '').localeCompare(String(b.name || ''))
+ ));
+}
+
+function SortModeButton({ active, children, onClick }) {
+ return (
+
+ );
+}
+
// ─────────────────────────────────────────────────────────────────────────────
function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
@@ -581,6 +626,14 @@ export default function BillsPage() {
const { prefs, toggle: togglePref } = useDisplayPrefs();
+ const [billsSort, setBillsSort] = useState(() => (
+ localStorage.getItem(BILLS_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
+ ));
+
+ useEffect(() => {
+ localStorage.setItem(BILLS_SORT_KEY, billsSort);
+ }, [billsSort]);
+
const load = useCallback(async () => {
try {
const [billsRes, catRes, templateRes] = await Promise.all([
@@ -765,7 +818,10 @@ export default function BillsPage() {
const inactive = filteredBills.filter(b => !b.active);
const totalActive = bills.filter(b => b.active).length;
const totalInactive = bills.filter(b => !b.active).length;
- const reorderEnabled = !hasFilters && !loading;
+ const reorderEnabled = !hasFilters && !loading && billsSort === 'custom';
+
+ const sortedActive = billsSort === 'cadence' ? sortBillsByCadence(active) : active;
+ const sortedInactive = billsSort === 'cadence' ? sortBillsByCadence(inactive) : inactive;
async function persistBillOrder(nextBills, movedId) {
setBills(nextBills);
@@ -951,10 +1007,27 @@ export default function BillsPage() {
Active
-
- {active.length}
- {!reorderEnabled && active.length > 1 && Clear filters to reorder}
-
+
+ {!reorderEnabled && sortedActive.length > 1 && (
+
+ {billsSort === 'cadence' ? 'Switch to Custom to reorder' : 'Clear filters to reorder'}
+
+ )}
+
+ setBillsSort('custom')}
+ >
+ Custom
+
+ setBillsSort('cadence')}
+ >
+ Cadence
+
+
+
{loading ? (
@@ -981,21 +1054,21 @@ export default function BillsPage() {
) : (
)}
)}
{/* ── Inactive Bills ── */}
- {!loading && inactive.length > 0 && (
+ {!loading && sortedInactive.length > 0 && (
- {inactive.length} inactive {inactive.length === 1 ? 'bill' : 'bills'}
+ {sortedInactive.length} inactive {sortedInactive.length === 1 ? 'bill' : 'bills'}
@@ -1018,20 +1091,19 @@ export default function BillsPage() {
Inactive
- {inactive.length}
- {!reorderEnabled && inactive.length > 1 && Clear filters to reorder}
+ {sortedInactive.length}
)}
diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx
index 1c227af..629964c 100644
--- a/client/pages/DataPage.jsx
+++ b/client/pages/DataPage.jsx
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn } from '@/lib/utils';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import BankSyncSection from '@/components/data/BankSyncSection';
import BillRulesManager from '@/components/BillRulesManager';
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
@@ -61,8 +62,24 @@ function DataStatusStrip({ history, historyLoading, simplefinConn, syncLoading }
return (
- SimpleFIN
- {syncStatus}
+
+
+
+ SimpleFIN
+
+ Open standard for syncing bank transactions
+
+ {simplefinConn?.last_error ? (
+
+
+ {syncStatus}
+
+ {simplefinConn.last_error}
+
+ ) : (
+ {syncStatus}
+ )}
+
Last Sync
@@ -147,9 +164,16 @@ export default function DataPage() {
Import, export, and review your user-owned bill tracker records.
-
- User data only
-
+
+
+
+
+ User data only
+
+
+ This page only manages your own records — other users' data is not accessible here
+
+
diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx
index d4ec561..5ce773b 100644
--- a/client/pages/SubscriptionsPage.jsx
+++ b/client/pages/SubscriptionsPage.jsx
@@ -300,20 +300,39 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
>
{item.name}
-
- {TYPE_LABELS[item.subscription_type] || 'Other'}
-
- {!item.active && (
-
- Paused
-
- )}
+
+
+
+
+ {TYPE_LABELS[item.subscription_type] || 'Other'}
+
+
+ Service category
+
+ {!item.active && (
+
+
+
+ Paused
+
+
+ Paused — not actively tracked
+
+ )}
+
{item.category_name || 'Uncategorized'}
Due {fmtDate(item.next_due_date)}
{cycleLabel(item)}
- {item.reminder_days_before ?? 3}d reminder
+
+
+
+ {item.reminder_days_before ?? 3}d reminder
+
+ Reminder sent {item.reminder_days_before ?? 3} days before the due date
+
+
@@ -323,7 +342,14 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
{fmt(item.expected_amount)}
- Monthly
+
+
+
+ Monthly
+
+ Normalized monthly equivalent regardless of billing frequency
+
+
{fmt(item.monthly_equivalent)}
@@ -415,19 +441,36 @@ function TxResultRow({ tx, onTrack }) {
{label}
- {isMatched ? (
-
-
- {tx.matched_bill_name || 'Matched'}
-
- ) : (
- Unmatched
- )}
- {catalogMatch && (
-
- Known: {catalogMatch.name}
-
- )}
+
+ {isMatched ? (
+
+
+
+
+ {tx.matched_bill_name || 'Matched'}
+
+
+ Already linked to this bill
+
+ ) : (
+
+
+ Unmatched
+
+ Not yet linked to any bill
+
+ )}
+ {catalogMatch && (
+
+
+
+ Known: {catalogMatch.name}
+
+
+ Recognized in the subscription catalog as {catalogMatch.name}
+
+ )}
+
{tx.posted_date}{account ? ` · ${account}` : ''}
@@ -547,42 +590,72 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
-
- {recommendation.confidence}% match
-
+
+
+
+ {recommendation.confidence}% match
+
+
+ Match confidence — how likely this is a real recurring subscription
+
{identity?.label && (
-
- {identity.label}
-
+
+
+
+ {identity.label}
+
+
+ Merchant identity evidence
+
)}
{amount?.label && (
-
- {amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
-
+
+
+
+ {amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
+
+
+ {amount.label}
+
)}
{cadence?.recurring && (
-
- Recurring
-
+
+
+
+ Recurring
+
+
+ {cadence.label || 'Regular recurring payment pattern detected'}
+
)}
{ambiguity?.ambiguous && (
-
-
- Review
-
+
+
+
+
+ Review
+
+
+ {ambiguity.label || 'Multiple patterns detected — verify before tracking'}
+
)}
{existingBill && (
-
- Existing bill
-
+
+
+
+ Existing bill
+
+
+ May match your tracked bill: {existingBill.name}
+
)}
{amountRange && amountRange.min !== amountRange.max && (
@@ -684,19 +757,40 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
{recommendation.name}
-
- {recommendation.confidence}% match
-
+
+
+
+
+ {recommendation.confidence}% match
+
+
+ Match confidence — how likely this is a real recurring subscription
+
+
{ambiguity?.ambiguous && (
-
-
- Review
-
+
+
+
+
+
+ Review
+
+
+ {ambiguity.label || 'Multiple patterns detected — verify before tracking'}
+
+
)}
{existingBill && (
-
- Existing bill
-
+
+
+
+
+ Existing bill
+
+
+ May match your tracked bill: {existingBill.name}
+
+
)}
@@ -842,6 +936,7 @@ export default function SubscriptionsPage() {
const [matchTarget, setMatchTarget] = useState(null);
const [detailsTarget, setDetailsTarget] = useState(null);
const [recSearch, setRecSearch] = useState('');
+ const [subSearch, setSubSearch] = useState('');
const [subscriptionSort, setSubscriptionSort] = useState(() => (
localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
));
@@ -1071,8 +1166,17 @@ export default function SubscriptionsPage() {
const summary = data.summary || {};
const subscriptions = data.subscriptions || [];
- const active = subscriptions.filter(item => item.active);
- const paused = subscriptions.filter(item => !item.active);
+ const filteredSubscriptions = useMemo(() => {
+ const q = subSearch.trim().toLowerCase();
+ if (!q) return subscriptions;
+ return subscriptions.filter(item =>
+ String(item.name || '').toLowerCase().includes(q) ||
+ String(item.subscription_type || '').toLowerCase().includes(q) ||
+ String(item.category_name || '').toLowerCase().includes(q)
+ );
+ }, [subscriptions, subSearch]);
+ const active = filteredSubscriptions.filter(item => item.active);
+ const paused = filteredSubscriptions.filter(item => !item.active);
const sortedActive = useMemo(
() => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(active) : active,
[active, subscriptionSort],
@@ -1260,20 +1364,51 @@ export default function SubscriptionsPage() {
Tracked Subscriptions
Subscriptions are bills with recurring-service metadata.
-
- setSubscriptionSort('custom')}
+
+
+
+
+ setSubscriptionSort('custom')}
+ >
+ Custom
+
+
+ Drag to reorder manually
+
+
+
+ setSubscriptionSort('cadence')}
+ >
+ Cadence
+
+
+ Sort by billing frequency: weekly → biweekly → monthly → quarterly → annual
+
+
+
+
+
+
+ setSubSearch(e.target.value)}
+ placeholder="Search subscriptions…"
+ className="h-8 pl-8 pr-8 text-sm"
+ />
+ {subSearch && (
+
+
+
+ )}
diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.jsx
index 171fcae..9c99ede 100644
--- a/client/pages/SummaryPage.jsx
+++ b/client/pages/SummaryPage.jsx
@@ -491,15 +491,36 @@ export default function SummaryPage() {
- 1st
+
+
+
+ 1st
+
+ Cash on hand for bills due on the 1st–14th
+
+
{fmt(starting.first_amount)}
- 15th
+
+
+
+ 15th
+
+ Cash on hand for bills due on the 15th–31st
+
+
{fmt(starting.fifteenth_amount)}
- Other
+
+
+
+ Other
+
+ Additional funds not tied to a specific pay period
+
+
{fmt(starting.other_amount)}
@@ -669,7 +690,14 @@ export default function SummaryPage() {
{fmt(summary.expense_total)}
- Result
+
+
+
+ Result
+
+ Starting balance minus total planned expenses
+
+
{fmt(summary.result)}
|