Files
SkyMoney/web/src/pages/settings/PlansPage.tsx

748 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
<span
className="badge"
style={{ borderColor: "#7f1d1d" }}
>
Overdue
</span>
);
if (d <= 7) return <span className="badge">Due in {d}d</span>;
return (
<span className="badge" aria-hidden="true">
On track
</span>
);
}
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 (
<div className="card max-w-3xl">
<SettingsNav />
<div className="muted">Loading</div>
</div>
);
if (error || !data) {
return (
<div className="card max-w-3xl">
<SettingsNav />
<p className="mb-3">Couldn't load fixed expenses.</p>
<button
className="btn"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
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 (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav />
{/* Header */}
<header className="mb-4 space-y-1">
<h1 className="text-lg font-semibold">
Fixed expenses
</h1>
<p className="text-sm muted">
Long-term goals and obligations youre funding over
time.
</p>
</header>
{/* KPI strip */}
<div className="grid gap-2 sm:grid-cols-4 mb-4">
<KpiCard label="Funded">
<Money cents={totals.funded} />
</KpiCard>
<KpiCard label="Total">
<Money cents={totals.total} />
</KpiCard>
<KpiCard label="Remaining">
<Money cents={totals.remaining} />
</KpiCard>
<KpiCard label="Overall progress">
<span className="text-xl font-semibold">
{overallPctFunded}%
</span>
</KpiCard>
</div>
{/* Overall progress bar */}
<div className="mb-4 space-y-1">
<div className="row text-xs muted">
<span>All fixed expenses funded</span>
<span className="ml-auto">
{overallPctFunded}% of target
</span>
</div>
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{
width: `${Math.min(100, overallPctFunded)}%`,
}}
/>
</div>
</div>
{/* Add form */}
<form
onSubmit={onAdd}
className="row gap-2 mb-4 flex-wrap items-end"
>
<input
className="input w-full sm:w-48"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Total $"
type="number"
min={0}
step="0.01"
value={total}
onChange={(e) => setTotal(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Funded $"
type="number"
min={0}
step="0.01"
value={funded}
onChange={(e) => setFunded(e.target.value)}
/>
<input
className="input w-full sm:w-24"
placeholder="Priority"
type="number"
min={0}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
<input
className="input w-full sm:w-40"
type="date"
value={due}
onChange={(e) => setDue(e.target.value)}
/>
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
<input
type="checkbox"
checked={autoPayEnabled}
onChange={(e) => setAutoPayEnabled(e.target.checked)}
/>
<span>Auto-fund</span>
</label>
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
Add
</button>
</form>
{/* Auto-payment configuration */}
{autoPayEnabled && (
<div className="card bg-[--color-panel] p-4 mb-4">
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
<div className="row gap-4 flex-wrap items-end">
<label className="stack text-sm">
<span className="muted text-xs">Frequency</span>
<select
className="input w-32"
value={frequency}
onChange={(e) => setFrequency(e.target.value as any)}
>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
<option value="daily">Daily</option>
<option value="custom">Custom</option>
</select>
</label>
{(frequency === "weekly" || frequency === "biweekly") && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Week</span>
<select
className="input w-32"
value={dayOfWeek}
onChange={(e) => setDayOfWeek(Number(e.target.value))}
>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
</label>
)}
{frequency === "monthly" && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Month</span>
<input
className="input w-24"
type="number"
min="1"
max="31"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
/>
</label>
)}
{frequency === "custom" && (
<label className="stack text-sm">
<span className="muted text-xs">Every N Days</span>
<input
className="input w-24"
type="number"
min="1"
value={everyNDays}
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
/>
</label>
)}
<label className="stack text-sm">
<span className="muted text-xs">Min. Funding %</span>
<input
className="input w-24"
type="number"
min="0"
max="100"
value={minFundingPercent}
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
/>
</label>
</div>
<p className="text-xs muted mt-2">
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
</p>
</div>
)}
{/* Table */}
{data.fixedPlans.length === 0 ? (
<div className="muted text-sm">
No fixed expenses yet.
</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Due</th>
<th>Priority</th>
<th>Funded</th>
<th>Total</th>
<th>Remaining</th>
<th>Status</th>
<th>Auto-Pay</th>
<th></th>
</tr>
</thead>
<tbody>
{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 (
<tr key={p.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText
value={p.name}
onChange={(v) =>
onEdit(p.id, { name: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditDate
value={p.dueOn}
timezone={userTimezone}
onChange={(iso) =>
onEdit(p.id, { dueOn: iso })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditNumber
value={p.priority}
min={0}
onChange={(n) =>
onEdit(p.id, { priority: n })
}
/>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) =>
onEdit(p.id, {
fundedCents: Math.max(
0,
Math.min(cents, p.totalCents),
),
})
}
/>
<div className="row text-xs muted">
<span>{pctFunded}% funded</span>
</div>
</div>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) =>
onEdit(p.id, {
totalCents: Math.max(cents, 0),
fundedCents: Math.min(
p.fundedCents,
cents,
),
})
}
/>
<FundingBar
pct={pctFunded}
/>
</div>
</td>
<td className="px-3 py-2">
<Money cents={remaining} />
</td>
<td className="px-3 py-2">
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
</td>
<td className="px-3 py-2">
<div className="flex items-center space-x-2">
<span className={`text-xs px-2 py-1 rounded ${
p.autoPayEnabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
</span>
{p.autoPayEnabled && p.paymentSchedule && (
<span className="text-xs text-gray-500">
{p.paymentSchedule.frequency === 'custom'
? `Every ${p.paymentSchedule.customDays} days`
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
}
</span>
)}
</div>
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button
className="btn"
type="button"
onClick={() => onDelete(p.id)}
disabled={deleteM.isPending}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</div>
);
}
/* --- Small presentational helpers --- */
function KpiCard({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="card kpi">
<h3>{label}</h3>
<div className="val">{children}</div>
</div>
);
}
function FundingBar({ pct }: { pct: number }) {
const clamped = Math.min(100, Math.max(0, pct));
return (
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
/* --- 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 ? (
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
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 ? (
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{value}
</button>
);
}
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 ? (
<input
className="input w-28 text-right font-mono"
type="number"
step="0.01"
min={0}
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link font-mono"
onClick={() => setEditing(true)}
>
{(valueCents / 100).toFixed(2)}
</button>
);
}
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 ? (
<input
className="input w-40"
type="date"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{formatDateInTimezone(value, timezone)}
</button>
);
}