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 { Clock, X, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
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;
|
const suggestion = row.autopay_suggestion;
|
||||||
if (!suggestion) return null;
|
if (!suggestion) return null;
|
||||||
|
|
||||||
|
|
@ -2,12 +2,14 @@ import { useId } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Wallet, CalendarClock, Sparkles, ArrowRight } from 'lucide-react';
|
import { Wallet, CalendarClock, Sparkles, ArrowRight } from 'lucide-react';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
|
import { asDollars } from '@/lib/money';
|
||||||
import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from '@/lib/cashflowUtils';
|
import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from '@/lib/cashflowUtils';
|
||||||
|
import type { TrackerCashflow, CashflowTimelinePoint, SafeToSpendUpcoming, TimelineBill } from '@/types';
|
||||||
|
|
||||||
const CHART_W = 400;
|
const CHART_W = 400;
|
||||||
const CHART_H = 96;
|
const CHART_H = 96;
|
||||||
|
|
||||||
function TimelineChart({ timeline, positive }) {
|
function TimelineChart({ timeline, positive }: { timeline: CashflowTimelinePoint[]; positive: boolean }) {
|
||||||
const gradId = useId();
|
const gradId = useId();
|
||||||
const geo = buildTimelineGeometry(timeline, CHART_W, CHART_H);
|
const geo = buildTimelineGeometry(timeline, CHART_W, CHART_H);
|
||||||
if (!geo) return null;
|
if (!geo) return null;
|
||||||
|
|
@ -52,7 +54,7 @@ function TimelineChart({ timeline, positive }) {
|
||||||
{geo.points.filter(p => p.isDrop).map(p => (
|
{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">
|
<circle key={p.date} cx={p.x} cy={p.y} r="3" fill={tone} stroke="var(--background, #18181b)" strokeWidth="1.5">
|
||||||
<title>
|
<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>
|
</title>
|
||||||
</circle>
|
</circle>
|
||||||
))}
|
))}
|
||||||
|
|
@ -62,7 +64,7 @@ function TimelineChart({ timeline, positive }) {
|
||||||
<g key="payday">
|
<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" />
|
<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">
|
<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>
|
</circle>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
@ -70,7 +72,7 @@ function TimelineChart({ timeline, positive }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpcomingList({ upcoming }) {
|
function UpcomingList({ upcoming }: { upcoming: SafeToSpendUpcoming[] }) {
|
||||||
const { visible, overflow } = splitUpcoming(upcoming, 4);
|
const { visible, overflow } = splitUpcoming(upcoming, 4);
|
||||||
|
|
||||||
if (visible.length === 0) {
|
if (visible.length === 0) {
|
||||||
|
|
@ -114,7 +116,7 @@ function UpcomingList({ upcoming }) {
|
||||||
* everything still due before the next payday is covered. Sits directly under
|
* everything still due before the next payday is covered. Sits directly under
|
||||||
* the summary cards and reads from the tracker payload's `cashflow` block.
|
* 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;
|
if (!cashflow) return null;
|
||||||
|
|
||||||
// No starting amounts and no bank sync — invite setup instead of guessing.
|
// 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`}
|
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>
|
||||||
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
<CalendarClock className="h-3.5 w-3.5" />
|
<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 { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { fmt } from '@/lib/utils';
|
import { fmt, errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import type { TrackerRow } from '@/types';
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
'January','February','March','April','May','June',
|
'January','February','March','April','May','June',
|
||||||
'July','August','September','October','November','December',
|
'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 [actualAmount, setActualAmount] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [isSkipped, setIsSkipped] = useState(false);
|
const [isSkipped, setIsSkipped] = useState(false);
|
||||||
|
|
@ -29,7 +39,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
|
||||||
}
|
}
|
||||||
}, [open, row]);
|
}, [open, row]);
|
||||||
|
|
||||||
async function handleSave(e) {
|
async function handleSave(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
|
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
|
||||||
if (amt !== null && (isNaN(amt) || amt < 0)) {
|
if (amt !== null && (isNaN(amt) || amt < 0)) {
|
||||||
|
|
@ -49,7 +59,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
|
||||||
onSaved();
|
onSaved();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(errMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
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 { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -16,18 +16,30 @@ import {
|
||||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||||
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
|
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 summary = paymentSummary(row, threshold);
|
||||||
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
|
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
|
||||||
const [date, setDate] = useState(defaultPaymentDate);
|
const [date, setDate] = useState(defaultPaymentDate);
|
||||||
const [method, setMethod] = useState(METHOD_NONE);
|
const [method, setMethod] = useState(METHOD_NONE);
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
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)));
|
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();
|
e.preventDefault();
|
||||||
const parsedAmount = parseFloat(amount);
|
const parsedAmount = parseFloat(amount);
|
||||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
||||||
|
|
@ -52,7 +64,7 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
onClose?.();
|
onClose?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to add payment');
|
toast.error(errMessage(err, 'Failed to add payment'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
@ -14,10 +15,18 @@ import {
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import type { Payment } from '@/types';
|
||||||
|
|
||||||
const METHOD_NONE = 'none';
|
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 [amount, setAmount] = useState(String(payment.amount));
|
||||||
const [date, setDate] = useState(payment.paid_date);
|
const [date, setDate] = useState(payment.paid_date);
|
||||||
// Use METHOD_NONE sentinel — empty string value crashes Radix Select
|
// 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';
|
const showAutopayFailure = autopayEnabled || method === 'autopay';
|
||||||
|
|
||||||
async function handleSave(e) {
|
async function handleSave(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -43,7 +52,7 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
|
||||||
toast.success('Payment saved');
|
toast.success('Payment saved');
|
||||||
onSave(); onClose();
|
onSave(); onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(errMessage(err));
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,14 +69,14 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
|
||||||
toast.success('Payment restored');
|
toast.success('Payment restored');
|
||||||
onSave();
|
onSave();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to restore payment');
|
toast.error(errMessage(err, 'Failed to restore payment'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
onSave(); onClose();
|
onSave(); onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message);
|
toast.error(errMessage(err));
|
||||||
} finally { setBusy(false); }
|
} finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
@ -14,14 +15,31 @@ const MONTHS = [
|
||||||
'July','August','September','October','November','December',
|
'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 [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [firstAmount, setFirstAmount] = useState('0');
|
const [firstAmount, setFirstAmount] = useState('0');
|
||||||
const [fifteenthAmount, setFifteenthAmount] = useState('0');
|
const [fifteenthAmount, setFifteenthAmount] = useState('0');
|
||||||
const [otherAmount, setOtherAmount] = 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 monthName = `${MONTHS[month - 1]} ${year}`;
|
||||||
const localFirst = Number(firstAmount) || 0;
|
const localFirst = Number(firstAmount) || 0;
|
||||||
|
|
@ -40,7 +58,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const result = await api.getMonthlyStartingAmounts(year, month);
|
const result = await api.getMonthlyStartingAmounts(year, month) as StartingAmountsPreview;
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setPreview(result);
|
setPreview(result);
|
||||||
setFirstAmount(String(result.first_amount ?? 0));
|
setFirstAmount(String(result.first_amount ?? 0));
|
||||||
|
|
@ -48,7 +66,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||||||
setOtherAmount(String(result.other_amount ?? 0));
|
setOtherAmount(String(result.other_amount ?? 0));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setError(err.message || 'Monthly starting amounts could not be loaded.');
|
setError(errMessage(err, 'Monthly starting amounts could not be loaded.'));
|
||||||
} finally {
|
} finally {
|
||||||
if (alive) setLoading(false);
|
if (alive) setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +75,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, [open, year, month]);
|
}, [open, year, month]);
|
||||||
|
|
||||||
async function handleSave(e) {
|
async function handleSave(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const first = Number(firstAmount);
|
const first = Number(firstAmount);
|
||||||
const fifteenth = Number(fifteenthAmount);
|
const fifteenth = Number(fifteenthAmount);
|
||||||
|
|
@ -80,7 +98,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||||||
toast.success('Monthly starting amounts saved.');
|
toast.success('Monthly starting amounts saved.');
|
||||||
onSave();
|
onSave();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Monthly starting amounts could not be saved.');
|
setError(errMessage(err, 'Monthly starting amounts could not be saved.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -153,29 +171,29 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
|
<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>
|
</div>
|
||||||
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
|
<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">
|
<div className="flex justify-between gap-3 sm:block">
|
||||||
<span className="text-muted-foreground">1st remaining</span>
|
<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>
|
||||||
<div className="flex justify-between gap-3 sm:block">
|
<div className="flex justify-between gap-3 sm:block">
|
||||||
<span className="text-muted-foreground">15th remaining</span>
|
<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>
|
||||||
<div className="flex justify-between gap-3 sm:block">
|
<div className="flex justify-between gap-3 sm:block">
|
||||||
<span className="text-muted-foreground">Other</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -14,6 +14,14 @@ import type { Dollars } from '@/lib/money';
|
||||||
export type TrackerStatus =
|
export type TrackerStatus =
|
||||||
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
|
| '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
|
// A payment record as serialized by the server (money in dollars). serializePayment
|
||||||
// spreads the full DB row, so extra columns pass through as `unknown`.
|
// spreads the full DB row, so extra columns pass through as `unknown`.
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
|
|
@ -21,8 +29,10 @@ export interface Payment {
|
||||||
bill_id?: number;
|
bill_id?: number;
|
||||||
amount: Dollars;
|
amount: Dollars;
|
||||||
paid_date: string;
|
paid_date: string;
|
||||||
|
method?: string | null;
|
||||||
payment_source?: string | null;
|
payment_source?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
|
autopay_failure?: number | null;
|
||||||
balance_delta?: Dollars | null;
|
balance_delta?: Dollars | null;
|
||||||
interest_delta?: Dollars | null;
|
interest_delta?: Dollars | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
|
@ -153,7 +163,7 @@ export interface TrackerRow {
|
||||||
is_skipped: boolean;
|
is_skipped: boolean;
|
||||||
snoozed_until: string | null;
|
snoozed_until: string | null;
|
||||||
monthly_notes?: string | null;
|
monthly_notes?: string | null;
|
||||||
autopay_suggestion?: unknown;
|
autopay_suggestion?: AutopaySuggestion | null;
|
||||||
amount_suggestion?: unknown;
|
amount_suggestion?: unknown;
|
||||||
previous_month_paid: Dollars;
|
previous_month_paid: Dollars;
|
||||||
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
||||||
|
|
@ -206,10 +216,16 @@ export interface SafeToSpendUpcoming {
|
||||||
status: TrackerStatus;
|
status: TrackerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A bill landing on a given cashflow-timeline day.
|
||||||
|
export interface TimelineBill {
|
||||||
|
name: string;
|
||||||
|
amount: Dollars;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CashflowTimelinePoint {
|
export interface CashflowTimelinePoint {
|
||||||
date: string;
|
date: string;
|
||||||
balance: number; // running dollar balance; cashflowUtils treats it loosely
|
balance: number; // running dollar balance; cashflowUtils treats it loosely
|
||||||
bills: unknown[];
|
bills: TimelineBill[];
|
||||||
payday?: boolean;
|
payday?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue