feat: admin UX cleanup, bills page reordering, subscriptions page cadence sort + in-place edits, summary polish

This commit is contained in:
null 2026-06-06 23:53:53 -05:00
parent 88cb9d5340
commit be95910ac2
8 changed files with 438 additions and 117 deletions

View File

@ -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.
</p>
</div>
<Badge className={settings.last_error ? 'bg-red-500/15 text-red-400 border-red-500/20' : 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'}>
{settings.last_error ? 'Attention' : 'Ready'}
</Badge>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge className={cn('cursor-default', settings.last_error ? 'bg-red-500/15 text-red-400 border-red-500/20' : 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20')}>
{settings.last_error ? 'Attention' : 'Ready'}
</Badge>
</TooltipTrigger>
<TooltipContent>{settings.last_error || 'Backup system is operational'}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>
<CardContent className="space-y-6">
@ -304,7 +312,14 @@ export default function BackupManagementCard() {
<Input type="time" value={settings.time} onChange={e => setSchedule('time', e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Keep scheduled</Label>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="cursor-default">Keep scheduled</Label>
</TooltipTrigger>
<TooltipContent>Number of scheduled backups to retain older ones are auto-deleted</TooltipContent>
</Tooltip>
</TooltipProvider>
<Input type="number" min="1" max="365" value={settings.retention_count} onChange={e => setSchedule('retention_count', e.target.value)} />
</div>
<div className="space-y-1.5">

View File

@ -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() {
</p>
</div>
</div>
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 shrink-0">Auto</Badge>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 shrink-0 cursor-default">Auto</Badge>
</TooltipTrigger>
<TooltipContent>Cleanup runs automatically at 6:00 AM daily</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>

View File

@ -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.
</p>
</div>
<Badge className={
currentMode === 'single'
? 'bg-violet-500/15 text-violet-400 border-violet-500/20'
: 'bg-sky-500/15 text-sky-400 border-sky-500/20'
}>
{currentMode === 'single' ? 'No Login' : 'Require Login'}
</Badge>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge className={cn(
'cursor-default',
currentMode === 'single'
? 'bg-violet-500/15 text-violet-400 border-violet-500/20'
: 'bg-sky-500/15 text-sky-400 border-sky-500/20',
)}>
{currentMode === 'single' ? 'No Login' : 'Require Login'}
</Badge>
</TooltipTrigger>
<TooltipContent>
{currentMode === 'single'
? 'Anyone who opens the app is automatically signed in'
: 'Users must authenticate to access the app'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>

View File

@ -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 }) {
<td className="px-6 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{user.username}</span>
{user.is_default_admin && <Badge variant="secondary">default admin</Badge>}
{user.is_default_admin && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="cursor-default">default admin</Badge>
</TooltipTrigger>
<TooltipContent>Initial admin account created during setup</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</td>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>{user.role}</Badge>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'} className="cursor-default">{user.role}</Badge>
</TooltipTrigger>
<TooltipContent>{user.role === 'admin' ? 'Full access including admin panel' : 'Standard user — no admin access'}</TooltipContent>
</Tooltip>
</TooltipProvider>
<select
value={user.role}
disabled={isSelf || roleUpdating === user.id}
@ -138,10 +155,18 @@ export default function UsersTable({ users, onRefresh, currentUser }) {
</select>
</td>
<td className="px-6 py-3">
{user.must_change_password
? <Badge variant="due_soon">Temporary</Badge>
: <span className="text-muted-foreground">Set</span>
}
{user.must_change_password ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="due_soon" className="cursor-default">Temporary</Badge>
</TooltipTrigger>
<TooltipContent>User must change their password on next login</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-muted-foreground">Set</span>
)}
</td>
<td className="px-6 py-3">
{form.open ? (

View File

@ -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 (
<button
type="button"
onClick={onClick}
className={cn(
'h-7 rounded-md px-3 text-xs font-semibold transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
aria-pressed={active}
>
{children}
</button>
);
}
//
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() {
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Active
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{active.length}
{!reorderEnabled && active.length > 1 && <span className="ml-2 hidden sm:inline">Clear filters to reorder</span>}
</span>
<div className="flex items-center gap-3">
{!reorderEnabled && sortedActive.length > 1 && (
<span className="text-xs text-muted-foreground hidden sm:inline">
{billsSort === 'cadence' ? 'Switch to Custom to reorder' : 'Clear filters to reorder'}
</span>
)}
<div className="grid grid-cols-2 rounded-lg border border-border/60 bg-muted/30 p-0.5">
<SortModeButton
active={billsSort === 'custom'}
onClick={() => setBillsSort('custom')}
>
Custom
</SortModeButton>
<SortModeButton
active={billsSort === 'cadence'}
onClick={() => setBillsSort('cadence')}
>
Cadence
</SortModeButton>
</div>
</div>
</div>
{loading ? (
@ -981,21 +1054,21 @@ export default function BillsPage() {
</div>
) : (
<BillsTableInner
bills={active}
bills={sortedActive}
prefs={prefs}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
onDuplicate={handleDuplicateBill}
moveControlsFor={moveControlsForGroup(active, true)}
dragPropsFor={dragPropsForGroup(active, true)}
moveControlsFor={moveControlsForGroup(sortedActive, true)}
dragPropsFor={dragPropsForGroup(sortedActive, true)}
/>
)}
</div>
)}
{/* ── Inactive Bills ── */}
{!loading && inactive.length > 0 && (
{!loading && sortedInactive.length > 0 && (
<div className="space-y-3">
<button
@ -1007,7 +1080,7 @@ export default function BillsPage() {
showInactive && 'rotate-90',
)} />
<span className="uppercase tracking-[0.08em]">
{inactive.length} inactive {inactive.length === 1 ? 'bill' : 'bills'}
{sortedInactive.length} inactive {sortedInactive.length === 1 ? 'bill' : 'bills'}
</span>
</button>
@ -1018,20 +1091,19 @@ export default function BillsPage() {
Inactive
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{inactive.length}
{!reorderEnabled && inactive.length > 1 && <span className="ml-2 hidden sm:inline">Clear filters to reorder</span>}
{sortedInactive.length}
</span>
</div>
<BillsTableInner
bills={inactive}
bills={sortedInactive}
prefs={prefs}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
onHistory={setHistoryTarget}
onDuplicate={handleDuplicateBill}
moveControlsFor={moveControlsForGroup(inactive, false)}
dragPropsFor={dragPropsForGroup(inactive, false)}
moveControlsFor={moveControlsForGroup(sortedInactive, false)}
dragPropsFor={dragPropsForGroup(sortedInactive, false)}
/>
</div>
)}

View File

@ -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 (
<div className="grid gap-2 sm:grid-cols-3">
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">SimpleFIN</p>
<p className={cn('mt-1 truncate text-sm font-semibold', syncTone)}>{syncStatus}</p>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground cursor-default w-fit">SimpleFIN</p>
</TooltipTrigger>
<TooltipContent>Open standard for syncing bank transactions</TooltipContent>
</Tooltip>
{simplefinConn?.last_error ? (
<Tooltip>
<TooltipTrigger asChild>
<p className={cn('mt-1 truncate text-sm font-semibold cursor-default', syncTone)}>{syncStatus}</p>
</TooltipTrigger>
<TooltipContent>{simplefinConn.last_error}</TooltipContent>
</Tooltip>
) : (
<p className={cn('mt-1 truncate text-sm font-semibold', syncTone)}>{syncStatus}</p>
)}
</TooltipProvider>
</div>
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Sync</p>
@ -147,9 +164,16 @@ export default function DataPage() {
Import, export, and review your user-owned bill tracker records.
</p>
</div>
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
User data only
</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground cursor-default">
User data only
</div>
</TooltipTrigger>
<TooltipContent>This page only manages your own records other users' data is not accessible here</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<DataStatusStrip history={history} historyLoading={historyLoading} simplefinConn={simplefinConn} syncLoading={syncLoading} />

View File

@ -300,20 +300,39 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
>
{item.name}
</button>
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary">
{TYPE_LABELS[item.subscription_type] || 'Other'}
</Badge>
{!item.active && (
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-amber-300">
Paused
</Badge>
)}
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary cursor-default">
{TYPE_LABELS[item.subscription_type] || 'Other'}
</Badge>
</TooltipTrigger>
<TooltipContent>Service category</TooltipContent>
</Tooltip>
{!item.active && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-amber-300 cursor-default">
Paused
</Badge>
</TooltipTrigger>
<TooltipContent>Paused not actively tracked</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground">
<span>{item.category_name || 'Uncategorized'}</span>
<span>Due {fmtDate(item.next_due_date)}</span>
<span className="capitalize">{cycleLabel(item)}</span>
<span>{item.reminder_days_before ?? 3}d reminder</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{item.reminder_days_before ?? 3}d reminder</span>
</TooltipTrigger>
<TooltipContent>Reminder sent {item.reminder_days_before ?? 3} days before the due date</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
@ -323,7 +342,14 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(item.expected_amount)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Monthly</p>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-xs text-muted-foreground cursor-default w-fit">Monthly</p>
</TooltipTrigger>
<TooltipContent>Normalized monthly equivalent regardless of billing frequency</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">{fmt(item.monthly_equivalent)}</p>
</div>
</div>
@ -415,19 +441,36 @@ function TxResultRow({ tx, onTrack }) {
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium truncate max-w-[200px]">{label}</span>
{isMatched ? (
<Badge className="shrink-0 border-emerald-400/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 gap-1 text-[10px]">
<CheckCircle className="h-3 w-3" />
{tx.matched_bill_name || 'Matched'}
</Badge>
) : (
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground">Unmatched</Badge>
)}
{catalogMatch && (
<Badge variant="outline" className="shrink-0 border-primary/25 bg-primary/10 text-primary text-[10px]">
Known: {catalogMatch.name}
</Badge>
)}
<TooltipProvider delayDuration={300}>
{isMatched ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="shrink-0 border-emerald-400/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 gap-1 text-[10px] cursor-default">
<CheckCircle className="h-3 w-3" />
{tx.matched_bill_name || 'Matched'}
</Badge>
</TooltipTrigger>
<TooltipContent>Already linked to this bill</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground cursor-default">Unmatched</Badge>
</TooltipTrigger>
<TooltipContent>Not yet linked to any bill</TooltipContent>
</Tooltip>
)}
{catalogMatch && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="shrink-0 border-primary/25 bg-primary/10 text-primary text-[10px] cursor-default">
Known: {catalogMatch.name}
</Badge>
</TooltipTrigger>
<TooltipContent>Recognized in the subscription catalog as {catalogMatch.name}</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{tx.posted_date}{account ? ` · ${account}` : ''}
@ -547,42 +590,72 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
{recommendation.confidence}% match
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
{recommendation.confidence}% match
</Badge>
</TooltipTrigger>
<TooltipContent>Match confidence how likely this is a real recurring subscription</TooltipContent>
</Tooltip>
{identity?.label && (
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
{identity.label}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300 cursor-default">
{identity.label}
</Badge>
</TooltipTrigger>
<TooltipContent>Merchant identity evidence</TooltipContent>
</Tooltip>
)}
{amount?.label && (
<Badge
variant="outline"
className={cn(
'text-[11px]',
amount.match === 'unusual'
? 'border-amber-500/25 bg-amber-500/10 text-amber-500'
: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300',
)}
>
{amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={cn(
'text-[11px] cursor-default',
amount.match === 'unusual'
? 'border-amber-500/25 bg-amber-500/10 text-amber-500'
: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300',
)}
>
{amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
</Badge>
</TooltipTrigger>
<TooltipContent>{amount.label}</TooltipContent>
</Tooltip>
)}
{cadence?.recurring && (
<Badge variant="outline" className="border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-300">
Recurring
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-300 cursor-default">
Recurring
</Badge>
</TooltipTrigger>
<TooltipContent>{cadence.label || 'Regular recurring payment pattern detected'}</TooltipContent>
</Tooltip>
)}
{ambiguity?.ambiguous && (
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300 cursor-default">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
</TooltipTrigger>
<TooltipContent>{ambiguity.label || 'Multiple patterns detected — verify before tracking'}</TooltipContent>
</Tooltip>
)}
{existingBill && (
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
Existing bill
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
Existing bill
</Badge>
</TooltipTrigger>
<TooltipContent>May match your tracked bill: {existingBill.name}</TooltipContent>
</Tooltip>
)}
{amountRange && amountRange.min !== amountRange.max && (
<span className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
@ -684,19 +757,40 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2">
<span>{recommendation.name}</span>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
{recommendation.confidence}% match
</Badge>
<TooltipProvider delayDuration={180}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
{recommendation.confidence}% match
</Badge>
</TooltipTrigger>
<TooltipContent>Match confidence how likely this is a real recurring subscription</TooltipContent>
</Tooltip>
</TooltipProvider>
{ambiguity?.ambiguous && (
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
<TooltipProvider delayDuration={180}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300 cursor-default">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
</TooltipTrigger>
<TooltipContent>{ambiguity.label || 'Multiple patterns detected — verify before tracking'}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{existingBill && (
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
Existing bill
</Badge>
<TooltipProvider delayDuration={180}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
Existing bill
</Badge>
</TooltipTrigger>
<TooltipContent>May match your tracked bill: {existingBill.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</DialogTitle>
<p className="text-sm text-muted-foreground">
@ -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() {
<CardTitle className="text-base">Tracked Subscriptions</CardTitle>
<CardDescription>Subscriptions are bills with recurring-service metadata.</CardDescription>
</div>
<div className="grid grid-cols-2 rounded-lg border border-border/60 bg-muted/30 p-1">
<SortModeButton
active={subscriptionSort === 'custom'}
onClick={() => setSubscriptionSort('custom')}
<TooltipProvider delayDuration={300}>
<div className="grid grid-cols-2 rounded-lg border border-border/60 bg-muted/30 p-1">
<Tooltip>
<TooltipTrigger asChild>
<SortModeButton
active={subscriptionSort === 'custom'}
onClick={() => setSubscriptionSort('custom')}
>
Custom
</SortModeButton>
</TooltipTrigger>
<TooltipContent>Drag to reorder manually</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SortModeButton
active={subscriptionSort === 'cadence'}
onClick={() => setSubscriptionSort('cadence')}
>
Cadence
</SortModeButton>
</TooltipTrigger>
<TooltipContent>Sort by billing frequency: weekly biweekly monthly quarterly annual</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={subSearch}
onChange={e => setSubSearch(e.target.value)}
placeholder="Search subscriptions…"
className="h-8 pl-8 pr-8 text-sm"
/>
{subSearch && (
<button
type="button"
onClick={() => setSubSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
>
Custom
</SortModeButton>
<SortModeButton
active={subscriptionSort === 'cadence'}
onClick={() => setSubscriptionSort('cadence')}
>
Cadence
</SortModeButton>
</div>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</CardHeader>
<CardContent className="p-0">

View File

@ -491,15 +491,36 @@ export default function SummaryPage() {
<div className="grid gap-3 rounded-2xl bg-muted/45 p-4 sm:grid-cols-3">
<div>
<div className="text-xs font-medium text-muted-foreground">1st</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs font-medium text-muted-foreground cursor-default w-fit">1st</div>
</TooltipTrigger>
<TooltipContent>Cash on hand for bills due on the 1st14th</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="tracker-number mt-1 text-base font-bold text-foreground">{fmt(starting.first_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">15th</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs font-medium text-muted-foreground cursor-default w-fit">15th</div>
</TooltipTrigger>
<TooltipContent>Cash on hand for bills due on the 15th31st</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="tracker-number mt-1 text-base font-bold text-foreground">{fmt(starting.fifteenth_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Other</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-xs font-medium text-muted-foreground cursor-default w-fit">Other</div>
</TooltipTrigger>
<TooltipContent>Additional funds not tied to a specific pay period</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="tracker-number mt-1 text-base font-bold text-foreground">{fmt(starting.other_amount)}</div>
</div>
<div className="border-t border-border/60 pt-3 sm:col-span-3">
@ -669,7 +690,14 @@ export default function SummaryPage() {
<div className="text-base font-semibold text-foreground">{fmt(summary.expense_total)}</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-border/60 pt-3">
<div className="text-base font-semibold text-foreground">Result</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-base font-semibold text-foreground cursor-default">Result</div>
</TooltipTrigger>
<TooltipContent>Starting balance minus total planned expenses</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className={cn('text-2xl font-bold', moneyClass(summary.result || 0))}>{fmt(summary.result)}</div>
</div>
</section>