v0.27.04
This commit is contained in:
parent
576163e85b
commit
263f1c5e6e
|
|
@ -1,3 +1,4 @@
|
||||||
{
|
{
|
||||||
|
"MD013": false,
|
||||||
"MD024": { "siblings_only": true }
|
"MD024": { "siblings_only": true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ export const api = {
|
||||||
changePassword: (data) => post('/auth/change-password', data),
|
changePassword: (data) => post('/auth/change-password', data),
|
||||||
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
|
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
|
||||||
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
||||||
|
loginHistory: () => get('/auth/login-history'),
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
hasUsers: () => get('/admin/has-users'),
|
hasUsers: () => get('/admin/has-users'),
|
||||||
|
|
|
||||||
|
|
@ -1,266 +1,190 @@
|
||||||
import {
|
import { PenLine, EyeOff, Eye, Clock, Trash2, Zap } from 'lucide-react';
|
||||||
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { History } from 'lucide-react';
|
|
||||||
|
|
||||||
function hasHistoricalVisibility(bill) {
|
function ordinal(n) {
|
||||||
const visibility = bill.history_visibility;
|
const d = Number(n);
|
||||||
return !!bill.has_history_ranges || (visibility && visibility !== 'default');
|
if (!d) return '—';
|
||||||
|
if (d > 3 && d < 21) return `${d}th`;
|
||||||
|
switch (d % 10) {
|
||||||
|
case 1: return `${d}st`;
|
||||||
|
case 2: return `${d}nd`;
|
||||||
|
case 3: return `${d}rd`;
|
||||||
|
default: return `${d}th`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) {
|
function hasHistoricalVisibility(bill) {
|
||||||
|
return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
function AprColor({ rate }) {
|
||||||
|
const cls =
|
||||||
|
rate >= 25 ? 'text-rose-400' :
|
||||||
|
rate >= 15 ? 'text-amber-400' :
|
||||||
|
'text-muted-foreground/60';
|
||||||
|
return <span className={cn('text-[10px] tabular-nums', cls)}>{rate}% APR</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_ON = {
|
||||||
|
showCategory: true, showDueDay: true, showAmount: true, showCycle: true,
|
||||||
|
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory }) {
|
||||||
|
const isDebt = bill.current_balance != null || bill.minimum_payment != null;
|
||||||
const hasHistory = hasHistoricalVisibility(bill);
|
const hasHistory = hasHistoricalVisibility(bill);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm">
|
<div className={cn(
|
||||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
'flex items-center gap-3 px-5 py-3.5 transition-colors',
|
||||||
<div className="min-w-0">
|
'hover:bg-accent/20',
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
!bill.active && 'opacity-60',
|
||||||
<button
|
)}>
|
||||||
type="button"
|
|
||||||
className="min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
|
|
||||||
onClick={() => onEdit?.(bill.id)}
|
|
||||||
title={`Edit ${bill.name}`}
|
|
||||||
>
|
|
||||||
{bill.name}
|
|
||||||
</button>
|
|
||||||
{hasHistory && (
|
|
||||||
<span
|
|
||||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
|
||||||
title="Historical visibility configured"
|
|
||||||
aria-label="Historical visibility configured"
|
|
||||||
>
|
|
||||||
<History className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
{/* Main info */}
|
||||||
<span className={cn(
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
|
||||||
bill.active
|
|
||||||
? 'bg-emerald-500/15 text-emerald-500'
|
|
||||||
: 'bg-muted text-muted-foreground',
|
|
||||||
)}>
|
|
||||||
{bill.active ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
{!!bill.autopay_enabled && (
|
|
||||||
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500">AP</span>
|
|
||||||
)}
|
|
||||||
{!!bill.has_2fa && (
|
|
||||||
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-400">2FA</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums text-foreground">
|
{/* Name + badges */}
|
||||||
${Number(bill.expected_amount).toFixed(2)}
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
</span>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={() => onEdit?.(bill.id)}
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
className="text-sm font-semibold text-foreground hover:text-primary transition-colors text-left truncate max-w-[240px]"
|
||||||
<div>
|
|
||||||
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
|
|
||||||
<p className="mt-0.5 text-sm text-foreground">Day {bill.due_day}</p>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
|
|
||||||
<p className="mt-0.5 truncate text-sm text-foreground">{bill.category_name || '—'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="uppercase tracking-wide text-muted-foreground/60">Cycle</p>
|
|
||||||
<p className="mt-0.5 text-sm capitalize text-foreground">{bill.billing_cycle || 'monthly'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'h-8 px-2.5 text-xs',
|
|
||||||
bill.active
|
|
||||||
? 'text-muted-foreground hover:text-destructive'
|
|
||||||
: 'text-emerald-500 hover:text-emerald-400',
|
|
||||||
)}
|
|
||||||
onClick={() => onToggle?.(bill)}
|
|
||||||
>
|
|
||||||
{bill.active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
{!bill.active && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
|
|
||||||
onClick={() => onHistory?.(bill)}
|
|
||||||
>
|
>
|
||||||
History
|
{bill.name}
|
||||||
</Button>
|
</button>
|
||||||
)}
|
|
||||||
<Button
|
{prefs.showCategory && bill.category_name && (
|
||||||
variant="ghost"
|
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
|
||||||
size="sm"
|
{bill.category_name}
|
||||||
className="h-8 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
</span>
|
||||||
onClick={() => onDelete?.(bill)}
|
)}
|
||||||
>
|
|
||||||
Delete
|
{prefs.showAutopay && !!bill.autopay_enabled && (
|
||||||
</Button>
|
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-500 shrink-0">
|
||||||
|
Autopay
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{prefs.show2fa && !!bill.has_2fa && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 shrink-0">
|
||||||
|
2FA
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasHistory && (
|
||||||
|
<span
|
||||||
|
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
||||||
|
title="Historical visibility configured"
|
||||||
|
>
|
||||||
|
<Clock className="h-2.5 w-2.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{prefs.showCycle && <span className="capitalize">{bill.billing_cycle || 'monthly'}</span>}
|
||||||
|
|
||||||
|
{prefs.showCycle && prefs.showDueDay && <span className="text-border">·</span>}
|
||||||
|
|
||||||
|
{prefs.showDueDay && <span>Due {ordinal(bill.due_day)}</span>}
|
||||||
|
|
||||||
|
{prefs.showApr && isDebt && bill.interest_rate != null && (
|
||||||
|
<>
|
||||||
|
{(prefs.showCycle || prefs.showDueDay) && <span className="text-border">·</span>}
|
||||||
|
<AprColor rate={bill.interest_rate} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prefs.showBalance && isDebt && bill.current_balance != null && (
|
||||||
|
<>
|
||||||
|
{(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>}
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
|
||||||
|
${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
{prefs.showAmount && (
|
||||||
|
<div className="text-right shrink-0 hidden sm:block">
|
||||||
|
<p className="font-mono text-sm font-semibold tabular-nums">
|
||||||
|
${Number(bill.expected_amount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
{prefs.showMinPayment && bill.minimum_payment != null && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
${Number(bill.minimum_payment).toFixed(0)} min
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action icons */}
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEdit?.(bill.id)}
|
||||||
|
title="Edit"
|
||||||
|
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||||
|
>
|
||||||
|
<PenLine className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!bill.active && onHistory && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onHistory?.(bill)}
|
||||||
|
title="History visibility"
|
||||||
|
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle?.(bill)}
|
||||||
|
title={bill.active ? 'Deactivate' : 'Activate'}
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded-md transition-colors',
|
||||||
|
bill.active
|
||||||
|
? 'text-muted-foreground/40 hover:text-amber-400 hover:bg-amber-500/10'
|
||||||
|
: 'text-muted-foreground/40 hover:text-emerald-400 hover:bg-emerald-500/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bill.active ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete?.(bill)}
|
||||||
|
title="Delete"
|
||||||
|
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accepts row action handlers from BillsPage
|
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory }) {
|
||||||
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="divide-y divide-border/30">
|
||||||
<div className="grid gap-3 p-3 lg:hidden">
|
{bills.map(bill => (
|
||||||
{bills.map((bill) => (
|
<BillCard
|
||||||
<MobileBillRow
|
key={bill.id}
|
||||||
key={bill.id}
|
bill={bill}
|
||||||
bill={bill}
|
prefs={prefs}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onHistory={onHistory}
|
onHistory={onHistory}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<Table className="min-w-[900px]">
|
|
||||||
|
|
||||||
<TableHeader className="bg-muted border-b border-border/70">
|
|
||||||
<TableRow className="hover:bg-transparent border-0">
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
|
|
||||||
<TableHead className="px-6 py-3 w-72" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{bills.map((bill) => (
|
|
||||||
<TableRow
|
|
||||||
key={bill.id}
|
|
||||||
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
|
|
||||||
>
|
|
||||||
|
|
||||||
{/* Bill name */}
|
|
||||||
<TableCell className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-left text-sm font-medium leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
|
|
||||||
onClick={() => onEdit?.(bill.id)}
|
|
||||||
title={`Edit ${bill.name}`}
|
|
||||||
>
|
|
||||||
{bill.name}
|
|
||||||
</button>
|
|
||||||
{hasHistoricalVisibility(bill) && (
|
|
||||||
<span
|
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
|
|
||||||
title="Historical visibility configured"
|
|
||||||
aria-label="Historical visibility configured"
|
|
||||||
>
|
|
||||||
<History className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Category */}
|
|
||||||
<TableCell className="px-6 py-4">
|
|
||||||
{bill.category_name ? (
|
|
||||||
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Due day */}
|
|
||||||
<TableCell className="px-6 py-4 w-24">
|
|
||||||
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Expected amount */}
|
|
||||||
<TableCell className="px-6 py-4 w-28 text-right">
|
|
||||||
<span className="font-mono text-sm tabular-nums text-muted-foreground">
|
|
||||||
${Number(bill.expected_amount).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Billing cycle — field is billing_cycle, not cycle */}
|
|
||||||
<TableCell className="px-6 py-4 w-28">
|
|
||||||
<span className="text-xs text-muted-foreground capitalize">
|
|
||||||
{bill.billing_cycle || 'monthly'}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Flags */}
|
|
||||||
<TableCell className="px-6 py-4 w-24">
|
|
||||||
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{!!bill.autopay_enabled && (
|
|
||||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
|
|
||||||
)}
|
|
||||||
{!!bill.has_2fa && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground/40 text-xs">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Actions — visible on row hover */}
|
|
||||||
<TableCell className="px-6 py-4 w-72 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1.5 opacity-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'h-7 px-2.5 text-xs',
|
|
||||||
bill.active
|
|
||||||
? 'text-muted-foreground hover:text-destructive'
|
|
||||||
: 'text-emerald-500 hover:text-emerald-400',
|
|
||||||
)}
|
|
||||||
onClick={() => onToggle?.(bill)}
|
|
||||||
>
|
|
||||||
{bill.active ? 'Deactivate' : 'Activate'}
|
|
||||||
</Button>
|
|
||||||
{!bill.active && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
|
|
||||||
onClick={() => onHistory?.(bill)}
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
onClick={() => onDelete?.(bill)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,32 @@ export const APP_NAME = 'BillTracker';
|
||||||
|
|
||||||
export const RELEASE_NOTES = {
|
export const RELEASE_NOTES = {
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
date: '2026-05-14',
|
date: '2026-05-15',
|
||||||
highlights: [
|
highlights: [
|
||||||
{
|
{
|
||||||
icon: '❄️',
|
icon: '📋',
|
||||||
title: 'Debt Snowball',
|
title: 'Bills page redesigned',
|
||||||
desc: 'New Snowball page built around Dave Ramsey\'s method: drag-and-drop ordering, attack-target highlight, auto-arrange by balance, and per-bill payoff date that updates live as you type your extra monthly budget.',
|
desc: 'The old table is gone. Bills now show as clean cards with icon actions, inline debt details (APR colour-coded, current balance), and a Columns button to choose exactly which fields are displayed — remembered across sessions.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '📉',
|
icon: '📈',
|
||||||
title: 'Payment → Balance sync',
|
title: 'Snowball projection is now live',
|
||||||
desc: 'Recording a payment on any debt bill now automatically reduces its current balance (payment minus one month of accrued interest = principal paid). Un-marking a payment reverses the change exactly.',
|
desc: 'The payoff sidebar updates instantly as you type your extra monthly budget — no save required. The projection now includes a minimum-only baseline so you can see exactly how many months and dollars the snowball saves you.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '💳',
|
icon: '🔑',
|
||||||
title: 'Debt Details on Bills',
|
title: 'Login history',
|
||||||
desc: 'Edit Bill now has a collapsible Debt / Credit Details section: current balance (inline-editable on the Snowball page), minimum payment, and APR. Bills in Credit Cards, Loans, or Mortgage categories are auto-detected.',
|
desc: 'Your last 3 sign-ins are recorded with timestamp, IP address, and browser. Click the Last Login field on your Profile page to see the full history.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '📊',
|
icon: '📥',
|
||||||
title: 'Avalanche comparison',
|
title: 'Import by bill',
|
||||||
desc: 'The Snowball page sidebar shows your full payoff projection alongside an Avalanche method comparison — see how much interest you\'d save by attacking highest-rate debts first.',
|
desc: 'The XLSX import page has a new Bills tab. Select any existing bill and import its entire history from the spreadsheet in one click — no row-by-row review needed.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🔔',
|
icon: '📐',
|
||||||
title: 'Update notifications',
|
title: 'APR calculation engine',
|
||||||
desc: 'The app now tracks which version you last saw. On your first login after an update you\'ll see this "What\'s new" panel. Admins can also check for newer releases from the Forgejo repo on the Status page.',
|
desc: 'New backend math service: monthly interest, months to payoff, total interest, and full amortization schedules. Available via GET /api/bills/:id/amortization.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Plus, ChevronRight, Trash2 } from 'lucide-react';
|
import { Plus, ChevronRight, SlidersHorizontal } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
@ -92,6 +92,115 @@ function validateRange(range) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Display preferences ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PREFS_KEY = 'bills-display-prefs-v1';
|
||||||
|
|
||||||
|
const PREFS_DEFAULTS = {
|
||||||
|
showCategory: true,
|
||||||
|
showDueDay: true,
|
||||||
|
showAmount: true,
|
||||||
|
showCycle: true,
|
||||||
|
showApr: true,
|
||||||
|
showBalance: true,
|
||||||
|
showMinPayment: true,
|
||||||
|
showAutopay: true,
|
||||||
|
show2fa: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFS_LABELS = [
|
||||||
|
['showCategory', 'Category'],
|
||||||
|
['showDueDay', 'Due day'],
|
||||||
|
['showAmount', 'Amount'],
|
||||||
|
['showCycle', 'Billing cycle'],
|
||||||
|
['showApr', 'APR'],
|
||||||
|
['showBalance', 'Balance'],
|
||||||
|
['showMinPayment', 'Min payment'],
|
||||||
|
['showAutopay', 'Autopay badge'],
|
||||||
|
['show2fa', '2FA badge'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function useDisplayPrefs() {
|
||||||
|
const [prefs, setPrefs] = useState(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PREFS_KEY);
|
||||||
|
return raw ? { ...PREFS_DEFAULTS, ...JSON.parse(raw) } : PREFS_DEFAULTS;
|
||||||
|
} catch {
|
||||||
|
return PREFS_DEFAULTS;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = (key) => {
|
||||||
|
setPrefs(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] };
|
||||||
|
try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { prefs, toggle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function DisplayPrefsPanel({ prefs, onToggle }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className={cn(
|
||||||
|
'h-9 px-3 rounded-md border border-border/70 bg-card/80 text-xs font-medium',
|
||||||
|
'flex items-center gap-2 transition-colors',
|
||||||
|
open
|
||||||
|
? 'bg-accent text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||||
|
)}
|
||||||
|
aria-label="Display options"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Columns</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-50 w-48 rounded-xl border border-border/60 bg-card/95 backdrop-blur-xl shadow-xl p-3 space-y-0.5">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1 pb-1.5">
|
||||||
|
Display options
|
||||||
|
</p>
|
||||||
|
{PREFS_LABELS.map(([key, label]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-2.5 rounded-md px-1.5 py-1.5 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!prefs[key]}
|
||||||
|
onChange={() => onToggle(key)}
|
||||||
|
className="h-3.5 w-3.5 rounded border-border accent-primary"
|
||||||
|
/>
|
||||||
|
<span className={cn('text-xs', prefs[key] ? 'text-foreground' : 'text-muted-foreground/60')}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
|
function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
|
||||||
const [visibility, setVisibility] = useState(bill?.history_visibility || 'default');
|
const [visibility, setVisibility] = useState(bill?.history_visibility || 'default');
|
||||||
const [ranges, setRanges] = useState([]);
|
const [ranges, setRanges] = useState([]);
|
||||||
|
|
@ -335,6 +444,8 @@ export default function BillsPage() {
|
||||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
const [historyTarget, setHistoryTarget] = useState(null);
|
const [historyTarget, setHistoryTarget] = useState(null);
|
||||||
|
|
||||||
|
const { prefs, toggle: togglePref } = useDisplayPrefs();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [billsRes, catRes] = await Promise.all([
|
const [billsRes, catRes] = await Promise.all([
|
||||||
|
|
@ -433,30 +544,32 @@ export default function BillsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setModal({ bill: null })}
|
<DisplayPrefsPanel prefs={prefs} onToggle={togglePref} />
|
||||||
className="h-9 px-4 gap-2 text-sm font-medium"
|
<Button
|
||||||
>
|
onClick={() => setModal({ bill: null })}
|
||||||
<Plus className="h-4 w-4" />
|
className="h-9 px-4 gap-2 text-sm font-medium"
|
||||||
Add Bill
|
>
|
||||||
</Button>
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Bill
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Active Bills ── */}
|
{/* ── Active Bills ── */}
|
||||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
<div className="surface-elevated rounded-xl overflow-hidden">
|
||||||
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Active Bills
|
Active
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-mono text-muted-foreground">{active.length}</span>
|
<span className="text-xs tabular-nums text-muted-foreground">{active.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
<div className="space-y-px p-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<div key={i} className="mx-auto mb-3 h-12 w-3/4 rounded-lg bg-muted animate-pulse" />
|
<div key={i} className="h-16 rounded-lg bg-muted/40 animate-pulse mb-2" />
|
||||||
))}
|
))}
|
||||||
<span className="animate-pulse">Loading bills…</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : active.length === 0 ? (
|
) : active.length === 0 ? (
|
||||||
<div className="py-16 text-center text-sm text-muted-foreground">
|
<div className="py-16 text-center text-sm text-muted-foreground">
|
||||||
|
|
@ -469,14 +582,13 @@ export default function BillsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<BillsTableInner
|
||||||
<BillsTableInner
|
bills={active}
|
||||||
bills={active}
|
prefs={prefs}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
onDelete={handleDeleteRequest}
|
onDelete={handleDeleteRequest}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -498,22 +610,21 @@ export default function BillsPage() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showInactive && (
|
{showInactive && (
|
||||||
<div className="rounded-xl border border-border overflow-hidden bg-card">
|
<div className="surface-elevated rounded-xl overflow-hidden">
|
||||||
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Inactive Bills
|
Inactive
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-mono text-muted-foreground">{inactive.length}</span>
|
<span className="text-xs tabular-nums text-muted-foreground">{inactive.length}</span>
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<BillsTableInner
|
|
||||||
bills={inactive}
|
|
||||||
onEdit={handleEdit}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
onDelete={handleDeleteRequest}
|
|
||||||
onHistory={setHistoryTarget}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<BillsTableInner
|
||||||
|
bills={inactive}
|
||||||
|
prefs={prefs}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onDelete={handleDeleteRequest}
|
||||||
|
onHistory={setHistoryTarget}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
|
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
|
||||||
AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
|
AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
|
||||||
ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
|
ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
|
||||||
|
List, Building2, ChevronLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -1039,6 +1040,202 @@ const INITIAL_OPTIONS = {
|
||||||
defaultMonth: '',
|
defaultMonth: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Bill History Import helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ConfidenceDot({ level }) {
|
||||||
|
const cls = level === 'high' ? 'bg-emerald-500'
|
||||||
|
: level === 'medium' ? 'bg-amber-500'
|
||||||
|
: 'bg-muted-foreground/30';
|
||||||
|
return <span className={cn('h-2 w-2 rounded-full shrink-0 inline-block', cls)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useBillGroups(previewRows, allBills) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const billMap = new Map(allBills.map(b => [b.id, b]));
|
||||||
|
const groups = new Map();
|
||||||
|
|
||||||
|
for (const row of previewRows) {
|
||||||
|
for (const match of (row.possible_bill_matches ?? [])) {
|
||||||
|
if (!billMap.has(match.bill_id)) continue;
|
||||||
|
if (!groups.has(match.bill_id)) {
|
||||||
|
groups.set(match.bill_id, {
|
||||||
|
bill: billMap.get(match.bill_id),
|
||||||
|
rows: [],
|
||||||
|
counts: { high: 0, medium: 0, low: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const g = groups.get(match.bill_id);
|
||||||
|
if (!g.rows.find(r => r.row_id === row.row_id)) {
|
||||||
|
g.rows.push({ ...row, _match: match });
|
||||||
|
g.counts[match.match_confidence] = (g.counts[match.match_confidence] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.values()].sort((a, b) =>
|
||||||
|
b.rows.length !== a.rows.length ? b.rows.length - a.rows.length : b.counts.high - a.counts.high
|
||||||
|
);
|
||||||
|
}, [previewRows, allBills]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowDateLabel(row) {
|
||||||
|
if (row.detected_year && row.detected_month)
|
||||||
|
return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`;
|
||||||
|
return row.detected_paid_date ?? '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillDetailView({ group, onBack, onImport, isImporting, importResult }) {
|
||||||
|
const { bill, rows } = group;
|
||||||
|
const sorted = [...rows].sort((a, b) => {
|
||||||
|
const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0);
|
||||||
|
const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0);
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="px-4 py-2.5 border-b border-border/50 flex items-center justify-between gap-3">
|
||||||
|
<button type="button" onClick={onBack}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" /> All bills
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium truncate">{bill.name}</span>
|
||||||
|
{importResult ? (
|
||||||
|
<span className="text-xs text-emerald-500 font-medium shrink-0">
|
||||||
|
✓ {importResult.created + importResult.updated} imported
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={onImport} disabled={isImporting} className="h-7 text-xs px-3 shrink-0 gap-1.5">
|
||||||
|
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
Import all {rows.length}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
|
||||||
|
{sorted.map(row => (
|
||||||
|
<div key={row.row_id} className="px-4 py-2 flex items-center gap-3">
|
||||||
|
<ConfidenceDot level={row._match.match_confidence} />
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground w-16 shrink-0">{rowDateLabel(row)}</span>
|
||||||
|
<span className="text-xs font-mono tabular-nums w-16 shrink-0">
|
||||||
|
{row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate flex-1">{row.detected_name ?? '—'}</span>
|
||||||
|
<span className={cn('text-[10px] shrink-0',
|
||||||
|
row._match.match_confidence === 'high' ? 'text-emerald-500' :
|
||||||
|
row._match.match_confidence === 'medium' ? 'text-amber-500' : 'text-muted-foreground/40')}>
|
||||||
|
{row._match.match_confidence}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) {
|
||||||
|
const [selectedBillId, setSelectedBillId] = useState(null);
|
||||||
|
const billGroups = useBillGroups(previewRows, allBills);
|
||||||
|
|
||||||
|
if (billGroups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No existing bills matched rows in this file.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedBillId) {
|
||||||
|
const group = billGroups.find(g => g.bill.id === selectedBillId);
|
||||||
|
return group
|
||||||
|
? <BillDetailView group={group}
|
||||||
|
isImporting={importingBillId === group.bill.id}
|
||||||
|
importResult={billImportResults.get(group.bill.id) ?? null}
|
||||||
|
onBack={() => setSelectedBillId(null)}
|
||||||
|
onImport={() => onImportBill(group)} />
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{billGroups.map(g => {
|
||||||
|
const { bill, rows, counts } = g;
|
||||||
|
const isImporting = importingBillId === bill.id;
|
||||||
|
const importResult = billImportResults.get(bill.id) ?? null;
|
||||||
|
|
||||||
|
const sorted3 = [...rows]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0);
|
||||||
|
const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0);
|
||||||
|
return da - db;
|
||||||
|
})
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={bill.id} className="px-4 py-3 flex items-start gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium truncate">{bill.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
|
||||||
|
{rows.length} row{rows.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{counts.high > 0 && <span className="text-[10px] text-emerald-500">{counts.high} high</span>}
|
||||||
|
{counts.medium > 0 && <span className="text-[10px] text-amber-500">{counts.medium} med</span>}
|
||||||
|
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
|
||||||
|
{importResult && (
|
||||||
|
<span className="text-[10px] text-emerald-500 font-medium">
|
||||||
|
✓ {importResult.created + importResult.updated} imported
|
||||||
|
{importResult.errored > 0 && ` · ${importResult.errored} errors`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 space-y-0.5">
|
||||||
|
{sorted3.map(row => (
|
||||||
|
<div key={row.row_id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<ConfidenceDot level={row._match.match_confidence} />
|
||||||
|
<span className="tabular-nums w-16 shrink-0">{rowDateLabel(row)}</span>
|
||||||
|
{row.detected_amount != null && (
|
||||||
|
<span className="tabular-nums">${Number(row.detected_amount).toFixed(2)}</span>
|
||||||
|
)}
|
||||||
|
{row.detected_name &&
|
||||||
|
row.detected_name.toLowerCase() !== bill.name.toLowerCase() && (
|
||||||
|
<span className="truncate italic opacity-60">"{row.detected_name}"</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rows.length > 3 && (
|
||||||
|
<button type="button" onClick={() => setSelectedBillId(bill.id)}
|
||||||
|
className="text-[10px] text-primary/70 hover:text-primary transition-colors">
|
||||||
|
+{rows.length - 3} more — view all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 shrink-0 pt-0.5">
|
||||||
|
{importResult ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => onImportBill(g)}
|
||||||
|
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5">
|
||||||
|
Re-import
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" onClick={() => onImportBill(g)}
|
||||||
|
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5">
|
||||||
|
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
Import {rows.length}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setSelectedBillId(bill.id)}
|
||||||
|
disabled={!!importingBillId} className="h-7 text-xs px-3">
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const [file, setFile] = useState(null);
|
const [file, setFile] = useState(null);
|
||||||
|
|
@ -1049,6 +1246,9 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
const [allBills, setAllBills] = useState([]);
|
const [allBills, setAllBills] = useState([]);
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [selectedRows, setSelectedRows] = useState(new Set());
|
const [selectedRows, setSelectedRows] = useState(new Set());
|
||||||
|
const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills'
|
||||||
|
const [importingBillId, setImportingBillId] = useState(null);
|
||||||
|
const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id → { created, updated, errored }
|
||||||
|
|
||||||
// Load bills/categories for the decision controls
|
// Load bills/categories for the decision controls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1065,6 +1265,9 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
setDecisions({});
|
setDecisions({});
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
setApplyState({ status: 'idle', result: null, error: null });
|
setApplyState({ status: 'idle', result: null, error: null });
|
||||||
|
setViewMode('rows');
|
||||||
|
setImportingBillId(null);
|
||||||
|
setBillImportResults(new Map());
|
||||||
try {
|
try {
|
||||||
const data = await api.previewSpreadsheetImport(file, {
|
const data = await api.previewSpreadsheetImport(file, {
|
||||||
parseAllSheets: options.parseAllSheets,
|
parseAllSheets: options.parseAllSheets,
|
||||||
|
|
@ -1094,6 +1297,43 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
|
|
||||||
const clearSelection = () => setSelectedRows(new Set());
|
const clearSelection = () => setSelectedRows(new Set());
|
||||||
|
|
||||||
|
// ── Bill-history direct import ────────────────────────────────────────────
|
||||||
|
// Applies all matching rows for a bill immediately — no queue, no review step.
|
||||||
|
const handleDirectImportBill = async (group) => {
|
||||||
|
const sessionId = preview.data?.import_session_id;
|
||||||
|
if (!sessionId || importingBillId) return;
|
||||||
|
|
||||||
|
setImportingBillId(group.bill.id);
|
||||||
|
try {
|
||||||
|
const decisionsList = group.rows.map(row => ({
|
||||||
|
row_id: row.row_id,
|
||||||
|
action: 'match_existing_bill',
|
||||||
|
bill_id: group.bill.id,
|
||||||
|
actual_amount: row.detected_amount ?? null,
|
||||||
|
payment_amount: row.detected_payment_amount ?? row.detected_amount ?? null,
|
||||||
|
payment_date: row.detected_paid_date ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await api.applySpreadsheetImport({
|
||||||
|
import_session_id: sessionId,
|
||||||
|
decisions: decisionsList,
|
||||||
|
options: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = result.rows_created ?? 0;
|
||||||
|
const updated = result.rows_updated ?? 0;
|
||||||
|
const errored = result.rows_errored ?? 0;
|
||||||
|
|
||||||
|
setBillImportResults(prev => new Map(prev).set(group.bill.id, { created, updated, errored }));
|
||||||
|
toast.success(`Imported ${created + updated} entr${created + updated === 1 ? 'y' : 'ies'} for "${group.bill.name}"`);
|
||||||
|
onHistoryRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || `Import failed for "${group.bill.name}"`);
|
||||||
|
} finally {
|
||||||
|
setImportingBillId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectAllVisibleRows = () => {
|
const selectAllVisibleRows = () => {
|
||||||
setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id)));
|
setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id)));
|
||||||
};
|
};
|
||||||
|
|
@ -1333,31 +1573,67 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
||||||
{/* Row decision table */}
|
{/* Row decision table */}
|
||||||
{previewRows.length > 0 ? (
|
{previewRows.length > 0 ? (
|
||||||
<div className="rounded-lg border border-border overflow-hidden bg-background">
|
<div className="rounded-lg border border-border overflow-hidden bg-background">
|
||||||
|
{/* Tab header */}
|
||||||
<div className="px-4 py-3 border-b border-border bg-muted/40 flex items-center justify-between gap-3 flex-wrap">
|
<div className="px-4 py-3 border-b border-border bg-muted/40 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div className="flex items-center gap-1 bg-background rounded-md border border-border p-0.5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">XLSX Review Table</p>
|
<button type="button" onClick={() => setViewMode('rows')}
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Select preview rows, then apply bulk review decisions before importing.</p>
|
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
viewMode === 'rows'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground')}>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
Rows ({previewRows.length})
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setViewMode('bills')}
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
viewMode === 'bills'
|
||||||
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground')}>
|
||||||
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
|
Bills
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{previewRows.length} preview row{previewRows.length === 1 ? '' : 's'}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{viewMode === 'rows'
|
||||||
|
? 'Select rows, apply bulk decisions, then import.'
|
||||||
|
: 'Click a bill to queue its entire history from this file.'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<BulkActionBar
|
|
||||||
rows={previewRows}
|
{/* Rows view */}
|
||||||
selectedRows={selectedRows}
|
{viewMode === 'rows' && (
|
||||||
onSelectAll={selectAllVisibleRows}
|
<>
|
||||||
onClearSelection={clearSelection}
|
<BulkActionBar
|
||||||
onBulkSkip={handleBulkSkip}
|
rows={previewRows}
|
||||||
onBulkCreateNew={handleBulkCreateNew}
|
selectedRows={selectedRows}
|
||||||
onBulkReset={handleBulkReset}
|
onSelectAll={selectAllVisibleRows}
|
||||||
/>
|
onClearSelection={clearSelection}
|
||||||
<PreviewTable
|
onBulkSkip={handleBulkSkip}
|
||||||
rows={previewRows}
|
onBulkCreateNew={handleBulkCreateNew}
|
||||||
decisions={decisions}
|
onBulkReset={handleBulkReset}
|
||||||
onDecisionChange={handleDecisionChange}
|
/>
|
||||||
allBills={allBills}
|
<PreviewTable
|
||||||
categories={categories}
|
rows={previewRows}
|
||||||
selectedRows={selectedRows}
|
decisions={decisions}
|
||||||
onSelectedChange={handleSelectedChange}
|
onDecisionChange={handleDecisionChange}
|
||||||
/>
|
allBills={allBills}
|
||||||
|
categories={categories}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
onSelectedChange={handleSelectedChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bills view */}
|
||||||
|
{viewMode === 'bills' && (
|
||||||
|
<BillHistoryView
|
||||||
|
previewRows={previewRows}
|
||||||
|
allBills={allBills}
|
||||||
|
importingBillId={importingBillId}
|
||||||
|
billImportResults={billImportResults}
|
||||||
|
onImportBill={handleDirectImportBill}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">No data rows found in this file.</p>
|
<p className="text-sm text-muted-foreground text-center py-4">No data rows found in this file.</p>
|
||||||
|
|
@ -1588,7 +1864,7 @@ export default function DataPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-2 xl:items-start">
|
<div className="space-y-5">
|
||||||
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
||||||
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
User, Mail, KeyRound, ShieldCheck, Loader2,
|
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
function asProfile(data) {
|
function asProfile(data) {
|
||||||
return data?.profile || data?.user || data || {};
|
return data?.profile || data?.user || data || {};
|
||||||
|
|
@ -61,7 +64,98 @@ function CheckRow({ id, label, checked, onChange, disabled }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUserAgent(ua) {
|
||||||
|
if (!ua) return { browser: 'Unknown', os: 'Unknown', mobile: false };
|
||||||
|
const s = ua;
|
||||||
|
const mobile = /iPhone|iPad|Android|Mobile/i.test(s);
|
||||||
|
const browser =
|
||||||
|
/Edg\//i.test(s) ? 'Edge' :
|
||||||
|
/OPR\//i.test(s) ? 'Opera' :
|
||||||
|
/Chrome\//i.test(s) ? 'Chrome' :
|
||||||
|
/Firefox\//i.test(s) ? 'Firefox' :
|
||||||
|
/Safari\//i.test(s) ? 'Safari' :
|
||||||
|
/curl\//i.test(s) ? 'curl' : 'Unknown';
|
||||||
|
const os =
|
||||||
|
/iPhone|iPad/i.test(s) ? 'iOS' :
|
||||||
|
/Android/i.test(s) ? 'Android' :
|
||||||
|
/Windows/i.test(s) ? 'Windows' :
|
||||||
|
/Macintosh/i.test(s) ? 'macOS' :
|
||||||
|
/Linux/i.test(s) ? 'Linux' : 'Unknown';
|
||||||
|
return { browser, os, mobile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginHistoryModal({ lastLoginAt, open, onClose }) {
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setLoading(true);
|
||||||
|
api.loginHistory()
|
||||||
|
.then(d => setHistory(d.history ?? []))
|
||||||
|
.catch(() => setHistory([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<History className="h-4 w-4 text-primary" />
|
||||||
|
Login History
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
Your last 3 sign-in events
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No login history recorded.</p>
|
||||||
|
) : history.map((entry, i) => {
|
||||||
|
const { browser, os, mobile } = parseUserAgent(entry.user_agent);
|
||||||
|
const DeviceIcon = mobile ? Smartphone : Monitor;
|
||||||
|
return (
|
||||||
|
<div key={entry.id}
|
||||||
|
className="flex items-start gap-3 rounded-lg border border-border/50 bg-muted/20 px-4 py-3">
|
||||||
|
<DeviceIcon className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{formatDateTime(entry.logged_in_at)}
|
||||||
|
{i === 0 && (
|
||||||
|
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-emerald-500">
|
||||||
|
most recent
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{browser} on {os}
|
||||||
|
{entry.ip_address && (
|
||||||
|
<span className="ml-2 font-mono">{entry.ip_address}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 text-center pt-1">
|
||||||
|
Showing up to 3 most recent sign-ins
|
||||||
|
</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ProfileSummary({ profile, loading }) {
|
function ProfileSummary({ profile, loading }) {
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Profile Summary" icon={User}>
|
<SectionCard title="Profile Summary" icon={User}>
|
||||||
|
|
@ -70,16 +164,38 @@ function ProfileSummary({ profile, loading }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastLoginAt = profile.last_login_at || profile.last_login;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
|
<>
|
||||||
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
|
||||||
<FieldRow label="Username" value={profile.username} />
|
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
|
<FieldRow label="Username" value={profile.username} />
|
||||||
<FieldRow label="Role" value={profile.role} />
|
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
|
||||||
<FieldRow label="Last Login" value={formatDateTime(profile.last_login_at || profile.last_login)} />
|
<FieldRow label="Role" value={profile.role} />
|
||||||
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
|
|
||||||
</div>
|
{/* Last Login — clickable, opens history modal */}
|
||||||
</SectionCard>
|
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Login</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHistoryOpen(true)}
|
||||||
|
className="mt-1 text-sm font-medium text-foreground hover:text-primary hover:underline underline-offset-2 transition-colors text-left"
|
||||||
|
>
|
||||||
|
{lastLoginAt ? formatDateTime(lastLoginAt) : 'Not recorded'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<LoginHistoryModal
|
||||||
|
lastLoginAt={lastLoginAt}
|
||||||
|
open={historyOpen}
|
||||||
|
onClose={() => setHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,8 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) {
|
||||||
|
|
||||||
// ── Pointer-based drag-and-drop hook (works on touch + mouse) ─────────────────
|
// ── Pointer-based drag-and-drop hook (works on touch + mouse) ─────────────────
|
||||||
function useSortable(items, setItems, setDirty) {
|
function useSortable(items, setItems, setDirty) {
|
||||||
const [draggingIdx, setDraggingIdx] = useState(null);
|
const [draggingIdx, setDraggingIdx] = useState(null);
|
||||||
|
const [draggingFromIdx, setDraggingFromIdx] = useState(null);
|
||||||
|
|
||||||
// Refs that live through the entire drag gesture
|
// Refs that live through the entire drag gesture
|
||||||
const state = useRef({
|
const state = useRef({
|
||||||
|
|
@ -300,6 +301,7 @@ function useSortable(items, setItems, setDirty) {
|
||||||
containerEl: list ?? null,
|
containerEl: list ?? null,
|
||||||
};
|
};
|
||||||
setDraggingIdx(index);
|
setDraggingIdx(index);
|
||||||
|
setDraggingFromIdx(index);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onPointerMove = useCallback((e) => {
|
const onPointerMove = useCallback((e) => {
|
||||||
|
|
@ -320,6 +322,7 @@ function useSortable(items, setItems, setDirty) {
|
||||||
state.current.fromIdx = null;
|
state.current.fromIdx = null;
|
||||||
state.current.currentIdx = null;
|
state.current.currentIdx = null;
|
||||||
setDraggingIdx(null);
|
setDraggingIdx(null);
|
||||||
|
setDraggingFromIdx(null);
|
||||||
|
|
||||||
if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return;
|
if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return;
|
||||||
setItems(prev => {
|
setItems(prev => {
|
||||||
|
|
@ -331,7 +334,7 @@ function useSortable(items, setItems, setDirty) {
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
}, [setItems, setDirty]);
|
}, [setItems, setDirty]);
|
||||||
|
|
||||||
return { draggingIdx, onPointerDown, onPointerMove, onPointerUp };
|
return { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -352,7 +355,7 @@ export default function SnowballPage() {
|
||||||
|
|
||||||
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
|
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
|
||||||
|
|
||||||
const { draggingIdx, onPointerDown, onPointerMove, onPointerUp } =
|
const { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp } =
|
||||||
useSortable(bills, setBills, setDirty);
|
useSortable(bills, setBills, setDirty);
|
||||||
|
|
||||||
// ── loading ───────────────────────────────────────────────────────────────
|
// ── loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -573,10 +576,11 @@ export default function SnowballPage() {
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
>
|
>
|
||||||
{bills.map((bill, index) => {
|
{bills.map((bill, index) => {
|
||||||
const isAttack = index === 0;
|
const isAttack = index === 0;
|
||||||
const isEditingBal = editingBalance.billId === bill.id;
|
const isEditingBal = editingBalance.billId === bill.id;
|
||||||
const isDragging = draggingIdx !== null;
|
const isDragging = draggingFromIdx !== null;
|
||||||
const isTarget = draggingIdx === index;
|
const isDragSource = draggingFromIdx === index;
|
||||||
|
const isLandTarget = isDragging && !isDragSource && draggingIdx === index;
|
||||||
|
|
||||||
// Pull this debt's payoff info from the live projection (attack card only)
|
// Pull this debt's payoff info from the live projection (attack card only)
|
||||||
const attackProjection = isAttack
|
const attackProjection = isAttack
|
||||||
|
|
@ -589,9 +593,12 @@ export default function SnowballPage() {
|
||||||
data-card
|
data-card
|
||||||
data-card-index={index}
|
data-card-index={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
'surface-elevated rounded-xl border transition-all duration-150 select-none touch-none',
|
||||||
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
|
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
|
||||||
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
|
// Card being actively dragged — lifted look
|
||||||
|
isDragSource && 'scale-[1.03] shadow-2xl ring-2 ring-primary/40 opacity-80 relative z-10',
|
||||||
|
// Where the card will land — slot highlight
|
||||||
|
isLandTarget && 'ring-2 ring-primary/60 scale-[0.98] opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-stretch">
|
<div className="flex items-stretch">
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,25 @@ function reconcileLegacyMigrations() {
|
||||||
}
|
}
|
||||||
console.log('[migration] users: last_seen_version column added');
|
console.log('[migration] users: last_seen_version column added');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.53',
|
||||||
|
description: 'user_login_history: track last 3 logins per user',
|
||||||
|
check: function() {
|
||||||
|
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_login_history'").get();
|
||||||
|
},
|
||||||
|
run: function() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_login_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('[migration] user_login_history table created');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1291,6 +1310,23 @@ function runMigrations() {
|
||||||
}
|
}
|
||||||
console.log('[migration] users: last_seen_version column added');
|
console.log('[migration] users: last_seen_version column added');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v0.53',
|
||||||
|
description: 'user_login_history: track last 3 logins per user',
|
||||||
|
dependsOn: ['v0.52'],
|
||||||
|
run: function() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_login_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('[migration] user_login_history table created');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -1685,6 +1721,10 @@ const ROLLBACK_SQL_MAP = {
|
||||||
'v0.52': {
|
'v0.52': {
|
||||||
description: 'users: last_seen_version column',
|
description: 'users: last_seen_version column',
|
||||||
sql: ['ALTER TABLE users DROP COLUMN last_seen_version']
|
sql: ['ALTER TABLE users DROP COLUMN last_seen_version']
|
||||||
|
},
|
||||||
|
'v0.53': {
|
||||||
|
description: 'user_login_history table',
|
||||||
|
sql: ['DROP TABLE IF EXISTS user_login_history']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./client/*"]
|
"@/*": ["./client/*"]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ function getAppVersion() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getDb, getSetting, setSetting } = require('../db/database');
|
const { getDb, getSetting, setSetting } = require('../db/database');
|
||||||
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions } = require('../services/authService');
|
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin } = require('../services/authService');
|
||||||
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
|
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
|
||||||
const { getPublicOidcInfo } = require('../services/oidcService');
|
const { getPublicOidcInfo } = require('../services/oidcService');
|
||||||
const { ValidationError, formatError } = require('../utils/apiError');
|
const { ValidationError, formatError } = require('../utils/apiError');
|
||||||
|
|
@ -47,6 +47,7 @@ router.post('/login', (req, res, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
logAudit({ user_id: result.user.id, action: 'login.success', ip_address: req.ip, user_agent: req.get('user-agent') });
|
logAudit({ user_id: result.user.id, action: 'login.success', ip_address: req.ip, user_agent: req.get('user-agent') });
|
||||||
|
recordLogin(result.user.id, req.ip, req.get('user-agent'));
|
||||||
|
|
||||||
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
|
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
|
||||||
res.json({ user: result.user });
|
res.json({ user: result.user });
|
||||||
|
|
@ -89,6 +90,19 @@ router.get('/me', requireAuth, (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/login-history — last 3 logins for the authenticated user
|
||||||
|
router.get('/login-history', requireAuth, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const history = db.prepare(`
|
||||||
|
SELECT id, logged_in_at, ip_address, user_agent
|
||||||
|
FROM user_login_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY logged_in_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
`).all(req.user.id);
|
||||||
|
res.json({ history });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/auth/acknowledge-version — user has seen the release notes
|
// POST /api/auth/acknowledge-version — user has seen the release notes
|
||||||
router.post('/acknowledge-version', requireAuth, (req, res) => {
|
router.post('/acknowledge-version', requireAuth, (req, res) => {
|
||||||
const currentVersion = getAppVersion();
|
const currentVersion = getAppVersion();
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const { createSession, cookieOpts, COOKIE_NAME } = require('../services/authService');
|
const { createSession, cookieOpts, COOKIE_NAME, recordLogin } = require('../services/authService');
|
||||||
const {
|
const {
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
isOidcLoginActive,
|
isOidcLoginActive,
|
||||||
|
|
@ -93,6 +93,7 @@ router.get('/callback', async (req, res) => {
|
||||||
const session = await createSession(user.id);
|
const session = await createSession(user.id);
|
||||||
if (!session) throw new Error('Failed to create local session after OIDC login');
|
if (!session) throw new Error('Failed to create local session after OIDC login');
|
||||||
|
|
||||||
|
recordLogin(user.id, req.ip, req.get('user-agent'));
|
||||||
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
||||||
res.redirect(savedState.redirect_to || '/');
|
res.redirect(savedState.redirect_to || '/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -593,24 +593,4 @@ router.patch('/:id/balance', (req, res) => {
|
||||||
res.json({ id: billId, current_balance: val });
|
res.json({ id: billId, current_balance: val });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── PATCH /api/bills/:id/snowball — lightweight snowball visibility update ───
|
|
||||||
router.patch('/:id/snowball', (req, res) => {
|
|
||||||
const db = getDb();
|
|
||||||
const billId = parseInt(req.params.id, 10);
|
|
||||||
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
|
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const include = req.body.snowball_include ? 1 : 0;
|
|
||||||
const exempt = req.body.snowball_exempt ? 1 : 0;
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE bills
|
|
||||||
SET snowball_include = ?, snowball_exempt = ?, updated_at = datetime('now')
|
|
||||||
WHERE id = ? AND user_id = ?
|
|
||||||
`).run(include, exempt, billId, req.user.id);
|
|
||||||
|
|
||||||
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,31 @@ function publicUser(u) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a successful login and prunes older entries so each user
|
||||||
|
* keeps at most 3 login history rows.
|
||||||
|
*/
|
||||||
|
function recordLogin(userId, ipAddress, userAgent) {
|
||||||
|
const db = getDb();
|
||||||
|
db.transaction(() => {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO user_login_history (user_id, logged_in_at, ip_address, user_agent)
|
||||||
|
VALUES (?, datetime('now'), ?, ?)
|
||||||
|
`).run(userId, ipAddress ?? null, userAgent ? userAgent.slice(0, 500) : null);
|
||||||
|
|
||||||
|
// Keep only the 3 most recent rows for this user
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM user_login_history
|
||||||
|
WHERE user_id = ? AND id NOT IN (
|
||||||
|
SELECT id FROM user_login_history
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY logged_in_at DESC, id DESC
|
||||||
|
LIMIT 3
|
||||||
|
)
|
||||||
|
`).run(userId, userId);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
// Prune expired sessions — called by daily worker
|
// Prune expired sessions — called by daily worker
|
||||||
function pruneExpiredSessions() {
|
function pruneExpiredSessions() {
|
||||||
const result = getDb().prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
|
const result = getDb().prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
|
||||||
|
|
@ -203,4 +228,4 @@ function invalidateOtherSessions(userId, keepSessionId) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions };
|
module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions, recordLogin };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue