2026-05-28 22:54:07 -05:00
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import {
|
|
|
|
|
Bell,
|
|
|
|
|
CalendarDays,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Cloud,
|
2026-05-29 03:38:48 -05:00
|
|
|
Link2,
|
2026-05-28 22:54:07 -05:00
|
|
|
Loader2,
|
|
|
|
|
Pause,
|
|
|
|
|
Plus,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Repeat,
|
|
|
|
|
Sparkles,
|
2026-05-29 02:51:30 -05:00
|
|
|
X,
|
2026-05-28 22:54:07 -05:00
|
|
|
} from 'lucide-react';
|
|
|
|
|
import { api } from '@/api';
|
|
|
|
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
2026-05-29 03:38:48 -05:00
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import {
|
|
|
|
|
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-05-28 22:54:07 -05:00
|
|
|
import BillModal from '@/components/BillModal';
|
|
|
|
|
|
|
|
|
|
const TYPE_LABELS = {
|
|
|
|
|
streaming: 'Streaming',
|
|
|
|
|
software: 'Software',
|
|
|
|
|
cloud: 'Cloud',
|
|
|
|
|
music: 'Music',
|
|
|
|
|
news: 'News',
|
|
|
|
|
fitness: 'Fitness',
|
|
|
|
|
gaming: 'Gaming',
|
|
|
|
|
utilities: 'Utilities',
|
|
|
|
|
insurance: 'Insurance',
|
2026-05-29 03:49:36 -05:00
|
|
|
food: 'Food',
|
|
|
|
|
education: 'Education',
|
|
|
|
|
shopping: 'Shopping',
|
|
|
|
|
security: 'Security',
|
2026-05-28 22:54:07 -05:00
|
|
|
other: 'Other',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function cycleLabel(item) {
|
|
|
|
|
const cycle = item.cycle_type || item.billing_cycle || 'monthly';
|
|
|
|
|
return cycle === 'annual' || cycle === 'annually' ? 'yearly' : cycle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatCard({ icon: Icon, label, value, hint }) {
|
|
|
|
|
return (
|
2026-05-29 03:49:36 -05:00
|
|
|
<div className="min-w-0 rounded-lg border border-border/70 bg-card/80 p-4">
|
2026-05-28 22:54:07 -05:00
|
|
|
<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" />
|
2026-05-29 03:49:36 -05:00
|
|
|
<span className="truncate">{label}</span>
|
2026-05-28 22:54:07 -05:00
|
|
|
</div>
|
2026-05-29 03:49:36 -05:00
|
|
|
<p className="tracker-number mt-2 truncate text-2xl font-bold tracking-tight text-foreground">{value}</p>
|
2026-05-28 22:54:07 -05:00
|
|
|
{hint && <p className="mt-1 text-xs font-medium text-muted-foreground">{hint}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SubscriptionRow({ item, onEdit, onToggle }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn(
|
2026-05-29 03:49:36 -05:00
|
|
|
'grid min-w-0 gap-3 border-b border-border/40 px-4 py-4 last:border-b-0',
|
|
|
|
|
'md:grid-cols-[minmax(0,1fr)_14rem_auto] md:items-center',
|
2026-05-28 22:54:07 -05:00
|
|
|
!item.active && 'opacity-60',
|
|
|
|
|
)}>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onEdit(item)}
|
2026-05-29 03:49:36 -05:00
|
|
|
className="min-w-0 max-w-full truncate text-left text-[15px] font-semibold text-foreground hover:text-primary"
|
2026-05-28 22:54:07 -05:00
|
|
|
>
|
|
|
|
|
{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>
|
|
|
|
|
|
2026-05-29 03:49:36 -05:00
|
|
|
<div className="grid grid-cols-2 gap-3 md:w-56">
|
2026-05-28 22:54:07 -05:00
|
|
|
<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>
|
|
|
|
|
|
2026-05-29 03:49:36 -05:00
|
|
|
<div className="flex gap-2 md:justify-end">
|
|
|
|
|
<Button type="button" variant="outline" size="sm" className="flex-1 md:flex-none" onClick={() => onEdit(item)}>
|
2026-05-28 22:54:07 -05:00
|
|
|
Edit
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2026-05-29 03:49:36 -05:00
|
|
|
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',
|
|
|
|
|
)}
|
2026-05-28 22:54:07 -05:00
|
|
|
onClick={() => onToggle(item)}
|
|
|
|
|
>
|
|
|
|
|
{item.active ? 'Pause' : 'Resume'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:38:48 -05:00
|
|
|
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 RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, busy }) {
|
2026-05-28 22:54:07 -05:00
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
2026-05-29 03:55:55 -05:00
|
|
|
<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>
|
2026-05-28 22:54:07 -05:00
|
|
|
</div>
|
2026-05-29 03:55:55 -05:00
|
|
|
<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>
|
2026-05-28 22:54:07 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-29 03:55:55 -05:00
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
{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-1 gap-2 min-[380px]:grid-cols-3">
|
|
|
|
|
<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="outline"
|
|
|
|
|
className="gap-1.5"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onClick={() => onMatch(recommendation)}
|
|
|
|
|
>
|
|
|
|
|
<Link2 className="h-3.5 w-3.5" />
|
|
|
|
|
Link to bill
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
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" />}
|
|
|
|
|
Track
|
|
|
|
|
</Button>
|
2026-05-28 22:54:07 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function SubscriptionsPage() {
|
|
|
|
|
const [data, setData] = useState({ summary: {}, subscriptions: [] });
|
|
|
|
|
const [recommendations, setRecommendations] = useState([]);
|
|
|
|
|
const [categories, setCategories] = useState([]);
|
2026-05-29 03:38:48 -05:00
|
|
|
const [bills, setBills] = useState([]);
|
2026-05-28 22:54:07 -05:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [recommendationsLoading, setRecommendationsLoading] = useState(true);
|
|
|
|
|
const [busyId, setBusyId] = useState(null);
|
|
|
|
|
const [modal, setModal] = useState(null);
|
2026-05-29 03:38:48 -05:00
|
|
|
const [matchTarget, setMatchTarget] = useState(null); // recommendation being linked
|
2026-05-28 22:54:07 -05:00
|
|
|
|
|
|
|
|
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();
|
2026-05-29 03:38:48 -05:00
|
|
|
api.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
2026-05-28 22:54:07 -05:00
|
|
|
}, [load, loadRecommendations]);
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 02:51:30 -05:00
|
|
|
async function declineRecommendation(recommendation) {
|
|
|
|
|
if (!recommendation.decline_key) return;
|
|
|
|
|
setBusyId(`dec-${recommendation.id}`);
|
|
|
|
|
try {
|
|
|
|
|
await api.declineRecommendation(recommendation.decline_key);
|
|
|
|
|
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Could not dismiss recommendation.');
|
|
|
|
|
} finally {
|
|
|
|
|
setBusyId(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 03:38:48 -05:00
|
|
|
async function matchRecommendationToBill(billId) {
|
|
|
|
|
if (!matchTarget || !billId) return;
|
|
|
|
|
setBusyId(`match-${matchTarget.id}`);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.matchRecommendationToBill(matchTarget.transaction_ids, billId, matchTarget.merchant);
|
|
|
|
|
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 !== matchTarget.id));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Could not link recommendation to bill.');
|
|
|
|
|
} finally {
|
|
|
|
|
setBusyId(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const summary = data.summary || {};
|
|
|
|
|
const subscriptions = data.subscriptions || [];
|
|
|
|
|
const active = subscriptions.filter(item => item.active);
|
|
|
|
|
const paused = subscriptions.filter(item => !item.active);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-29 03:49:36 -05:00
|
|
|
<div className="mx-auto w-full max-w-6xl space-y-6">
|
2026-05-28 22:54:07 -05:00
|
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
2026-05-29 03:49:36 -05:00
|
|
|
<div className="min-w-0">
|
2026-05-28 22:54:07 -05:00
|
|
|
<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>
|
2026-05-29 03:49:36 -05:00
|
|
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
2026-05-28 22:54:07 -05:00
|
|
|
<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>
|
|
|
|
|
|
2026-05-29 03:49:36 -05:00
|
|
|
<div className="grid min-w-0 gap-5 2xl:grid-cols-[minmax(0,1fr)_minmax(360px,420px)]">
|
2026-05-28 22:54:07 -05:00
|
|
|
<Card className="overflow-hidden">
|
2026-05-29 03:49:36 -05:00
|
|
|
<CardHeader className="px-4 pb-3 sm:px-6">
|
2026-05-28 22:54:07 -05:00
|
|
|
<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 => (
|
|
|
|
|
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} />
|
|
|
|
|
))}
|
|
|
|
|
{paused.map(item => (
|
|
|
|
|
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} />
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card className="overflow-hidden">
|
2026-05-29 03:49:36 -05:00
|
|
|
<CardHeader className="px-4 pb-3 sm:px-6">
|
2026-05-28 22:54:07 -05:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Sparkles className="h-4 w-4 text-primary" />
|
2026-05-29 19:21:46 -05:00
|
|
|
<CardTitle className="text-base">Recommendations</CardTitle>
|
2026-05-28 22:54:07 -05:00
|
|
|
</div>
|
|
|
|
|
<CardDescription>Recurring unmatched bank charges that look like subscriptions.</CardDescription>
|
|
|
|
|
</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>
|
|
|
|
|
) : recommendations.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 recommendations right now.</p>
|
|
|
|
|
<p className="mt-1 text-sm text-muted-foreground">Sync SimpleFIN after a few recurring charges appear.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
recommendations.map(recommendation => (
|
|
|
|
|
<RecommendationCard
|
|
|
|
|
key={recommendation.id}
|
|
|
|
|
recommendation={recommendation}
|
|
|
|
|
categoryId={subscriptionCategoryId}
|
2026-05-29 03:38:48 -05:00
|
|
|
busy={busyId === `rec-${recommendation.id}` || busyId === `dec-${recommendation.id}` || busyId === `match-${recommendation.id}`}
|
2026-05-28 22:54:07 -05:00
|
|
|
onAccept={acceptRecommendation}
|
2026-05-29 02:51:30 -05:00
|
|
|
onDecline={declineRecommendation}
|
2026-05-29 03:38:48 -05:00
|
|
|
onMatch={rec => setMatchTarget(rec)}
|
2026-05-28 22:54:07 -05:00
|
|
|
/>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{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();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-05-29 03:38:48 -05:00
|
|
|
|
|
|
|
|
<BillPickerDialog
|
|
|
|
|
open={!!matchTarget}
|
|
|
|
|
onClose={() => setMatchTarget(null)}
|
|
|
|
|
recommendation={matchTarget}
|
|
|
|
|
bills={bills}
|
|
|
|
|
onConfirm={matchRecommendationToBill}
|
|
|
|
|
busy={!!busyId?.startsWith('match-')}
|
|
|
|
|
/>
|
2026-05-28 22:54:07 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|