// 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 (
On track
);
}
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 (
);
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 */}
{/* KPI strip */}
{overallPctFunded}%
{/* Overall progress bar */}
All fixed expenses funded
{overallPctFunded}% of target
{/* Add form */}
{/* Auto-payment configuration */}
{autoPayEnabled && (
)}
{/* Table */}
{data.fixedPlans.length === 0 ? (
No fixed expenses yet.
) : (
| Name |
Due |
Priority |
Funded |
Total |
Remaining |
Status |
Auto-Pay |
|
{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 (
|
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 (
);
}
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
/>
) : (
);
}