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 { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
CheckCircle2, ChevronDown, ExternalLink, Link2, Link2Off,
|
CheckCircle2, ChevronDown, ExternalLink, Link2, Link2Off,
|
||||||
|
|
@ -7,7 +5,8 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
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 { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
@ -17,14 +16,37 @@ import {
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
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',
|
streaming: 'Streaming', software: 'Software', cloud: 'Cloud',
|
||||||
music: 'Music', news: 'News', fitness: 'Fitness', gaming: 'Gaming',
|
music: 'Music', news: 'News', fitness: 'Fitness', gaming: 'Gaming',
|
||||||
utilities: 'Utilities', insurance: 'Insurance', food: 'Food',
|
utilities: 'Utilities', insurance: 'Insurance', food: 'Food',
|
||||||
education: 'Education', shopping: 'Shopping', security: 'Security', other: 'Other',
|
education: 'Education', shopping: 'Shopping', security: 'Security', other: 'Other',
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHIPS = [
|
const CHIPS: { value: string | null; label: string }[] = [
|
||||||
{ value: null, label: 'All' },
|
{ value: null, label: 'All' },
|
||||||
{ value: 'streaming', label: 'Streaming' },
|
{ value: 'streaming', label: 'Streaming' },
|
||||||
{ value: 'music', label: 'Music' },
|
{ value: 'music', label: 'Music' },
|
||||||
|
|
@ -40,7 +62,7 @@ const CHIPS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
// Returns 'match' | 'above' | 'below' | null
|
// 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;
|
if (!catalogStarting || !monthly) return null;
|
||||||
const pct = (monthly - catalogStarting) / catalogStarting;
|
const pct = (monthly - catalogStarting) / catalogStarting;
|
||||||
if (Math.abs(pct) <= 0.05) return 'match';
|
if (Math.abs(pct) <= 0.05) return 'match';
|
||||||
|
|
@ -48,7 +70,12 @@ function priceDrift(monthly, catalogStarting) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Descriptor editor inline inside a matched card ─────────────────────────
|
// ── 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 [open, setOpen] = useState(false);
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
@ -126,7 +153,13 @@ function DescriptorEditor({ catalogId, descriptors, onAdd, onDelete }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Card for an already-tracked catalog entry ──────────────────────────────
|
// ── 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 { matched_bill: bill, user_descriptors: descs = [] } = entry;
|
||||||
const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd);
|
const drift = priceDrift(bill?.monthly_equivalent, entry.starting_monthly_usd);
|
||||||
|
|
||||||
|
|
@ -140,7 +173,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-primary/25 bg-primary/10 text-[10px] text-primary"
|
className="border-primary/25 bg-primary/10 text-[10px] text-primary"
|
||||||
>
|
>
|
||||||
{TYPE_LABELS[entry.subscription_type] || 'Other'}
|
{TYPE_LABELS[entry.subscription_type ?? ''] || 'Other'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{bill && !bill.active && (
|
{bill && !bill.active && (
|
||||||
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-[10px] text-amber-400">
|
<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' && (
|
{drift === 'above' && (
|
||||||
<span className="text-amber-500">
|
<span className="text-amber-500">
|
||||||
catalog from ${entry.starting_monthly_usd.toFixed(2)}/mo
|
catalog from ${entry.starting_monthly_usd?.toFixed(2)}/mo
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{drift === 'below' && entry.starting_monthly_usd && (
|
{drift === 'below' && entry.starting_monthly_usd && (
|
||||||
|
|
@ -188,7 +221,7 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 gap-1 text-xs"
|
className="h-7 gap-1 text-xs"
|
||||||
onClick={() => onEdit(bill.id)}
|
onClick={() => bill && onEdit(bill.id)}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
Edit
|
Edit
|
||||||
|
|
@ -218,7 +251,13 @@ function MatchedCard({ entry, onEdit, onRelink, onAddDescriptor, onDeleteDescrip
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Card for an untracked catalog entry ───────────────────────────────────
|
// ── 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -270,9 +309,16 @@ function UnmatchedCard({ entry, selected, onToggleSelect, onTrack, trackingBusy
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Re-link dialog ─────────────────────────────────────────────────────────
|
// ── 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 [search, setSearch] = useState('');
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState<number | string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) { setSearch(''); setSelected(null); }
|
if (open) { setSearch(''); setSelected(null); }
|
||||||
|
|
@ -374,27 +420,30 @@ function ReLinkDialog({ open, relinkEntry, allEntries, onClose, onConfirm, busy
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }) {
|
export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete }: {
|
||||||
const [catalog, setCatalog] = useState([]);
|
onEditBill: (id: number) => void;
|
||||||
|
onTrackComplete?: () => void;
|
||||||
|
}) {
|
||||||
|
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showUnmatched, setShowUnmatched] = useState(false);
|
const [showUnmatched, setShowUnmatched] = useState(false);
|
||||||
const [activeType, setActiveType] = useState(null);
|
const [activeType, setActiveType] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number | string>>(new Set());
|
||||||
const [relinkEntry, setRelinkEntry] = useState(null);
|
const [relinkEntry, setRelinkEntry] = useState<CatalogEntry | null>(null);
|
||||||
const [relinkBusy, setRelinkBusy] = useState(false);
|
const [relinkBusy, setRelinkBusy] = useState(false);
|
||||||
const [trackBusyId, setTrackBusyId] = useState(null);
|
const [trackBusyId, setTrackBusyId] = useState<number | string | null>(null);
|
||||||
const [bulkBusy, setBulkBusy] = useState(false);
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
|
||||||
async function loadCatalog() {
|
async function loadCatalog() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await api.subscriptionCatalog();
|
const data = await api.subscriptionCatalog() as { catalog?: CatalogEntry[] };
|
||||||
setCatalog(data.catalog || []);
|
setCatalog(data.catalog || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.message || 'Failed to load catalog';
|
const message = errMessage(err, 'Failed to load catalog');
|
||||||
setError(message);
|
setError(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -414,12 +463,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
|
||||||
});
|
});
|
||||||
}, [catalog, activeType, search]);
|
}, [catalog, activeType, search]);
|
||||||
|
|
||||||
const matched = 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);
|
const unmatched = filtered.filter(e => e.matched_bill == null);
|
||||||
|
|
||||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleTrack(entry) {
|
async function handleTrack(entry: CatalogEntry) {
|
||||||
setTrackBusyId(entry.id);
|
setTrackBusyId(entry.id);
|
||||||
try {
|
try {
|
||||||
await api.createSubscriptionFromRecommendation({
|
await api.createSubscriptionFromRecommendation({
|
||||||
|
|
@ -436,7 +485,7 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
|
||||||
await loadCatalog();
|
await loadCatalog();
|
||||||
onTrackComplete?.();
|
onTrackComplete?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || `Failed to add ${entry.name}`);
|
toast.error(errMessage(err, `Failed to add ${entry.name}`));
|
||||||
} finally {
|
} finally {
|
||||||
setTrackBusyId(null);
|
setTrackBusyId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -475,23 +524,24 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
|
||||||
setBulkBusy(false);
|
setBulkBusy(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRelink(billId, catalogId) {
|
async function handleRelink(billId: number | undefined, catalogId: number | string | null) {
|
||||||
|
if (billId == null) return;
|
||||||
setRelinkBusy(true);
|
setRelinkBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.updateSubscriptionCatalogLink(billId, catalogId);
|
await api.updateSubscriptionCatalogLink(billId, catalogId as number | string);
|
||||||
toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed');
|
toast.success(catalogId ? 'Catalog link updated' : 'Catalog link removed');
|
||||||
setRelinkEntry(null);
|
setRelinkEntry(null);
|
||||||
await loadCatalog();
|
await loadCatalog();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to update catalog link');
|
toast.error(errMessage(err, 'Failed to update catalog link'));
|
||||||
} finally {
|
} finally {
|
||||||
setRelinkBusy(false);
|
setRelinkBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddDescriptor(catalogId, descriptor) {
|
async function handleAddDescriptor(catalogId: number | string, descriptor: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const newDesc = await api.addCatalogDescriptor(catalogId, descriptor);
|
const newDesc = await api.addCatalogDescriptor(catalogId, descriptor) as Descriptor;
|
||||||
setCatalog(prev => prev.map(e =>
|
setCatalog(prev => prev.map(e =>
|
||||||
e.id === catalogId
|
e.id === catalogId
|
||||||
? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] }
|
? { ...e, user_descriptors: [...(e.user_descriptors || []), newDesc] }
|
||||||
|
|
@ -500,12 +550,12 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
|
||||||
toast.success('Descriptor added');
|
toast.success('Descriptor added');
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to add descriptor');
|
toast.error(errMessage(err, 'Failed to add descriptor'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteDescriptor(catalogId, descriptorId) {
|
async function handleDeleteDescriptor(catalogId: number | string, descriptorId: number | string) {
|
||||||
try {
|
try {
|
||||||
await api.deleteCatalogDescriptor(descriptorId);
|
await api.deleteCatalogDescriptor(descriptorId);
|
||||||
setCatalog(prev => prev.map(e =>
|
setCatalog(prev => prev.map(e =>
|
||||||
|
|
@ -515,14 +565,15 @@ export default function SubscriptionCatalogSection({ onEditBill, onTrackComplete
|
||||||
));
|
));
|
||||||
toast.success('Descriptor removed');
|
toast.success('Descriptor removed');
|
||||||
} catch (err) {
|
} 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 => {
|
setSelectedIds(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
checked ? next.add(id) : next.delete(id);
|
if (checked) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue