// web/src/pages/settings/PlansPage.tsx import { useMemo, useState, useEffect, type FormEvent, type ReactNode, } from "react"; import { useDashboard } from "../../hooks/useDashboard"; import SettingsNav from "./_SettingsNav"; import { useCreatePlan, useUpdatePlan, useDeletePlan, } from "../../hooks/useFixedPlans"; import { Money } from "../../components/ui"; import { useToast } from "../../components/Toast"; import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone"; function daysUntil(iso: string, userTimezone: string) { const today = getTodayInTimezone(userTimezone); const due = isoToDateString(iso, userTimezone); const todayDate = new Date(today); const dueDate = new Date(due); const diffMs = dueDate.getTime() - todayDate.getTime(); return Math.round(diffMs / (24 * 60 * 60 * 1000)); } function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) { const d = daysUntil(dueISO, userTimezone); if (d < 0) return ( Overdue ); if (d <= 7) return Due in {d}d; return ( ); } export default function SettingsPlansPage() { const { data, isLoading, error, refetch, isFetching } = useDashboard(); const createM = useCreatePlan(); const updateM = useUpdatePlan(); const deleteM = useDeletePlan(); const { push } = useToast(); // Get user timezone from dashboard data const userTimezone = data?.user?.timezone || getBrowserTimezone(); // Add form state const [name, setName] = useState(""); const [total, setTotal] = useState(""); const [funded, setFunded] = useState(""); const [priority, setPriority] = useState(""); const [due, setDue] = useState(getTodayInTimezone(userTimezone)); // Auto-payment form state const [autoPayEnabled, setAutoPayEnabled] = useState(false); const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly"); const [dayOfMonth, setDayOfMonth] = useState(1); const [dayOfWeek, setDayOfWeek] = useState(0); const [everyNDays, setEveryNDays] = useState(30); const [minFundingPercent, setMinFundingPercent] = useState(100); const totals = useMemo(() => { if (!data) return { funded: 0, total: 0, remaining: 0 }; const funded = data.fixedPlans.reduce( (s, p) => s + p.fundedCents, 0, ); const total = data.fixedPlans.reduce( (s, p) => s + p.totalCents, 0, ); return { funded, total, remaining: Math.max(0, total - funded), }; }, [data]); const overallPctFunded = useMemo(() => { if (!totals.total) return 0; return Math.round((totals.funded / totals.total) * 100); }, [totals.funded, totals.total]); if (isLoading) return (
Loading…
); if (error || !data) { return (

Couldn't load fixed expenses.

); } const onAdd = (e: FormEvent) => { e.preventDefault(); const totalCents = Math.max( 0, Math.round((parseFloat(total || "0")) * 100), ); const fundedCents = Math.max( 0, Math.round((parseFloat(funded || "0")) * 100), ); const paymentSchedule = autoPayEnabled ? { frequency, ...(frequency === "monthly" ? { dayOfMonth } : {}), ...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}), ...(frequency === "custom" ? { everyNDays } : {}), minFundingPercent, } : undefined; const body = { name: name.trim(), totalCents, fundedCents: Math.min(fundedCents, totalCents), priority: Math.max( 0, Math.floor(Number(priority) || 0), ), dueOn: dateStringToUTCMidnight(due, userTimezone), autoPayEnabled, paymentSchedule, }; if (!body.name || totalCents <= 0) return; createM.mutate(body, { onSuccess: () => { push("ok", "Plan created"); setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(getTodayInTimezone(userTimezone)); setAutoPayEnabled(false); setFrequency("monthly"); setDayOfMonth(1); setDayOfWeek(0); setEveryNDays(30); setMinFundingPercent(100); }, onError: (err: any) => push("err", err?.message ?? "Create failed"), }); }; const onEdit = ( id: string, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string; }>, ) => { if ( "totalCents" in patch && "fundedCents" in patch && (patch.totalCents ?? 0) < (patch.fundedCents ?? 0) ) { patch.fundedCents = patch.totalCents; } updateM.mutate( { id, body: patch }, { onSuccess: () => push("ok", "Plan updated"), onError: (err: any) => push("err", err?.message ?? "Update failed"), }, ); }; const onDelete = (id: string) => { deleteM.mutate(id, { onSuccess: () => push("ok", "Plan deleted"), onError: (err: any) => push("err", err?.message ?? "Delete failed"), }); }; const addDisabled = !name || !total || createM.isPending; return (
{/* Header */}

Fixed expenses

Long-term goals and obligations you’re funding over time.

{/* KPI strip */}
{overallPctFunded}%
{/* Overall progress bar */}
All fixed expenses funded {overallPctFunded}% of target
{/* Add form */}
setName(e.target.value)} /> setTotal(e.target.value)} /> setFunded(e.target.value)} /> setPriority(e.target.value)} /> setDue(e.target.value)} />
{/* Auto-payment configuration */} {autoPayEnabled && (

Auto-Fund Schedule

{(frequency === "weekly" || frequency === "biweekly") && ( )} {frequency === "monthly" && ( )} {frequency === "custom" && ( )}

Automatic payments will only occur if the expense is funded to at least the minimum percentage.

)} {/* Table */} {data.fixedPlans.length === 0 ? (
No fixed expenses yet.
) : ( {data.fixedPlans .slice() .sort( (a, b) => a.priority - b.priority || new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime(), ) .map((p) => { const remaining = Math.max( 0, p.totalCents - p.fundedCents, ); const pctFunded = p.totalCents ? Math.round( (p.fundedCents / p.totalCents) * 100, ) : 0; return ( ); })}
Name Due Priority Funded Total Remaining Status Auto-Pay
onEdit(p.id, { name: v }) } /> onEdit(p.id, { dueOn: iso }) } /> onEdit(p.id, { priority: n }) } />
onEdit(p.id, { fundedCents: Math.max( 0, Math.min(cents, p.totalCents), ), }) } />
{pctFunded}% funded
onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min( p.fundedCents, cents, ), }) } />
{p.autoPayEnabled ? 'Enabled' : 'Disabled'} {p.autoPayEnabled && p.paymentSchedule && ( {p.paymentSchedule.frequency === 'custom' ? `Every ${p.paymentSchedule.customDays} days` : p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1) } )}
)}
); } /* --- Small presentational helpers --- */ function KpiCard({ label, children, }: { label: string; children: ReactNode; }) { return (

{label}

{children}
); } function FundingBar({ pct }: { pct: number }) { const clamped = Math.min(100, Math.max(0, pct)); return (
); } /* --- Inline editors (same behavior, slightly nicer UX) --- */ function InlineEditText({ value, onChange, }: { value: string; onChange: (v: string) => void; }) { const [editing, setEditing] = useState(false); const [v, setV] = useState(value); useEffect(() => setV(value), [value]); const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); }; return editing ? ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus /> ) : ( ); } function InlineEditNumber({ value, onChange, min = 0, max = Number.MAX_SAFE_INTEGER, }: { value: number; onChange: (v: number) => void; min?: number; max?: number; }) { const [editing, setEditing] = useState(false); const [v, setV] = useState(String(value)); useEffect(() => setV(String(value)), [value]); const commit = () => { const n = Math.max( min, Math.min(max, Math.floor(Number(v) || 0)), ); if (n !== value) onChange(n); setEditing(false); }; return editing ? ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus /> ) : ( ); } function InlineEditMoney({ valueCents, onChange, }: { valueCents: number; onChange: (cents: number) => void; }) { const [editing, setEditing] = useState(false); const [v, setV] = useState((valueCents / 100).toFixed(2)); useEffect( () => setV((valueCents / 100).toFixed(2)), [valueCents], ); const commit = () => { const cents = Math.max( 0, Math.round((parseFloat(v || "0")) * 100), ); if (cents !== valueCents) onChange(cents); setEditing(false); }; return editing ? ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus /> ) : ( ); } function InlineEditDate({ value, onChange, timezone, }: { value: string; onChange: (iso: string) => void; timezone: string; }) { const [editing, setEditing] = useState(false); const [v, setV] = useState( isoToDateString(value, timezone), ); useEffect( () => setV( isoToDateString(value, timezone), ), [value, timezone], ); const commit = () => { const iso = dateStringToUTCMidnight(v, timezone); if (iso !== value) onChange(iso); setEditing(false); }; return editing ? ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus /> ) : ( ); }