diff --git a/client/components/tracker/AutopaySuggestionActions.jsx b/client/components/tracker/AutopaySuggestionActions.tsx similarity index 87% rename from client/components/tracker/AutopaySuggestionActions.jsx rename to client/components/tracker/AutopaySuggestionActions.tsx index 9b88377..03dfb59 100644 --- a/client/components/tracker/AutopaySuggestionActions.jsx +++ b/client/components/tracker/AutopaySuggestionActions.tsx @@ -1,8 +1,17 @@ import { Clock, X, CheckCircle2, Loader2 } from 'lucide-react'; import { cn, fmt, fmtDate } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import type { TrackerRow } from '@/types'; -export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) { +interface AutopaySuggestionActionsProps { + row: TrackerRow; + loading?: boolean; + onConfirm?: () => void; + onDismiss?: () => void; + compact?: boolean; +} + +export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }: AutopaySuggestionActionsProps) { const suggestion = row.autopay_suggestion; if (!suggestion) return null; diff --git a/client/components/tracker/CashFlowCard.jsx b/client/components/tracker/CashFlowCard.tsx similarity index 91% rename from client/components/tracker/CashFlowCard.jsx rename to client/components/tracker/CashFlowCard.tsx index 889bdff..e719496 100644 --- a/client/components/tracker/CashFlowCard.jsx +++ b/client/components/tracker/CashFlowCard.tsx @@ -2,12 +2,14 @@ import { useId } from 'react'; import { motion } from 'framer-motion'; import { Wallet, CalendarClock, Sparkles, ArrowRight } from 'lucide-react'; import { cn, fmt, fmtDate } from '@/lib/utils'; +import { asDollars } from '@/lib/money'; import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from '@/lib/cashflowUtils'; +import type { TrackerCashflow, CashflowTimelinePoint, SafeToSpendUpcoming, TimelineBill } from '@/types'; const CHART_W = 400; const CHART_H = 96; -function TimelineChart({ timeline, positive }) { +function TimelineChart({ timeline, positive }: { timeline: CashflowTimelinePoint[]; positive: boolean }) { const gradId = useId(); const geo = buildTimelineGeometry(timeline, CHART_W, CHART_H); if (!geo) return null; @@ -52,7 +54,7 @@ function TimelineChart({ timeline, positive }) { {geo.points.filter(p => p.isDrop).map(p => ( - {`${fmtDate(p.date)} — ${p.bills.map(b => `${b.name} ${fmt(b.amount)}`).join(', ')} → ${fmt(p.balance)} left`} + {`${fmtDate(p.date)} — ${(p.bills as TimelineBill[]).map(b => `${b.name} ${fmt(b.amount)}`).join(', ')} → ${fmt(asDollars(p.balance))} left`} ))} @@ -62,7 +64,7 @@ function TimelineChart({ timeline, positive }) { - {`Payday ${fmtDate(p.date)} — ${fmt(p.balance)} left`} + {`Payday ${fmtDate(p.date)} — ${fmt(asDollars(p.balance))} left`} ))} @@ -70,7 +72,7 @@ function TimelineChart({ timeline, positive }) { ); } -function UpcomingList({ upcoming }) { +function UpcomingList({ upcoming }: { upcoming: SafeToSpendUpcoming[] }) { const { visible, overflow } = splitUpcoming(upcoming, 4); if (visible.length === 0) { @@ -114,7 +116,7 @@ function UpcomingList({ upcoming }) { * everything still due before the next payday is covered. Sits directly under * the summary cards and reads from the tracker payload's `cashflow` block. */ -export default function CashFlowCard({ cashflow, onSetStartingAmounts }) { +export default function CashFlowCard({ cashflow, onSetStartingAmounts }: { cashflow?: TrackerCashflow | null; onSetStartingAmounts?: () => void }) { if (!cashflow) return null; // No starting amounts and no bank sync — invite setup instead of guessing. @@ -182,7 +184,7 @@ export default function CashFlowCard({ cashflow, onSetStartingAmounts }) { )} title={`${fmt(cashflow.available)} available − ${fmt(cashflow.still_due_total)} still due before payday`} > - {positive ? '' : '−'}{fmt(Math.abs(safe))} + {positive ? '' : '−'}{fmt(asDollars(Math.abs(safe)))}

diff --git a/client/components/tracker/MonthlyStateDialog.jsx b/client/components/tracker/MonthlyStateDialog.tsx similarity index 91% rename from client/components/tracker/MonthlyStateDialog.jsx rename to client/components/tracker/MonthlyStateDialog.tsx index 9b0314e..d69bb84 100644 --- a/client/components/tracker/MonthlyStateDialog.jsx +++ b/client/components/tracker/MonthlyStateDialog.tsx @@ -1,20 +1,30 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { fmt } from '@/lib/utils'; +import { fmt, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import type { TrackerRow } from '@/types'; const MONTHS = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', ]; -function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) { +interface MonthlyStateDialogProps { + row: TrackerRow; + year: number; + month: number; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +} + +function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }: MonthlyStateDialogProps) { const [actualAmount, setActualAmount] = useState(''); const [notes, setNotes] = useState(''); const [isSkipped, setIsSkipped] = useState(false); @@ -29,7 +39,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) { } }, [open, row]); - async function handleSave(e) { + async function handleSave(e: React.FormEvent) { e.preventDefault(); const amt = actualAmount.trim() ? parseFloat(actualAmount) : null; if (amt !== null && (isNaN(amt) || amt < 0)) { @@ -49,7 +59,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) { onSaved(); onOpenChange(false); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err)); } finally { setSaving(false); } diff --git a/client/components/tracker/PaymentLedgerDialog.jsx b/client/components/tracker/PaymentLedgerDialog.tsx similarity index 93% rename from client/components/tracker/PaymentLedgerDialog.jsx rename to client/components/tracker/PaymentLedgerDialog.tsx index a660356..7391181 100644 --- a/client/components/tracker/PaymentLedgerDialog.jsx +++ b/client/components/tracker/PaymentLedgerDialog.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Plus } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { fmt, fmtDate } from '@/lib/utils'; +import { fmt, fmtDate, errMessage } from '@/lib/utils'; import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -16,18 +16,30 @@ import { import PaymentModal from '@/components/tracker/PaymentModal'; import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'; +import type { Dollars } from '@/lib/money'; +import type { Payment, TrackerRow } from '@/types'; -export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) { +interface PaymentLedgerDialogProps { + row: TrackerRow; + year: number; + month: number; + threshold: Dollars; + defaultPaymentDate: string; + onClose: () => void; + onSaved?: () => void; +} + +export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }: PaymentLedgerDialogProps) { const summary = paymentSummary(row, threshold); const [amount, setAmount] = useState(String(summary.remaining || summary.target || '')); const [date, setDate] = useState(defaultPaymentDate); const [method, setMethod] = useState(METHOD_NONE); const [notes, setNotes] = useState(''); const [busy, setBusy] = useState(false); - const [editPayment, setEditPayment] = useState(null); + const [editPayment, setEditPayment] = useState(null); const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date))); - async function handleAdd(e) { + async function handleAdd(e: React.FormEvent) { e.preventDefault(); const parsedAmount = parseFloat(amount); if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { @@ -52,7 +64,7 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen onSaved?.(); onClose?.(); } catch (err) { - toast.error(err.message || 'Failed to add payment'); + toast.error(errMessage(err, 'Failed to add payment')); } finally { setBusy(false); } diff --git a/client/components/tracker/PaymentModal.jsx b/client/components/tracker/PaymentModal.tsx similarity index 93% rename from client/components/tracker/PaymentModal.jsx rename to client/components/tracker/PaymentModal.tsx index 25e7134..7678bec 100644 --- a/client/components/tracker/PaymentModal.jsx +++ b/client/components/tracker/PaymentModal.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; +import { errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -14,10 +15,18 @@ import { import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; +import type { Payment } from '@/types'; const METHOD_NONE = 'none'; -function PaymentModal({ payment, autopayEnabled, onClose, onSave }) { +interface PaymentModalProps { + payment: Payment; + autopayEnabled?: boolean; + onClose: () => void; + onSave: () => void; +} + +function PaymentModal({ payment, autopayEnabled, onClose, onSave }: PaymentModalProps) { const [amount, setAmount] = useState(String(payment.amount)); const [date, setDate] = useState(payment.paid_date); // Use METHOD_NONE sentinel — empty string value crashes Radix Select @@ -29,7 +38,7 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) { const showAutopayFailure = autopayEnabled || method === 'autopay'; - async function handleSave(e) { + async function handleSave(e: React.FormEvent) { e.preventDefault(); setBusy(true); try { @@ -43,7 +52,7 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) { toast.success('Payment saved'); onSave(); onClose(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err)); } finally { setBusy(false); } } @@ -60,14 +69,14 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) { toast.success('Payment restored'); onSave(); } catch (err) { - toast.error(err.message || 'Failed to restore payment'); + toast.error(errMessage(err, 'Failed to restore payment')); } }, }, }); onSave(); onClose(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err)); } finally { setBusy(false); } } diff --git a/client/components/tracker/StartingAmountsEditDialog.jsx b/client/components/tracker/StartingAmountsEditDialog.tsx similarity index 87% rename from client/components/tracker/StartingAmountsEditDialog.jsx rename to client/components/tracker/StartingAmountsEditDialog.tsx index 9716e69..fd4a89f 100644 --- a/client/components/tracker/StartingAmountsEditDialog.jsx +++ b/client/components/tracker/StartingAmountsEditDialog.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { fmt } from '@/lib/utils'; +import { fmt, errMessage } from '@/lib/utils'; +import { asDollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -14,14 +15,31 @@ const MONTHS = [ 'July','August','September','October','November','December', ]; -function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) { +interface StartingAmountsPreview { + first_amount?: number; + fifteenth_amount?: number; + other_amount?: number; + paid_total?: number; + paid_from_first?: number; + paid_from_fifteenth?: number; +} + +interface StartingAmountsEditDialogProps { + open: boolean; + onClose: () => void; + year: number; + month: number; + onSave: () => void; +} + +function StartingAmountsEditDialog({ open, onClose, year, month, onSave }: StartingAmountsEditDialogProps) { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [firstAmount, setFirstAmount] = useState('0'); const [fifteenthAmount, setFifteenthAmount] = useState('0'); const [otherAmount, setOtherAmount] = useState('0'); - const [preview, setPreview] = useState(null); + const [preview, setPreview] = useState(null); const monthName = `${MONTHS[month - 1]} ${year}`; const localFirst = Number(firstAmount) || 0; @@ -40,7 +58,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) { setLoading(true); setError(''); try { - const result = await api.getMonthlyStartingAmounts(year, month); + const result = await api.getMonthlyStartingAmounts(year, month) as StartingAmountsPreview; if (!alive) return; setPreview(result); setFirstAmount(String(result.first_amount ?? 0)); @@ -48,7 +66,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) { setOtherAmount(String(result.other_amount ?? 0)); } catch (err) { if (!alive) return; - setError(err.message || 'Monthly starting amounts could not be loaded.'); + setError(errMessage(err, 'Monthly starting amounts could not be loaded.')); } finally { if (alive) setLoading(false); } @@ -57,7 +75,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) { return () => { alive = false; }; }, [open, year, month]); - async function handleSave(e) { + async function handleSave(e: React.FormEvent) { e.preventDefault(); const first = Number(firstAmount); const fifteenth = Number(fifteenthAmount); @@ -80,7 +98,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) { toast.success('Monthly starting amounts saved.'); onSave(); } catch (err) { - setError(err.message || 'Monthly starting amounts could not be saved.'); + setError(errMessage(err, 'Monthly starting amounts could not be saved.')); } finally { setSaving(false); } @@ -153,29 +171,29 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {

Total starting

-

{fmt(totalStarting)}

+

{fmt(asDollars(totalStarting))}

Paid so far

-

{fmt(paidSoFar)}

+

{fmt(asDollars(paidSoFar))}

Total remaining

-

{fmt(totalRemaining)}

+

{fmt(asDollars(totalRemaining))}

1st remaining - {fmt(firstRemaining)} + {fmt(asDollars(firstRemaining))}
15th remaining - {fmt(fifteenthRemaining)} + {fmt(asDollars(fifteenthRemaining))}
Other - {fmt(localOther)} + {fmt(asDollars(localOther))}
diff --git a/client/types.ts b/client/types.ts index 2416624..2e63693 100644 --- a/client/types.ts +++ b/client/types.ts @@ -14,6 +14,14 @@ import type { Dollars } from '@/lib/money'; export type TrackerStatus = | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; +// A suggested autopay payment (trackerService.applyAutopaySuggestions). +export interface AutopaySuggestion { + bill_id: number; + amount: Dollars; + paid_date: string; + method: string; +} + // A payment record as serialized by the server (money in dollars). serializePayment // spreads the full DB row, so extra columns pass through as `unknown`. export interface Payment { @@ -21,8 +29,10 @@ export interface Payment { bill_id?: number; amount: Dollars; paid_date: string; + method?: string | null; payment_source?: string | null; notes?: string | null; + autopay_failure?: number | null; balance_delta?: Dollars | null; interest_delta?: Dollars | null; created_at?: string; @@ -153,7 +163,7 @@ export interface TrackerRow { is_skipped: boolean; snoozed_until: string | null; monthly_notes?: string | null; - autopay_suggestion?: unknown; + autopay_suggestion?: AutopaySuggestion | null; amount_suggestion?: unknown; previous_month_paid: Dollars; // Bank-mode augmentation (present only when bank_tracking.enabled): @@ -206,10 +216,16 @@ export interface SafeToSpendUpcoming { status: TrackerStatus; } +// A bill landing on a given cashflow-timeline day. +export interface TimelineBill { + name: string; + amount: Dollars; +} + export interface CashflowTimelinePoint { date: string; balance: number; // running dollar balance; cashflowUtils treats it loosely - bills: unknown[]; + bills: TimelineBill[]; payday?: boolean; }