feat: admin UX cleanup, bills page reordering, subscriptions page cadence sort + in-place edits, summary polish
This commit is contained in:
parent
88cb9d5340
commit
be95910ac2
|
|
@ -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'}>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<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'
|
||||
}>
|
||||
: '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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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">
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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 && (
|
||||
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-amber-300">
|
||||
<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>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
{isMatched ? (
|
||||
<Badge className="shrink-0 border-emerald-400/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 gap-1 text-[10px]">
|
||||
<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>
|
||||
) : (
|
||||
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground">Unmatched</Badge>
|
||||
<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 && (
|
||||
<Badge variant="outline" className="shrink-0 border-primary/25 bg-primary/10 text-primary text-[10px]">
|
||||
<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,19 +590,31 @@ 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">
|
||||
<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">
|
||||
<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 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[11px]',
|
||||
'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',
|
||||
|
|
@ -567,22 +622,40 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
>
|
||||
{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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
|
|
|
|||
|
|
@ -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 1st–14th</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 15th–31st</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue