refactor(ts): SubscriptionCatalogSection → TypeScript

Typed CatalogEntry/Descriptor/MatchedBill; branded Dollars on monthly_equivalent.
Completes all loose components/ conversions. typecheck 0, lint 0 errors, build green.
This commit is contained in:
null 2026-07-04 22:12:49 -05:00
parent 2f62d6383a
commit b48a34ee33
1 changed files with 88 additions and 37 deletions

View File

@ -1,5 +1,3 @@
'use strict';
import { useEffect, useMemo, useState } from 'react';
import {
CheckCircle2, ChevronDown, ExternalLink, Link2, Link2Off,
@ -7,7 +5,8 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt } from '@/lib/utils';
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';
@ -17,14 +16,37 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
const TYPE_LABELS = {
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 = [
const CHIPS: { value: string | null; label: string }[] = [
{ value: null, label: 'All' },
{ value: 'streaming', label: 'Streaming' },
{ value: 'music', label: 'Music' },
@ -40,7 +62,7 @@ const CHIPS = [
];
// Returns 'match' | 'above' | 'below' | null
function priceDrift(monthly, catalogStarting) {
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';
@ -48,7 +70,12 @@ function priceDrift(monthly, catalogStarting) {
}
// ── Descriptor editor inline inside a matched card ─────────────────────────
function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) {
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);
@ -126,7 +153,13 @@ function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) {
}
// ── Card for an already-tracked catalog entry ──────────────────────────────
function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescriptor }) {
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);
@ -140,7 +173,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip
variant="outline"
className="border-primary/25 bg-primary/10 text-[10px] text-primary"
>
{TYPE_LABELS[entry.subscription_type] || 'Other'}
{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">
@ -161,7 +194,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip
)}
{drift === 'above' && (
<span className="text-amber-500">
catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
catalog from ${entry.starting_monthly_usd?.toFixed(2)}/mo
</span>
)}
{drift === 'below' && entry.starting_monthly_usd && (
@ -188,7 +221,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip
size="sm"
variant="outline"
className="h-7 gap-1 text-xs"
onClick={() => onEdit(bill.id)}
onClick={() => bill && onEdit(bill.id)}
>
<Pencil className="h-3 w-3" />
Edit
@ -218,7 +251,13 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip
}
// ── Card for an untracked catalog entry ───────────────────────────────────
function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy }) {
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(
@ -270,9 +309,16 @@ function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy
}
// ── Re-link dialog ─────────────────────────────────────────────────────────
function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy }) {
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(null);
const [selected, setSelected] = useState<number | string | null>(null);
useEffect(() => {
if (open) { setSearch(''); setSelected(null); }
@ -374,27 +420,30 @@ function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy
}
// ── Main component ─────────────────────────────────────────────────────────
export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }) {
const [catalog, setCatalog] = useState([]);
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(null);
const [error, setError] = useState<string | null>(null);
const [showUnmatched, setShowUnmatched] = useState(false);
const [activeType, setActiveType] = useState(null);
const [activeType, setActiveType] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState(new Set());
const [relinkEntry, setRelinkEntry] = useState(null);
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(null);
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();
const data = await api.subscriptionCatalog() as { catalog?: CatalogEntry[] };
setCatalog(data.catalog || []);
} catch (err) {
const message = err.message || 'Failed to load catalog';
const message = errMessage(err, 'Failed to load catalog');
setError(message);
toast.error(message);
} finally {
@ -414,12 +463,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
});
}, [catalog, activeType, search]);
const matched = filtered.filter(e => e.matched_bill !== null);
const unmatched = filtered.filter(e => e.matched_bill === null);
const matched = filtered.filter(e => e.matched_bill != null);
const unmatched = filtered.filter(e => e.matched_bill == null);
// ── Handlers ─────────────────────────────────────────────────────────────
async function handleTrack(entry) {
async function handleTrack(entry: CatalogEntry) {
setTrackBusyId(entry.id);
try {
await api.createSubscriptionFromRecommendation({
@ -436,7 +485,7 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
await loadCatalog();
onTrackComplete?.();
} catch (err) {
toast.error(err.message || `Failed to add ${entry.name}`);
toast.error(errMessage(err, `Failed to add ${entry.name}`));
} finally {
setTrackBusyId(null);
}
@ -475,23 +524,24 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
setBulkBusy(false);
}
async function handleRelink(billId, catalogId) {
async function handleRelink(billId: number | undefined, catalogId: number | string | null) {
if (billId == null) return;
setRelinkBusy(true);
try {
await api.updateSubscriptionCatalogLink(billId, catalogId);
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(err.message || 'Failed to update catalog link');
toast.error(errMessage(err, 'Failed to update catalog link'));
} finally {
setRelinkBusy(false);
}
}
async function handleAddDescriptor(catalogId, descriptor) {
async function handleAddDescriptor(catalogId: number | string, descriptor: string): Promise<boolean> {
try {
const newDesc = await api.addCatalogDescriptor(catalogId, descriptor);
const newDesc = await api.addCatalogDescriptor(catalogId, descriptor) as Descriptor;
setCatalog(prev => prev.map(e =>
e.id === catalogId
? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] }
@ -500,12 +550,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
toast.success('Descriptor added');
return true;
} catch (err) {
toast.error(err.message || 'Failed to add descriptor');
toast.error(errMessage(err, 'Failed to add descriptor'));
return false;
}
}
async function handleDeleteDescriptor(catalogId, descriptorId) {
async function handleDeleteDescriptor(catalogId: number | string, descriptorId: number | string) {
try {
await api.deleteCatalogDescriptor(descriptorId);
setCatalog(prev => prev.map(e =>
@ -515,14 +565,15 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
));
toast.success('Descriptor removed');
} catch (err) {
toast.error(err.message || 'Failed to remove descriptor');
toast.error(errMessage(err, 'Failed to remove descriptor'));
}
}
function toggleSelect(id, checked) {
function toggleSelect(id: number | string, checked: boolean) {
setSelectedIds(prev => {
const next = new Set(prev);
checked ? next.add(id) : next.delete(id);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}