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:
parent
2f62d6383a
commit
b48a34ee33
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue