diff --git a/client/components/BillHistoricalImportDialog.tsx b/client/components/BillHistoricalImportDialog.tsx index e931a69..bac61dc 100644 --- a/client/components/BillHistoricalImportDialog.tsx +++ b/client/components/BillHistoricalImportDialog.tsx @@ -51,7 +51,7 @@ function StatusChip({ candidate }: { candidate: Candidate }) { // ── Main dialog ─────────────────────────────────────────────────────────────── interface BillHistoricalImportDialogProps { - billId: number; + billId: number | string; billName: string; open: boolean; onClose: () => void; diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.tsx similarity index 82% rename from client/components/BillMerchantRules.jsx rename to client/components/BillMerchantRules.tsx index cfb0372..3c54bb0 100644 --- a/client/components/BillMerchantRules.jsx +++ b/client/components/BillMerchantRules.tsx @@ -3,16 +3,47 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; import { - AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, Trash2, X, + AlertTriangle, Building2, CheckCircle2, CalendarDays, Loader2, Plus, Tag, X, } from 'lucide-react'; import { api } from '@/api'; -import { cn, fmt } from '@/lib/utils'; +import { cn, fmt, errMessage } from '@/lib/utils'; +import { asDollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog'; +interface MerchantRule { + id: number | string; + merchant?: string; + auto_attribute_late?: number; +} + +interface Conflict { + id: number | string; + name?: string; +} + +interface Suggestion { + id: number | string; + label?: string; + normalized?: string; + amount?: number; + date?: string; +} + +interface SubTx { + id: number | string; + payee?: string | null; + description?: string | null; + memo?: string | null; + merchant?: string | null; + amount?: number; + posted_date?: string | null; + match_status?: string | null; +} + // Debounce helper -function useDebounce(value, delay) { +function useDebounce(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), delay); @@ -21,7 +52,13 @@ function useDebounce(value, delay) { return debounced; } -function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }) { +function RuleChip({ rule, onDelete, onToggleAutoAttr, deleting, togglingAutoAttr }: { + rule: MerchantRule; + onDelete: (rule: MerchantRule) => void; + onToggleAutoAttr: (rule: MerchantRule, enabled: boolean) => void; + deleting: number | string | null; + togglingAutoAttr: number | string | null; +}) { return (
@@ -65,7 +102,7 @@ function RuleChip({ rule, billId, onDelete, onToggleAutoAttr, deleting, toggling ); } -function ConflictWarning({ conflicts }) { +function ConflictWarning({ conflicts }: { conflicts?: Conflict[] }) { if (!conflicts?.length) return null; return (
@@ -84,7 +121,7 @@ function ConflictWarning({ conflicts }) { ); } -function PreviewBadge({ count, loading, error }) { +function PreviewBadge({ count, loading, error }: { count: number | null; loading: boolean; error: boolean }) { if (loading) return ; if (error) return Error; if (count === null) return null; @@ -100,24 +137,28 @@ function PreviewBadge({ count, loading, error }) { ); } -export default function BillMerchantRules({ billId, billName, onRulesChanged }) { - const [rules, setRules] = useState([]); - const [suggestions, setSuggestions] = useState([]); +export default function BillMerchantRules({ billId, billName, onRulesChanged }: { + billId: number | string; + billName?: string; + onRulesChanged?: () => void; +}) { + const [rules, setRules] = useState([]); + const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(true); - const [deleting, setDeleting] = useState(null); + const [deleting, setDeleting] = useState(null); const [adding, setAdding] = useState(false); const [input, setInput] = useState(''); const [showSuggestions, setShowSuggestions] = useState(false); - const [previewCount, setPreviewCount] = useState(null); + const [previewCount, setPreviewCount] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(false); - const [conflicts, setConflicts] = useState([]); - const [retroFeedback, setRetroFeedback] = useState(null); + const [conflicts, setConflicts] = useState([]); + const [retroFeedback, setRetroFeedback] = useState(null); const [showHistoricalDialog, setShowHistoricalDialog] = useState(false); - const [togglingAutoAttr, setTogglingAutoAttr] = useState(null); - const [liveResults, setLiveResults] = useState([]); + const [togglingAutoAttr, setTogglingAutoAttr] = useState(null); + const [liveResults, setLiveResults] = useState([]); const [liveSearching, setLiveSearching] = useState(false); - const inputRef = useRef(null); + const inputRef = useRef(null); const debouncedInput = useDebounce(input.trim(), 380); @@ -125,7 +166,7 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) if (!billId) return; setLoading(true); try { - const data = await api.billMerchantRules(billId); + const data = await api.billMerchantRules(billId) as { rules?: MerchantRule[]; suggestions?: Suggestion[] }; setRules(data.rules || []); setSuggestions(data.suggestions || []); } catch { @@ -148,9 +189,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) setPreviewLoading(true); setPreviewError(false); api.previewMerchantRule(billId, debouncedInput) - .then(data => { + .then(raw => { if (cancelled) return; - setPreviewCount(data.match_count); + const data = raw as { match_count?: number; conflicts?: Conflict[] }; + setPreviewCount(data.match_count ?? null); setConflicts(data.conflicts || []); }) .catch(() => { @@ -169,9 +211,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) let cancelled = false; setLiveSearching(true); api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 }) - .then(data => { + .then(raw => { if (cancelled) return; - const rows = Array.isArray(data) ? data : (data?.transactions ?? []); + const data = raw as SubTx[] | { transactions?: SubTx[] }; + const rows: SubTx[] = Array.isArray(data) ? data : (data?.transactions ?? []); setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({ id: tx.id, label: tx.payee || tx.description || tx.memo || '', @@ -187,16 +230,16 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) // Popover handles its own outside-click dismissal — no manual handler needed - async function handleAdd(merchantText) { + async function handleAdd(merchantText?: string) { const text = (merchantText || input).trim(); if (!text) return; setAdding(true); setRetroFeedback(null); try { - const result = await api.addMerchantRule(billId, text); + const result = await api.addMerchantRule(billId, text) as { rule?: MerchantRule }; setRules(prev => { if (prev.some(r => r.id === result.rule?.id)) return prev; - return [...prev, result.rule].filter(Boolean); + return [...prev, result.rule].filter(Boolean) as MerchantRule[]; }); setInput(''); setPreviewCount(null); @@ -207,13 +250,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) setShowHistoricalDialog(true); onRulesChanged?.(); } catch (err) { - toast.error(err.message || 'Failed to add rule'); + toast.error(errMessage(err, 'Failed to add rule')); } finally { setAdding(false); } } - async function handleDelete(rule) { + async function handleDelete(rule: MerchantRule) { setDeleting(rule.id); try { await api.deleteMerchantRule(billId, rule.id); @@ -221,13 +264,13 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) toast.success(`Rule "${rule.merchant}" removed`); onRulesChanged?.(); } catch (err) { - toast.error(err.message || 'Failed to remove rule'); + toast.error(errMessage(err, 'Failed to remove rule')); } finally { setDeleting(null); } } - async function handleToggleAutoAttr(rule, enabled) { + async function handleToggleAutoAttr(rule: MerchantRule, enabled: boolean) { setTogglingAutoAttr(rule.id); try { await api.toggleRuleAutoAttribute(billId, rule.id, enabled); @@ -236,14 +279,14 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) ? `Auto-fix on — ${rule.merchant} payments will automatically count for the prior month` : 'Auto-fix off'); } catch (err) { - toast.error(err.message || 'Failed to update rule'); + toast.error(errMessage(err, 'Failed to update rule')); } finally { setTogglingAutoAttr(null); } } - function pickSuggestion(s) { - setInput(s.label); + function pickSuggestion(s: Suggestion) { + setInput(s.label || ''); setShowSuggestions(false); inputRef.current?.focus(); } @@ -272,7 +315,6 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) {s.label} - {fmt(amountVal)} + {fmt(asDollars(amountVal))} ); diff --git a/client/components/bill-modal/LinkedTransactionsSection.tsx b/client/components/bill-modal/LinkedTransactionsSection.tsx index bf0b9c5..b7f8608 100644 --- a/client/components/bill-modal/LinkedTransactionsSection.tsx +++ b/client/components/bill-modal/LinkedTransactionsSection.tsx @@ -65,7 +65,7 @@ export default function LinkedTransactionsSection({