BillTracker/client/pages/SubscriptionsPage.jsx

1170 lines
48 KiB
JavaScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import {
Bell,
CalendarDays,
CheckCircle2,
CheckCircle,
Cloud,
ArrowDown,
ArrowUp,
GripVertical,
AlertTriangle,
Info,
Link2,
Loader2,
Pause,
Plus,
RefreshCw,
Repeat,
Search,
Sparkles,
X,
} from 'lucide-react';
import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import BillModal from '@/components/BillModal';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
const TYPE_LABELS = {
streaming: 'Streaming',
software: 'Software',
cloud: 'Cloud',
music: 'Music',
news: 'News',
fitness: 'Fitness',
gaming: 'Gaming',
utilities: 'Utilities',
insurance: 'Insurance',
food: 'Food',
education: 'Education',
shopping: 'Shopping',
security: 'Security',
other: 'Other',
};
function cycleLabel(item) {
return scheduleLabel(item);
}
function StatCard({ icon: Icon, label, value, hint }) {
return (
<div className="min-w-0 rounded-lg border border-border/70 bg-card/80 p-4">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground/70">
<Icon className="h-4 w-4 text-primary" />
<span className="truncate">{label}</span>
</div>
<p className="tracker-number mt-2 truncate text-2xl font-bold tracking-tight text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs font-medium text-muted-foreground">{hint}</p>}
</div>
);
}
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps }) {
return (
<div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'group',
'grid min-w-0 gap-3 border-b border-border/40 px-4 py-4 last:border-b-0',
'md:grid-cols-[auto_minmax(0,1fr)_14rem_auto] md:items-center',
!item.active && 'opacity-60',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}>
<div className="flex shrink-0 items-center gap-0.5 self-start md:self-center">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="flex flex-col">
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move subscription up"
aria-label={`Move ${item.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move subscription down"
aria-label={`Move ${item.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onEdit(item)}
className="min-w-0 max-w-full truncate text-left text-[15px] font-semibold text-foreground hover:text-primary"
>
{item.name}
</button>
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary">
{TYPE_LABELS[item.subscription_type] || 'Other'}
</Badge>
{!item.active && (
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-amber-300">
Paused
</Badge>
)}
</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>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:w-56">
<div>
<p className="text-xs text-muted-foreground">Per cycle</p>
<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>
<p className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">{fmt(item.monthly_equivalent)}</p>
</div>
</div>
<div className="flex gap-2 md:justify-end">
<Button type="button" variant="outline" size="sm" className="flex-1 md:flex-none" onClick={() => onEdit(item)}>
Edit
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'flex-1 md:flex-none',
item.active ? 'text-amber-300 hover:bg-amber-500/10 hover:text-amber-200' : 'text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200',
)}
onClick={() => onToggle(item)}
>
{item.active ? 'Pause' : 'Resume'}
</Button>
</div>
</div>
);
}
function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) {
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q));
}, [bills, search]);
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Link to existing bill</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
<span className="font-medium text-foreground">{recommendation?.name}</span>
{recommendation && (
<span className="ml-2 text-xs">{recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · {fmt(recommendation.expected_amount)}</span>
)}
</p>
<p className="text-xs text-muted-foreground">
The matching transactions will be marked as paid under the selected bill.
</p>
</DialogHeader>
<Input
autoFocus
placeholder="Search bills…"
value={search}
onChange={e => setSearch(e.target.value)}
className="text-sm"
/>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 divide-y divide-border/40">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">No bills found.</p>
) : filtered.map(bill => (
<button
key={bill.id}
type="button"
onClick={() => setSelectedId(bill.id)}
className={cn(
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
selectedId === bill.id && 'bg-primary/10 text-primary',
)}
>
<span className="truncate font-medium">{bill.name}</span>
<span className="shrink-0 ml-3 text-xs text-muted-foreground tabular-nums">
${(bill.expected_amount ?? 0).toFixed(2)}/mo
</span>
</button>
))}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />Linking</> : 'Link to bill'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function TxResultRow({ tx, onTrack }) {
const dollars = (Math.abs(tx.amount) / 100).toFixed(2);
const label = tx.payee || tx.description || tx.memo || '—';
const account = tx.account_name || tx.data_source_name || null;
const isMatched = tx.match_status === 'matched';
const catalogMatch = tx.catalog_match;
return (
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium truncate max-w-[200px]">{label}</span>
{isMatched ? (
<Badge className="shrink-0 border-emerald-400/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 gap-1 text-[10px]">
<CheckCircle className="h-3 w-3" />
{tx.matched_bill_name || 'Matched'}
</Badge>
) : (
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground">Unmatched</Badge>
)}
{catalogMatch && (
<Badge variant="outline" className="shrink-0 border-primary/25 bg-primary/10 text-primary text-[10px]">
Known: {catalogMatch.name}
</Badge>
)}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{tx.posted_date}{account ? ` · ${account}` : ''}
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
</p>
</div>
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span>
{!isMatched && (
<Button size="sm" variant="outline" onClick={() => onTrack(tx)}
className="shrink-0 h-7 px-2 text-xs gap-1">
<Plus className="h-3 w-3" /> Track
</Button>
)}
</div>
);
}
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) {
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
const existingBill = recommendation.existing_bill_match;
return (
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
<div className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">{recommendation.name}</p>
<p className="mt-1 text-xs font-medium text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
</p>
{recommendation.catalog_match?.starting_monthly_usd && (
<p className="mt-1 text-xs font-medium text-muted-foreground">
Catalog starts at {fmt(recommendation.catalog_match.starting_monthly_usd)} / mo
{recommendation.catalog_match.starting_annual_usd
? ` or ${fmt(recommendation.catalog_match.starting_annual_usd)} / yr`
: ''}
</p>
)}
{recommendation.accounts?.length > 0 && (
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}
</p>
)}
{existingBill && (
<p className="mt-1 truncate text-xs font-semibold text-primary">
Link to existing bill: {existingBill.name}
</p>
)}
</div>
<div className="shrink-0 text-right">
<p className="tracker-number text-base font-semibold text-foreground">{fmt(recommendation.expected_amount)}</p>
<p className="tracker-number text-xs font-semibold text-emerald-600 dark:text-emerald-300">
{fmt(recommendation.monthly_equivalent)} / mo
</p>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
{recommendation.confidence}% match
</Badge>
{identity?.label && (
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
{identity.label}
</Badge>
)}
{amount?.label && (
<Badge
variant="outline"
className={cn(
'text-[11px]',
amount.match === 'unusual'
? 'border-amber-500/25 bg-amber-500/10 text-amber-500'
: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300',
)}
>
{amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
</Badge>
)}
{cadence?.recurring && (
<Badge variant="outline" className="border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-300">
Recurring
</Badge>
)}
{ambiguity?.ambiguous && (
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
)}
{existingBill && (
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
Existing bill
</Badge>
)}
{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">
Range {fmt(amountRange.min)}-{fmt(amountRange.max)}
</span>
)}
{recommendation.reasons?.map(reason => (
<span key={reason} className="max-w-full rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
{reason}
</span>
))}
</div>
<div className="grid grid-cols-2 gap-2 min-[520px]:grid-cols-4">
<Button
type="button"
size="sm"
variant="outline"
className="gap-1.5"
disabled={busy}
onClick={() => onDetails(recommendation)}
>
<Info className="h-3.5 w-3.5" />
Details
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-destructive"
disabled={busy}
onClick={() => onDecline(recommendation)}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Decline
</Button>
<Button
type="button"
size="sm"
variant={existingBill ? 'default' : 'outline'}
className="gap-1.5"
disabled={busy}
onClick={() => existingBill ? onQuickLink(recommendation, existingBill.id) : onMatch(recommendation)}
>
<Link2 className="h-3.5 w-3.5" />
{existingBill ? 'Link existing' : 'Link to bill'}
</Button>
<Button
type="button"
size="sm"
variant={existingBill ? 'outline' : 'default'}
className="gap-2"
disabled={busy}
onClick={() => onAccept({ ...recommendation, category_id: categoryId })}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
{existingBill ? 'Track new' : 'Track'}
</Button>
</div>
</div>
</div>
);
}
function EvidenceItem({ label, value, tone = 'default' }) {
if (!value) return null;
return (
<div className={cn(
'rounded-lg border px-3 py-2',
tone === 'warn'
? 'border-amber-500/25 bg-amber-500/10'
: 'border-border/60 bg-muted/20',
)}>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium text-foreground">{value}</p>
</div>
);
}
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) {
if (!recommendation) return null;
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
const existingBill = recommendation.existing_bill_match;
const transactions = recommendation.transactions || [];
const handleAccept = async () => {
await onAccept({ ...recommendation, category_id: categoryId });
onClose();
};
const handleDecline = async () => {
await onDecline(recommendation);
onClose();
};
const handleMatch = () => {
onMatch(recommendation);
onClose();
};
const handleQuickLink = async () => {
if (!existingBill) return;
await onQuickLink(recommendation, existingBill.id);
onClose();
};
return (
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2">
<span>{recommendation.name}</span>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
{recommendation.confidence}% match
</Badge>
{ambiguity?.ambiguous && (
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
)}
{existingBill && (
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary">
Existing bill
</Badge>
)}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}
</p>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<EvidenceItem label="Expected" value={`${fmt(recommendation.expected_amount)} ${recommendation.cycle_type || 'monthly'}`} />
<EvidenceItem label="Monthly" value={`${fmt(recommendation.monthly_equivalent)} / mo`} />
<EvidenceItem label="Identity" value={identity?.label} />
<EvidenceItem label="Price" value={amount?.label} tone={amount?.match === 'unusual' ? 'warn' : 'default'} />
<EvidenceItem label="Cadence" value={cadence?.label} />
<EvidenceItem
label="Catalog"
value={recommendation.catalog_match
? [
recommendation.catalog_match.name,
recommendation.catalog_match.starting_monthly_usd ? `from ${fmt(recommendation.catalog_match.starting_monthly_usd)} / mo` : null,
].filter(Boolean).join(' · ')
: null}
/>
</div>
{amountRange && (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Amount Range</p>
<p className="mt-1 text-sm font-medium text-foreground">
{fmt(amountRange.min)}-{fmt(amountRange.max)} across matching transactions
</p>
</div>
)}
{ambiguity?.ambiguous && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2">
<p className="flex items-center gap-1.5 text-sm font-semibold text-amber-700 dark:text-amber-300">
<AlertTriangle className="h-4 w-4" />
{ambiguity.label || 'Review before tracking'}
</p>
{ambiguity.reasons?.length > 0 && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{ambiguity.reasons.map(reason => (
<p key={reason}>{reason}</p>
))}
</div>
)}
</div>
)}
{existingBill && (
<div className="rounded-lg border border-primary/25 bg-primary/10 px-3 py-2">
<p className="text-sm font-semibold text-primary">Recommended action: link existing bill</p>
<p className="mt-1 text-sm text-muted-foreground">
{existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'}
</p>
{existingBill.reasons?.length > 0 && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{existingBill.reasons.map(reason => (
<p key={reason}>{reason}</p>
))}
</div>
)}
</div>
)}
{recommendation.reasons?.length > 0 && (
<div className="flex flex-wrap gap-2">
{recommendation.reasons.map(reason => (
<span key={reason} className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
{reason}
</span>
))}
</div>
)}
{transactions.length > 0 && (
<div className="overflow-hidden rounded-lg border border-border/60">
<div className="border-b border-border/50 bg-muted/20 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Bank Transactions
</p>
</div>
<div className="divide-y divide-border/40">
{transactions.map(tx => (
<div key={tx.id} className="grid gap-1 px-3 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{tx.payee || tx.description || tx.memo || 'Transaction'}
</p>
<p className="truncate text-xs text-muted-foreground">
{fmtDate(tx.date)}{tx.account ? ` · ${tx.account}` : ''}
</p>
</div>
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(tx.amount)}</p>
</div>
))}
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={handleDecline} disabled={busy} className="gap-1.5 text-muted-foreground hover:text-destructive">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Decline
</Button>
<Button variant={existingBill ? 'default' : 'outline'} onClick={existingBill ? handleQuickLink : handleMatch} disabled={busy} className="gap-1.5">
<Link2 className="h-3.5 w-3.5" />
{existingBill ? 'Link existing' : 'Link to bill'}
</Button>
<Button variant={existingBill ? 'outline' : 'default'} onClick={handleAccept} disabled={busy} className="gap-2">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
{existingBill ? 'Track new' : 'Track'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function SubscriptionsPage() {
const [data, setData] = useState({ summary: {}, subscriptions: [] });
const [recommendations, setRecommendations] = useState([]);
const [categories, setCategories] = useState([]);
const [bills, setBills] = useState([]);
const [loading, setLoading] = useState(true);
const [recommendationsLoading, setRecommendationsLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [modal, setModal] = useState(null);
const [matchTarget, setMatchTarget] = useState(null);
const [detailsTarget, setDetailsTarget] = useState(null);
const [recSearch, setRecSearch] = useState('');
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const [txQuery, setTxQuery] = useState('');
const [txResults, setTxResults] = useState([]);
const [txSearching, setTxSearching] = useState(false);
const txDebounce = useRef(null);
const subscriptionCategoryId = useMemo(() => {
const match = categories.find(category => /subscrip/i.test(category.name));
return match?.id || null;
}, [categories]);
const load = useCallback(async () => {
setLoading(true);
try {
const [subscriptionData, categoryData] = await Promise.all([
api.subscriptions(),
api.categories(),
]);
setData(subscriptionData);
setCategories(categoryData || []);
} catch (err) {
toast.error(err.message || 'Failed to load subscriptions.');
} finally {
setLoading(false);
}
}, []);
const loadRecommendations = useCallback(async () => {
setRecommendationsLoading(true);
try {
const result = await api.subscriptionRecommendations();
setRecommendations(result.recommendations || []);
} catch (err) {
toast.error(err.message || 'Failed to scan subscription recommendations.');
} finally {
setRecommendationsLoading(false);
}
}, []);
useEffect(() => {
load();
loadRecommendations();
api.allBills()
.then(b => setBills(Array.isArray(b) ? b : []))
.catch(err => {
console.error('[SubscriptionsPage] failed to load bills', err);
toast.error(err.message || 'Bills could not be loaded for subscription linking.');
});
}, [load, loadRecommendations]);
useEffect(() => {
clearTimeout(txDebounce.current);
const q = txQuery.trim();
if (!q) { setTxResults([]); return; }
txDebounce.current = setTimeout(async () => {
setTxSearching(true);
try {
const result = await api.subscriptionTransactionMatches({ q, limit: 50 });
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
} catch (err) {
setTxResults([]);
toast.error(err.message || 'Transaction search failed.');
}
finally { setTxSearching(false); }
}, 300);
return () => clearTimeout(txDebounce.current);
}, [txQuery]);
async function refreshAll() {
await Promise.all([load(), loadRecommendations()]);
}
async function toggleSubscription(item) {
setBusyId(`toggle-${item.id}`);
try {
await api.updateSubscription(item.id, { active: !item.active });
toast.success(item.active ? 'Subscription paused' : 'Subscription resumed');
await load();
} catch (err) {
toast.error(err.message || 'Subscription could not be updated.');
} finally {
setBusyId(null);
}
}
async function acceptRecommendation(recommendation) {
setBusyId(`rec-${recommendation.id}`);
try {
await api.createSubscriptionFromRecommendation(recommendation);
toast.success(`${recommendation.name} is now tracked.`);
await refreshAll();
} catch (err) {
toast.error(err.message || 'Could not create subscription.');
} finally {
setBusyId(null);
}
}
async function declineRecommendation(recommendation) {
if (!recommendation.decline_key) return;
setBusyId(`dec-${recommendation.id}`);
try {
await api.declineRecommendation(recommendation);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) {
toast.error(err.message || 'Could not dismiss recommendation.');
} finally {
setBusyId(null);
}
}
async function linkRecommendationToBill(recommendation, billId) {
if (!recommendation || !billId) return;
setBusyId(`match-${recommendation.id}`);
try {
const result = await api.matchRecommendationToBill(
recommendation.transaction_ids,
billId,
recommendation.merchant,
recommendation.catalog_match?.id,
recommendation.confidence,
);
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
setMatchTarget(null);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) {
toast.error(err.message || 'Could not link recommendation to bill.');
} finally {
setBusyId(null);
}
}
async function matchRecommendationToBill(billId) {
await linkRecommendationToBill(matchTarget, billId);
}
function openManualSubscription() {
setModal({
bill: null,
initialBill: {
name: '',
category_id: subscriptionCategoryId,
due_day: new Date().getDate(),
expected_amount: '',
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: String(new Date().getDate()),
is_subscription: 1,
subscription_type: 'other',
reminder_days_before: 3,
},
});
}
function openFromTransaction(tx) {
const catalogMatch = tx.catalog_match;
const label = catalogMatch?.name || tx.payee || tx.description || tx.memo || '';
const dollars = Math.abs(tx.amount ?? 0) / 100;
const rawDay = tx.posted_date ? new Date(tx.posted_date + 'T12:00:00').getDate() : NaN;
const dueDay = Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 31 ? rawDay : new Date().getDate();
setModal({
bill: null,
initialBill: {
name: label,
category_id: subscriptionCategoryId,
due_day: dueDay,
expected_amount: dollars > 0 ? dollars.toFixed(2) : '',
cycle_type: 'monthly',
is_subscription: 1,
subscription_type: catalogMatch?.subscription_type || 'other',
website: catalogMatch?.website || undefined,
notes: catalogMatch
? `Matched known subscription catalog entry: ${catalogMatch.name}`
: undefined,
reminder_days_before: 3,
},
});
}
const summary = data.summary || {};
const subscriptions = data.subscriptions || [];
const active = subscriptions.filter(item => item.active);
const paused = subscriptions.filter(item => !item.active);
const reorderEnabled = !loading && bills.length > 0;
async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) {
setData(prev => ({ ...prev, subscriptions: nextSubscriptions }));
setBills(nextBills);
setMovingBillId(movedId);
try {
await api.reorderBills(reorderPayload(nextBills));
toast.success('Subscription order saved');
await load();
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to refresh bills after reorder', err));
} catch (err) {
toast.error(err.message || 'Failed to save subscription order');
await load();
} finally {
setMovingBillId(null);
}
}
function reorderSubscriptionGroup(activeState, orderedGroup) {
const sourceGroup = subscriptions.filter(item => !!item.active === activeState);
const replacements = [...orderedGroup];
const nextSubscriptions = subscriptions.map(item => (
!!item.active === activeState ? replacements.shift() : item
));
const sourceBills = bills.length ? bills : subscriptions;
const affectedIds = new Set(sourceGroup.map(item => item.id));
const billById = new Map(sourceBills.map(item => [item.id, item]));
const orderedBills = orderedGroup.map(item => ({ ...(billById.get(item.id) || item), ...item }));
const nextBills = sourceBills.map(bill => (
affectedIds.has(bill.id) ? orderedBills.shift() : bill
));
persistSubscriptionOrder(nextSubscriptions, nextBills, movedItemId(sourceGroup, orderedGroup));
}
function moveControlsForGroup(group, activeState) {
return (item, index) => ({
enabled: reorderEnabled,
moving: movingBillId === item.id,
canMoveUp: index > 0,
canMoveDown: index < group.length - 1,
onMoveUp: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index - 1)),
onMoveDown: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index + 1)),
});
}
function dragPropsForGroup(group, activeState) {
return (item, index) => {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === item.id,
isDropTarget: dropTargetId === item.id && draggingId !== item.id,
onDragStart: (event) => {
setDraggingId(item.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(item.id));
},
onDragEnter: () => {
if (draggingId && draggingId !== item.id) setDropTargetId(item.id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== item.id) setDropTargetId(item.id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = group.findIndex(row => row.id === sourceId);
if (fromIndex >= 0) reorderSubscriptionGroup(activeState, moveInArray(group, fromIndex, index));
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
};
}
const MIN_CONFIDENCE = 90;
const highConfidenceRecs = useMemo(
() => recommendations.filter(r => r.catalog_match && (r.confidence ?? 0) >= MIN_CONFIDENCE),
[recommendations],
);
const filteredRecs = useMemo(() => {
const q = recSearch.trim().toLowerCase();
return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs;
}, [highConfidenceRecs, recSearch]);
return (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
Recurring Services
</p>
<h1 className="text-3xl font-semibold tracking-tight">Subscriptions</h1>
<p className="mt-1 text-sm text-muted-foreground">
Track manual subscriptions and review recurring SimpleFIN charges.
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button type="button" variant="outline" className="gap-2" onClick={refreshAll} disabled={loading || recommendationsLoading}>
<RefreshCw className={cn('h-4 w-4', (loading || recommendationsLoading) && 'animate-spin')} />
Refresh
</Button>
<Button type="button" className="gap-2" onClick={openManualSubscription}>
<Plus className="h-4 w-4" />
Add Subscription
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Repeat} label="Monthly" value={fmt(summary.monthly_total)} hint={`${summary.active_count || 0} active subscriptions`} />
<StatCard icon={CalendarDays} label="Yearly" value={fmt(summary.yearly_total)} hint="Normalized yearly impact" />
<StatCard icon={Pause} label="Paused" value={summary.paused_count || 0} hint="Kept but not active" />
<StatCard icon={Cloud} label="Top Type" value={summary.top_type ? TYPE_LABELS[summary.top_type.type] || summary.top_type.type : '—'} hint={summary.top_type ? `${fmt(summary.top_type.monthly_total)} / mo` : 'No active subscriptions'} />
</div>
<div className="grid min-w-0 gap-5 2xl:grid-cols-[minmax(0,1fr)_minmax(360px,420px)]">
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<CardTitle className="text-base">Tracked Subscriptions</CardTitle>
<CardDescription>Subscriptions are bills with recurring-service metadata.</CardDescription>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="mb-2 h-20 animate-pulse rounded-lg bg-muted/40" />
))}
</div>
) : subscriptions.length === 0 ? (
<div className="px-4 py-14 text-center">
<Repeat className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No subscriptions tracked yet.</p>
<p className="mt-1 text-sm text-muted-foreground">Add one manually or accept a SimpleFIN recommendation.</p>
</div>
) : (
<>
{active.map((item, index) => (
<SubscriptionRow
key={item.id}
item={item}
onEdit={bill => setModal({ bill })}
onToggle={toggleSubscription}
moveControls={moveControlsForGroup(active, true)(item, index)}
dragProps={dragPropsForGroup(active, true)(item, index)}
/>
))}
{paused.map((item, index) => (
<SubscriptionRow
key={item.id}
item={item}
onEdit={bill => setModal({ bill })}
onToggle={toggleSubscription}
moveControls={moveControlsForGroup(paused, false)(item, index)}
dragProps={dragPropsForGroup(paused, false)(item, index)}
/>
))}
</>
)}
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Recommendations</CardTitle>
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
<span className="ml-auto text-[11px] font-medium text-muted-foreground tabular-nums">
{filteredRecs.length} of {highConfidenceRecs.length}
</span>
)}
</div>
<CardDescription>Known subscription services found in your bank transactions with 90%+ confidence.</CardDescription>
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
<div className="relative mt-2">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search recommendations…"
value={recSearch}
onChange={e => setRecSearch(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
)}
</CardHeader>
<CardContent className="space-y-3">
{recommendationsLoading ? (
<div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
Scanning transactions
</div>
) : highConfidenceRecs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-4 py-10 text-center">
<Bell className="mx-auto h-7 w-7 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No high-confidence recommendations.</p>
<p className="mt-1 text-sm text-muted-foreground">
{recommendations.length > 0
? 'More account activity or stronger descriptors will improve accuracy.'
: 'Sync your accounts after charges from known subscription services appear.'}
</p>
</div>
) : filteredRecs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No matches for "{recSearch}"</p>
<button
type="button"
className="mt-1 text-xs text-primary hover:underline"
onClick={() => setRecSearch('')}
>
Clear search
</button>
</div>
) : (
filteredRecs.map(recommendation => (
<RecommendationCard
key={recommendation.id}
recommendation={recommendation}
categoryId={subscriptionCategoryId}
busy={busyId === `rec-${recommendation.id}` || busyId === `dec-${recommendation.id}` || busyId === `match-${recommendation.id}`}
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
onQuickLink={linkRecommendationToBill}
onDetails={setDetailsTarget}
/>
))
)}
</CardContent>
</Card>
</div>
{/* Transaction search */}
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Search Bank Transactions</CardTitle>
</div>
<CardDescription>
Search all account charges matched and unmatched to find subscriptions the algorithm may have missed.
</CardDescription>
<div className="relative mt-2">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search by merchant, description, or payee…"
value={txQuery}
onChange={e => setTxQuery(e.target.value)}
className="pl-8 h-9 text-sm"
autoComplete="off"
/>
</div>
</CardHeader>
{(txQuery.trim() || txSearching) && (
<CardContent className="p-0">
{txSearching ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching transactions
</div>
) : txResults.length === 0 ? (
<div className="px-4 py-8 text-center">
<Search className="mx-auto h-7 w-7 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No transactions found for "{txQuery}"</p>
<p className="mt-1 text-xs text-muted-foreground">Try a different merchant name or description.</p>
</div>
) : (
<div>
<div className="px-4 py-2 border-b border-border/40 bg-muted/20">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{txResults.length} result{txResults.length !== 1 ? 's' : ''}
{txResults.length === 50 ? ' (showing first 50)' : ''}
</p>
</div>
{txResults.map(tx => (
<TxResultRow key={tx.id} tx={tx} onTrack={openFromTransaction} />
))}
</div>
)}
</CardContent>
)}
</Card>
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Improve Matching</CardTitle>
</div>
<CardDescription>
Manage known services, catalog links, and custom bank descriptors on a dedicated page.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 px-4 pb-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="text-sm text-muted-foreground">
Use the service catalog when a recommendation names the wrong service, a bill needs a catalog link, or your bank uses a custom descriptor.
</p>
<Button asChild type="button" variant="outline" className="shrink-0 gap-2">
<Link to="/subscriptions/catalog">
<Link2 className="h-4 w-4" />
Service Catalog
</Link>
</Button>
</CardContent>
</Card>
{modal && (
<BillModal
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
bill={modal.bill}
initialBill={modal.initialBill}
categories={categories}
onClose={() => setModal(null)}
onSave={async () => {
setModal(null);
await refreshAll();
}}
/>
)}
<RecommendationDetailsDialog
open={!!detailsTarget}
recommendation={detailsTarget}
categoryId={subscriptionCategoryId}
onClose={() => setDetailsTarget(null)}
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
onQuickLink={linkRecommendationToBill}
busy={detailsTarget ? (
busyId === `rec-${detailsTarget.id}`
|| busyId === `dec-${detailsTarget.id}`
|| busyId === `match-${detailsTarget.id}`
) : false}
/>
<BillPickerDialog
open={!!matchTarget}
onClose={() => setMatchTarget(null)}
recommendation={matchTarget}
bills={bills}
onConfirm={matchRecommendationToBill}
busy={!!busyId?.startsWith('match-')}
/>
</div>
);
}