880 lines
29 KiB
TypeScript
880 lines
29 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
ExternalLink,
|
|
Link2,
|
|
Link2Off,
|
|
Loader2,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api';
|
|
import { cn, fmt, errMessage } from '@/lib/utils';
|
|
import type { Dollars } from '@/lib/money';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
interface Descriptor {
|
|
id: number | string;
|
|
descriptor?: string;
|
|
}
|
|
|
|
interface MatchedBill {
|
|
id: number;
|
|
name?: string;
|
|
active?: number;
|
|
monthly_equivalent?: Dollars;
|
|
}
|
|
|
|
interface CatalogEntry {
|
|
id: number | string;
|
|
name: string;
|
|
category?: string | null;
|
|
subscription_type?: string;
|
|
starting_monthly_usd?: number | null;
|
|
website?: string | null;
|
|
matched_bill?: MatchedBill | null;
|
|
user_descriptors?: Descriptor[];
|
|
}
|
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
streaming: 'Streaming',
|
|
software: 'Software',
|
|
cloud: 'Cloud',
|
|
music: 'Music',
|
|
news: 'News',
|
|
fitness: 'Fitness',
|
|
gaming: 'Gaming',
|
|
utilities: 'Utilities',
|
|
insurance: 'Insurance',
|
|
food: 'Food',
|
|
education: 'Education',
|
|
shopping: 'Shopping',
|
|
security: 'Security',
|
|
other: 'Other',
|
|
};
|
|
|
|
const CHIPS: { value: string | null; label: string }[] = [
|
|
{ value: null, label: 'All' },
|
|
{ value: 'streaming', label: 'Streaming' },
|
|
{ value: 'music', label: 'Music' },
|
|
{ value: 'gaming', label: 'Gaming' },
|
|
{ value: 'news', label: 'News' },
|
|
{ value: 'fitness', label: 'Fitness' },
|
|
{ value: 'software', label: 'Software' },
|
|
{ value: 'security', label: 'Security' },
|
|
{ value: 'food', label: 'Food' },
|
|
{ value: 'cloud', label: 'Cloud' },
|
|
{ value: 'shopping', label: 'Shopping' },
|
|
{ value: 'education', label: 'Education' },
|
|
];
|
|
|
|
// Returns 'match' | 'above' | 'below' | null
|
|
function priceDrift(
|
|
monthly: number | null | undefined,
|
|
catalogStarting: number | null | undefined,
|
|
): 'match' | 'above' | 'below' | null {
|
|
if (!catalogStarting || !monthly) return null;
|
|
const pct = (monthly - catalogStarting) / catalogStarting;
|
|
if (Math.abs(pct) <= 0.05) return 'match';
|
|
return pct > 0 ? 'above' : 'below';
|
|
}
|
|
|
|
// ── Descriptor editor inline inside a matched card ─────────────────────────
|
|
function DescriptorEditor({
|
|
catalogId,
|
|
descriptors,
|
|
onAdd,
|
|
onDelete,
|
|
}: {
|
|
catalogId: number | string;
|
|
descriptors: Descriptor[];
|
|
onAdd: (catalogId: number | string, descriptor: string) => Promise<boolean> | void;
|
|
onDelete: (descriptorId: number | string) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [value, setValue] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function handleAdd() {
|
|
const d = value.trim();
|
|
if (!d) return;
|
|
setBusy(true);
|
|
try {
|
|
await onAdd(catalogId, d);
|
|
setValue('');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mt-2 border-t border-border/30 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((s) => !s)}
|
|
className="flex items-center gap-1 text-[11px] text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', !open && '-rotate-90')} />
|
|
Custom bank descriptors
|
|
{descriptors.length > 0 && (
|
|
<span className="ml-1 rounded bg-muted/50 px-1 font-mono text-[10px]">
|
|
{descriptors.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="mt-2 space-y-1.5">
|
|
{descriptors.map((d) => (
|
|
<div key={d.id} className="flex items-center gap-2">
|
|
<span className="flex-1 rounded border border-border/40 bg-muted/30 px-2 py-0.5 font-mono text-[11px]">
|
|
{d.descriptor}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDelete(d.id)}
|
|
className="text-muted-foreground transition-colors hover:text-destructive"
|
|
title="Remove descriptor"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex gap-1.5">
|
|
<Input
|
|
placeholder="e.g. NETFLIX DIGITAL"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && value.trim()) handleAdd();
|
|
}}
|
|
className="h-7 font-mono text-xs"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!value.trim() || busy}
|
|
onClick={handleAdd}
|
|
className="h-7 px-2"
|
|
>
|
|
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground/70">
|
|
Add the exact payee text your bank uses to improve auto-matching.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Card for an already-tracked catalog entry ──────────────────────────────
|
|
function MatchedCard({
|
|
entry,
|
|
onEdit,
|
|
onRelink,
|
|
onAddDescriptor,
|
|
onDeleteDescriptor,
|
|
}: {
|
|
entry: CatalogEntry;
|
|
onEdit: (id: number) => void;
|
|
onRelink: (entry: CatalogEntry) => void;
|
|
onAddDescriptor: (catalogId: number | string, descriptor: string) => Promise<boolean> | void;
|
|
onDeleteDescriptor: (descriptorId: number | string) => void;
|
|
}) {
|
|
const { matched_bill: bill, user_descriptors: descs = [] } = entry;
|
|
const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd);
|
|
|
|
return (
|
|
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3 transition-colors hover:border-primary/30">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-sm font-semibold text-foreground">{entry.name}</span>
|
|
<Badge
|
|
variant="outline"
|
|
className="border-primary/25 bg-primary/10 text-[10px] text-primary"
|
|
>
|
|
{TYPE_LABELS[entry.subscription_type ?? ''] || 'Other'}
|
|
</Badge>
|
|
{bill && !bill.active && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-amber-500/25 bg-amber-500/10 text-[10px] text-amber-400"
|
|
>
|
|
Paused
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
|
|
{bill && (
|
|
<span className="font-mono font-semibold text-foreground">
|
|
{fmt(bill.monthly_equivalent)}/mo
|
|
</span>
|
|
)}
|
|
{drift === 'match' && entry.starting_monthly_usd && (
|
|
<span className="flex items-center gap-1 text-emerald-500">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
matches catalog price
|
|
</span>
|
|
)}
|
|
{drift === 'above' && (
|
|
<span className="text-amber-500">
|
|
catalog from ${entry.starting_monthly_usd?.toFixed(2)}/mo
|
|
</span>
|
|
)}
|
|
{drift === 'below' && entry.starting_monthly_usd && (
|
|
<span className="text-sky-500">
|
|
catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
|
|
</span>
|
|
)}
|
|
{!entry.starting_monthly_usd && entry.website && (
|
|
<a
|
|
href={entry.website}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex items-center gap-0.5 text-muted-foreground hover:text-primary"
|
|
>
|
|
website <ExternalLink className="h-2.5 w-2.5" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 gap-1 text-xs"
|
|
onClick={() => bill && onEdit(bill.id)}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-7 gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => onRelink(entry)}
|
|
title="Change catalog link"
|
|
>
|
|
<Link2 className="h-3 w-3" />
|
|
Re-link
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<DescriptorEditor
|
|
catalogId={entry.id}
|
|
descriptors={descs}
|
|
onAdd={onAddDescriptor}
|
|
onDelete={onDeleteDescriptor}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Card for an untracked catalog entry ───────────────────────────────────
|
|
function UnmatchedCard({
|
|
entry,
|
|
selected,
|
|
onToggleSelect,
|
|
onTrack,
|
|
trackingBusy,
|
|
}: {
|
|
entry: CatalogEntry;
|
|
selected: boolean;
|
|
onToggleSelect: (id: number | string, checked: boolean) => void;
|
|
onTrack: (entry: CatalogEntry) => void;
|
|
trackingBusy: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
|
selected
|
|
? 'border-primary/30 bg-primary/5'
|
|
: 'border-border/60 bg-background/35 hover:border-border/80',
|
|
)}
|
|
>
|
|
<Checkbox
|
|
checked={selected}
|
|
onCheckedChange={(checked) => onToggleSelect(entry.id, !!checked)}
|
|
aria-label={`Select ${entry.name}`}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-foreground">{entry.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{entry.category}
|
|
{entry.starting_monthly_usd && (
|
|
<span className="ml-2 font-mono">from ${entry.starting_monthly_usd.toFixed(2)}/mo</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
{entry.website && (
|
|
<a
|
|
href={entry.website}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="shrink-0 text-muted-foreground hover:text-primary"
|
|
title="Open website"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
</a>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 shrink-0 gap-1 text-xs"
|
|
disabled={trackingBusy}
|
|
onClick={() => onTrack(entry)}
|
|
>
|
|
{trackingBusy ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Plus className="h-3 w-3" />
|
|
Track
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Re-link dialog ─────────────────────────────────────────────────────────
|
|
function ReLinkDialog({
|
|
open,
|
|
relinkEntry,
|
|
allEntries,
|
|
onClose,
|
|
onConfirm,
|
|
busy,
|
|
}: {
|
|
open: boolean;
|
|
relinkEntry: CatalogEntry | null;
|
|
allEntries: CatalogEntry[];
|
|
onClose: () => void;
|
|
onConfirm: (billId: number | undefined, catalogId: number | string | null) => void;
|
|
busy: boolean;
|
|
}) {
|
|
const [search, setSearch] = useState('');
|
|
const [selected, setSelected] = useState<number | string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setSearch('');
|
|
setSelected(null);
|
|
}
|
|
}, [open]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.toLowerCase();
|
|
return allEntries.filter(
|
|
(e) => !q || e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q),
|
|
);
|
|
}, [allEntries, search]);
|
|
|
|
const bill = relinkEntry?.matched_bill;
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(v) => {
|
|
if (!v) onClose();
|
|
}}
|
|
>
|
|
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base font-semibold">Re-link to catalog entry</DialogTitle>
|
|
{bill && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Choose the correct service for{' '}
|
|
<span className="font-medium text-foreground">{bill.name}</span>.
|
|
</p>
|
|
)}
|
|
</DialogHeader>
|
|
|
|
<Input
|
|
autoFocus
|
|
placeholder="Search services…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="text-sm"
|
|
/>
|
|
|
|
<div className="max-h-60 divide-y divide-border/40 overflow-y-auto rounded-lg border border-border/60">
|
|
{/* Unlink option */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected('unlink')}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted/30',
|
|
selected === 'unlink' && 'bg-primary/5',
|
|
)}
|
|
>
|
|
<Link2Off className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">Unlink from catalog</span>
|
|
{selected === 'unlink' && (
|
|
<CheckCircle2 className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
|
)}
|
|
</button>
|
|
|
|
{filtered.length === 0 && search ? (
|
|
<p className="px-3 py-4 text-center text-sm text-muted-foreground">
|
|
No services found.
|
|
</p>
|
|
) : (
|
|
filtered.map((e) => (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
onClick={() => setSelected(e.id)}
|
|
disabled={e.id === relinkEntry?.id}
|
|
className={cn(
|
|
'flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors',
|
|
'hover:bg-muted/30 disabled:pointer-events-none disabled:opacity-40',
|
|
selected === e.id && 'bg-primary/5',
|
|
)}
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-medium">{e.name}</p>
|
|
<p className="text-xs text-muted-foreground">{e.category}</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{e.starting_monthly_usd && (
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
${e.starting_monthly_usd.toFixed(2)}/mo
|
|
</span>
|
|
)}
|
|
{selected === e.id && <CheckCircle2 className="h-4 w-4 text-primary" />}
|
|
</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button type="button" variant="ghost" onClick={onClose} className="text-xs">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!selected || busy}
|
|
onClick={() => onConfirm(bill?.id, selected === 'unlink' ? null : selected)}
|
|
className="text-xs"
|
|
>
|
|
{busy ? 'Saving…' : 'Confirm'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ── Main component ─────────────────────────────────────────────────────────
|
|
export default function SubscriptionCatalogSection({
|
|
onEditBill,
|
|
onTrackComplete,
|
|
}: {
|
|
onEditBill: (id: number) => void;
|
|
onTrackComplete?: () => void;
|
|
}) {
|
|
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showUnmatched, setShowUnmatched] = useState(false);
|
|
const [activeType, setActiveType] = useState<string | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(new Set());
|
|
const [relinkEntry, setRelinkEntry] = useState<CatalogEntry | null>(null);
|
|
const [relinkBusy, setRelinkBusy] = useState(false);
|
|
const [trackBusyId, setTrackBusyId] = useState<number | string | null>(null);
|
|
const [bulkBusy, setBulkBusy] = useState(false);
|
|
|
|
async function loadCatalog() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = (await api.subscriptionCatalog()) as { catalog?: CatalogEntry[] };
|
|
setCatalog(data.catalog || []);
|
|
} catch (err) {
|
|
const message = errMessage(err, 'Failed to load catalog');
|
|
setError(message);
|
|
toast.error(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadCatalog();
|
|
}, []);
|
|
|
|
// Filter by type chip + search
|
|
const filtered = useMemo(() => {
|
|
const q = search.toLowerCase().trim();
|
|
return catalog.filter((e) => {
|
|
if (activeType && e.subscription_type !== activeType) return false;
|
|
if (q)
|
|
return e.name.toLowerCase().includes(q) || (e.category || '').toLowerCase().includes(q);
|
|
return true;
|
|
});
|
|
}, [catalog, activeType, search]);
|
|
|
|
const matched = filtered.filter((e) => e.matched_bill != null);
|
|
const unmatched = filtered.filter((e) => e.matched_bill == null);
|
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
|
|
|
async function handleTrack(entry: CatalogEntry) {
|
|
setTrackBusyId(entry.id);
|
|
try {
|
|
await api.createSubscriptionFromRecommendation({
|
|
name: entry.name,
|
|
due_day: 1,
|
|
expected_amount: entry.starting_monthly_usd || 0,
|
|
cycle_type: 'monthly',
|
|
subscription_type: entry.subscription_type || 'other',
|
|
catalog_match: {
|
|
id: entry.id,
|
|
name: entry.name,
|
|
subscription_type: entry.subscription_type,
|
|
},
|
|
});
|
|
toast.success(`${entry.name} added to your subscriptions`, {
|
|
description: 'Open it with Edit to set the correct amount and due date.',
|
|
});
|
|
await loadCatalog();
|
|
onTrackComplete?.();
|
|
} catch (err) {
|
|
toast.error(errMessage(err, `Failed to add ${entry.name}`));
|
|
} finally {
|
|
setTrackBusyId(null);
|
|
}
|
|
}
|
|
|
|
async function handleBulkTrack() {
|
|
if (selectedIds.size === 0) return;
|
|
setBulkBusy(true);
|
|
const toTrack = unmatched.filter((e) => selectedIds.has(e.id));
|
|
let succeeded = 0;
|
|
let failed = 0;
|
|
for (const entry of toTrack) {
|
|
try {
|
|
await api.createSubscriptionFromRecommendation({
|
|
name: entry.name,
|
|
due_day: 1,
|
|
expected_amount: entry.starting_monthly_usd || 0,
|
|
cycle_type: 'monthly',
|
|
subscription_type: entry.subscription_type || 'other',
|
|
catalog_match: {
|
|
id: entry.id,
|
|
name: entry.name,
|
|
subscription_type: entry.subscription_type,
|
|
},
|
|
});
|
|
succeeded++;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
if (succeeded > 0) {
|
|
toast.success(`${succeeded} subscription${succeeded !== 1 ? 's' : ''} added`);
|
|
setSelectedIds(new Set());
|
|
await loadCatalog();
|
|
onTrackComplete?.();
|
|
}
|
|
if (failed > 0) {
|
|
toast.error(`${failed} subscription${failed !== 1 ? 's' : ''} could not be added`);
|
|
}
|
|
setBulkBusy(false);
|
|
}
|
|
|
|
async function handleRelink(billId: number | undefined, catalogId: number | string | null) {
|
|
if (billId == null) return;
|
|
setRelinkBusy(true);
|
|
try {
|
|
await api.updateSubscriptionCatalogLink(billId, catalogId as number | string);
|
|
toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed');
|
|
setRelinkEntry(null);
|
|
await loadCatalog();
|
|
} catch (err) {
|
|
toast.error(errMessage(err, 'Failed to update catalog link'));
|
|
} finally {
|
|
setRelinkBusy(false);
|
|
}
|
|
}
|
|
|
|
async function handleAddDescriptor(
|
|
catalogId: number | string,
|
|
descriptor: string,
|
|
): Promise<boolean> {
|
|
try {
|
|
const newDesc = (await api.addCatalogDescriptor(catalogId, descriptor)) as Descriptor;
|
|
setCatalog((prev) =>
|
|
prev.map((e) =>
|
|
e.id === catalogId
|
|
? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] }
|
|
: e,
|
|
),
|
|
);
|
|
toast.success('Descriptor added');
|
|
return true;
|
|
} catch (err) {
|
|
toast.error(errMessage(err, 'Failed to add descriptor'));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function handleDeleteDescriptor(catalogId: number | string, descriptorId: number | string) {
|
|
try {
|
|
await api.deleteCatalogDescriptor(descriptorId);
|
|
setCatalog((prev) =>
|
|
prev.map((e) =>
|
|
e.id === catalogId
|
|
? {
|
|
...e,
|
|
user_descriptors: (e.user_descriptors || []).filter((d) => d.id !== descriptorId),
|
|
}
|
|
: e,
|
|
),
|
|
);
|
|
toast.success('Descriptor removed');
|
|
} catch (err) {
|
|
toast.error(errMessage(err, 'Failed to remove descriptor'));
|
|
}
|
|
}
|
|
|
|
function toggleSelect(id: number | string, checked: boolean) {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<Card className="overflow-hidden">
|
|
<CardHeader className="px-4 pb-3 sm:px-6">
|
|
<CardTitle className="text-base">Known Service Catalog</CardTitle>
|
|
<CardDescription>
|
|
Popular services, linked bills, and custom bank descriptors used to improve matching.
|
|
</CardDescription>
|
|
|
|
{/* Category filter chips */}
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
{CHIPS.map((chip) => (
|
|
<button
|
|
key={String(chip.value)}
|
|
type="button"
|
|
onClick={() => setActiveType(chip.value)}
|
|
className={cn(
|
|
'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
|
|
activeType === chip.value
|
|
? 'border-primary/40 bg-primary/15 text-primary'
|
|
: 'border-border/60 bg-muted/20 text-muted-foreground hover:border-border/80 hover:text-foreground',
|
|
)}
|
|
>
|
|
{chip.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative mt-2">
|
|
<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
|
|
type="search"
|
|
placeholder="Search services…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="h-9 pl-8 text-sm"
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="p-0">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading catalog…
|
|
</div>
|
|
) : error ? (
|
|
<div className="px-4 py-8 text-center">
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-3 text-xs"
|
|
onClick={loadCatalog}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border/40">
|
|
{/* ── Matched group ───────────────────────────────────────── */}
|
|
{matched.length > 0 && (
|
|
<div className="px-4 pb-3 pt-3 sm:px-6">
|
|
<p className="mb-2.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Tracking ({matched.length})
|
|
</p>
|
|
<div className="space-y-2">
|
|
{matched.map((entry) => (
|
|
<MatchedCard
|
|
key={entry.id}
|
|
entry={entry}
|
|
onEdit={onEditBill}
|
|
onRelink={setRelinkEntry}
|
|
onAddDescriptor={handleAddDescriptor}
|
|
onDeleteDescriptor={(descriptorId) =>
|
|
handleDeleteDescriptor(entry.id, descriptorId)
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Unmatched toggle + group ─────────────────────────── */}
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowUnmatched((s) => !s)}
|
|
className={cn(
|
|
'flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-muted-foreground',
|
|
'transition-colors hover:bg-muted/20 hover:text-foreground sm:px-6',
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<ChevronDown
|
|
className={cn('h-4 w-4 transition-transform', !showUnmatched && '-rotate-90')}
|
|
/>
|
|
{showUnmatched
|
|
? `Hide ${unmatched.length} more service${unmatched.length !== 1 ? 's' : ''}`
|
|
: `Show ${unmatched.length} more service${unmatched.length !== 1 ? 's' : ''}`}
|
|
</span>
|
|
{selectedIds.size > 0 && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-primary/30 bg-primary/10 text-primary text-[10px]"
|
|
>
|
|
{selectedIds.size} selected
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
|
|
{showUnmatched && (
|
|
<div className="px-4 pb-4 pt-1 sm:px-6">
|
|
{unmatched.length === 0 ? (
|
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
|
{search || activeType
|
|
? 'No services match your filter.'
|
|
: 'All known services are already being tracked.'}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{unmatched.map((entry) => (
|
|
<UnmatchedCard
|
|
key={entry.id}
|
|
entry={entry}
|
|
selected={selectedIds.has(entry.id)}
|
|
onToggleSelect={toggleSelect}
|
|
onTrack={handleTrack}
|
|
trackingBusy={trackBusyId === entry.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
|
|
{/* ── Re-link dialog ─────────────────────────────────────────────── */}
|
|
<ReLinkDialog
|
|
open={!!relinkEntry}
|
|
relinkEntry={relinkEntry}
|
|
allEntries={catalog}
|
|
onClose={() => setRelinkEntry(null)}
|
|
onConfirm={handleRelink}
|
|
busy={relinkBusy}
|
|
/>
|
|
|
|
{/* ── Bulk action bar ────────────────────────────────────────────── */}
|
|
{selectedIds.size > 0 && (
|
|
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-primary/20 bg-card/95 px-4 py-3 backdrop-blur-md sm:px-6">
|
|
<div className="flex items-center gap-2">
|
|
<Badge
|
|
variant="outline"
|
|
className="border-primary/30 bg-primary/10 font-mono text-primary"
|
|
>
|
|
{selectedIds.size}
|
|
</Badge>
|
|
<span className="text-sm font-medium">
|
|
{selectedIds.size === 1 ? 'service' : 'services'} selected
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-xs"
|
|
onClick={() => setSelectedIds(new Set())}
|
|
disabled={bulkBusy}
|
|
>
|
|
Clear
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="text-xs"
|
|
disabled={bulkBusy}
|
|
onClick={handleBulkTrack}
|
|
>
|
|
{bulkBusy ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
Adding…
|
|
</>
|
|
) : (
|
|
`Track ${selectedIds.size} selected`
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|