refactor(ts): convert tracker dialogs + cards to TSX (B3)
Six tracker components consuming the branded domain types: AutopaySuggestionActions, CashFlowCard (typed TrackerCashflow + TimelineChart/ UpcomingList), MonthlyStateDialog, PaymentModal, PaymentLedgerDialog, StartingAmountsEditDialog. New @/types: AutopaySuggestion, TimelineBill; Payment gains method/autopay_failure. Branded money paid off again — fmt(Math.abs(safe)) and the starting-amounts arithmetic correctly required asDollars wraps at the format boundary. typecheck 0, lint 0 errors (41 warns), build green, 48 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2cac46fda2
commit
c1ec0a4f2e
|
|
@ -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;
|
||||
|
||||
|
|
@ -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 => (
|
||||
<circle key={p.date} cx={p.x} cy={p.y} r="3" fill={tone} stroke="var(--background, #18181b)" strokeWidth="1.5">
|
||||
<title>
|
||||
{`${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`}
|
||||
</title>
|
||||
</circle>
|
||||
))}
|
||||
|
|
@ -62,7 +64,7 @@ function TimelineChart({ timeline, positive }) {
|
|||
<g key="payday">
|
||||
<line x1={p.x} x2={p.x} y1="4" y2={CHART_H - 4} stroke={tone} strokeOpacity="0.35" strokeWidth="1" strokeDasharray="2 3" vectorEffect="non-scaling-stroke" />
|
||||
<circle cx={p.x} cy={p.y} r="3.5" fill="none" stroke={tone} strokeWidth="2">
|
||||
<title>{`Payday ${fmtDate(p.date)} — ${fmt(p.balance)} left`}</title>
|
||||
<title>{`Payday ${fmtDate(p.date)} — ${fmt(asDollars(p.balance))} left`}</title>
|
||||
</circle>
|
||||
</g>
|
||||
))}
|
||||
|
|
@ -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)))}
|
||||
</p>
|
||||
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<CalendarClock className="h-3.5 w-3.5" />
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<Payment | null>(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);
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
||||
|
|
@ -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<StartingAmountsPreview | null>(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 }) {
|
|||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold">{fmt(asDollars(totalStarting))}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(asDollars(paidSoFar))}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
|
||||
<p className="mt-1 font-mono text-lg font-bold">{fmt(asDollars(totalRemaining))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
|
||||
<div className="flex justify-between gap-3 sm:block">
|
||||
<span className="text-muted-foreground">1st remaining</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(asDollars(firstRemaining))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 sm:block">
|
||||
<span className="text-muted-foreground">15th remaining</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(asDollars(fifteenthRemaining))}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 sm:block">
|
||||
<span className="text-muted-foreground">Other</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
|
||||
<span className="font-mono font-semibold sm:block">{fmt(asDollars(localOther))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue