2026-05-30 17:27:15 -05:00
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2026-06-06 20:44:54 -05:00
|
|
|
|
import { Link } from 'react-router-dom';
|
2026-05-28 22:54:07 -05:00
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Bell,
|
|
|
|
|
|
CalendarDays,
|
|
|
|
|
|
CheckCircle2,
|
2026-05-30 17:27:15 -05:00
|
|
|
|
CheckCircle,
|
2026-06-07 02:03:00 -05:00
|
|
|
|
ChevronDown,
|
2026-05-28 22:54:07 -05:00
|
|
|
|
Cloud,
|
2026-05-30 20:04:50 -05:00
|
|
|
|
ArrowDown,
|
|
|
|
|
|
ArrowUp,
|
|
|
|
|
|
GripVertical,
|
2026-06-06 21:05:01 -05:00
|
|
|
|
AlertTriangle,
|
|
|
|
|
|
Info,
|
2026-05-29 03:38:48 -05:00
|
|
|
|
Link2,
|
2026-05-28 22:54:07 -05:00
|
|
|
|
Loader2,
|
2026-06-06 22:07:55 -05:00
|
|
|
|
MoreHorizontal,
|
2026-05-28 22:54:07 -05:00
|
|
|
|
Pause,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Repeat,
|
2026-05-30 17:27:15 -05:00
|
|
|
|
Search,
|
2026-05-28 22:54:07 -05:00
|
|
|
|
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';
|
2026-05-30 21:20:51 -05:00
|
|
|
|
import { scheduleLabel } from '@/lib/billingSchedule';
|
2026-05-28 22:54:07 -05:00
|
|
|
|
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';
|
2026-06-07 01:28:35 -05:00
|
|
|
|
import SearchFilterPanel from '@/components/SearchFilterPanel';
|
2026-05-29 03:38:48 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-06-06 22:07:55 -05:00
|
|
|
|
import {
|
|
|
|
|
|
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
|
|
|
|
|
} from '@/components/ui/tooltip';
|
|
|
|
|
|
import {
|
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
2026-05-28 22:54:07 -05:00
|
|
|
|
import BillModal from '@/components/BillModal';
|
2026-06-06 23:04:53 -05:00
|
|
|
|
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
|
|
|
|
|
import { getLinkImportPref } from '@/pages/SettingsPage';
|
2026-06-07 01:28:35 -05:00
|
|
|
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
2026-05-30 20:04:50 -05:00
|
|
|
|
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
2026-05-28 22:54:07 -05:00
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-06 22:25:58 -05:00
|
|
|
|
const SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode';
|
|
|
|
|
|
const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other'];
|
|
|
|
|
|
const CADENCE_LABELS = {
|
|
|
|
|
|
weekly: 'Weekly',
|
|
|
|
|
|
biweekly: 'Biweekly',
|
|
|
|
|
|
monthly: 'Monthly',
|
|
|
|
|
|
quarterly: 'Quarterly',
|
|
|
|
|
|
annual: 'Yearly',
|
|
|
|
|
|
other: 'Other',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const SUBSCRIPTION_MONTHLY_FACTORS = {
|
|
|
|
|
|
weekly: 52 / 12,
|
|
|
|
|
|
biweekly: 26 / 12,
|
|
|
|
|
|
monthly: 1,
|
|
|
|
|
|
quarterly: 1 / 3,
|
|
|
|
|
|
annual: 1 / 12,
|
|
|
|
|
|
annually: 1 / 12,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function normalizedCadence(item) {
|
|
|
|
|
|
const raw = String(item?.cycle_type || item?.billing_cycle || '').toLowerCase();
|
|
|
|
|
|
if (raw.includes('week') && raw.includes('bi')) return 'biweekly';
|
|
|
|
|
|
if (raw === 'biweekly') return 'biweekly';
|
|
|
|
|
|
if (raw.includes('week')) return 'weekly';
|
|
|
|
|
|
if (raw.includes('quarter')) return 'quarterly';
|
|
|
|
|
|
if (raw.includes('annual') || raw.includes('year')) return 'annual';
|
|
|
|
|
|
if (raw.includes('month') || !raw) return 'monthly';
|
|
|
|
|
|
return 'other';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function subscriptionMonthlyEquivalent(item) {
|
|
|
|
|
|
const key = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase();
|
|
|
|
|
|
const fallback = String(item?.billing_cycle || '').toLowerCase() === 'quarterly'
|
|
|
|
|
|
? 'quarterly'
|
|
|
|
|
|
: String(item?.billing_cycle || '').toLowerCase() === 'annually'
|
|
|
|
|
|
? 'annual'
|
|
|
|
|
|
: key;
|
|
|
|
|
|
const factor = SUBSCRIPTION_MONTHLY_FACTORS[key] ?? SUBSCRIPTION_MONTHLY_FACTORS[fallback] ?? 1;
|
|
|
|
|
|
return Math.round(Number(item?.expected_amount || 0) * factor * 100) / 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function subscriptionNextDueDate(item, now = new Date()) {
|
|
|
|
|
|
const dueDay = Math.min(Math.max(Number(item?.due_day) || 1, 1), 31);
|
|
|
|
|
|
const cycle = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase();
|
|
|
|
|
|
let date = new Date(now.getFullYear(), now.getMonth(), dueDay);
|
|
|
|
|
|
if (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
|
|
|
|
|
|
date = new Date(now.getFullYear(), now.getMonth() + 1, dueDay);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cycle === 'quarterly' || cycle === 'annual' || cycle === 'annually') {
|
|
|
|
|
|
const startMonth = Math.min(Math.max(Number(item?.cycle_day) || 1, 1), 12) - 1;
|
|
|
|
|
|
const step = cycle === 'quarterly' ? 3 : 12;
|
|
|
|
|
|
date = new Date(now.getFullYear(), startMonth, dueDay);
|
|
|
|
|
|
while (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
|
|
|
|
|
|
date = new Date(date.getFullYear(), date.getMonth() + step, dueDay);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return date.toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function decorateSavedSubscriptionBill(bill, categories) {
|
|
|
|
|
|
const monthly = subscriptionMonthlyEquivalent(bill);
|
|
|
|
|
|
const category = categories.find(item => Number(item.id) === Number(bill.category_id));
|
|
|
|
|
|
return {
|
|
|
|
|
|
...bill,
|
|
|
|
|
|
active: !!bill.active,
|
|
|
|
|
|
is_subscription: !!bill.is_subscription,
|
|
|
|
|
|
category_name: bill.category_name || category?.name || null,
|
|
|
|
|
|
monthly_equivalent: monthly,
|
|
|
|
|
|
yearly_equivalent: Math.round(monthly * 12 * 100) / 100,
|
|
|
|
|
|
next_due_date: subscriptionNextDueDate(bill),
|
|
|
|
|
|
subscription_type: bill.subscription_type || 'other',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function subscriptionSummaryFromList(subscriptions) {
|
|
|
|
|
|
const active = subscriptions.filter(item => item.active);
|
|
|
|
|
|
const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0);
|
|
|
|
|
|
const typeTotals = new Map();
|
|
|
|
|
|
for (const item of active) {
|
|
|
|
|
|
const type = item.subscription_type || 'other';
|
|
|
|
|
|
typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0));
|
|
|
|
|
|
}
|
|
|
|
|
|
const topType = [...typeTotals.entries()].sort((a, b) => b[1] - a[1])[0] || null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
active_count: active.length,
|
|
|
|
|
|
paused_count: subscriptions.length - active.length,
|
|
|
|
|
|
monthly_total: Math.round(monthlyTotal * 100) / 100,
|
|
|
|
|
|
yearly_total: Math.round(monthlyTotal * 12 * 100) / 100,
|
|
|
|
|
|
top_type: topType ? { type: topType[0], monthly_total: Math.round(topType[1] * 100) / 100 } : null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 02:03:00 -05:00
|
|
|
|
// Extended group order: monthly bills split by 1st/15th pay bucket
|
|
|
|
|
|
const GROUP_ORDER = ['weekly', 'biweekly', 'monthly-1st', 'monthly-15th', 'quarterly', 'annual', 'other'];
|
|
|
|
|
|
const GROUP_LABELS = {
|
|
|
|
|
|
'weekly': 'Weekly',
|
|
|
|
|
|
'biweekly': 'Biweekly',
|
|
|
|
|
|
'monthly-1st': '1st · Due days 1–14',
|
|
|
|
|
|
'monthly-15th': '15th · Due days 15–31',
|
|
|
|
|
|
'quarterly': 'Quarterly',
|
|
|
|
|
|
'annual': 'Annual',
|
|
|
|
|
|
'other': 'Other',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function subscriptionGroup(item) {
|
|
|
|
|
|
const cadence = normalizedCadence(item);
|
|
|
|
|
|
if (cadence === 'monthly') {
|
|
|
|
|
|
return (Number(item.due_day) || 1) <= 14 ? 'monthly-1st' : 'monthly-15th';
|
|
|
|
|
|
}
|
|
|
|
|
|
return cadence;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function groupIndex(item) {
|
|
|
|
|
|
const idx = GROUP_ORDER.indexOf(subscriptionGroup(item));
|
|
|
|
|
|
return idx >= 0 ? idx : GROUP_ORDER.length - 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function daysUntil(dateStr) {
|
|
|
|
|
|
if (!dateStr) return null;
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
|
|
return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000);
|
2026-06-06 22:25:58 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sortSubscriptionsByCadence(items) {
|
|
|
|
|
|
return [...items].sort((a, b) => (
|
2026-06-07 02:03:00 -05:00
|
|
|
|
groupIndex(a) - groupIndex(b)
|
2026-06-06 22:25:58 -05:00
|
|
|
|
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
|
|
|
|
|
|
|| String(a.name || '').localeCompare(String(b.name || ''))
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SortModeButton({ active, children, onClick }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-8 rounded-md px-3 text-xs font-semibold transition-colors',
|
|
|
|
|
|
active
|
|
|
|
|
|
? 'bg-background text-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground',
|
|
|
|
|
|
)}
|
|
|
|
|
|
aria-pressed={active}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
|
function cycleLabel(item) {
|
2026-05-30 21:20:51 -05:00
|
|
|
|
return scheduleLabel(item);
|
2026-05-28 22:54:07 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 22:07:55 -05:00
|
|
|
|
function SubscriptionRowActions({ item, onEdit, onToggle, busy }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 md:flex md:justify-end">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="h-8 min-w-0 px-3"
|
|
|
|
|
|
onClick={() => onEdit(item)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Edit
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="h-8 w-8 p-0"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
aria-label={`More actions for ${item.name}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="end" className="w-40">
|
|
|
|
|
|
<DropdownMenuItem onSelect={() => onToggle(item)}>
|
|
|
|
|
|
{item.active ? <Pause className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4" />}
|
|
|
|
|
|
{item.active ? 'Pause' : 'Resume'}
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }) {
|
2026-05-28 22:54:07 -05:00
|
|
|
|
return (
|
2026-05-30 20:04:50 -05:00
|
|
|
|
<div
|
|
|
|
|
|
draggable={dragProps?.draggable}
|
|
|
|
|
|
onDragStart={dragProps?.onDragStart}
|
|
|
|
|
|
onDragEnter={dragProps?.onDragEnter}
|
|
|
|
|
|
onDragOver={dragProps?.onDragOver}
|
|
|
|
|
|
onDragEnd={dragProps?.onDragEnd}
|
|
|
|
|
|
onDrop={dragProps?.onDrop}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'group',
|
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',
|
2026-05-30 20:04:50 -05:00
|
|
|
|
'md:grid-cols-[auto_minmax(0,1fr)_14rem_auto] md:items-center',
|
2026-05-28 22:54:07 -05:00
|
|
|
|
!item.active && 'opacity-60',
|
2026-05-30 20:04:50 -05:00
|
|
|
|
dragProps?.isDragging && 'opacity-45',
|
|
|
|
|
|
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
|
2026-05-28 22:54:07 -05:00
|
|
|
|
)}>
|
2026-05-30 20:04:50 -05:00
|
|
|
|
<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>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
<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>
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={300}>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary cursor-default">
|
|
|
|
|
|
{TYPE_LABELS[item.subscription_type] || 'Other'}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Service category</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
{!item.active && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-amber-300 cursor-default">
|
|
|
|
|
|
Paused
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Paused — not actively tracked</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
2026-06-07 00:11:00 -05:00
|
|
|
|
{!!item.autopay_enabled && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<span className="shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-sky-600 dark:text-sky-300 cursor-default">AP</span>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Autopay enabled</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!!item.has_2fa && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<span className="shrink-0 rounded bg-violet-500/20 px-1.5 py-0.5 text-[11px] font-semibold text-violet-300 cursor-default">2FA</span>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Two-factor authentication configured</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{(!!item.has_merchant_rule || !!item.has_linked_transactions) && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<span className="shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 cursor-default">L</span>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Linked to bank transactions</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
2026-06-06 23:53:53 -05:00
|
|
|
|
</TooltipProvider>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</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>
|
2026-06-07 02:03:00 -05:00
|
|
|
|
{(() => {
|
|
|
|
|
|
const days = daysUntil(item.next_due_date);
|
|
|
|
|
|
const label = days === null ? null
|
|
|
|
|
|
: days < 0 ? `${Math.abs(days)}d overdue`
|
|
|
|
|
|
: days === 0 ? 'Due today'
|
|
|
|
|
|
: days === 1 ? 'Due tomorrow'
|
|
|
|
|
|
: `Due in ${days}d`;
|
|
|
|
|
|
const cls = days === null ? 'text-muted-foreground'
|
|
|
|
|
|
: days <= 1 ? 'text-rose-500'
|
|
|
|
|
|
: days <= 7 ? 'text-amber-500'
|
|
|
|
|
|
: 'text-emerald-600 dark:text-emerald-400';
|
|
|
|
|
|
return label ? (
|
|
|
|
|
|
<span className={cls}>{label} · {fmtDate(item.next_due_date)}</span>
|
|
|
|
|
|
) : null;
|
|
|
|
|
|
})()}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
<span className="capitalize">{cycleLabel(item)}</span>
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={300}>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<span className="cursor-default">{item.reminder_days_before ?? 3}d reminder</span>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Reminder sent {item.reminder_days_before ?? 3} days before the due date</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</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>
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={300}>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground cursor-default w-fit">Monthly</p>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Normalized monthly equivalent regardless of billing frequency</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
<p className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">{fmt(item.monthly_equivalent)}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<SubscriptionRowActions item={item} onEdit={onEdit} onToggle={onToggle} busy={busy} />
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<DialogFooter className="gap-2">
|
|
|
|
|
|
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
|
|
|
|
|
Cancel
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" className="gap-2" onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
|
|
|
|
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
|
|
|
|
|
|
Link Bill
|
2026-05-29 03:38:48 -05:00
|
|
|
|
</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 17:27:15 -05:00
|
|
|
|
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>
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={300}>
|
|
|
|
|
|
{isMatched ? (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge className="shrink-0 border-emerald-400/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 gap-1 text-[10px] cursor-default">
|
|
|
|
|
|
<CheckCircle className="h-3 w-3" />
|
|
|
|
|
|
{tx.matched_bill_name || 'Matched'}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Already linked to this bill</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground cursor-default">Unmatched</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Not yet linked to any bill</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{catalogMatch && (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="shrink-0 border-primary/25 bg-primary/10 text-primary text-[10px] cursor-default">
|
|
|
|
|
|
Known: {catalogMatch.name}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Recognized in the subscription catalog as {catalogMatch.name}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</TooltipProvider>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</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)}
|
2026-06-06 22:07:55 -05:00
|
|
|
|
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs">
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<Plus className="h-3 w-3" /> Track
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 22:07:55 -05:00
|
|
|
|
function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant={variant}
|
|
|
|
|
|
className={cn('h-9 w-9 shrink-0 p-0', className)}
|
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
aria-label={label}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon className="h-3.5 w-3.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>{label}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="h-9 w-9 shrink-0 p-0"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
aria-label="More recommendation actions"
|
|
|
|
|
|
>
|
|
|
|
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
|
|
|
|
{existingBill && (
|
|
|
|
|
|
<DropdownMenuItem onSelect={() => onAccept({ ...recommendation, category_id: categoryId })}>
|
|
|
|
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
|
|
|
|
Track as new
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<DropdownMenuItem onSelect={() => onMatch(recommendation)}>
|
|
|
|
|
|
<Link2 className="h-4 w-4" />
|
|
|
|
|
|
{existingBill ? 'Choose different bill' : 'Link existing bill'}
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem destructive onSelect={() => onDecline(recommendation)}>
|
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
|
Dismiss
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 21:15:08 -05:00
|
|
|
|
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) {
|
2026-06-06 20:44:54 -05:00
|
|
|
|
const identity = recommendation.evidence?.identity;
|
|
|
|
|
|
const amount = recommendation.evidence?.amount;
|
|
|
|
|
|
const cadence = recommendation.evidence?.cadence;
|
|
|
|
|
|
const amountRange = recommendation.evidence?.amount_range;
|
2026-06-06 21:05:01 -05:00
|
|
|
|
const ambiguity = recommendation.evidence?.ambiguity;
|
2026-06-06 21:15:08 -05:00
|
|
|
|
const existingBill = recommendation.existing_bill_match;
|
2026-06-06 20:44:54 -05:00
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
|
return (
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<TooltipProvider delayDuration={180}>
|
|
|
|
|
|
<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-06-06 20:44:54 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-05-30 21:52:02 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-06-06 21:15:08 -05:00
|
|
|
|
{existingBill && (
|
|
|
|
|
|
<p className="mt-1 truncate text-xs font-semibold text-primary">
|
|
|
|
|
|
Link to existing bill: {existingBill.name}
|
|
|
|
|
|
</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">
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
|
|
|
|
|
|
{recommendation.confidence}% match
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Match confidence — how likely this is a real recurring subscription</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
2026-06-06 20:44:54 -05:00
|
|
|
|
{identity?.label && (
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300 cursor-default">
|
|
|
|
|
|
{identity.label}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Merchant identity evidence</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
2026-06-06 20:44:54 -05:00
|
|
|
|
)}
|
|
|
|
|
|
{amount?.label && (
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'text-[11px] cursor-default',
|
|
|
|
|
|
amount.match === 'unusual'
|
|
|
|
|
|
? 'border-amber-500/25 bg-amber-500/10 text-amber-500'
|
|
|
|
|
|
: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>{amount.label}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
2026-06-06 20:44:54 -05:00
|
|
|
|
)}
|
|
|
|
|
|
{cadence?.recurring && (
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-300 cursor-default">
|
|
|
|
|
|
Recurring
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>{cadence.label || 'Regular recurring payment pattern detected'}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
2026-06-06 20:44:54 -05:00
|
|
|
|
)}
|
2026-06-06 21:05:01 -05:00
|
|
|
|
{ambiguity?.ambiguous && (
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300 cursor-default">
|
|
|
|
|
|
<AlertTriangle className="h-3 w-3" />
|
|
|
|
|
|
Review
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>{ambiguity.label || 'Multiple patterns detected — verify before tracking'}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
2026-06-06 21:05:01 -05:00
|
|
|
|
)}
|
2026-06-06 21:15:08 -05:00
|
|
|
|
{existingBill && (
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
|
|
|
|
|
|
Existing bill
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>May match your tracked bill: {existingBill.name}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
2026-06-06 21:15:08 -05:00
|
|
|
|
)}
|
2026-06-06 20:44:54 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-05-29 03:55:55 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2">
|
2026-05-29 03:55:55 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
size="sm"
|
2026-06-06 22:07:55 -05:00
|
|
|
|
variant="default"
|
|
|
|
|
|
className="h-9 min-w-0 gap-2 px-3"
|
2026-05-29 03:55:55 -05:00
|
|
|
|
disabled={busy}
|
2026-06-06 22:07:55 -05:00
|
|
|
|
onClick={() => existingBill
|
|
|
|
|
|
? onQuickLink(recommendation, existingBill.id)
|
|
|
|
|
|
: onAccept({ ...recommendation, category_id: categoryId })}
|
2026-05-29 03:55:55 -05:00
|
|
|
|
>
|
2026-06-06 22:07:55 -05:00
|
|
|
|
{busy ? (
|
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
|
|
|
|
) : existingBill ? (
|
|
|
|
|
|
<Link2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="min-w-0 truncate">
|
|
|
|
|
|
{existingBill ? 'Link Existing Bill' : 'Track Subscription'}
|
|
|
|
|
|
</span>
|
2026-05-29 03:55:55 -05:00
|
|
|
|
</Button>
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<RecommendationIconButton
|
|
|
|
|
|
label="View recommendation details"
|
|
|
|
|
|
icon={Info}
|
2026-05-29 03:55:55 -05:00
|
|
|
|
disabled={busy}
|
2026-06-06 22:07:55 -05:00
|
|
|
|
onClick={() => onDetails(recommendation)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<RecommendationMoreMenu
|
|
|
|
|
|
recommendation={recommendation}
|
|
|
|
|
|
existingBill={existingBill}
|
|
|
|
|
|
busy={busy}
|
|
|
|
|
|
onAccept={onAccept}
|
|
|
|
|
|
onDecline={onDecline}
|
|
|
|
|
|
onMatch={onMatch}
|
|
|
|
|
|
categoryId={categoryId}
|
|
|
|
|
|
/>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-06 22:07:55 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</TooltipProvider>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 21:05:01 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 21:15:08 -05:00
|
|
|
|
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) {
|
2026-06-06 21:05:01 -05:00
|
|
|
|
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;
|
2026-06-06 21:15:08 -05:00
|
|
|
|
const existingBill = recommendation.existing_bill_match;
|
2026-06-06 21:05:01 -05:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
2026-06-06 21:15:08 -05:00
|
|
|
|
const handleQuickLink = async () => {
|
|
|
|
|
|
if (!existingBill) return;
|
|
|
|
|
|
await onQuickLink(recommendation, existingBill.id);
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
};
|
2026-06-06 21:05:01 -05:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={180}>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
|
|
|
|
|
|
{recommendation.confidence}% match
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Match confidence — how likely this is a real recurring subscription</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
2026-06-06 21:05:01 -05:00
|
|
|
|
{ambiguity?.ambiguous && (
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={180}>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300 cursor-default">
|
|
|
|
|
|
<AlertTriangle className="h-3 w-3" />
|
|
|
|
|
|
Review
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>{ambiguity.label || 'Multiple patterns detected — verify before tracking'}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
2026-06-06 21:05:01 -05:00
|
|
|
|
)}
|
2026-06-06 21:15:08 -05:00
|
|
|
|
{existingBill && (
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={180}>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
|
|
|
|
|
|
Existing bill
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>May match your tracked bill: {existingBill.name}</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
2026-06-06 21:15:08 -05:00
|
|
|
|
)}
|
2026-06-06 21:05:01 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-06 21:15:08 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-06 21:05:01 -05:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
2026-06-06 21:05:01 -05:00
|
|
|
|
<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" />}
|
2026-06-06 22:07:55 -05:00
|
|
|
|
Dismiss
|
2026-06-06 21:05:01 -05:00
|
|
|
|
</Button>
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
|
|
|
|
|
{existingBill && (
|
|
|
|
|
|
<Button variant="outline" onClick={handleAccept} disabled={busy} className="gap-1.5">
|
|
|
|
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
Track New
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button variant="outline" onClick={handleMatch} disabled={busy} className="gap-1.5">
|
|
|
|
|
|
<Link2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
{existingBill ? 'Choose Bill' : 'Link Existing'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="default" onClick={existingBill ? handleQuickLink : handleAccept} disabled={busy} className="gap-2">
|
|
|
|
|
|
{busy ? (
|
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
|
|
|
|
) : existingBill ? (
|
|
|
|
|
|
<Link2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{existingBill ? 'Link Existing' : 'Track Subscription'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-06-06 21:05:01 -05:00
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
|
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-30 17:27:15 -05:00
|
|
|
|
const [matchTarget, setMatchTarget] = useState(null);
|
2026-06-06 21:05:01 -05:00
|
|
|
|
const [detailsTarget, setDetailsTarget] = useState(null);
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const [recSearch, setRecSearch] = useState('');
|
2026-06-06 23:53:53 -05:00
|
|
|
|
const [subSearch, setSubSearch] = useState('');
|
2026-06-06 22:25:58 -05:00
|
|
|
|
const [subscriptionSort, setSubscriptionSort] = useState(() => (
|
|
|
|
|
|
localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
|
|
|
|
|
|
));
|
2026-05-30 20:04:50 -05:00
|
|
|
|
const [draggingId, setDraggingId] = useState(null);
|
|
|
|
|
|
const [dropTargetId, setDropTargetId] = useState(null);
|
|
|
|
|
|
const [movingBillId, setMovingBillId] = useState(null);
|
2026-06-07 01:28:35 -05:00
|
|
|
|
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
2026-06-07 02:03:00 -05:00
|
|
|
|
const [collapsedGroups, setCollapsedGroups] = useState(new Set());
|
|
|
|
|
|
const cardHeaderRef = useRef(null);
|
|
|
|
|
|
const [cardHeaderHeight, setCardHeaderHeight] = useState(0);
|
2026-05-30 17:27:15 -05:00
|
|
|
|
|
|
|
|
|
|
const [txQuery, setTxQuery] = useState('');
|
|
|
|
|
|
const [txResults, setTxResults] = useState([]);
|
|
|
|
|
|
const [txSearching, setTxSearching] = useState(false);
|
2026-06-06 23:04:53 -05:00
|
|
|
|
const [importDialog, setImportDialog] = useState(null); // { billId, billName }
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const txDebounce = useRef(null);
|
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-06-06 20:44:54 -05:00
|
|
|
|
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.');
|
|
|
|
|
|
});
|
2026-05-28 22:54:07 -05:00
|
|
|
|
}, [load, loadRecommendations]);
|
|
|
|
|
|
|
2026-06-06 22:25:58 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem(SUBSCRIPTION_SORT_KEY, subscriptionSort);
|
|
|
|
|
|
}, [subscriptionSort]);
|
|
|
|
|
|
|
2026-06-07 02:03:00 -05:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = cardHeaderRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const obs = new ResizeObserver(() => setCardHeaderHeight(el.offsetHeight));
|
|
|
|
|
|
obs.observe(el);
|
|
|
|
|
|
return () => obs.disconnect();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-05-30 17:27:15 -05:00
|
|
|
|
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 ?? []));
|
2026-06-06 20:44:54 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setTxResults([]);
|
|
|
|
|
|
toast.error(err.message || 'Transaction search failed.');
|
|
|
|
|
|
}
|
2026-05-30 17:27:15 -05:00
|
|
|
|
finally { setTxSearching(false); }
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
return () => clearTimeout(txDebounce.current);
|
|
|
|
|
|
}, [txQuery]);
|
|
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
|
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 {
|
2026-06-06 23:04:53 -05:00
|
|
|
|
const created = await api.createSubscriptionFromRecommendation(recommendation);
|
2026-05-28 22:54:07 -05:00
|
|
|
|
toast.success(`${recommendation.name} is now tracked.`);
|
|
|
|
|
|
await refreshAll();
|
2026-06-06 23:04:53 -05:00
|
|
|
|
if (getLinkImportPref() && recommendation.merchant && created?.id) {
|
|
|
|
|
|
setImportDialog({ billId: created.id, billName: created.name || recommendation.name });
|
|
|
|
|
|
}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
} 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 {
|
2026-06-06 21:15:08 -05:00
|
|
|
|
await api.declineRecommendation(recommendation);
|
2026-05-29 02:51:30 -05:00
|
|
|
|
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Could not dismiss recommendation.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 21:15:08 -05:00
|
|
|
|
async function linkRecommendationToBill(recommendation, billId) {
|
|
|
|
|
|
if (!recommendation || !billId) return;
|
|
|
|
|
|
setBusyId(`match-${recommendation.id}`);
|
2026-05-29 03:38:48 -05:00
|
|
|
|
try {
|
2026-06-06 21:15:08 -05:00
|
|
|
|
const result = await api.matchRecommendationToBill(
|
|
|
|
|
|
recommendation.transaction_ids,
|
|
|
|
|
|
billId,
|
|
|
|
|
|
recommendation.merchant,
|
|
|
|
|
|
recommendation.catalog_match?.id,
|
|
|
|
|
|
recommendation.confidence,
|
|
|
|
|
|
);
|
2026-05-29 03:38:48 -05:00
|
|
|
|
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
|
|
|
|
|
|
setMatchTarget(null);
|
2026-06-06 21:15:08 -05:00
|
|
|
|
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
2026-05-29 03:38:48 -05:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
toast.error(err.message || 'Could not link recommendation to bill.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyId(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 21:15:08 -05:00
|
|
|
|
async function matchRecommendationToBill(billId) {
|
|
|
|
|
|
await linkRecommendationToBill(matchTarget, billId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-06 22:25:58 -05:00
|
|
|
|
function applySavedBillToPage(savedBill) {
|
|
|
|
|
|
if (!savedBill?.id) return;
|
|
|
|
|
|
const decorated = decorateSavedSubscriptionBill(savedBill, categories);
|
|
|
|
|
|
|
|
|
|
|
|
setBills(prev => {
|
|
|
|
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
|
|
|
|
const exists = current.some(item => Number(item.id) === Number(savedBill.id));
|
|
|
|
|
|
return exists
|
|
|
|
|
|
? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...savedBill } : item)
|
|
|
|
|
|
: [...current, savedBill];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setData(prev => {
|
|
|
|
|
|
const current = prev.subscriptions || [];
|
|
|
|
|
|
const exists = current.some(item => Number(item.id) === Number(savedBill.id));
|
|
|
|
|
|
const nextSubscriptions = decorated.is_subscription
|
|
|
|
|
|
? exists
|
|
|
|
|
|
? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...decorated } : item)
|
|
|
|
|
|
: [...current, decorated]
|
|
|
|
|
|
: current.filter(item => Number(item.id) !== Number(savedBill.id));
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
subscriptions: nextSubscriptions,
|
|
|
|
|
|
summary: subscriptionSummaryFromList(nextSubscriptions),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleBillModalSave(savedBill) {
|
|
|
|
|
|
if (!savedBill?.id) return;
|
|
|
|
|
|
applySavedBillToPage(savedBill);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 17:27:15 -05:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
|
const summary = data.summary || {};
|
|
|
|
|
|
const subscriptions = data.subscriptions || [];
|
2026-06-06 23:53:53 -05:00
|
|
|
|
const filteredSubscriptions = useMemo(() => {
|
|
|
|
|
|
const q = subSearch.trim().toLowerCase();
|
|
|
|
|
|
if (!q) return subscriptions;
|
|
|
|
|
|
return subscriptions.filter(item =>
|
|
|
|
|
|
String(item.name || '').toLowerCase().includes(q) ||
|
|
|
|
|
|
String(item.subscription_type || '').toLowerCase().includes(q) ||
|
|
|
|
|
|
String(item.category_name || '').toLowerCase().includes(q)
|
|
|
|
|
|
);
|
|
|
|
|
|
}, [subscriptions, subSearch]);
|
|
|
|
|
|
const active = filteredSubscriptions.filter(item => item.active);
|
|
|
|
|
|
const paused = filteredSubscriptions.filter(item => !item.active);
|
2026-06-06 22:25:58 -05:00
|
|
|
|
const sortedActive = useMemo(
|
|
|
|
|
|
() => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(active) : active,
|
|
|
|
|
|
[active, subscriptionSort],
|
|
|
|
|
|
);
|
|
|
|
|
|
const sortedPaused = useMemo(
|
|
|
|
|
|
() => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(paused) : paused,
|
|
|
|
|
|
[paused, subscriptionSort],
|
|
|
|
|
|
);
|
|
|
|
|
|
const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom';
|
2026-05-30 20:04:50 -05:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-05-31 15:06:10 -05:00
|
|
|
|
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to refresh bills after reorder', err));
|
2026-05-30 20:04:50 -05:00
|
|
|
|
} 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);
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
|
2026-05-30 17:27:15 -05:00
|
|
|
|
const MIN_CONFIDENCE = 90;
|
|
|
|
|
|
const highConfidenceRecs = useMemo(
|
2026-06-06 20:44:54 -05:00
|
|
|
|
() => recommendations.filter(r => r.catalog_match && (r.confidence ?? 0) >= MIN_CONFIDENCE),
|
2026-05-30 17:27:15 -05:00
|
|
|
|
[recommendations],
|
|
|
|
|
|
);
|
|
|
|
|
|
const filteredRecs = useMemo(() => {
|
|
|
|
|
|
const q = recSearch.trim().toLowerCase();
|
|
|
|
|
|
return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs;
|
|
|
|
|
|
}, [highConfidenceRecs, recSearch]);
|
|
|
|
|
|
|
2026-06-06 22:25:58 -05:00
|
|
|
|
function renderSubscriptionRows(group, activeState) {
|
|
|
|
|
|
if (subscriptionSort !== 'cadence') {
|
|
|
|
|
|
return group.map((item, index) => (
|
|
|
|
|
|
<SubscriptionRow
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
item={item}
|
|
|
|
|
|
onEdit={bill => setModal({ bill })}
|
|
|
|
|
|
onToggle={toggleSubscription}
|
|
|
|
|
|
busy={busyId === `toggle-${item.id}`}
|
|
|
|
|
|
moveControls={moveControlsForGroup(group, activeState)(item, index)}
|
|
|
|
|
|
dragProps={dragPropsForGroup(group, activeState)(item, index)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 02:03:00 -05:00
|
|
|
|
return GROUP_ORDER.flatMap(groupKey => {
|
|
|
|
|
|
const groupItems = group.filter(item => subscriptionGroup(item) === groupKey);
|
|
|
|
|
|
if (groupItems.length === 0) return [];
|
|
|
|
|
|
const sectionKey = `${activeState ? 'active' : 'paused'}-${groupKey}`;
|
|
|
|
|
|
const isCollapsed = collapsedGroups.has(sectionKey);
|
|
|
|
|
|
const monthlySum = groupItems.reduce((s, i) => s + (Number(i.monthly_equivalent) || 0), 0);
|
2026-06-06 22:25:58 -05:00
|
|
|
|
return [
|
2026-06-07 02:03:00 -05:00
|
|
|
|
<button
|
|
|
|
|
|
key={`${sectionKey}-heading`}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setCollapsedGroups(prev => {
|
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
|
next.has(sectionKey) ? next.delete(sectionKey) : next.add(sectionKey);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
})}
|
|
|
|
|
|
style={{ top: cardHeaderHeight }}
|
|
|
|
|
|
className="sticky z-10 w-full border-b border-t border-border/40 bg-card/95 backdrop-blur-sm px-4 py-2 text-left transition-colors hover:bg-muted/30"
|
2026-06-06 22:25:58 -05:00
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
2026-06-07 02:03:00 -05:00
|
|
|
|
{GROUP_LABELS[groupKey]}
|
2026-06-06 22:25:58 -05:00
|
|
|
|
</p>
|
2026-06-07 02:03:00 -05:00
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<span className="text-[11px] font-medium text-muted-foreground tabular-nums">
|
|
|
|
|
|
{fmt(monthlySum)}/mo · {groupItems.length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<ChevronDown className={cn('h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-150', isCollapsed && '-rotate-90')} />
|
|
|
|
|
|
</div>
|
2026-06-06 22:25:58 -05:00
|
|
|
|
</div>
|
2026-06-07 02:03:00 -05:00
|
|
|
|
</button>,
|
|
|
|
|
|
...(!isCollapsed ? groupItems.map((item, index) => (
|
2026-06-06 22:25:58 -05:00
|
|
|
|
<SubscriptionRow
|
|
|
|
|
|
key={item.id}
|
|
|
|
|
|
item={item}
|
|
|
|
|
|
onEdit={bill => setModal({ bill })}
|
|
|
|
|
|
onToggle={toggleSubscription}
|
|
|
|
|
|
busy={busyId === `toggle-${item.id}`}
|
2026-06-07 02:03:00 -05:00
|
|
|
|
moveControls={moveControlsForGroup(groupItems, activeState)(item, index)}
|
|
|
|
|
|
dragProps={dragPropsForGroup(groupItems, activeState)(item, index)}
|
2026-06-06 22:25:58 -05:00
|
|
|
|
/>
|
2026-06-07 02:03:00 -05:00
|
|
|
|
)) : []),
|
2026-06-06 22:25:58 -05:00
|
|
|
|
];
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
|
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-06-06 22:07:55 -05:00
|
|
|
|
<Button type="button" variant="outline" className="h-9 gap-2" onClick={refreshAll} disabled={loading || recommendationsLoading}>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
<RefreshCw className={cn('h-4 w-4', (loading || recommendationsLoading) && 'animate-spin')} />
|
|
|
|
|
|
Refresh
|
|
|
|
|
|
</Button>
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<Button type="button" className="h-9 gap-2" onClick={openManualSubscription}>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
<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-06-07 02:03:00 -05:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader ref={cardHeaderRef} className="sticky top-0 z-20 rounded-t-xl border-b border-border/50 bg-card/95 px-4 pb-3 backdrop-blur-sm sm:px-6">
|
2026-06-06 22:25:58 -05:00
|
|
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<CardTitle className="text-base">Tracked Subscriptions</CardTitle>
|
|
|
|
|
|
<CardDescription>Subscriptions are bills with recurring-service metadata.</CardDescription>
|
|
|
|
|
|
</div>
|
2026-06-06 23:53:53 -05:00
|
|
|
|
<TooltipProvider delayDuration={300}>
|
|
|
|
|
|
<div className="grid grid-cols-2 rounded-lg border border-border/60 bg-muted/30 p-1">
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<SortModeButton
|
|
|
|
|
|
active={subscriptionSort === 'custom'}
|
|
|
|
|
|
onClick={() => setSubscriptionSort('custom')}
|
|
|
|
|
|
>
|
|
|
|
|
|
Custom
|
|
|
|
|
|
</SortModeButton>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Drag to reorder manually</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
<Tooltip>
|
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
|
<SortModeButton
|
|
|
|
|
|
active={subscriptionSort === 'cadence'}
|
|
|
|
|
|
onClick={() => setSubscriptionSort('cadence')}
|
|
|
|
|
|
>
|
|
|
|
|
|
Cadence
|
|
|
|
|
|
</SortModeButton>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>Sort by billing frequency: weekly → biweekly → monthly → quarterly → annual</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</div>
|
2026-06-07 01:28:35 -05:00
|
|
|
|
<SearchFilterPanel
|
|
|
|
|
|
title="Search subscriptions"
|
|
|
|
|
|
collapsed={searchPanelCollapsed}
|
|
|
|
|
|
onCollapsedChange={setSearchPanelCollapsed}
|
|
|
|
|
|
hasFilters={!!subSearch.trim()}
|
|
|
|
|
|
resultLabel={`${filteredSubscriptions.length} of ${subscriptions.length} shown`}
|
|
|
|
|
|
onClear={() => setSubSearch('')}
|
|
|
|
|
|
variant="embedded"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={subSearch}
|
|
|
|
|
|
onChange={e => setSubSearch(e.target.value)}
|
|
|
|
|
|
placeholder="Search subscriptions…"
|
|
|
|
|
|
className="h-8 pl-8 pr-8 text-sm"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{subSearch && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSubSearch('')}
|
|
|
|
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
|
aria-label="Clear search"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</SearchFilterPanel>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-06-06 22:25:58 -05:00
|
|
|
|
{renderSubscriptionRows(sortedActive, true)}
|
2026-06-07 02:03:00 -05:00
|
|
|
|
{sortedPaused.length > 0 && subscriptionSort === 'cadence' && (
|
|
|
|
|
|
<div className="border-t-2 border-border/60 bg-muted/20 px-4 py-1.5">
|
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60">
|
|
|
|
|
|
Paused · {sortedPaused.length}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-06-06 22:25:58 -05:00
|
|
|
|
{renderSubscriptionRows(sortedPaused, false)}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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-30 17:27:15 -05:00
|
|
|
|
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
|
|
|
|
|
|
<span className="ml-auto text-[11px] font-medium text-muted-foreground tabular-nums">
|
|
|
|
|
|
{filteredRecs.length} of {highConfidenceRecs.length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</div>
|
2026-06-06 20:44:54 -05:00
|
|
|
|
<CardDescription>Known subscription services found in your bank transactions with 90%+ confidence.</CardDescription>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
|
2026-06-07 01:28:35 -05:00
|
|
|
|
<SearchFilterPanel
|
|
|
|
|
|
title="Search recommendations"
|
|
|
|
|
|
collapsed={searchPanelCollapsed}
|
|
|
|
|
|
onCollapsedChange={setSearchPanelCollapsed}
|
|
|
|
|
|
hasFilters={!!recSearch.trim()}
|
|
|
|
|
|
resultLabel={`${filteredRecs.length} of ${highConfidenceRecs.length} shown`}
|
|
|
|
|
|
onClear={() => setRecSearch('')}
|
|
|
|
|
|
variant="embedded"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</SearchFilterPanel>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
)}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</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">
|
2026-05-30 17:27:15 -05:00
|
|
|
|
Scanning transactions…
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</div>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
) : highConfidenceRecs.length === 0 ? (
|
2026-05-28 22:54:07 -05:00
|
|
|
|
<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" />
|
2026-05-30 17:27:15 -05:00
|
|
|
|
<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
|
2026-06-06 20:44:54 -05:00
|
|
|
|
? 'More account activity or stronger descriptors will improve accuracy.'
|
|
|
|
|
|
: 'Sync your accounts after charges from known subscription services appear.'}
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</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>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-05-30 17:27:15 -05:00
|
|
|
|
filteredRecs.map(recommendation => (
|
2026-05-28 22:54:07 -05:00
|
|
|
|
<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-06-06 21:15:08 -05:00
|
|
|
|
onQuickLink={linkRecommendationToBill}
|
2026-06-06 21:05:01 -05:00
|
|
|
|
onDetails={setDetailsTarget}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-30 17:27:15 -05:00
|
|
|
|
{/* 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>
|
2026-06-07 01:28:35 -05:00
|
|
|
|
<SearchFilterPanel
|
|
|
|
|
|
title="Search transactions"
|
|
|
|
|
|
collapsed={searchPanelCollapsed}
|
|
|
|
|
|
onCollapsedChange={setSearchPanelCollapsed}
|
|
|
|
|
|
hasFilters={!!txQuery.trim()}
|
|
|
|
|
|
resultLabel={txQuery.trim() ? `${txResults.length} result${txResults.length === 1 ? '' : 's'}` : 'all bank transactions'}
|
|
|
|
|
|
onClear={() => setTxQuery('')}
|
|
|
|
|
|
variant="embedded"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</SearchFilterPanel>
|
2026-05-30 17:27:15 -05:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-06-06 20:44:54 -05:00
|
|
|
|
<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>
|
2026-06-06 22:07:55 -05:00
|
|
|
|
<Button asChild type="button" variant="outline" className="h-9 shrink-0 gap-2">
|
2026-06-06 20:44:54 -05:00
|
|
|
|
<Link to="/subscriptions/catalog">
|
|
|
|
|
|
<Link2 className="h-4 w-4" />
|
|
|
|
|
|
Service Catalog
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
2026-06-06 20:02:13 -05:00
|
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
|
{modal && (
|
|
|
|
|
|
<BillModal
|
|
|
|
|
|
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
|
|
|
|
|
|
bill={modal.bill}
|
|
|
|
|
|
initialBill={modal.initialBill}
|
|
|
|
|
|
categories={categories}
|
|
|
|
|
|
onClose={() => setModal(null)}
|
2026-06-06 22:25:58 -05:00
|
|
|
|
onSave={handleBillModalSave}
|
2026-05-28 22:54:07 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-29 03:38:48 -05:00
|
|
|
|
|
2026-06-06 21:05:01 -05:00
|
|
|
|
<RecommendationDetailsDialog
|
|
|
|
|
|
open={!!detailsTarget}
|
|
|
|
|
|
recommendation={detailsTarget}
|
|
|
|
|
|
categoryId={subscriptionCategoryId}
|
|
|
|
|
|
onClose={() => setDetailsTarget(null)}
|
|
|
|
|
|
onAccept={acceptRecommendation}
|
|
|
|
|
|
onDecline={declineRecommendation}
|
|
|
|
|
|
onMatch={rec => setMatchTarget(rec)}
|
2026-06-06 21:15:08 -05:00
|
|
|
|
onQuickLink={linkRecommendationToBill}
|
2026-06-06 21:05:01 -05:00
|
|
|
|
busy={detailsTarget ? (
|
|
|
|
|
|
busyId === `rec-${detailsTarget.id}`
|
|
|
|
|
|
|| busyId === `dec-${detailsTarget.id}`
|
|
|
|
|
|
|| busyId === `match-${detailsTarget.id}`
|
|
|
|
|
|
) : false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
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-06-06 23:04:53 -05:00
|
|
|
|
|
|
|
|
|
|
<BillHistoricalImportDialog
|
|
|
|
|
|
billId={importDialog?.billId}
|
|
|
|
|
|
billName={importDialog?.billName}
|
|
|
|
|
|
open={!!importDialog}
|
|
|
|
|
|
onClose={() => setImportDialog(null)}
|
|
|
|
|
|
onImported={() => { setImportDialog(null); refreshAll(); }}
|
|
|
|
|
|
/>
|
2026-05-28 22:54:07 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|