final touches for beta skymoney (at least i think)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,218 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { http } from "../api/http";
|
||||
|
||||
type AppHealth = { ok: true };
|
||||
type DbHealth = { ok: true; nowISO: string; latencyMs: number };
|
||||
|
||||
export default function HealthPage() {
|
||||
const app = useQuery({ queryKey: ["health"], queryFn: () => api.get<{ok:true}>("/health") });
|
||||
const db = useQuery({ queryKey: ["health","db"], queryFn: () => api.get<{ok:true; nowISO:string; latencyMs:number}>("/health/db") });
|
||||
const app = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => http<AppHealth>("/health"),
|
||||
});
|
||||
|
||||
const db = useQuery({
|
||||
queryKey: ["health", "db"],
|
||||
queryFn: () => http<DbHealth>("/health/db"),
|
||||
});
|
||||
|
||||
const appStatus = getStatus(app.isLoading, !!app.data?.ok, !!app.error);
|
||||
const dbStatus = getStatus(db.isLoading, !!db.data?.ok, !!db.error);
|
||||
|
||||
return (
|
||||
<div className="card max-w-lg">
|
||||
<h2 className="section-title">Health</h2>
|
||||
<ul className="stack">
|
||||
<li>API: {app.isLoading ? "…" : app.data?.ok ? "OK" : "Down"}</li>
|
||||
<li>DB: {db.isLoading ? "…" : db.data?.ok ? `OK (${db.data.latencyMs} ms)` : "Down"}</li>
|
||||
<li>Server Time: {db.data?.nowISO ? new Date(db.data.nowISO).toLocaleString() : "…"}</li>
|
||||
</ul>
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="card w-full max-w-xl space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h2 className="section-title">System Health</h2>
|
||||
<p className="muted text-sm">
|
||||
Quick status for the SkyMoney API and database. Use this when debugging issues or latency.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Status overview */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<HealthCard
|
||||
label="API"
|
||||
status={appStatus}
|
||||
description="Core application and auth endpoints."
|
||||
details={
|
||||
<>
|
||||
<StatusLine
|
||||
label="Status"
|
||||
value={labelForStatus(appStatus)}
|
||||
/>
|
||||
{app.error && (
|
||||
<StatusLine
|
||||
label="Error"
|
||||
value={
|
||||
app.error instanceof Error
|
||||
? app.error.message
|
||||
: "Unknown error"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onRetry={!app.isLoading ? () => app.refetch() : undefined}
|
||||
/>
|
||||
|
||||
<HealthCard
|
||||
label="Database"
|
||||
status={dbStatus}
|
||||
description="Primary PostgreSQL connection and latency."
|
||||
details={
|
||||
<>
|
||||
<StatusLine
|
||||
label="Status"
|
||||
value={labelForStatus(dbStatus)}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Latency"
|
||||
value={
|
||||
db.data?.latencyMs != null
|
||||
? `${db.data.latencyMs} ms`
|
||||
: db.isLoading
|
||||
? "Measuring…"
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Server time"
|
||||
value={
|
||||
db.data?.nowISO
|
||||
? new Date(db.data.nowISO).toLocaleString()
|
||||
: db.isLoading
|
||||
? "Loading…"
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
{db.error && (
|
||||
<StatusLine
|
||||
label="Error"
|
||||
value={
|
||||
db.error instanceof Error
|
||||
? db.error.message
|
||||
: "Unknown error"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onRetry={!db.isLoading ? () => db.refetch() : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Raw data (tiny, for debugging) */}
|
||||
<section className="border rounded-xl p-3 space-y-2 bg-[--color-panel]">
|
||||
<div className="row items-center">
|
||||
<span className="text-xs uppercase tracking-[0.2em] muted">
|
||||
Debug
|
||||
</span>
|
||||
<span className="ml-auto text-xs muted">
|
||||
Useful for logs / screenshots
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all muted">
|
||||
API: {app.isLoading ? "Loading…" : JSON.stringify(app.data ?? { ok: false })}{"\n"}
|
||||
DB: {db.isLoading ? "Loading…" : JSON.stringify(db.data ?? { ok: false })}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Status = "checking" | "up" | "down";
|
||||
|
||||
function getStatus(
|
||||
isLoading: boolean,
|
||||
ok: boolean,
|
||||
hasError: boolean,
|
||||
): Status {
|
||||
if (isLoading) return "checking";
|
||||
if (ok && !hasError) return "up";
|
||||
return "down";
|
||||
}
|
||||
|
||||
function labelForStatus(status: Status) {
|
||||
if (status === "checking") return "Checking…";
|
||||
if (status === "up") return "Operational";
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
function HealthCard({
|
||||
label,
|
||||
status,
|
||||
description,
|
||||
details,
|
||||
onRetry,
|
||||
}: {
|
||||
label: string;
|
||||
status: Status;
|
||||
description: string;
|
||||
details: React.ReactNode;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="border rounded-xl p-4 bg-[--color-panel] space-y-3">
|
||||
<div className="row items-center gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold">{label}</div>
|
||||
<p className="text-xs muted">{description}</p>
|
||||
</div>
|
||||
<StatusPill status={status} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs">{details}</div>
|
||||
|
||||
{onRetry && (
|
||||
<div className="row mt-2">
|
||||
<button type="button" className="btn text-xs ml-auto" onClick={onRetry}>
|
||||
Recheck
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
status,
|
||||
className = "",
|
||||
}: {
|
||||
status: Status;
|
||||
className?: string;
|
||||
}) {
|
||||
let text = "";
|
||||
let tone = "";
|
||||
|
||||
switch (status) {
|
||||
case "checking":
|
||||
text = "Checking…";
|
||||
tone = "bg-amber-500/10 text-amber-100 border border-amber-500/40";
|
||||
break;
|
||||
case "up":
|
||||
text = "OK";
|
||||
tone = "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40";
|
||||
break;
|
||||
case "down":
|
||||
text = "Down";
|
||||
tone = "bg-red-500/10 text-red-100 border border-red-500/40";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`badge text-xs ${tone} ${className}`}>
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1 bg-current" />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="row text-xs">
|
||||
<span className="muted">{label}</span>
|
||||
<span className="ml-auto text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,223 +1,596 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useCreateIncome } from "../hooks/useIncome";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { Money, Field, Button } from "../components/ui";
|
||||
import CurrencyInput from "../components/CurrencyInput";
|
||||
import { previewAllocation } from "../utils/allocatorPreview";
|
||||
import PercentGuard from "../components/PercentGuard";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { computeNeedsFixedFunding } from "../utils/funding";
|
||||
import { useCreateIncome, type AllocationOverrideInput, type CreateIncomeInput } from "../hooks/useIncome";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { useIncomePreview } from "../hooks/useIncomePreview";
|
||||
import EarlyFundingModal from "../components/EarlyFundingModal";
|
||||
import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone";
|
||||
|
||||
function dollarsToCents(input: string): number {
|
||||
const n = Number.parseFloat(input || "0");
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100);
|
||||
function fmt(cents: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
type Alloc = { id: number | string; amountCents: number; name: string };
|
||||
|
||||
export default function IncomePage() {
|
||||
const [amountStr, setAmountStr] = useState("");
|
||||
const { push } = useToast();
|
||||
const m = useCreateIncome();
|
||||
const dash = useDashboard();
|
||||
|
||||
const cents = dollarsToCents(amountStr);
|
||||
const canSubmit = (dash.data?.percentTotal ?? 0) === 100;
|
||||
|
||||
// Server preview (preferred) with client fallback
|
||||
const srvPreview = useIncomePreview(cents);
|
||||
const preview = useMemo(() => {
|
||||
if (!dash.data || cents <= 0) return null;
|
||||
if (srvPreview.data) return srvPreview.data;
|
||||
// fallback: local simulation
|
||||
return previewAllocation(cents, dash.data.fixedPlans, dash.data.variableCategories);
|
||||
}, [cents, dash.data, srvPreview.data]);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (cents <= 0 || !canSubmit) return;
|
||||
m.mutate(
|
||||
{ amountCents: cents },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
const fixed = (res.fixedAllocations ?? []).reduce(
|
||||
(s: number, a: any) => s + (a.amountCents ?? 0),
|
||||
0
|
||||
);
|
||||
const variable = (res.variableAllocations ?? []).reduce(
|
||||
(s: number, a: any) => s + (a.amountCents ?? 0),
|
||||
0
|
||||
);
|
||||
const unalloc = res.remainingUnallocatedCents ?? 0;
|
||||
push(
|
||||
"ok",
|
||||
`Allocated: Fixed ${(fixed / 100).toFixed(2)} + Variable ${(variable / 100).toFixed(
|
||||
2
|
||||
)}. Unallocated ${(unalloc / 100).toFixed(2)}.`
|
||||
);
|
||||
setAmountStr("");
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Income failed"),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const variableAllocations: Alloc[] = useMemo(() => {
|
||||
if (!m.data) return [];
|
||||
const nameById = new Map<string | number, string>(
|
||||
(dash.data?.variableCategories ?? []).map((c) => [c.id as string | number, c.name] as const)
|
||||
);
|
||||
const grouped = new Map<string | number, number>();
|
||||
for (const a of m.data.variableAllocations ?? []) {
|
||||
const id = (a as any).variableCategoryId ?? (a as any).id ?? -1;
|
||||
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
|
||||
}
|
||||
return [...grouped.entries()]
|
||||
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Category #${id}` }))
|
||||
.sort((a, b) => b.amountCents - a.amountCents);
|
||||
}, [m.data, dash.data]);
|
||||
|
||||
const fixedAllocations: Alloc[] = useMemo(() => {
|
||||
if (!m.data) return [];
|
||||
const nameById = new Map<string | number, string>(
|
||||
(dash.data?.fixedPlans ?? []).map((p) => [p.id as string | number, p.name] as const)
|
||||
);
|
||||
const grouped = new Map<string | number, number>();
|
||||
for (const a of m.data.fixedAllocations ?? []) {
|
||||
const id = (a as any).fixedPlanId ?? (a as any).id ?? -1;
|
||||
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
|
||||
}
|
||||
return [...grouped.entries()]
|
||||
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Plan #${id}` }))
|
||||
.sort((a, b) => b.amountCents - a.amountCents);
|
||||
}, [m.data, dash.data]);
|
||||
|
||||
const hasResult = !!m.data;
|
||||
function parseCurrencyToCents(value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
||||
const [whole, fraction = ""] = cleaned.split(".");
|
||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
function ManualRow({
|
||||
label,
|
||||
amountCents,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
amountCents: number;
|
||||
onChange: (cents: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack max-w-lg">
|
||||
<PercentGuard />
|
||||
|
||||
<form onSubmit={submit} className="card">
|
||||
<h2 className="section-title">Record Income</h2>
|
||||
<Field label="Amount (USD)">
|
||||
<CurrencyInput value={amountStr} onValue={setAmountStr} />
|
||||
</Field>
|
||||
|
||||
<Button disabled={m.isPending || cents <= 0 || !canSubmit}>
|
||||
{m.isPending ? "Allocating…" : canSubmit ? "Submit" : "Fix percents to 100%"}
|
||||
</Button>
|
||||
|
||||
{/* Live Preview */}
|
||||
{!hasResult && preview && (
|
||||
<div className="mt-4 stack">
|
||||
<div className="row">
|
||||
<h3 className="text-sm muted">Preview (not yet applied)</h3>
|
||||
<span className="ml-auto text-sm">
|
||||
Unallocated: <Money cents={preview.unallocatedCents} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h4 className="text-sm muted">Fixed Plans</h4>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={preview.fixed.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
{preview.fixed.length === 0 ? (
|
||||
<div className="muted text-sm">No fixed allocations.</div>
|
||||
) : (
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{preview.fixed.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h4 className="text-sm muted">Variable Categories</h4>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={preview.variable.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
{preview.variable.length === 0 ? (
|
||||
<div className="muted text-sm">No variable allocations.</div>
|
||||
) : (
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{preview.variable.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actual Result */}
|
||||
{m.error && <div className="toast-err mt-3">⚠️ {(m.error as any).message}</div>}
|
||||
{hasResult && (
|
||||
<div className="mt-4 stack">
|
||||
<div className="row">
|
||||
<span className="muted text-sm">Unallocated</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={m.data?.remainingUnallocatedCents ?? 0} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h3 className="text-sm muted">Fixed Plans (Applied)</h3>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={fixedAllocations.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{fixedAllocations.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h3 className="text-sm muted">Variable Categories (Applied)</h3>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={variableAllocations.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{variableAllocations.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<div className="row gap-2 p-2 rounded-xl border bg-[--color-panel] shadow-sm flex-col sm:flex-row sm:items-center">
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
<CurrencyInput
|
||||
className="input ml-auto w-40"
|
||||
valueCents={amountCents}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function allocateIrregularFixed(
|
||||
plans: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
totalCents?: number;
|
||||
fundedCents?: number;
|
||||
dueOn: string;
|
||||
priority: number;
|
||||
}>,
|
||||
fixedPool: number,
|
||||
now: Date,
|
||||
timezone: string
|
||||
) {
|
||||
const userNowIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone);
|
||||
const userNow = new Date(userNowIso);
|
||||
|
||||
const planStates = plans.map((plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
const remainingCents = Math.max(0, total - funded);
|
||||
const dueIso = dateStringToUTCMidnight(isoToDateString(plan.dueOn, timezone), timezone);
|
||||
const dueDate = new Date(dueIso);
|
||||
const daysUntilDue = Math.max(0, Math.ceil((dueDate.getTime() - userNow.getTime()) / DAY_MS));
|
||||
const isCrisis = remainingCents > 0 && daysUntilDue <= 14;
|
||||
return { ...plan, remainingCents, daysUntilDue, isCrisis };
|
||||
});
|
||||
|
||||
const fixedAlloc: Record<string, number> = {};
|
||||
const fixedNeed = planStates.reduce((sum, plan) => sum + plan.remainingCents, 0);
|
||||
let remainingPool = fixedPool;
|
||||
|
||||
const crisisPlans = planStates
|
||||
.filter((p) => p.isCrisis && p.remainingCents > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const plan of crisisPlans) {
|
||||
if (remainingPool <= 0) break;
|
||||
const allocation = Math.min(remainingPool, plan.remainingCents);
|
||||
if (allocation > 0) {
|
||||
fixedAlloc[plan.id] = allocation;
|
||||
remainingPool -= allocation;
|
||||
plan.remainingCents -= allocation;
|
||||
}
|
||||
}
|
||||
|
||||
const regularPlans = planStates.filter((p) => !p.isCrisis && p.remainingCents > 0);
|
||||
const totalRegularNeeded = regularPlans.reduce((sum, p) => sum + p.remainingCents, 0);
|
||||
|
||||
if (remainingPool > 0 && totalRegularNeeded > 0) {
|
||||
for (const plan of regularPlans) {
|
||||
const proportion = plan.remainingCents / totalRegularNeeded;
|
||||
const allocation = Math.floor(remainingPool * proportion);
|
||||
if (allocation > 0) {
|
||||
fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + allocation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allocatedTotal = Object.values(fixedAlloc).reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return { fixedAlloc, allocatedTotal, fixedNeed };
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function IncomePage() {
|
||||
const { data: dashboard } = useDashboard();
|
||||
const toast = useToast();
|
||||
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
const debugNow = new URLSearchParams(window.location.search).get("debugNow");
|
||||
const debugNowISO = debugNow ? dateStringToUTCMidnight(debugNow, userTimezone) : null;
|
||||
const debugNowDate = debugNowISO ? new Date(debugNowISO) : null;
|
||||
const [amountInput, setAmountInput] = useState("");
|
||||
const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
const [manualFixed, setManualFixed] = useState<Record<string, number>>({});
|
||||
const [manualVariable, setManualVariable] = useState<Record<string, number>>({});
|
||||
const [earlyFundingModals, setEarlyFundingModals] = useState<Array<{
|
||||
planId: string;
|
||||
planName: string;
|
||||
nextDueDate: string;
|
||||
}>>([]);
|
||||
|
||||
const allocation = useMemo(() => {
|
||||
if (!dashboard || amountCents <= 0) return null;
|
||||
const fixedPlans = dashboard.fixedPlans ?? [];
|
||||
const variableCats = dashboard.variableCategories ?? [];
|
||||
if (fixedPlans.length === 0 && variableCats.length === 0) return null;
|
||||
|
||||
const fixedAlloc: Record<string, number> = {};
|
||||
const varAlloc: Record<string, number> = {};
|
||||
const isIrregular = dashboard?.user?.incomeType === "irregular";
|
||||
const fixedExpensePercentage = dashboard?.user?.fixedExpensePercentage ?? 40;
|
||||
const eligibleFixedPlans = fixedPlans.filter((plan) => plan.autoPayEnabled);
|
||||
|
||||
let remainingAmount = amountCents;
|
||||
let shouldFundFixed = false;
|
||||
let fixedNeed = 0;
|
||||
|
||||
if (isIrregular) {
|
||||
const fixedPool = Math.floor((amountCents * fixedExpensePercentage) / 100);
|
||||
const rawFixedNeed = eligibleFixedPlans.reduce((sum, plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
return sum + Math.max(total - funded, 0);
|
||||
}, 0);
|
||||
const fixedAllocationPool = Math.min(fixedPool, rawFixedNeed);
|
||||
const irregularFunding = allocateIrregularFixed(
|
||||
eligibleFixedPlans,
|
||||
fixedAllocationPool,
|
||||
debugNowDate ?? new Date(),
|
||||
userTimezone
|
||||
);
|
||||
fixedNeed = rawFixedNeed;
|
||||
if (fixedAllocationPool > 0 && rawFixedNeed > 0) {
|
||||
Object.assign(fixedAlloc, irregularFunding.fixedAlloc);
|
||||
remainingAmount = Math.max(0, amountCents - fixedAllocationPool);
|
||||
shouldFundFixed = true;
|
||||
}
|
||||
} else {
|
||||
// Use client-side smart fixed funding detection (preferred)
|
||||
shouldFundFixed = computeNeedsFixedFunding(
|
||||
dashboard?.user?.incomeType ?? "regular",
|
||||
dashboard?.user?.incomeFrequency ?? "biweekly",
|
||||
fixedPlans,
|
||||
debugNowDate ?? new Date(),
|
||||
dashboard?.crisis?.active ?? false,
|
||||
100
|
||||
);
|
||||
fixedNeed = shouldFundFixed
|
||||
? eligibleFixedPlans.reduce((sum, plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
return sum + Math.max(total - funded, 0);
|
||||
}, 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Calculate fixed plan allocation only if needed (regular income path)
|
||||
if (!isIrregular && shouldFundFixed) {
|
||||
// STEP 1: Pay overdue bills first (oldest first)
|
||||
const overduePlans = fixedPlans
|
||||
.filter(p => (p as any).isOverdue && ((p as any).overdueAmount ?? 0) > 0)
|
||||
.sort((a, b) => {
|
||||
const aTime = (a as any).overdueSince ? new Date((a as any).overdueSince).getTime() : 0;
|
||||
const bTime = (b as any).overdueSince ? new Date((b as any).overdueSince).getTime() : 0;
|
||||
return aTime - bTime; // oldest first
|
||||
});
|
||||
|
||||
overduePlans.forEach((plan) => {
|
||||
if (remainingAmount <= 0) return;
|
||||
const overdueAmount = (plan as any).overdueAmount ?? 0;
|
||||
const payment = Math.min(overdueAmount, remainingAmount);
|
||||
if (payment > 0) {
|
||||
fixedAlloc[plan.id] = payment;
|
||||
remainingAmount -= payment;
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 2: Allocate remaining to non-overdue plans (proportional)
|
||||
if (remainingAmount > 0) {
|
||||
const nonOverduePlans = eligibleFixedPlans.filter(
|
||||
(p) => !(p as any).isOverdue || ((p as any).overdueAmount ?? 0) === 0
|
||||
);
|
||||
const fixedNeed = nonOverduePlans.reduce((sum, plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
const alreadyAllocated = fixedAlloc[plan.id] ?? 0;
|
||||
return sum + Math.max(total - funded - alreadyAllocated, 0);
|
||||
}, 0);
|
||||
|
||||
if (fixedNeed > 0) {
|
||||
nonOverduePlans.forEach((plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
const alreadyAllocated = fixedAlloc[plan.id] ?? 0;
|
||||
const need = Math.max(total - funded - alreadyAllocated, 0);
|
||||
const allocation = Math.floor((need / fixedNeed) * remainingAmount);
|
||||
fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + Math.min(allocation, need);
|
||||
remainingAmount -= Math.min(allocation, need);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smart variable allocation with deficit recovery
|
||||
const totalPercent = variableCats.reduce((sum, cat) => sum + (cat.percent || 0), 0);
|
||||
|
||||
if (totalPercent > 0 && remainingAmount > 0) {
|
||||
// Step 1: Handle negative balances first
|
||||
let poolAfterDeficits = remainingAmount;
|
||||
|
||||
variableCats.forEach((cat) => {
|
||||
const currentBalance = cat.balanceCents ?? 0;
|
||||
if (currentBalance < 0 && poolAfterDeficits > 0) {
|
||||
const deficitAmount = Math.min(Math.abs(currentBalance), poolAfterDeficits);
|
||||
varAlloc[cat.id] = (varAlloc[cat.id] || 0) + deficitAmount;
|
||||
poolAfterDeficits -= deficitAmount;
|
||||
}
|
||||
});
|
||||
|
||||
// Step 2: Distribute remaining by percentages
|
||||
if (poolAfterDeficits > 0) {
|
||||
variableCats.forEach((cat) => {
|
||||
const percentageAmount = Math.floor(((cat.percent || 0) / totalPercent) * poolAfterDeficits);
|
||||
varAlloc[cat.id] = (varAlloc[cat.id] || 0) + percentageAmount;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fixedAlloc,
|
||||
varAlloc,
|
||||
shouldFundFixed,
|
||||
fixedNeed
|
||||
};
|
||||
}, [dashboard, amountCents]);
|
||||
|
||||
const manualTotal = useMemo(() => {
|
||||
const sumFixed = Object.values(manualFixed).reduce((a, b) => a + b, 0);
|
||||
const sumVar = Object.values(manualVariable).reduce((a, b) => a + b, 0);
|
||||
return sumFixed + sumVar;
|
||||
}, [manualFixed, manualVariable]);
|
||||
|
||||
const manualOver = manualTotal > amountCents;
|
||||
|
||||
const createIncome = useCreateIncome();
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (amountCents <= 0) {
|
||||
toast.push("err", "Enter an amount before recording income.");
|
||||
return;
|
||||
}
|
||||
if (manualMode && manualOver) {
|
||||
toast.push("err", "Total exceeds deposit amount.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateIncomeInput = {
|
||||
amountCents,
|
||||
};
|
||||
if (debugNowISO) {
|
||||
payload.occurredAtISO = debugNowISO;
|
||||
}
|
||||
|
||||
const trimmedNote = notes.trim();
|
||||
if (trimmedNote) {
|
||||
payload.note = trimmedNote;
|
||||
}
|
||||
|
||||
if (manualMode) {
|
||||
const overrides: AllocationOverrideInput[] = [];
|
||||
Object.entries(manualFixed).forEach(([id, cents]) => {
|
||||
if (cents > 0) overrides.push({ type: "fixed", id, amountCents: cents });
|
||||
});
|
||||
Object.entries(manualVariable).forEach(([id, cents]) => {
|
||||
if (cents > 0) overrides.push({ type: "variable", id, amountCents: cents });
|
||||
});
|
||||
if (overrides.length > 0) {
|
||||
payload.overrides = overrides;
|
||||
}
|
||||
}
|
||||
|
||||
createIncome.mutate(payload, {
|
||||
onSuccess: (result: any) => {
|
||||
// Check if overdue bills were paid first
|
||||
if (result?.overduePaid?.totalAmount > 0) {
|
||||
const totalPaid = fmt(result.overduePaid.totalAmount);
|
||||
const plans = result.overduePaid.plans;
|
||||
|
||||
if (plans.length === 1) {
|
||||
// Single overdue bill
|
||||
toast.push("ok", `Paid ${fmt(plans[0].amountPaid)} to overdue bill: ${plans[0].name}`);
|
||||
} else {
|
||||
// Multiple overdue bills - show priority order
|
||||
const plansList = plans
|
||||
.map((p: any) => `${p.name} (${fmt(p.amountPaid)})`)
|
||||
.join(", ");
|
||||
toast.push("ok", `Paid ${totalPaid} to ${plans.length} overdue bills (oldest first): ${plansList}`);
|
||||
}
|
||||
} else {
|
||||
toast.push("ok", "Income recorded.");
|
||||
}
|
||||
|
||||
// Check if any bills were fully funded
|
||||
if (result?.fullyFundedPlans && result.fullyFundedPlans.length > 0) {
|
||||
const modals = result.fullyFundedPlans.map((plan: any) => ({
|
||||
planId: plan.id,
|
||||
planName: plan.name,
|
||||
nextDueDate: plan.dueOn,
|
||||
}));
|
||||
setEarlyFundingModals(modals);
|
||||
}
|
||||
|
||||
setAmountInput("");
|
||||
setManualMode(false);
|
||||
setManualFixed({});
|
||||
setManualVariable({});
|
||||
setNotes("");
|
||||
},
|
||||
onError: (err: any) => toast.push("err", err?.message ?? "Failed."),
|
||||
});
|
||||
};
|
||||
|
||||
if (!dashboard) {
|
||||
return <div className="muted p-4">Loading…</div>;
|
||||
}
|
||||
|
||||
const fixed = dashboard.fixedPlans ?? [];
|
||||
const variable = dashboard.variableCategories ?? [];
|
||||
const isIrregularUser = dashboard?.user?.incomeType === "irregular";
|
||||
const autoFundFixed = isIrregularUser ? fixed.filter((plan) => plan.autoPayEnabled) : fixed;
|
||||
const pageTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
const monthLabel = new Date().toLocaleString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: pageTimezone,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 fade-in">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<h1 className="text-xl font-bold">Record Income</h1>
|
||||
<span className="badge sm:ml-auto">{monthLabel}</span>
|
||||
</header>
|
||||
|
||||
<form className="card stack pb-28 relative" onSubmit={handleSubmit} noValidate>
|
||||
<section className="stack">
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Amount</span>
|
||||
<CurrencyInput
|
||||
className="input"
|
||||
value={amountInput}
|
||||
onValue={setAmountInput}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Date field removed: income uses current time automatically */}
|
||||
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Note (optional)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="e.g., Paycheck"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{amountCents > 0 && allocation && !manualMode && (
|
||||
<section className="mt-4 stack">
|
||||
<h2 className="section-title">Automatic Allocation</h2>
|
||||
|
||||
{allocation.shouldFundFixed && allocation.fixedNeed > 0 && (
|
||||
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 mb-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-sm">
|
||||
<span className="font-semibold text-blue-800 dark:text-blue-200">
|
||||
{fmt(Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} will go to fixed expenses
|
||||
</span>
|
||||
<span className="sm:ml-auto font-semibold text-blue-800 dark:text-blue-200">
|
||||
{fmt(amountCents - Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} remains for categories
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allocation.shouldFundFixed && allocation.fixedNeed > 0 && (
|
||||
<div className="p-4 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl font-bold flex-shrink-0">!</span>
|
||||
<div className="stack gap-1 min-w-0">
|
||||
<span className="font-semibold">Fixed Expenses Need Funding</span>
|
||||
<span className="text-sm">
|
||||
{dashboard?.crisis?.active ? "Crisis mode - prioritizing fixed expenses" :
|
||||
dashboard?.user?.incomeType === "irregular" ? "Irregular income - funding available plans" :
|
||||
"Behind schedule - catching up on fixed plans"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allocation.shouldFundFixed && (
|
||||
<div className="stack">
|
||||
<h3 className="text-sm muted">Fixed Expenses (deducted from income)</h3>
|
||||
{autoFundFixed.map((plan) => {
|
||||
const currentFunded = plan.fundedCents ?? 0;
|
||||
const newAllocation = allocation.fixedAlloc[plan.id] ?? 0;
|
||||
const newFunded = currentFunded + newAllocation;
|
||||
const total = plan.totalCents ?? 0;
|
||||
return (
|
||||
<div key={plan.id} className="stack gap-1 p-3 rounded-xl border bg-[--color-panel]">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center">
|
||||
<span className="font-semibold">{plan.name}</span>
|
||||
<span className="sm:ml-auto font-mono font-bold text-blue-600 dark:text-blue-400">
|
||||
+{fmt(newAllocation)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-xs muted">
|
||||
<span>Funded: {fmt(currentFunded)} / {fmt(total)}</span>
|
||||
<span className="sm:ml-auto">New: <span className="font-semibold text-[--color-ink]">{fmt(newFunded)} / {fmt(total)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stack mt-3">
|
||||
<h3 className="text-sm muted">Variable Categories</h3>
|
||||
{variable.map((cat) => {
|
||||
const currentBalance = cat.balanceCents ?? 0;
|
||||
const newAllocation = allocation.varAlloc[cat.id] ?? 0;
|
||||
const newTotal = currentBalance + newAllocation;
|
||||
return (
|
||||
<div key={cat.id} className="stack gap-1 p-3 rounded-xl border bg-[--color-panel]">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center">
|
||||
<span className="font-semibold">{cat.name}</span>
|
||||
<span className="sm:ml-auto font-mono font-bold text-green-600 dark:text-green-400">
|
||||
+{fmt(newAllocation)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-xs muted">
|
||||
<span>Current: {fmt(currentBalance)}</span>
|
||||
<span className="sm:ml-auto">New Total: <span className="font-semibold text-[--color-ink]">{fmt(newTotal)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
)}
|
||||
|
||||
{manualMode && (
|
||||
<section className="stack mt-4">
|
||||
<h2 className="section-title">Manual Allocation</h2>
|
||||
<div className="stack">
|
||||
<h3 className="text-sm muted">Fixed Expenses</h3>
|
||||
{fixed.map((plan) => (
|
||||
<ManualRow
|
||||
key={plan.id}
|
||||
label={plan.name}
|
||||
amountCents={manualFixed[plan.id] ?? 0}
|
||||
onChange={(cents) =>
|
||||
setManualFixed((prev) => ({
|
||||
...prev,
|
||||
[plan.id]: cents,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stack mt-3">
|
||||
<h3 className="text-sm muted">Expenses</h3>
|
||||
{variable.map((cat) => (
|
||||
<ManualRow
|
||||
key={cat.id}
|
||||
label={cat.name}
|
||||
amountCents={manualVariable[cat.id] ?? 0}
|
||||
onChange={(cents) =>
|
||||
setManualVariable((prev) => ({
|
||||
...prev,
|
||||
[cat.id]: cents,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center mt-3">
|
||||
<span className="badge">Total Allocated</span>
|
||||
<span
|
||||
className={
|
||||
"sm:ml-auto font-semibold " + (manualOver ? "text-red-400" : "")
|
||||
}
|
||||
>
|
||||
{fmt(manualTotal)} / {fmt(amountCents)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center p-4 bg-[--color-bg] border-t relative bottom-0 left-0 right-0 rounded-b-xl">
|
||||
{manualMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-full sm:w-auto"
|
||||
onClick={() => setManualMode(false)}
|
||||
>
|
||||
Back to Auto
|
||||
</button>
|
||||
)}
|
||||
{!manualMode && allocation && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
if (!allocation) return;
|
||||
setManualFixed({ ...allocation.fixedAlloc });
|
||||
setManualVariable({ ...allocation.varAlloc });
|
||||
setManualMode(true);
|
||||
}}
|
||||
>
|
||||
Customize Allocation
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createIncome.isPending}
|
||||
className="btn w-full sm:w-auto sm:ml-auto"
|
||||
>
|
||||
{createIncome.isPending ? "Saving…" : "Record Income"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="stack">
|
||||
<h2 className="section-title">History</h2>
|
||||
{/* History content can live here when ready */}
|
||||
</section>
|
||||
|
||||
{earlyFundingModals.map((modal) => (
|
||||
<EarlyFundingModal
|
||||
key={modal.planId}
|
||||
planId={modal.planId}
|
||||
planName={modal.planName}
|
||||
nextDueDate={modal.nextDueDate}
|
||||
timezone={userTimezone}
|
||||
onClose={() => {
|
||||
setEarlyFundingModals((prev) =>
|
||||
prev.filter((m) => m.planId !== modal.planId)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
136
web/src/pages/LoginPage.tsx
Normal file
136
web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { http } from "../api/http";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
|
||||
function useNextPath() {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("next") || "/";
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const next = useNextPath();
|
||||
const qc = useQueryClient();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [touched, setTouched] = useState({ email: false, password: false });
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const session = useAuthSession({ retry: false });
|
||||
|
||||
const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value);
|
||||
const emailError =
|
||||
(touched.email || submitted) && !email.trim()
|
||||
? "Email is required."
|
||||
: (touched.email || submitted) && !isEmailValid(email)
|
||||
? "Enter a valid email address."
|
||||
: "";
|
||||
const passwordError =
|
||||
(touched.password || submitted) && !password
|
||||
? "Password is required."
|
||||
: (touched.password || submitted) && password.length < 8
|
||||
? "Password must be at least 8 characters."
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (session.data?.userId) {
|
||||
navigate(next || "/", { replace: true });
|
||||
}
|
||||
}, [session.data, navigate, next]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
setSubmitted(true);
|
||||
if (emailError || passwordError) {
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await http<{ ok: true }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
qc.clear();
|
||||
navigate(next || "/", { replace: true });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
const message =
|
||||
status === 401
|
||||
? "Email or password is incorrect."
|
||||
: status === 400
|
||||
? "Enter a valid email and password."
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unable to login. Try again.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-16 px-4">
|
||||
<div className="card w-full max-w-md">
|
||||
<h1 className="section-title mb-2">Login</h1>
|
||||
<p className="muted mb-6">Sign in to continue budgeting.</p>
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
{/* Session errors are expected on login page, so don't show them */}
|
||||
<form className="stack gap-4" onSubmit={handleSubmit}>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Email</span>
|
||||
<input
|
||||
className={`input ${emailError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, email: true }))}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
{emailError && <span className="text-xs text-red-400">{emailError}</span>}
|
||||
</label>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<input
|
||||
className={`input ${passwordError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, password: true }))}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
{passwordError && (
|
||||
<span className="text-xs text-red-400">{passwordError}</span>
|
||||
)}
|
||||
</label>
|
||||
<button className="btn primary" type="submit" disabled={pending}>
|
||||
{pending ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="muted text-sm mt-6 text-center">
|
||||
Need an account?{" "}
|
||||
<Link
|
||||
className="link"
|
||||
to={next && next !== "/" ? `/register?next=${encodeURIComponent(next)}` : "/register"}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1423
web/src/pages/OnboardingPage.tsx
Normal file
1423
web/src/pages/OnboardingPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
170
web/src/pages/RegisterPage.tsx
Normal file
170
web/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { http } from "../api/http";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
|
||||
function useNextPath() {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("next") || "/";
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const next = useNextPath();
|
||||
const qc = useQueryClient();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [touched, setTouched] = useState({
|
||||
email: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const session = useAuthSession({ retry: false });
|
||||
|
||||
const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value);
|
||||
const emailError =
|
||||
(touched.email || submitted) && !email.trim()
|
||||
? "Email is required."
|
||||
: (touched.email || submitted) && !isEmailValid(email)
|
||||
? "Enter a valid email address."
|
||||
: "";
|
||||
const passwordError =
|
||||
(touched.password || submitted) && !password
|
||||
? "Password is required."
|
||||
: (touched.password || submitted) && password.length < 8
|
||||
? "Password must be at least 8 characters."
|
||||
: "";
|
||||
const confirmError =
|
||||
(touched.confirmPassword || submitted) && !confirmPassword
|
||||
? "Please confirm your password."
|
||||
: (touched.confirmPassword || submitted) &&
|
||||
password &&
|
||||
confirmPassword !== password
|
||||
? "Passwords do not match."
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (session.data?.userId) {
|
||||
navigate(next || "/", { replace: true });
|
||||
}
|
||||
}, [session.data, navigate, next]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
setSubmitted(true);
|
||||
if (emailError || passwordError || confirmError) {
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await http<{ ok: true }>("/auth/register", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
qc.clear();
|
||||
navigate(next || "/", { replace: true });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
const message =
|
||||
status === 409
|
||||
? "That email is already registered. Try signing in."
|
||||
: status === 400
|
||||
? "Enter a valid email and password."
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unable to register. Try again.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-16 px-4">
|
||||
<div className="card w-full max-w-md">
|
||||
<h1 className="section-title mb-2">Register</h1>
|
||||
<p className="muted mb-6">Create an account to track your money buckets.</p>
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
{/* Session errors are expected on registration page, so don't show them */}
|
||||
<form className="stack gap-4" onSubmit={handleSubmit}>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Email</span>
|
||||
<input
|
||||
className={`input ${emailError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, email: true }))}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
{emailError && <span className="text-xs text-red-400">{emailError}</span>}
|
||||
</label>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<input
|
||||
className={`input ${passwordError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, password: true }))}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{passwordError && (
|
||||
<span className="text-xs text-red-400">{passwordError}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Confirm password</span>
|
||||
<input
|
||||
className={`input ${confirmError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
setTouched((prev) => ({ ...prev, confirmPassword: true }))
|
||||
}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{confirmError && (
|
||||
<span className="text-xs text-red-400">{confirmError}</span>
|
||||
)}
|
||||
</label>
|
||||
<button className="btn primary" type="submit" disabled={pending}>
|
||||
{pending ? "Creating account..." : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="muted text-sm mt-6 text-center">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
className="link"
|
||||
to={next && next !== "/" ? `/login?next=${encodeURIComponent(next)}` : "/login"}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +1,581 @@
|
||||
import { useMemo, useState, type FormEvent, type ChangeEvent } from "react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { useCreateTransaction } from "../hooks/useTransactions";
|
||||
import { Money, Field, Button } from "../components/ui";
|
||||
import { useCreateTransaction, useDeleteTransaction } from "../hooks/useTransactions";
|
||||
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
|
||||
import { Money } from "../components/ui";
|
||||
import CurrencyInput from "../components/CurrencyInput";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { nowLocalISOStringMinute } from "../utils/format";
|
||||
import { getCurrentTimestamp, getBrowserTimezone, formatDateInTimezone } from "../utils/timezone";
|
||||
import EarlyFundingModal from "../components/EarlyFundingModal";
|
||||
import PaymentConfirmationModal from "../components/PaymentConfirmationModal";
|
||||
|
||||
|
||||
type Kind = "variable_spend" | "fixed_payment";
|
||||
type Errors = Partial<Record<"amount" | "kindSpecific", string>>;
|
||||
|
||||
function dollarsToCents(input: string): number {
|
||||
const n = Number.parseFloat(input || "0");
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100);
|
||||
const LS_KEY = "spend.lastKind";
|
||||
const OTHER_CATEGORY_ID = "__other__";
|
||||
|
||||
function parseCurrencyToCents(value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
||||
const [whole, fraction = ""] = cleaned.split(".");
|
||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
export default function SpendPage() {
|
||||
const dash = useDashboard();
|
||||
const m = useCreateTransaction();
|
||||
const createTx = useCreateTransaction();
|
||||
const deleteTx = useDeleteTransaction();
|
||||
const { push } = useToast();
|
||||
|
||||
const [kind, setKind] = useState<Kind>("variable_spend");
|
||||
const [amountStr, setAmountStr] = useState("");
|
||||
const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute());
|
||||
// ---- form state
|
||||
const [kind, setKind] = useState<Kind>(() => (localStorage.getItem(LS_KEY) as Kind) || "variable_spend");
|
||||
const [amountInput, setAmountInput] = useState<string>("");
|
||||
const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]);
|
||||
const [variableCategoryId, setVariableCategoryId] = useState<string>("");
|
||||
const [fixedPlanId, setFixedPlanId] = useState<string>("");
|
||||
const [note, setNote] = useState<string>("");
|
||||
const [showSavingsWarning, setShowSavingsWarning] = useState<boolean>(false);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [earlyFundingModal, setEarlyFundingModal] = useState<{
|
||||
planId: string;
|
||||
planName: string;
|
||||
nextDueDate?: string;
|
||||
} | null>(null);
|
||||
const [confirmationModal, setConfirmationModal] = useState<{
|
||||
message: string;
|
||||
payload: any;
|
||||
} | null>(null);
|
||||
const [overdraftConfirmation, setOverdraftConfirmation] = useState<{
|
||||
message: string;
|
||||
overdraftAmount: number;
|
||||
categoryName: string;
|
||||
payload: any;
|
||||
} | null>(null);
|
||||
const [showMoreRecent, setShowMoreRecent] = useState(false);
|
||||
|
||||
const amountCents = dollarsToCents(amountStr);
|
||||
|
||||
// Optional UX lock: block variable spend if chosen category has 0 balance.
|
||||
const selectedCategory = useMemo(() => {
|
||||
if (!dash.data) return null;
|
||||
return dash.data.variableCategories.find(c => String(c.id) === variableCategoryId) ?? null;
|
||||
}, [dash.data, variableCategoryId]);
|
||||
|
||||
const disableIfZeroBalance = true; // flip to false to allow negatives
|
||||
const categoryBlocked =
|
||||
kind === "variable_spend" &&
|
||||
!!selectedCategory &&
|
||||
disableIfZeroBalance &&
|
||||
(selectedCategory.balanceCents ?? 0) <= 0;
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!dash.data) return false;
|
||||
if (amountCents <= 0) return false;
|
||||
if (kind === "variable_spend") return !!variableCategoryId && !categoryBlocked;
|
||||
return !!fixedPlanId; // fixed_payment
|
||||
}, [dash.data, amountCents, kind, variableCategoryId, fixedPlanId, categoryBlocked]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
const payload = {
|
||||
kind,
|
||||
amountCents,
|
||||
occurredAtISO: new Date(occurredAt).toISOString(),
|
||||
variableCategoryId: kind === "variable_spend" ? Number(variableCategoryId) : undefined,
|
||||
fixedPlanId: kind === "fixed_payment" ? Number(fixedPlanId) : undefined,
|
||||
} as any;
|
||||
|
||||
m.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
push("ok", kind === "variable_spend" ? "Recorded spend." : "Recorded payment.");
|
||||
setAmountStr("");
|
||||
// Keep date defaulting to “now” for quick entry
|
||||
setOccurredAt(nowLocalISOStringMinute());
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Failed to record"),
|
||||
});
|
||||
};
|
||||
// remember chosen kind
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_KEY, kind);
|
||||
}, [kind]);
|
||||
|
||||
// data
|
||||
const cats = dash.data?.variableCategories ?? [];
|
||||
const plans = dash.data?.fixedPlans ?? [];
|
||||
const planOptions = useMemo(() => plans.map(p => ({ id: p.id, name: p.name })), [plans]);
|
||||
const catOptions = useMemo(() => cats.map(c => ({ id: c.id, name: c.name, isSavings: c.isSavings })), [cats]);
|
||||
|
||||
// Check if selected category is savings
|
||||
const selectedCategory = useMemo(() =>
|
||||
variableCategoryId === OTHER_CATEGORY_ID ? undefined : cats.find(c => c.id === variableCategoryId),
|
||||
[cats, variableCategoryId]
|
||||
);
|
||||
const isSpendingFromSavings = kind === "variable_spend" && selectedCategory?.isSavings;
|
||||
const isOtherSpend = kind === "variable_spend" && variableCategoryId === OTHER_CATEGORY_ID;
|
||||
|
||||
// quick stats
|
||||
const remainingVariable = useMemo(() => {
|
||||
const categories = dash.data?.variableCategories ?? [];
|
||||
return categories.reduce((acc, c) => acc + (c.balanceCents ?? 0), 0);
|
||||
}, [dash.data?.variableCategories]);
|
||||
|
||||
const budgetDenominator =
|
||||
(dash.data?.totals.variableBalanceCents ?? 0) +
|
||||
(dash.data?.totals.fixedRemainingCents ?? 0) ||
|
||||
1;
|
||||
const lowBalance =
|
||||
remainingVariable > 0 && remainingVariable / budgetDenominator < 0.1;
|
||||
const userTimezone = dash.data?.user?.timezone || getBrowserTimezone();
|
||||
const monthLabel = useMemo(
|
||||
() =>
|
||||
new Date().toLocaleString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
[userTimezone]
|
||||
);
|
||||
|
||||
const recentQuery = useTransactionsQuery({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
sort: "date",
|
||||
direction: "desc",
|
||||
});
|
||||
const recentTransactions = useMemo(() => {
|
||||
const items = recentQuery.data?.items ?? [];
|
||||
const filtered = items.filter((t) => {
|
||||
if (kind === "variable_spend") return t.kind === "variable_spend";
|
||||
return t.kind === "fixed_payment" && !t.isAutoPayment;
|
||||
});
|
||||
const limitRecent = showMoreRecent ? 10 : 3;
|
||||
return filtered.slice(0, limitRecent);
|
||||
}, [recentQuery.data, showMoreRecent, kind]);
|
||||
|
||||
// ---- validation
|
||||
function validate(): boolean {
|
||||
const next: Errors = {};
|
||||
if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0.";
|
||||
if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category.";
|
||||
if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan.";
|
||||
|
||||
// Enhanced validation for savings withdrawals
|
||||
if (isSpendingFromSavings) {
|
||||
if (!note || note.trim().length < 10) {
|
||||
next.kindSpecific = "Savings withdrawal requires detailed justification (min 10 characters).";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function onBlurField(key: keyof Errors) {
|
||||
return () => {
|
||||
// re-run partial validation
|
||||
const next = { ...errors };
|
||||
if (key === "amount") {
|
||||
if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0.";
|
||||
else delete next.amount;
|
||||
}
|
||||
if (key === "kindSpecific") {
|
||||
if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category.";
|
||||
else if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan.";
|
||||
else delete next.kindSpecific;
|
||||
}
|
||||
setErrors(next);
|
||||
};
|
||||
}
|
||||
|
||||
// ---- submit
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
// Show confirmation dialog for savings withdrawals
|
||||
if (isSpendingFromSavings && !showSavingsWarning) {
|
||||
setShowSavingsWarning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
kind,
|
||||
amountCents,
|
||||
occurredAtISO: getCurrentTimestamp(), // Use current time in UTC
|
||||
note: note.trim() || undefined,
|
||||
categoryId: kind === "variable_spend" && !isOtherSpend ? variableCategoryId : undefined,
|
||||
useAvailableBudget: isOtherSpend ? true : undefined,
|
||||
planId: kind === "fixed_payment" ? fixedPlanId : undefined,
|
||||
};
|
||||
|
||||
createTx.mutate(payload, {
|
||||
onSuccess: (res: any) => {
|
||||
if (kind === "fixed_payment") {
|
||||
const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill";
|
||||
const nextISO: string | undefined = res?.nextDueOn;
|
||||
const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined;
|
||||
const msg = nextLabel
|
||||
? `Funded ${planName}. Fully funded. Next due: ${nextLabel}`
|
||||
: `Funded ${planName}.`;
|
||||
push("ok", msg);
|
||||
|
||||
// Show early funding modal if this is a recurring bill
|
||||
if (nextISO) {
|
||||
setEarlyFundingModal({
|
||||
planId: fixedPlanId,
|
||||
planName,
|
||||
nextDueDate: nextISO,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
push("ok", "Recorded spend.");
|
||||
}
|
||||
// soft reset for quick entry
|
||||
setAmountInput("");
|
||||
setNote("");
|
||||
if (kind === "variable_spend") setVariableCategoryId("");
|
||||
if (kind === "fixed_payment") setFixedPlanId("");
|
||||
setErrors({});
|
||||
setShowSavingsWarning(false);
|
||||
setOverdraftConfirmation(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
// Check for overdraft confirmation requirement
|
||||
if (err?.code === "OVERDRAFT_CONFIRMATION") {
|
||||
setOverdraftConfirmation({
|
||||
message: err.message,
|
||||
overdraftAmount: err.overdraftAmount,
|
||||
categoryName: err.categoryName,
|
||||
payload: { ...payload, allowOverdraft: true },
|
||||
});
|
||||
} else if (err?.code === "CONFIRMATION_REQUIRED") {
|
||||
setConfirmationModal({
|
||||
message: err.message,
|
||||
payload: { ...payload, confirmVariableImpact: true },
|
||||
});
|
||||
} else {
|
||||
push("err", err?.message ?? "Failed to record.");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmOverdraft() {
|
||||
if (!overdraftConfirmation) return;
|
||||
createTx.mutate(overdraftConfirmation.payload, {
|
||||
onSuccess: () => {
|
||||
push("ok", `Recorded spend. ${overdraftConfirmation.categoryName} is now in overdraft.`);
|
||||
setAmountInput("");
|
||||
setNote("");
|
||||
setVariableCategoryId("");
|
||||
setErrors({});
|
||||
setOverdraftConfirmation(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
push("err", err?.message ?? "Failed to record.");
|
||||
setOverdraftConfirmation(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancelOverdraft() {
|
||||
setOverdraftConfirmation(null);
|
||||
}
|
||||
|
||||
function confirmSavingsWithdrawal() {
|
||||
setShowSavingsWarning(false);
|
||||
handleSubmit({ preventDefault: () => {} } as FormEvent);
|
||||
}
|
||||
|
||||
function cancelSavingsWithdrawal() {
|
||||
setShowSavingsWarning(false);
|
||||
}
|
||||
|
||||
// ---- UI
|
||||
return (
|
||||
<div className="grid gap-4 max-w-xl">
|
||||
<form onSubmit={onSubmit} className="card stack">
|
||||
<h2 className="section-title">Spend / Pay</h2>
|
||||
<div className="space-y-8 fade-in">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<h1 className="text-xl font-bold">Record Spend</h1>
|
||||
<span className="badge sm:ml-auto">{monthLabel}</span>
|
||||
</header>
|
||||
|
||||
{/* Summary cards */}
|
||||
<section className="grid md:grid-cols-3 gap-4">
|
||||
<InfoCard label="Variable budget remaining" value={<Money cents={remainingVariable} />} helper={lowBalance ? "Heads up: under 10% remaining." : undefined} />
|
||||
<InfoCard label="Fixed expenses" value={`${plans.length}`} helper="Active expenses you can pay into." />
|
||||
<InfoCard label="Expenses" value={`${cats.length}`} helper="Available expense categories." />
|
||||
</section>
|
||||
|
||||
{/* Recent activity */}
|
||||
<section className="rounded-xl border bg-[--color-panel] p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Recent activity</h2>
|
||||
<div className="text-xs muted">Quick undo for your latest transactions.</div>
|
||||
</div>
|
||||
{(recentQuery.data?.items?.length ?? 0) > 3 && (
|
||||
<button
|
||||
className="btn w-full sm:w-auto"
|
||||
type="button"
|
||||
onClick={() => setShowMoreRecent((prev) => !prev)}
|
||||
>
|
||||
{showMoreRecent ? "Show 3" : "Show 10"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{recentQuery.isLoading ? (
|
||||
<div className="muted text-sm">Loading recent transactions…</div>
|
||||
) : recentTransactions.length === 0 ? (
|
||||
<div className="muted text-sm">No recent transactions yet.</div>
|
||||
) : (
|
||||
<div className="stack gap-2">
|
||||
{recentTransactions.map((t) => (
|
||||
<div key={t.id} className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between rounded-xl border px-3 py-2">
|
||||
<div className="stack">
|
||||
<span className="font-semibold">
|
||||
{t.categoryName ?? t.planName ?? t.note ?? "–"}
|
||||
</span>
|
||||
<span className="text-xs muted">
|
||||
{t.kind === "variable_spend" ? "Variable spend" : "Fixed payment"} ·{" "}
|
||||
{formatDateInTimezone(t.occurredAt, userTimezone)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row items-center gap-3 sm:ml-auto">
|
||||
<Money cents={t.amountCents} />
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteTx.mutateAsync(t.id);
|
||||
push("ok", "Transaction undone.");
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Failed to undo transaction.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="card stack" noValidate>
|
||||
{/* Kind toggle */}
|
||||
<div className="row gap-2">
|
||||
<label className="row">
|
||||
<input
|
||||
type="radio"
|
||||
checked={kind === "variable_spend"}
|
||||
onChange={() => setKind("variable_spend")}
|
||||
/>
|
||||
<span className="text-sm">Variable Spend</span>
|
||||
</label>
|
||||
<label className="row">
|
||||
<input
|
||||
type="radio"
|
||||
checked={kind === "fixed_payment"}
|
||||
onChange={() => setKind("fixed_payment")}
|
||||
/>
|
||||
<span className="text-sm">Fixed Payment</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row" role="tablist" aria-label="Transaction type">
|
||||
<Toggle
|
||||
pressed={kind === "variable_spend"}
|
||||
onClick={() => { setKind("variable_spend"); setFixedPlanId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }}
|
||||
>
|
||||
Variable Spend
|
||||
</Toggle>
|
||||
<Toggle
|
||||
pressed={kind === "fixed_payment"}
|
||||
onClick={() => { setKind("fixed_payment"); setVariableCategoryId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }}
|
||||
>
|
||||
Fixed Payment
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
{/* Pick target */}
|
||||
{/* Amount */}
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Amount</span>
|
||||
<CurrencyInput
|
||||
className={"input" + (errors.amount ? " border-[--color-ink]" : "")}
|
||||
value={amountInput}
|
||||
onValue={setAmountInput}
|
||||
onBlur={onBlurField("amount")}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.amount && <div className="toast-err mt-2">{errors.amount}</div>}
|
||||
</label>
|
||||
|
||||
|
||||
|
||||
{/* Kind-specific inputs */}
|
||||
{kind === "variable_spend" ? (
|
||||
<Field label="Category">
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Category</span>
|
||||
<select
|
||||
className="input"
|
||||
value={variableCategoryId}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setVariableCategoryId(e.target.value)}
|
||||
onChange={(e) => setVariableCategoryId(e.target.value)}
|
||||
onBlur={onBlurField("kindSpecific")}
|
||||
>
|
||||
<option value="">Select a category…</option>
|
||||
{cats
|
||||
.slice()
|
||||
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
|
||||
.map(c => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name} — <Money cents={c.balanceCents} />
|
||||
</option>
|
||||
))}
|
||||
<option value={OTHER_CATEGORY_ID}>Other (use available budget)</option>
|
||||
{catOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{errors.kindSpecific && <div className="toast-err mt-2">{errors.kindSpecific}</div>}
|
||||
</label>
|
||||
) : (
|
||||
<Field label="Fixed Plan">
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Fixed expense</span>
|
||||
<select
|
||||
className="input"
|
||||
value={fixedPlanId}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setFixedPlanId(e.target.value)}
|
||||
onChange={(e) => setFixedPlanId(e.target.value)}
|
||||
onBlur={onBlurField("kindSpecific")}
|
||||
>
|
||||
<option value="">Select a plan…</option>
|
||||
{plans
|
||||
.slice()
|
||||
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
|
||||
.map(p => (
|
||||
<option key={p.id} value={String(p.id)}>
|
||||
{p.name} — Funded <Money cents={p.fundedCents} /> / <Money cents={p.totalCents} />
|
||||
</option>
|
||||
))}
|
||||
{planOptions.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{errors.kindSpecific && <div className="toast-err mt-2">{errors.kindSpecific}</div>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Amount + Date */}
|
||||
<Field label="Amount (USD)">
|
||||
<CurrencyInput value={amountStr} onValue={setAmountStr} />
|
||||
</Field>
|
||||
<Field label="When">
|
||||
{/* Optional note */}
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">
|
||||
Note {isSpendingFromSavings ? "(required for savings withdrawal)" : "(optional)"}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="datetime-local"
|
||||
value={occurredAt}
|
||||
onChange={(e) => setOccurredAt(e.target.value)}
|
||||
className={`input ${isSpendingFromSavings ? 'border-warning' : ''}`}
|
||||
placeholder={isSpendingFromSavings ? "Detailed reason for savings withdrawal..." : "e.g., Grocery run at Market St."}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{isSpendingFromSavings && (
|
||||
<div className="text-xs text-warning mt-1">
|
||||
Withdrawing from savings - please explain why this is necessary
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Guard + submit */}
|
||||
{categoryBlocked && (
|
||||
<div className="toast-err">
|
||||
Selected category has no available balance. Add income or pick another category.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button disabled={m.isPending || !canSubmit}>
|
||||
{m.isPending ? "Saving…" : "Submit"}
|
||||
</Button>
|
||||
|
||||
{m.error && <div className="toast-err">⚠️ {(m.error as any).message}</div>}
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-3">
|
||||
<span />
|
||||
<button type="submit" className="btn sm:ml-auto" disabled={createTx.isPending}>
|
||||
{createTx.isPending ? "Saving…" : kind === "variable_spend" ? "Add Spend" : "Add Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Savings Withdrawal Warning Dialog */}
|
||||
{showSavingsWarning && (
|
||||
<div className="fixed inset-0 bg-color-bg/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[--color-bg] border-2 border-[var(--color-warning-border)] rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center gap-3 text-warning">
|
||||
<span className="text-2xl">!</span>
|
||||
<h3 className="font-semibold text-lg">Savings Withdrawal Warning</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-color-text">You're about to withdraw <strong><Money cents={amountCents} /></strong> from your <strong>{selectedCategory?.name}</strong> savings.</p>
|
||||
<p className="text-warning-light">Reason: "{note}"</p>
|
||||
<p className="text-muted">This will reduce your savings progress. Are you sure this is necessary?</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelSavingsWithdrawal}
|
||||
className="btn flex-1 bg-gray-600 hover:bg-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmSavingsWithdrawal}
|
||||
className="btn flex-1 bg-warning-solid hover:bg-warning-hover text-white"
|
||||
>
|
||||
Confirm Withdrawal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdraft Confirmation Modal */}
|
||||
{overdraftConfirmation && (
|
||||
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[--color-bg] border-2 border-yellow-500 rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center gap-3 text-yellow-400">
|
||||
<span className="text-2xl">!</span>
|
||||
<h3 className="font-semibold text-lg">Overdraft Warning</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>{overdraftConfirmation.message}</p>
|
||||
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<div className="text-yellow-300 font-medium">
|
||||
{overdraftConfirmation.categoryName} will go negative by <Money cents={overdraftConfirmation.overdraftAmount} />
|
||||
</div>
|
||||
<div className="text-xs muted mt-1">
|
||||
This deficit will be automatically recovered from your next income.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelOverdraft}
|
||||
className="btn flex-1 bg-gray-600 hover:bg-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmOverdraft}
|
||||
className="btn flex-1 bg-yellow-600 hover:bg-yellow-500"
|
||||
>
|
||||
Allow Overdraft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Early Funding Modal */}
|
||||
{earlyFundingModal && (
|
||||
<EarlyFundingModal
|
||||
planId={earlyFundingModal.planId}
|
||||
planName={earlyFundingModal.planName}
|
||||
nextDueDate={earlyFundingModal.nextDueDate}
|
||||
timezone={userTimezone}
|
||||
onClose={() => setEarlyFundingModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmationModal && (
|
||||
<PaymentConfirmationModal
|
||||
message={confirmationModal.message}
|
||||
onConfirm={() => {
|
||||
createTx.mutate(confirmationModal.payload, {
|
||||
onSuccess: (res: any) => {
|
||||
if (kind === "fixed_payment") {
|
||||
const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill";
|
||||
const nextISO: string | undefined = res?.nextDueOn;
|
||||
const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined;
|
||||
const msg = nextLabel
|
||||
? `Paid ${planName}. Next due: ${nextLabel}`
|
||||
: `Paid ${planName}.`;
|
||||
push("ok", msg);
|
||||
|
||||
if (nextISO) {
|
||||
setEarlyFundingModal({
|
||||
planId: fixedPlanId,
|
||||
planName,
|
||||
nextDueDate: nextISO,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
push("ok", "Recorded spend.");
|
||||
}
|
||||
setAmountInput("");
|
||||
setNote("");
|
||||
if (kind === "variable_spend") setVariableCategoryId("");
|
||||
if (kind === "fixed_payment") setFixedPlanId("");
|
||||
setErrors({});
|
||||
setShowSavingsWarning(false);
|
||||
setConfirmationModal(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
push("err", err?.message ?? "Failed to record.");
|
||||
setConfirmationModal(null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCancel={() => setConfirmationModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Toggle({ pressed, onClick, children }: { pressed: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={pressed}
|
||||
onClick={onClick}
|
||||
className={"nav-link " + (pressed ? "nav-link-active" : "")}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value, helper }: { label: string; value: React.ReactNode; helper?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-[--color-panel] p-4 shadow-sm">
|
||||
<div className="text-xs uppercase tracking-wide muted">{label}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{value}</div>
|
||||
{helper && <div className="text-xs muted mt-1">{helper}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,183 +1,263 @@
|
||||
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Money } from "../components/ui";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import Pagination from "../components/Pagination";
|
||||
|
||||
type Kind = "all" | "variable_spend" | "fixed_payment";
|
||||
|
||||
function isoDateOnly(d: Date) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const da = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${da}`;
|
||||
}
|
||||
import { useTransactionsQuery, type TxQueryParams } from "../hooks/useTransactionsQuery";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { getTodayInTimezone, getBrowserTimezone, addDaysToDate } from "../utils/timezone";
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const today = isoDateOnly(new Date());
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const { data: dashboard } = useDashboard();
|
||||
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all");
|
||||
const [catFilter, setCatFilter] = useState<string>("all");
|
||||
const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("month");
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 100;
|
||||
|
||||
// init from URL
|
||||
const initKind = (sp.get("kind") as Kind) || "all";
|
||||
const initQ = sp.get("q") || "";
|
||||
const initFrom = sp.get("from") || "";
|
||||
const initTo = sp.get("to") || today;
|
||||
const initPage = Math.max(1, Number(sp.get("page") || 1));
|
||||
// Get current date in user's timezone
|
||||
const todayStr = getTodayInTimezone(userTimezone);
|
||||
const [year, month] = todayStr.split('-').map(Number);
|
||||
const firstOfMonthStr = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const thirtyAgoStr = addDaysToDate(todayStr, -30, userTimezone);
|
||||
|
||||
const [kind, setKind] = useState<Kind>(initKind);
|
||||
const [qRaw, setQRaw] = useState(initQ);
|
||||
const [q, setQ] = useState(initQ.trim());
|
||||
const [from, setFrom] = useState(initFrom);
|
||||
const [to, setTo] = useState(initTo);
|
||||
const [page, setPage] = useState(initPage);
|
||||
const limit = 20;
|
||||
|
||||
// debounce search
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => {
|
||||
setPage(1);
|
||||
setQ(qRaw.trim());
|
||||
}, 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [qRaw]);
|
||||
|
||||
// write to URL on change
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams();
|
||||
if (kind !== "all") next.set("kind", kind);
|
||||
if (q) next.set("q", q);
|
||||
if (from) next.set("from", from);
|
||||
if (to) next.set("to", to);
|
||||
if (page !== 1) next.set("page", String(page));
|
||||
setSp(next, { replace: true });
|
||||
}, [kind, q, from, to, page, setSp]);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
const params = useMemo<TxQueryParams>(() => {
|
||||
const qp: TxQueryParams = {
|
||||
page,
|
||||
limit,
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
kind: kind === "all" ? undefined : kind,
|
||||
}),
|
||||
[page, limit, q, from, to, kind]
|
||||
);
|
||||
sort: "date",
|
||||
direction: "desc",
|
||||
};
|
||||
const term = search.trim();
|
||||
if (term) qp.q = term;
|
||||
if (typeFilter !== "all") {
|
||||
qp.kind = typeFilter === "variable" ? "variable_spend" : "fixed_payment";
|
||||
}
|
||||
if (catFilter !== "all") {
|
||||
qp.bucketId = catFilter;
|
||||
}
|
||||
if (dateFilter === "month") {
|
||||
qp.from = firstOfMonthStr;
|
||||
} else if (dateFilter === "30") {
|
||||
qp.from = thirtyAgoStr;
|
||||
} else {
|
||||
qp.from = undefined;
|
||||
}
|
||||
return qp;
|
||||
}, [page, limit, search, typeFilter, catFilter, dateFilter, firstOfMonthStr, thirtyAgoStr]);
|
||||
|
||||
const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params);
|
||||
const txQuery = useTransactionsQuery(params);
|
||||
const transactions = txQuery.data?.items ?? [];
|
||||
const total = txQuery.data?.total ?? 0;
|
||||
|
||||
const catOptions = useMemo(() => {
|
||||
if (transactions.length === 0) return [];
|
||||
const buckets = new Map<string, string>();
|
||||
transactions.forEach((t) => {
|
||||
if (t.categoryId && t.categoryName) buckets.set(t.categoryId, t.categoryName);
|
||||
if (t.planId && t.planName) buckets.set(t.planId, t.planName);
|
||||
});
|
||||
return Array.from(buckets.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [transactions]);
|
||||
|
||||
const clear = () => {
|
||||
setKind("all");
|
||||
setQRaw("");
|
||||
setFrom("");
|
||||
setTo(today);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalAmount = rows.reduce((s, r) => s + (r.amountCents ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<section className="card">
|
||||
<h2 className="section-title">Transactions</h2>
|
||||
<div className="fade-in space-y-8">
|
||||
<header className="row">
|
||||
<h1 className="text-xl font-bold">Records</h1>
|
||||
</header>
|
||||
|
||||
<div className="topnav p-3 rounded-xl sticky top-14 z-10" style={{ backdropFilter: "blur(8px)" }}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<input
|
||||
className="input w-full sm:w-56"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border rounded-xl p-1 bg-[--color-panel] w-full sm:w-auto">
|
||||
<FilterToggle
|
||||
label="All"
|
||||
active={typeFilter === "all"}
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="Variable"
|
||||
active={typeFilter === "variable"}
|
||||
onClick={() => {
|
||||
setTypeFilter("variable");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="Fixed"
|
||||
active={typeFilter === "fixed"}
|
||||
onClick={() => {
|
||||
setTypeFilter("fixed");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="row gap-2 flex-wrap mb-3">
|
||||
<select
|
||||
className="input w-44"
|
||||
value={kind}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setKind(e.target.value as Kind);
|
||||
className="input w-full sm:w-auto"
|
||||
value={catFilter}
|
||||
onChange={(e) => {
|
||||
setCatFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="variable_spend">Variable Spend</option>
|
||||
<option value="fixed_payment">Fixed Payment</option>
|
||||
<option value="all">All categories & plans…</option>
|
||||
{catOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="input w-56"
|
||||
placeholder="Search…"
|
||||
value={qRaw}
|
||||
onChange={(e) => setQRaw(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-40"
|
||||
type="date"
|
||||
value={from}
|
||||
|
||||
<select
|
||||
className="input w-full sm:w-auto"
|
||||
value={dateFilter}
|
||||
onChange={(e) => {
|
||||
setFrom(e.target.value);
|
||||
setDateFilter(e.target.value as typeof dateFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="input w-40"
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(e) => {
|
||||
setTo(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn" onClick={clear}>
|
||||
Clear
|
||||
</button>
|
||||
<div className="badge ml-auto">
|
||||
{isFetching ? "Refreshing…" : `Showing ${rows.length}`}
|
||||
</div>
|
||||
>
|
||||
<option value="month">This month</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* States */}
|
||||
{isLoading && <div className="muted text-sm">Loading…</div>}
|
||||
{error && !isLoading && (
|
||||
<div className="toast-err mb-3">
|
||||
Couldn’t load transactions.{" "}
|
||||
<button className="btn ml-2" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!isLoading && rows.length === 0 ? (
|
||||
<div className="muted text-sm">No transactions match your filters.</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="table">
|
||||
{txQuery.isLoading ? (
|
||||
<Skeleton className="h-48" />
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden md:block card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Date</th>
|
||||
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Type</th>
|
||||
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Category</th>
|
||||
<th className="text-right py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">{t.kind}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Money cents={t.amountCents} />
|
||||
{transactions.map((t) => (
|
||||
<tr key={t.id} className="border-t transition-colors hover:bg-[--color-panel-hover]">
|
||||
<td className="py-3 px-4">{formatDate(t.occurredAt, userTimezone)}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="inline-flex items-center gap-2 text-sm muted">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
t.kind === "variable_spend"
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
}}
|
||||
></span>
|
||||
{prettyKind(t.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
{new Date(t.occurredAt).toLocaleString()}
|
||||
<td className="py-3 px-4 font-medium">{t.categoryName ?? t.planName ?? t.note ?? "–"}</td>
|
||||
<td className="py-3 px-4 text-right font-semibold">
|
||||
<Money cents={t.amountCents} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="row mt-2">
|
||||
<div className="muted text-sm">Page total</div>
|
||||
<div className="ml-auto font-semibold">
|
||||
<Money cents={totalAmount} />
|
||||
</div>
|
||||
</div>
|
||||
{data && (
|
||||
<Pagination page={data.page} limit={data.limit} total={data.total} onPage={setPage} />
|
||||
{total > limit && (
|
||||
<Pagination page={page} limit={limit} total={total} onPage={setPage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden space-y-3 fade-in">
|
||||
{transactions.map((t) => (
|
||||
<div key={t.id} className="rounded-xl border bg-[--color-panel] p-4 shadow-sm stack">
|
||||
<div className="row">
|
||||
<div className="stack">
|
||||
<span className="font-semibold">{t.categoryName ?? t.planName ?? t.note ?? "–"}</span>
|
||||
<span className="row gap-2 text-sm muted">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
t.kind === "variable_spend"
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
}}
|
||||
></span>
|
||||
{prettyKind(t.kind)} · {formatDate(t.occurredAt, userTimezone)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<Money cents={t.amountCents} />
|
||||
</div>
|
||||
</div>
|
||||
{t.note && (
|
||||
<div className="row mt-2">
|
||||
<span className="muted text-sm">{t.note}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{total > limit && (
|
||||
<Pagination page={page} limit={limit} total={total} onPage={setPage} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={"nav-link text-sm " + (active ? "nav-link-active font-semibold" : "")}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function prettyKind(kind: string) {
|
||||
if (kind === "variable_spend") return "Variable Spend";
|
||||
if (kind === "fixed_payment") return "Fixed Payment";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function formatDate(iso: string, userTimezone: string) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: userTimezone,
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
515
web/src/pages/settings/AccountSettings.tsx
Normal file
515
web/src/pages/settings/AccountSettings.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
// web/src/pages/settings/AccountSettings.tsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useAuthSession } from "../../hooks/useAuthSession";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { http } from "../../api/http";
|
||||
import {
|
||||
addDaysToDate,
|
||||
dateStringToUTCMidnight,
|
||||
getBrowserTimezone,
|
||||
getTodayInTimezone,
|
||||
isoToDateString,
|
||||
} from "../../utils/timezone";
|
||||
|
||||
export default function AccountSettings() {
|
||||
const qc = useQueryClient();
|
||||
const { data: session, refetch: refetchSession } = useAuthSession();
|
||||
const { data: dashboard } = useDashboard();
|
||||
const { push } = useToast();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [timezone, setTimezone] = useState(getBrowserTimezone());
|
||||
const [isUpdatingTimezone, setIsUpdatingTimezone] = useState(false);
|
||||
const [incomeFrequency, setIncomeFrequency] = useState<
|
||||
"weekly" | "biweekly" | "monthly"
|
||||
>("weekly");
|
||||
const [nextPayDate, setNextPayDate] = useState("");
|
||||
const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false);
|
||||
const [fixedExpensePercentage, setFixedExpensePercentage] = useState(40);
|
||||
const [isUpdatingConservatism, setIsUpdatingConservatism] = useState(false);
|
||||
|
||||
const browserTimezone = useMemo(() => getBrowserTimezone(), []);
|
||||
const timezoneOptions = useMemo(() => {
|
||||
const supported =
|
||||
typeof Intl !== "undefined" && "supportedValuesOf" in Intl
|
||||
? (Intl as unknown as { supportedValuesOf: (k: string) => string[] }).supportedValuesOf("timeZone")
|
||||
: [];
|
||||
const list = supported.length ? supported : [browserTimezone];
|
||||
const withBrowser = list.includes(browserTimezone) ? list : [browserTimezone, ...list];
|
||||
return Array.from(new Set(withBrowser)).sort();
|
||||
}, [browserTimezone]);
|
||||
|
||||
// Load session data when available
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
setDisplayName(session.displayName || "");
|
||||
setEmail(session.email || "");
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard?.user?.timezone) {
|
||||
setTimezone(dashboard.user.timezone);
|
||||
}
|
||||
}, [dashboard?.user?.timezone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard?.user?.incomeType !== "regular") return;
|
||||
if (dashboard.user.incomeFrequency) {
|
||||
setIncomeFrequency(dashboard.user.incomeFrequency);
|
||||
}
|
||||
if (dashboard.user.firstIncomeDate && dashboard.user.timezone) {
|
||||
setNextPayDate(
|
||||
isoToDateString(dashboard.user.firstIncomeDate, dashboard.user.timezone)
|
||||
);
|
||||
} else if (dashboard?.user?.timezone) {
|
||||
setNextPayDate(getTodayInTimezone(dashboard.user.timezone));
|
||||
}
|
||||
}, [
|
||||
dashboard?.user?.incomeType,
|
||||
dashboard?.user?.incomeFrequency,
|
||||
dashboard?.user?.firstIncomeDate,
|
||||
dashboard?.user?.timezone,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard?.user?.incomeType !== "irregular") return;
|
||||
setFixedExpensePercentage(dashboard.user.fixedExpensePercentage ?? 40);
|
||||
}, [dashboard?.user?.incomeType, dashboard?.user?.fixedExpensePercentage]);
|
||||
|
||||
const scheduleError = useMemo(() => {
|
||||
if (dashboard?.user?.incomeType !== "regular") return null;
|
||||
if (!nextPayDate) return "Next payday is required.";
|
||||
const todayStr = getTodayInTimezone(timezone);
|
||||
if (nextPayDate < todayStr) {
|
||||
return "Next payday must be today or in the future.";
|
||||
}
|
||||
const maxDays =
|
||||
incomeFrequency === "weekly"
|
||||
? 7
|
||||
: incomeFrequency === "biweekly"
|
||||
? 14
|
||||
: 31;
|
||||
const maxDateStr = addDaysToDate(todayStr, maxDays, timezone);
|
||||
if (nextPayDate > maxDateStr) {
|
||||
return `For ${incomeFrequency} income, next payday should be within ${maxDays} days.`;
|
||||
}
|
||||
return null;
|
||||
}, [dashboard?.user?.incomeType, incomeFrequency, nextPayDate, timezone]);
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
await http("/me", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
displayName: displayName.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch session to update displayed data
|
||||
await refetchSession();
|
||||
push("ok", "Profile updated successfully");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update profile");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
push("err", "New passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
push("err", "New password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
await http("/me/password", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
push("ok", "Password changed successfully");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to change password");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTimezone = async () => {
|
||||
if (!timezone) return;
|
||||
setIsUpdatingTimezone(true);
|
||||
try {
|
||||
await http("/user/config", {
|
||||
method: "PATCH",
|
||||
body: { timezone },
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
push("ok", "Timezone updated");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update timezone");
|
||||
} finally {
|
||||
setIsUpdatingTimezone(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSchedule = async () => {
|
||||
if (dashboard?.user?.incomeType !== "regular") return;
|
||||
if (scheduleError) {
|
||||
push("err", scheduleError);
|
||||
return;
|
||||
}
|
||||
if (!nextPayDate) {
|
||||
push("err", "Next payday is required.");
|
||||
return;
|
||||
}
|
||||
setIsUpdatingSchedule(true);
|
||||
try {
|
||||
await http("/user/config", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
incomeFrequency,
|
||||
budgetPeriod: incomeFrequency,
|
||||
firstIncomeDate: dateStringToUTCMidnight(nextPayDate, timezone),
|
||||
},
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
push("ok", "Income schedule updated");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update schedule");
|
||||
} finally {
|
||||
setIsUpdatingSchedule(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await http("/auth/logout", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
} finally {
|
||||
qc.clear();
|
||||
window.location.replace("/login");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateConservatism = async () => {
|
||||
if (dashboard?.user?.incomeType !== "irregular") return;
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(fixedExpensePercentage)));
|
||||
setIsUpdatingConservatism(true);
|
||||
try {
|
||||
await http("/user/config", {
|
||||
method: "PATCH",
|
||||
body: { fixedExpensePercentage: clamped },
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
push("ok", "Auto-fund percentage updated");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update auto-fund percentage");
|
||||
} finally {
|
||||
setIsUpdatingConservatism(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Profile Information</h3>
|
||||
<p className="settings-section-desc">Update your display name and view your account details.</p>
|
||||
</div>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input settings-input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Email Address</label>
|
||||
<div className="font-medium">{email || "Not set"}</div>
|
||||
<p className="settings-help">
|
||||
Email address cannot be changed at this time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn"
|
||||
disabled={isUpdating || !displayName.trim()}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update Profile"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Timezone */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Timezone</h3>
|
||||
<p className="settings-section-desc">Used for income dates and due dates. Update this if you travel or want a different reference timezone.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Timezone</label>
|
||||
<select
|
||||
className="input settings-input"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
>
|
||||
{timezoneOptions.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="settings-help">Current: {dashboard?.user?.timezone ?? "Not set"}</p>
|
||||
</div>
|
||||
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setTimezone(browserTimezone)}
|
||||
disabled={timezone === browserTimezone}
|
||||
>
|
||||
Use browser timezone
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleUpdateTimezone}
|
||||
disabled={isUpdatingTimezone || !timezone}
|
||||
>
|
||||
{isUpdatingTimezone ? "Saving..." : "Save Timezone"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Income Schedule */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Income Schedule</h3>
|
||||
<p className="settings-section-desc">
|
||||
{dashboard?.user?.incomeType !== "regular"
|
||||
? "Income schedule changes are available for regular income users only."
|
||||
: "Update your pay frequency and next payday. This recalculates payment plan timelines without changing current funding."}
|
||||
</p>
|
||||
</div>
|
||||
{dashboard?.user?.incomeType === "regular" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Pay frequency</label>
|
||||
<select
|
||||
className="input settings-input"
|
||||
value={incomeFrequency}
|
||||
onChange={(e) =>
|
||||
setIncomeFrequency(e.target.value as typeof incomeFrequency)
|
||||
}
|
||||
>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Next payday</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input settings-input"
|
||||
value={nextPayDate}
|
||||
onChange={(e) => setNextPayDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{scheduleError && (
|
||||
<div className="text-sm text-red-400">{scheduleError}</div>
|
||||
)}
|
||||
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleUpdateSchedule}
|
||||
disabled={isUpdatingSchedule || !!scheduleError}
|
||||
>
|
||||
{isUpdatingSchedule ? "Saving..." : "Save Schedule"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Irregular Income Auto-Fund */}
|
||||
{dashboard?.user?.incomeType === "irregular" && (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Irregular Income Auto-Fund</h3>
|
||||
<p className="settings-section-desc">Choose how much of each income deposit is reserved to auto-fund fixed expenses.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Auto-fund percentage: {fixedExpensePercentage}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
className="w-full max-w-md"
|
||||
value={fixedExpensePercentage}
|
||||
onChange={(e) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
setFixedExpensePercentage(Number.isFinite(nextValue) ? nextValue : 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleUpdateConservatism}
|
||||
disabled={isUpdatingConservatism}
|
||||
>
|
||||
{isUpdatingConservatism ? "Saving..." : "Save Auto-Fund"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Change Password */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Change Password</h3>
|
||||
<p className="settings-section-desc">Update your account password for security.</p>
|
||||
</div>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input settings-input"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input settings-input"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<p className="settings-help">Must be at least 8 characters long.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input settings-input"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn"
|
||||
disabled={
|
||||
isUpdating ||
|
||||
!currentPassword ||
|
||||
!newPassword ||
|
||||
!confirmPassword ||
|
||||
newPassword !== confirmPassword
|
||||
}
|
||||
>
|
||||
{isUpdating ? "Changing..." : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Account Actions */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Account Actions</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="settings-option-card" style={{ cursor: "default" }}>
|
||||
<div className="option-label">Sign out</div>
|
||||
<p className="option-desc mb-3">Log out of SkyMoney on this device.</p>
|
||||
<button type="button" className="btn" onClick={handleLogout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-option-card" style={{ cursor: "default", borderLeftColor: "var(--color-warning, #f59e0b)", borderLeftWidth: "3px" }}>
|
||||
<div className="option-label">Export Data</div>
|
||||
<p className="option-desc mb-3">Download a copy of all your financial data including transactions, expenses, and fixed expenses.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
push("ok", "Data export feature coming soon");
|
||||
}}
|
||||
>
|
||||
Export My Data
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-section settings-danger-section" style={{ marginBottom: 0 }}>
|
||||
<div className="settings-section-header" style={{ borderBottom: "none", paddingBottom: 0, marginBottom: "0.5rem" }}>
|
||||
<h3 className="settings-section-title">Delete Account</h3>
|
||||
<p className="settings-section-desc">Permanently delete your account and all associated data. This action cannot be undone.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
|
||||
onClick={() => {
|
||||
push("ok", "Account deletion requires email confirmation");
|
||||
}}
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,91 @@
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
// web/src/pages/settings/CategoriesPage.tsx
|
||||
import { useMemo, useState, useEffect, type FormEvent } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { Money } from "../../components/ui";
|
||||
import SettingsNav from "./_SettingsNav";
|
||||
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from "../../hooks/useCategories";
|
||||
import {
|
||||
useCategories,
|
||||
useCreateCategory,
|
||||
useUpdateCategory,
|
||||
useDeleteCategory,
|
||||
} from "../../hooks/useCategories";
|
||||
import { useToast } from "../../components/Toast";
|
||||
|
||||
type Row = { id: number; name: string; percent: number; priority: number; isSavings: boolean; balanceCents: number };
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings: boolean;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
function SumBadge({ total }: { total: number }) {
|
||||
const ok = total === 100;
|
||||
const tone =
|
||||
total === 100
|
||||
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
|
||||
: total < 100
|
||||
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
|
||||
: "bg-red-500/10 text-red-100 border border-red-500/40";
|
||||
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
|
||||
return (
|
||||
<div className={`badge ${ok ? "" : ""}`}>
|
||||
Total: {total}%
|
||||
<div className={`badge ${tone}`}>
|
||||
{label}: {total}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsCategoriesPage() {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const cats: Row[] = useCategories();
|
||||
const cats = useCategories() as Row[];
|
||||
const createM = useCreateCategory();
|
||||
const updateM = useUpdateCategory();
|
||||
const deleteM = useDeleteCategory();
|
||||
const { push } = useToast();
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
const MIN_SAVINGS_PERCENT = 20;
|
||||
|
||||
const total = useMemo(() => cats.reduce((s, c) => s + c.percent, 0), [cats]);
|
||||
const total = useMemo(
|
||||
() => cats.reduce((s, c) => s + c.percent, 0),
|
||||
[cats],
|
||||
);
|
||||
const savingsTotal = useMemo(
|
||||
() => cats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
|
||||
[cats]
|
||||
);
|
||||
const remainingPercent = Math.max(0, 100 - total);
|
||||
|
||||
// Drag ordering state (initially from priority)
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const sorted = cats
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority || a.name.localeCompare(b.name),
|
||||
);
|
||||
const next = sorted.map((c) => c.id);
|
||||
// Reset order when cats change in length or ids
|
||||
if (
|
||||
order.length !== next.length ||
|
||||
next.some((id, i) => order[i] !== id)
|
||||
) {
|
||||
setOrder(next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cats.map((c) => c.id).join("|")]);
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
@@ -32,45 +93,157 @@ export default function SettingsCategoriesPage() {
|
||||
const [priority, setPriority] = useState("");
|
||||
const [isSavings, setIsSavings] = useState(false);
|
||||
|
||||
const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100;
|
||||
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled =
|
||||
!name.trim() ||
|
||||
parsedPercent <= 0 ||
|
||||
parsedPercent > 100 ||
|
||||
parsedPercent > remainingPercent ||
|
||||
createM.isPending;
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const normalizedName = normalizeName(name);
|
||||
if (cats.some((c) => normalizeName(c.name) === normalizedName)) {
|
||||
push("err", `Expense name '${normalizedName}' already exists`);
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
percent: Math.max(0, Math.min(100, Math.floor(Number(percent) || 0))),
|
||||
priority: Math.max(0, Math.floor(Number(priority) || 0)),
|
||||
isSavings
|
||||
name: normalizedName,
|
||||
percent: parsedPercent,
|
||||
priority: parsedPriority,
|
||||
isSavings,
|
||||
};
|
||||
if (!body.name) return;
|
||||
if (body.percent > remainingPercent) {
|
||||
push("err", `Only ${remainingPercent}% is available right now.`);
|
||||
return;
|
||||
}
|
||||
const nextTotal = total + body.percent;
|
||||
const nextSavingsTotal = savingsTotal + (body.isSavings ? body.percent : 0);
|
||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
createM.mutate(body, {
|
||||
onSuccess: () => {
|
||||
push("ok", "Category created");
|
||||
setName(""); setPercent(""); setPriority(""); setIsSavings(false);
|
||||
push("ok", "Expense created");
|
||||
setName("");
|
||||
setPercent("");
|
||||
setPriority("");
|
||||
setIsSavings(false);
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Create failed")
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Create failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onEdit = (id: number, patch: Partial<Row>) => {
|
||||
updateM.mutate({ id, body: patch }, {
|
||||
onError: (err: any) => push("err", err?.message ?? "Update failed")
|
||||
});
|
||||
const onEdit = (id: string, patch: Partial<Row>) => {
|
||||
if (patch.name !== undefined) {
|
||||
const normalizedName = normalizeName(patch.name);
|
||||
if (
|
||||
cats.some((c) => c.id !== id && normalizeName(c.name) === normalizedName)
|
||||
) {
|
||||
push("err", `Expense name '${normalizedName}' already exists`);
|
||||
return;
|
||||
}
|
||||
patch.name = normalizedName;
|
||||
}
|
||||
if (patch.percent !== undefined) {
|
||||
const current = cats.find((c) => c.id === id);
|
||||
if (!current) return;
|
||||
const sanitized = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.floor(patch.percent)),
|
||||
);
|
||||
const nextTotal = total - current.percent + sanitized;
|
||||
if (nextTotal > 100) {
|
||||
push("err", `Updating this would push totals to ${nextTotal}%.`);
|
||||
return;
|
||||
}
|
||||
patch.percent = sanitized;
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
patch.priority = Math.max(0, Math.floor(patch.priority));
|
||||
}
|
||||
if (patch.isSavings !== undefined || patch.percent !== undefined) {
|
||||
const current = cats.find((c) => c.id === id);
|
||||
if (!current) return;
|
||||
const nextPercent = patch.percent ?? current.percent;
|
||||
const wasSavings = current.isSavings ? current.percent : 0;
|
||||
const nextIsSavings = patch.isSavings ?? current.isSavings;
|
||||
const nextSavings = nextIsSavings ? nextPercent : 0;
|
||||
const nextTotal =
|
||||
total - current.percent + (patch.percent ?? current.percent);
|
||||
const nextSavingsTotal =
|
||||
savingsTotal - wasSavings + nextSavings;
|
||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateM.mutate(
|
||||
{ id, body: patch },
|
||||
{
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Update failed"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: number) => {
|
||||
const onDelete = (id: string) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Category deleted"),
|
||||
onError: (err: any) => push("err", err?.message ?? "Delete failed")
|
||||
onSuccess: () => push("ok", "Expense deleted"),
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Delete failed"),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="card max-w-2xl"><SettingsNav/><div className="muted">Loading…</div></div>;
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setOrder((prev) => {
|
||||
const oldIndex = prev.indexOf(String(active.id));
|
||||
const newIndex = prev.indexOf(String(over.id));
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
// Apply new priorities to server (only changed ones)
|
||||
const updates = onDragOrderApply(next);
|
||||
updates.forEach(({ id, priority }) => {
|
||||
const existing = cats.find((c) => c.id === id);
|
||||
if (existing && existing.priority !== priority) {
|
||||
updateM.mutate({ id, body: { priority } });
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="card max-w-2xl">
|
||||
<SettingsNav />
|
||||
<div className="muted">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="card max-w-2xl">
|
||||
<SettingsNav/>
|
||||
<p className="mb-3">Couldn’t load categories.</p>
|
||||
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
|
||||
<SettingsNav />
|
||||
<p className="mb-3">Couldn't load expenses.</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,60 +251,165 @@ export default function SettingsCategoriesPage() {
|
||||
return (
|
||||
<div className="grid gap-4 max-w-5xl">
|
||||
<section className="card">
|
||||
<SettingsNav/>
|
||||
<SettingsNav />
|
||||
|
||||
<header className="mb-4 space-y-1">
|
||||
<h1 className="text-lg font-semibold">Expenses</h1>
|
||||
<p className="text-sm muted">
|
||||
Decide how every dollar is divided. Percentages must always
|
||||
add up to 100%.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
|
||||
<input className="input w-44" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input className="input w-28" placeholder="%"
|
||||
type="number" min={0} max={100} value={percent} onChange={(e) => setPercent(e.target.value)} />
|
||||
<input className="input w-28" placeholder="Priority"
|
||||
type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
|
||||
<label className="row">
|
||||
<input type="checkbox" checked={isSavings} onChange={(e) => setIsSavings(e.target.checked)} />
|
||||
<form
|
||||
onSubmit={onAdd}
|
||||
className="row gap-2 mb-4 flex-wrap items-end"
|
||||
>
|
||||
<input
|
||||
className="input w-full sm:w-44"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="%"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={percent}
|
||||
onChange={(e) => setPercent(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="Priority"
|
||||
type="number"
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
<label className="row w-full sm:w-auto">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSavings}
|
||||
onChange={(e) => setIsSavings(e.target.checked)}
|
||||
/>
|
||||
<span className="muted text-sm">Savings</span>
|
||||
</label>
|
||||
<button className="btn" disabled={addDisabled || createM.isPending}>Add</button>
|
||||
<div className="ml-auto"><SumBadge total={total} /></div>
|
||||
<button
|
||||
className="btn w-full sm:w-auto"
|
||||
disabled={addDisabled || createM.isPending}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<div className="ml-auto text-right">
|
||||
<SumBadge total={total} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<div className="muted text-sm">No categories yet.</div>
|
||||
<div className="muted text-sm mt-4">
|
||||
No expenses yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead><tr><th>Name</th><th>%</th><th>Priority</th><th>Savings</th><th>Balance</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{cats
|
||||
.slice()
|
||||
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
|
||||
.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
||||
<InlineEditText value={c.name} onChange={(v) => onEdit(c.id, { name: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber value={c.percent} min={0} max={100} onChange={(v) => onEdit(c.id, { percent: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber value={c.priority} min={0} onChange={(v) => onEdit(c.id, { priority: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditCheckbox checked={c.isSavings} onChange={(v) => onEdit(c.id, { isSavings: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2"><Money cents={c.balanceCents} /></td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
<button className="btn" type="button" onClick={() => onDelete(c.id)} disabled={deleteM.isPending}>Delete</button>
|
||||
</td>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<table className="table mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>%</th>
|
||||
<th>Priority</th>
|
||||
<th>Savings</th>
|
||||
<th>Balance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<SortableContext
|
||||
items={order}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{order
|
||||
.map((id) => cats.find((c) => c.id === id))
|
||||
.filter(Boolean)
|
||||
.map((c) => (
|
||||
<SortableTr key={c!.id} id={c!.id}>
|
||||
<td className="px-3 py-2">
|
||||
<span className="drag-handle inline-flex items-center justify-center w-6 h-6 rounded-full bg-[--color-panel] text-lg cursor-grab active:cursor-grabbing">
|
||||
⋮⋮
|
||||
</span>
|
||||
</td>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
||||
<InlineEditText
|
||||
value={c!.name}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { name: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber
|
||||
value={c!.percent}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { percent: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber
|
||||
value={c!.priority}
|
||||
min={0}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { priority: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditCheckbox
|
||||
checked={c!.isSavings}
|
||||
onChange={(v) =>
|
||||
onEdit(c!.id, { isSavings: v })
|
||||
}
|
||||
/>
|
||||
{c!.isSavings && (
|
||||
<span className="badge ml-2 text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
|
||||
Savings
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Money cents={c!.balanceCents ?? 0} />
|
||||
</td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => onDelete(c!.id)}
|
||||
disabled={deleteM.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</SortableTr>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Guard if total != 100 */}
|
||||
{total !== 100 && (
|
||||
<div className="toast-err mt-3">
|
||||
Percents must sum to <strong>100%</strong> for allocations. Current total: {total}%.
|
||||
Percents must sum to <strong>100%</strong> for allocations.
|
||||
Current total: {total}%.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -140,40 +418,135 @@ export default function SettingsCategoriesPage() {
|
||||
}
|
||||
|
||||
/* --- tiny inline editors --- */
|
||||
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
function InlineEditText({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [v, setV] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const commit = () => { if (v !== value) onChange(v.trim()); setEditing(false); };
|
||||
useEffect(() => setV(value), [value]);
|
||||
const commit = () => {
|
||||
if (v !== value) onChange(v.trim());
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input className="input" value={v} onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
<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" onClick={() => setEditing(true)}>{value}</button>
|
||||
<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; }) {
|
||||
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)));
|
||||
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" value={v} onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
function InlineEditCheckbox({
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="row">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span className="muted text-sm">{checked ? "Yes" : "No"}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="muted text-sm">
|
||||
{checked ? "Yes" : "No"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SortableTr({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} as React.CSSProperties;
|
||||
return (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="sortable-row"
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function onDragOrderApply(ids: string[]) {
|
||||
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
|
||||
}
|
||||
|
||||
650
web/src/pages/settings/CategoriesSettings.tsx
Normal file
650
web/src/pages/settings/CategoriesSettings.tsx
Normal file
@@ -0,0 +1,650 @@
|
||||
// web/src/pages/settings/CategoriesSettings.tsx
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { categoriesApi } from "../../api/categories";
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings: boolean;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
type LocalRow = Row & { _isNew?: boolean; _isDeleted?: boolean };
|
||||
|
||||
const MIN_SAVINGS_PERCENT = 20;
|
||||
|
||||
function SumBadge({ total }: { total: number }) {
|
||||
const tone =
|
||||
total === 100
|
||||
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
|
||||
: total < 100
|
||||
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
|
||||
: "bg-red-500/10 text-red-100 border border-red-500/40";
|
||||
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
|
||||
return (
|
||||
<div className={`badge ${tone}`}>
|
||||
{label}: {total}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategoriesSettingsProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export type CategoriesSettingsHandle = {
|
||||
save: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
function CategoriesSettingsInner(
|
||||
{ onDirtyChange }: CategoriesSettingsProps,
|
||||
ref: React.ForwardedRef<CategoriesSettingsHandle>
|
||||
) {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const serverCats = (data?.variableCategories ?? []) as Row[];
|
||||
|
||||
const { push } = useToast();
|
||||
const normalizeName = useCallback((value: string) => value.trim().toLowerCase(), []);
|
||||
const recalcBalances = useCallback((rows: LocalRow[]) => {
|
||||
const active = rows.filter((c) => !c._isDeleted);
|
||||
if (active.length === 0) return rows;
|
||||
|
||||
const totalBalance = active.reduce((sum, c) => sum + (c.balanceCents ?? 0), 0);
|
||||
const percentTotal = active.reduce((sum, c) => sum + (c.percent || 0), 0);
|
||||
if (totalBalance <= 0 || percentTotal <= 0) return rows;
|
||||
|
||||
const targets = active.map((cat) => {
|
||||
const raw = (totalBalance * cat.percent) / percentTotal;
|
||||
const floored = Math.floor(raw);
|
||||
return { id: cat.id, target: floored, frac: raw - floored };
|
||||
});
|
||||
|
||||
let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0);
|
||||
targets
|
||||
.slice()
|
||||
.sort((a, b) => b.frac - a.frac)
|
||||
.forEach((t) => {
|
||||
if (remainder > 0) {
|
||||
t.target += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
const targetById = new Map(targets.map((t) => [t.id, t.target]));
|
||||
return rows.map((cat) =>
|
||||
cat._isDeleted ? cat : { ...cat, balanceCents: targetById.get(cat.id) ?? cat.balanceCents }
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Local editable state
|
||||
const [localCats, setLocalCats] = useState<LocalRow[]>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Initialize local state from server once
|
||||
useEffect(() => {
|
||||
if (!initialized && serverCats && serverCats.length > 0) {
|
||||
setLocalCats(serverCats.map((c) => ({ ...c })));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [serverCats, initialized]);
|
||||
|
||||
const resetToServer = useCallback(() => {
|
||||
setLocalCats((serverCats ?? []).map((c) => ({ ...c })));
|
||||
setInitialized(true);
|
||||
}, [serverCats]);
|
||||
|
||||
const activeCats = useMemo(
|
||||
() => localCats.filter((c) => !c._isDeleted),
|
||||
[localCats]
|
||||
);
|
||||
|
||||
const total = useMemo(
|
||||
() => activeCats.reduce((s, c) => s + c.percent, 0),
|
||||
[activeCats]
|
||||
);
|
||||
|
||||
const savingsCount = useMemo(
|
||||
() => activeCats.filter((c) => c.isSavings).length,
|
||||
[activeCats]
|
||||
);
|
||||
const savingsTotal = useMemo(
|
||||
() => activeCats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
|
||||
[activeCats]
|
||||
);
|
||||
|
||||
const duplicateNames = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
activeCats.forEach((c) => {
|
||||
const key = normalizeName(c.name);
|
||||
if (!key) return;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([name]) => name);
|
||||
}, [activeCats, normalizeName]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (localCats.length === 0) return false;
|
||||
|
||||
if (localCats.some((c) => c._isNew || c._isDeleted)) return true;
|
||||
|
||||
for (const local of localCats) {
|
||||
if (local._isNew || local._isDeleted) continue;
|
||||
const server = serverCats.find((s) => s.id === local.id);
|
||||
if (!server) return true;
|
||||
|
||||
if (
|
||||
local.name !== server.name ||
|
||||
local.percent !== server.percent ||
|
||||
local.priority !== server.priority ||
|
||||
local.isSavings !== server.isSavings
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of serverCats) {
|
||||
if (!localCats.find((l) => l.id === server.id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [localCats, serverCats]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
|
||||
// Drag ordering
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const sorted = activeCats
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
|
||||
const next = sorted.map((c) => c.id);
|
||||
|
||||
if (order.length !== next.length || next.some((id, i) => order[i] !== id)) {
|
||||
setOrder(next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeCats.map((c) => c.id).join("|")]);
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
const [percent, setPercent] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [isSavings, setIsSavings] = useState(false);
|
||||
|
||||
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled = !name.trim() || parsedPercent <= 0 || parsedPercent > 100;
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const normalized = normalizeName(name);
|
||||
if (activeCats.some((c) => normalizeName(c.name) === normalized)) {
|
||||
push("err", `Expense name '${normalized}' already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const newCat: LocalRow = {
|
||||
id: tempId,
|
||||
name: normalized,
|
||||
percent: parsedPercent,
|
||||
priority: parsedPriority || activeCats.length + 1,
|
||||
isSavings,
|
||||
balanceCents: 0,
|
||||
_isNew: true,
|
||||
};
|
||||
|
||||
setLocalCats((prev) => recalcBalances([...prev, newCat]));
|
||||
setName("");
|
||||
setPercent("");
|
||||
setPriority("");
|
||||
setIsSavings(false);
|
||||
};
|
||||
|
||||
const onEdit = (id: string, patch: Partial<LocalRow>) => {
|
||||
setLocalCats((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const updated = {
|
||||
...c,
|
||||
...patch,
|
||||
...(patch.name !== undefined ? { name: normalizeName(patch.name) } : {}),
|
||||
};
|
||||
|
||||
if (patch.percent !== undefined) {
|
||||
updated.percent = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.floor(patch.percent))
|
||||
);
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
updated.priority = Math.max(0, Math.floor(patch.priority));
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
setLocalCats((prev) =>
|
||||
prev
|
||||
.map((c) => (c.id === id ? { ...c, _isDeleted: true } : c))
|
||||
.filter((c) => !(c._isNew && c._isDeleted))
|
||||
);
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
setOrder((prev) => {
|
||||
const oldIndex = prev.indexOf(String(active.id));
|
||||
const newIndex = prev.indexOf(String(over.id));
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
|
||||
const updates = onDragOrderApply(next);
|
||||
setLocalCats((prevCats) =>
|
||||
prevCats.map((c) => {
|
||||
const update = updates.find((u) => u.id === c.id);
|
||||
if (update && c.priority !== update.priority) {
|
||||
return { ...c, priority: update.priority };
|
||||
}
|
||||
return c;
|
||||
})
|
||||
);
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
resetToServer();
|
||||
push("ok", "Changes discarded");
|
||||
};
|
||||
|
||||
const onSave = useCallback(async (): Promise<boolean> => {
|
||||
const normalizedPriorityOrder = activeCats
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
.map((cat, index) => ({ id: cat.id, priority: index + 1 }));
|
||||
const priorityById = new Map(
|
||||
normalizedPriorityOrder.map((item) => [item.id, item.priority])
|
||||
);
|
||||
const normalizedCats = localCats.map((cat) =>
|
||||
cat._isDeleted ? cat : { ...cat, priority: priorityById.get(cat.id) ?? cat.priority }
|
||||
);
|
||||
|
||||
if (duplicateNames.length > 0) {
|
||||
push("err", `Duplicate expense names: ${duplicateNames.join(", ")}`);
|
||||
return false;
|
||||
}
|
||||
if (total !== 100) {
|
||||
push("err", `Percentages must sum to 100% (currently ${total}%)`);
|
||||
return false;
|
||||
}
|
||||
if (savingsCount === 0) {
|
||||
push("err", "You must have at least one Savings expense");
|
||||
return false;
|
||||
}
|
||||
if (savingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${savingsTotal}%)`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const hasNew = normalizedCats.some((c) => c._isNew && !c._isDeleted);
|
||||
|
||||
// Deletes
|
||||
const toDelete = normalizedCats.filter((c) => c._isDeleted && !c._isNew);
|
||||
for (const cat of toDelete) {
|
||||
await categoriesApi.delete(cat.id);
|
||||
}
|
||||
|
||||
// Creates
|
||||
const toCreate = normalizedCats.filter((c) => c._isNew && !c._isDeleted);
|
||||
for (const cat of toCreate) {
|
||||
await categoriesApi.create({
|
||||
name: normalizeName(cat.name),
|
||||
percent: cat.percent,
|
||||
priority: cat.priority,
|
||||
isSavings: cat.isSavings,
|
||||
});
|
||||
}
|
||||
|
||||
// Updates
|
||||
const toUpdate = normalizedCats.filter((c) => !c._isNew && !c._isDeleted);
|
||||
for (const local of toUpdate) {
|
||||
const server = serverCats.find((s) => s.id === local.id);
|
||||
if (!server) continue;
|
||||
|
||||
const patch: Partial<Row> = {};
|
||||
if (local.name !== server.name) patch.name = normalizeName(local.name);
|
||||
if (local.percent !== server.percent) patch.percent = local.percent;
|
||||
if (local.priority !== server.priority) patch.priority = local.priority;
|
||||
if (local.isSavings !== server.isSavings)
|
||||
patch.isSavings = local.isSavings;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await categoriesApi.update(local.id, patch);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNew) {
|
||||
try {
|
||||
await categoriesApi.rebalance();
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Failed to rebalance expenses");
|
||||
}
|
||||
}
|
||||
|
||||
push("ok", "Expenses saved successfully");
|
||||
const refreshed = await refetch();
|
||||
const nextCats =
|
||||
(refreshed.data?.variableCategories ?? serverCats) as Row[];
|
||||
setLocalCats(nextCats.map((c) => ({ ...c })));
|
||||
setInitialized(true);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Save failed");
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
total,
|
||||
savingsCount,
|
||||
localCats,
|
||||
serverCats,
|
||||
refetch,
|
||||
resetToServer,
|
||||
push,
|
||||
]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
|
||||
|
||||
if (isLoading) return <div className="muted">Loading expenses...</div>;
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3">Couldn't load expenses.</p>
|
||||
<button className="btn" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 className="text-lg font-semibold">Expense Categories</h2>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm muted">
|
||||
Decide how every dollar is divided. Percentages must always add up to
|
||||
100%.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onAdd} className="settings-add-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="%"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={percent}
|
||||
onChange={(e) => setPercent(e.target.value)}
|
||||
/>
|
||||
<label className="settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSavings}
|
||||
onChange={(e) => setIsSavings(e.target.checked)}
|
||||
/>
|
||||
<span>Savings</span>
|
||||
</label>
|
||||
<button className="btn" type="submit" disabled={addDisabled}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SumBadge total={total} />
|
||||
</div>
|
||||
|
||||
{activeCats.length === 0 ? (
|
||||
<div className="muted text-sm">No expenses yet.</div>
|
||||
) : (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={order} strategy={verticalListSortingStrategy}>
|
||||
<div className="settings-category-list">
|
||||
{order
|
||||
.map((id) => activeCats.find((c) => c.id === id))
|
||||
.filter(Boolean)
|
||||
.map((c) => (
|
||||
<SortableRow key={c!.id} id={c!.id}>
|
||||
{(dragListeners: any) => (
|
||||
<div className={`settings-category-row ${c!.isSavings ? 'savings' : ''}`}>
|
||||
<div className="settings-category-main">
|
||||
<span
|
||||
{...dragListeners}
|
||||
className="settings-drag-handle"
|
||||
>
|
||||
⋮⋮
|
||||
</span>
|
||||
<div className="settings-category-info">
|
||||
<div className="settings-category-name">
|
||||
<InlineEditText
|
||||
value={c!.name}
|
||||
onChange={(v) => onEdit(c!.id, { name: v })}
|
||||
/>
|
||||
{c!._isNew && (
|
||||
<span className="badge text-[10px] bg-blue-500/10 text-blue-200 border border-blue-500/40">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
{c!.isSavings && (
|
||||
<span className="badge text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
|
||||
Savings
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-category-balance">
|
||||
<Money cents={c!.balanceCents ?? 0} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-category-percent">
|
||||
<InlineEditNumber
|
||||
value={c!.percent}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => onEdit(c!.id, { percent: v })}
|
||||
/>
|
||||
<span className="text-xs muted">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-category-actions">
|
||||
<label className="settings-checkbox-label small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c!.isSavings}
|
||||
onChange={(e) => onEdit(c!.id, { isSavings: e.target.checked })}
|
||||
/>
|
||||
<span>Savings</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
onClick={() => onDelete(c!.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SortableRow>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex gap-3 pt-4 border-t border-[--color-border]">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void onSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<button className="btn" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CategoriesSettings = forwardRef(CategoriesSettingsInner);
|
||||
export default CategoriesSettings;
|
||||
|
||||
/* --- tiny inline editors --- */
|
||||
function InlineEditText({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [v, setV] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
useEffect(() => setV(value), [value]);
|
||||
const commit = () => {
|
||||
const next = v.trim();
|
||||
if (next !== value) onChange(next);
|
||||
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 SortableRow({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: (dragListeners: any) => React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} as React.CSSProperties;
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function onDragOrderApply(ids: string[]) {
|
||||
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
|
||||
}
|
||||
@@ -1,28 +1,48 @@
|
||||
// web/src/pages/settings/PlansPage.tsx
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
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 {
|
||||
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 isoDateLocal(d: Date = new Date()) {
|
||||
const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
return z.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function daysUntil(iso: string) {
|
||||
const today = new Date(isoDateLocal());
|
||||
const due = new Date(isoDateLocal(new Date(iso)));
|
||||
const diffMs = due.getTime() - today.getTime();
|
||||
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 }: { dueISO: string }) {
|
||||
const d = daysUntil(dueISO);
|
||||
if (d < 0) return <span className="badge" style={{ borderColor: "#7f1d1d" }}>Overdue</span>;
|
||||
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>;
|
||||
return (
|
||||
<span className="badge" aria-hidden="true">
|
||||
On track
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPlansPage() {
|
||||
@@ -31,136 +51,490 @@ export default function SettingsPlansPage() {
|
||||
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(isoDateLocal());
|
||||
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) };
|
||||
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]);
|
||||
|
||||
if (isLoading) return <div className="card max-w-3xl"><SettingsNav/><div className="muted">Loading…</div></div>;
|
||||
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 plans.</p>
|
||||
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
|
||||
<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 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: new Date(due).toISOString(),
|
||||
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(isoDateLocal());
|
||||
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"),
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Create failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onEdit = (id: number, 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)) {
|
||||
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"),
|
||||
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 onDelete = (id: number) => {
|
||||
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/>
|
||||
<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 you’re funding over
|
||||
time.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid gap-2 sm:grid-cols-3 mb-4">
|
||||
<div className="card kpi"><h3>Funded</h3><div className="val"><Money cents={totals.funded} /></div></div>
|
||||
<div className="card kpi"><h3>Total</h3><div className="val"><Money cents={totals.total} /></div></div>
|
||||
<div className="card kpi"><h3>Remaining</h3><div className="val"><Money cents={totals.remaining} /></div></div>
|
||||
<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">
|
||||
<input className="input w-48" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input className="input w-28" placeholder="Total $" type="number" min={0} step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
|
||||
<input className="input w-28" placeholder="Funded $" type="number" min={0} step="0.01" value={funded} onChange={(e) => setFunded(e.target.value)} />
|
||||
<input className="input w-24" placeholder="Priority" type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
|
||||
<input className="input w-40" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||
<button className="btn" disabled={!name || !total || createM.isPending}>Add</button>
|
||||
<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 plans yet.</div>
|
||||
<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></th>
|
||||
<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);
|
||||
.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} 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">
|
||||
<InlineEditMoney
|
||||
valueCents={p.fundedCents}
|
||||
onChange={(cents) => onEdit(p.id, { fundedCents: Math.max(0, Math.min(cents, p.totalCents)) })}
|
||||
<InlineEditText
|
||||
value={p.name}
|
||||
onChange={(v) =>
|
||||
onEdit(p.id, { name: v })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditMoney
|
||||
valueCents={p.totalCents}
|
||||
onChange={(cents) => onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })}
|
||||
<InlineEditDate
|
||||
value={p.dueOn}
|
||||
timezone={userTimezone}
|
||||
onChange={(iso) =>
|
||||
onEdit(p.id, { dueOn: iso })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2"><Money cents={remaining} /></td>
|
||||
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></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>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => onDelete(p.id)}
|
||||
disabled={deleteM.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -173,46 +547,201 @@ export default function SettingsPlansPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Inline editors (minimal) --- */
|
||||
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
/* --- 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);
|
||||
const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); };
|
||||
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" onClick={() => setEditing(true)}>{value}</button>;
|
||||
<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 }) {
|
||||
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));
|
||||
const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); };
|
||||
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" 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>;
|
||||
<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 }) {
|
||||
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));
|
||||
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" 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" onClick={() => setEditing(true)}>{(valueCents/100).toFixed(2)}</button>;
|
||||
<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 }: { value: string; onChange: (iso: string) => void }) {
|
||||
function InlineEditDate({
|
||||
value,
|
||||
onChange,
|
||||
timezone,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
timezone: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const local = new Date(value);
|
||||
const [v, setV] = useState(local.toISOString().slice(0, 10));
|
||||
const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(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)}>{new Date(value).toLocaleDateString()}</button>;
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
894
web/src/pages/settings/PlansSettings.tsx
Normal file
894
web/src/pages/settings/PlansSettings.tsx
Normal file
@@ -0,0 +1,894 @@
|
||||
// web/src/pages/settings/PlansSettings.tsx
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import {
|
||||
dateStringToUTCMidnight,
|
||||
formatDateInTimezone,
|
||||
getBrowserTimezone,
|
||||
getTodayInTimezone,
|
||||
isoToDateString,
|
||||
} from "../../utils/timezone";
|
||||
import { fixedPlansApi } from "../../api/fixedPlans";
|
||||
|
||||
type FixedPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
priority: number;
|
||||
dueOn: string;
|
||||
cycleStart: string;
|
||||
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: any;
|
||||
nextPaymentDate?: string | null;
|
||||
};
|
||||
|
||||
type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean };
|
||||
|
||||
export type PlansSettingsHandle = {
|
||||
save: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
interface PlansSettingsProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
function PlansSettings({ onDirtyChange }, ref) {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const plans = (data?.fixedPlans ?? []) as FixedPlan[];
|
||||
const { push } = useToast();
|
||||
|
||||
// Get user timezone from dashboard data
|
||||
const userTimezone = data?.user?.timezone || getBrowserTimezone();
|
||||
const incomeType = data?.user?.incomeType ?? "regular";
|
||||
const incomeFrequency = data?.user?.incomeFrequency;
|
||||
const firstIncomeDate = data?.user?.firstIncomeDate ?? null;
|
||||
|
||||
// Local editable state (preview mode)
|
||||
const [localPlans, setLocalPlans] = useState<LocalPlan[]>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deletePrompt, setDeletePrompt] = useState<LocalPlan | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && plans.length > 0) {
|
||||
setLocalPlans(plans.map((p) => ({ ...p })));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [plans, initialized]);
|
||||
|
||||
const resetToServer = useCallback(
|
||||
(nextPlans: FixedPlan[] = plans) => {
|
||||
setLocalPlans(nextPlans.map((p) => ({ ...p })));
|
||||
setInitialized(true);
|
||||
},
|
||||
[plans]
|
||||
);
|
||||
|
||||
const activePlans = useMemo(
|
||||
() => localPlans.filter((p) => !p._isDeleted),
|
||||
[localPlans]
|
||||
);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (localPlans.length === 0) return false;
|
||||
if (localPlans.some((p) => p._isNew || p._isDeleted)) return true;
|
||||
|
||||
for (const local of localPlans) {
|
||||
if (local._isNew || local._isDeleted) continue;
|
||||
const server = plans.find((p) => p.id === local.id);
|
||||
if (!server) return true;
|
||||
|
||||
const scheduleEqual =
|
||||
JSON.stringify(local.paymentSchedule ?? null) ===
|
||||
JSON.stringify(server.paymentSchedule ?? null);
|
||||
|
||||
if (
|
||||
local.name !== server.name ||
|
||||
local.totalCents !== server.totalCents ||
|
||||
local.priority !== server.priority ||
|
||||
local.dueOn !== server.dueOn ||
|
||||
(local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) ||
|
||||
(local.frequency ?? null) !== (server.frequency ?? null) ||
|
||||
!scheduleEqual ||
|
||||
(local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of plans) {
|
||||
if (!localPlans.find((p) => p.id === server.id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [localPlans, plans]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
|
||||
// Form state for adding new plan
|
||||
const [name, setName] = useState("");
|
||||
const [total, setTotal] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||||
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
|
||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||||
|
||||
const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving;
|
||||
|
||||
function mapScheduleFrequency(
|
||||
nextFrequency?: FixedPlan["frequency"]
|
||||
): "daily" | "weekly" | "biweekly" | "monthly" | "custom" {
|
||||
if (nextFrequency === "weekly") return "weekly";
|
||||
if (nextFrequency === "biweekly") return "biweekly";
|
||||
return "monthly";
|
||||
}
|
||||
|
||||
function buildDefaultSchedule(nextFrequency?: FixedPlan["frequency"]) {
|
||||
return {
|
||||
frequency: mapScheduleFrequency(nextFrequency),
|
||||
minFundingPercent: 100,
|
||||
};
|
||||
}
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const dueOnISO = dateStringToUTCMidnight(due, userTimezone);
|
||||
const schedule = autoPayEnabled
|
||||
? buildDefaultSchedule(frequency || undefined)
|
||||
: null;
|
||||
const nextPaymentDate =
|
||||
autoPayEnabled && schedule
|
||||
? calculateNextPaymentDate(dueOnISO, schedule, userTimezone)
|
||||
: null;
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const newPlan: LocalPlan = {
|
||||
id: tempId,
|
||||
name: name.trim(),
|
||||
totalCents,
|
||||
fundedCents: 0,
|
||||
priority: parsedPriority || localPlans.length + 1,
|
||||
dueOn: dueOnISO,
|
||||
cycleStart: dueOnISO,
|
||||
frequency: frequency || undefined,
|
||||
autoPayEnabled,
|
||||
paymentSchedule: schedule,
|
||||
nextPaymentDate,
|
||||
_isNew: true,
|
||||
};
|
||||
setLocalPlans((prev) => [...prev, newPlan]);
|
||||
setName("");
|
||||
setTotal("");
|
||||
setPriority("");
|
||||
setDue(getTodayInTimezone(userTimezone));
|
||||
setFrequency("monthly");
|
||||
setAutoPayEnabled(false);
|
||||
};
|
||||
|
||||
function toUserMidnight(iso: string, timezone: string) {
|
||||
const dateStr = isoToDateString(iso, timezone);
|
||||
return new Date(dateStringToUTCMidnight(dateStr, timezone));
|
||||
}
|
||||
|
||||
function countPayPeriodsBetween(
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
firstIncomeIso: string,
|
||||
frequency: NonNullable<typeof incomeFrequency>,
|
||||
timezone: string
|
||||
) {
|
||||
let count = 0;
|
||||
let nextPayDate = toUserMidnight(firstIncomeIso, timezone);
|
||||
const normalizedStart = toUserMidnight(startIso, timezone);
|
||||
const normalizedEnd = toUserMidnight(endIso, timezone);
|
||||
|
||||
const targetDay = Number(isoToDateString(firstIncomeIso, timezone).split("-")[2] || "1");
|
||||
const advanceByPeriod = () => {
|
||||
if (frequency === "monthly") {
|
||||
const year = nextPayDate.getUTCFullYear();
|
||||
const month = nextPayDate.getUTCMonth() + 1;
|
||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth)));
|
||||
} else {
|
||||
const days = frequency === "biweekly" ? 14 : 7;
|
||||
nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000);
|
||||
}
|
||||
};
|
||||
|
||||
while (nextPayDate < normalizedStart) {
|
||||
advanceByPeriod();
|
||||
}
|
||||
while (nextPayDate < normalizedEnd) {
|
||||
count++;
|
||||
advanceByPeriod();
|
||||
}
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
function getFundingAhead(plan: FixedPlan) {
|
||||
if (
|
||||
incomeType !== "regular" ||
|
||||
!incomeFrequency ||
|
||||
!firstIncomeDate ||
|
||||
!plan.cycleStart ||
|
||||
!plan.dueOn
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let cycleStart = plan.cycleStart;
|
||||
const dueOn = plan.dueOn;
|
||||
|
||||
let cycleStartDate: Date;
|
||||
let dueDate: Date;
|
||||
let nowDate: Date;
|
||||
try {
|
||||
cycleStartDate = toUserMidnight(cycleStart, userTimezone);
|
||||
dueDate = toUserMidnight(dueOn, userTimezone);
|
||||
nowDate = toUserMidnight(now, userTimezone);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (cycleStartDate >= dueDate || cycleStartDate > nowDate) {
|
||||
cycleStart = now;
|
||||
}
|
||||
|
||||
const totalPeriods = countPayPeriodsBetween(
|
||||
cycleStart,
|
||||
dueOn,
|
||||
firstIncomeDate,
|
||||
incomeFrequency,
|
||||
userTimezone
|
||||
);
|
||||
const elapsedPeriods = countPayPeriodsBetween(
|
||||
cycleStart,
|
||||
now,
|
||||
firstIncomeDate,
|
||||
incomeFrequency,
|
||||
userTimezone
|
||||
);
|
||||
const targetFunded = Math.min(
|
||||
plan.totalCents,
|
||||
Math.ceil((plan.totalCents * elapsedPeriods) / totalPeriods)
|
||||
);
|
||||
const aheadBy = Math.max(0, plan.fundedCents - targetFunded);
|
||||
return aheadBy > 0 ? aheadBy : null;
|
||||
}
|
||||
|
||||
function calculateNextPaymentDate(
|
||||
dueOnISO: string,
|
||||
schedule: any,
|
||||
timezone: string
|
||||
): string | null {
|
||||
if (!schedule || !schedule.frequency) return null;
|
||||
const dateStr = isoToDateString(dueOnISO, timezone);
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
const base = new Date(Date.UTC(year, month - 1, day));
|
||||
const toDateString = (d: Date) =>
|
||||
`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getUTCDate()
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
switch (schedule.frequency) {
|
||||
case "daily":
|
||||
base.setUTCDate(base.getUTCDate() + 1);
|
||||
break;
|
||||
case "weekly": {
|
||||
const targetDay = schedule.dayOfWeek ?? 0;
|
||||
const currentDay = base.getUTCDay();
|
||||
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||
base.setUTCDate(base.getUTCDate() + (daysUntilTarget || 7));
|
||||
break;
|
||||
}
|
||||
case "biweekly":
|
||||
base.setUTCDate(base.getUTCDate() + 14);
|
||||
break;
|
||||
case "monthly": {
|
||||
const targetDay = schedule.dayOfMonth;
|
||||
const nextMonth = base.getUTCMonth() + 1;
|
||||
const nextYear = base.getUTCFullYear() + Math.floor(nextMonth / 12);
|
||||
const normalizedMonth = nextMonth % 12;
|
||||
const daysInMonth = new Date(Date.UTC(nextYear, normalizedMonth + 1, 0)).getUTCDate();
|
||||
base.setUTCFullYear(nextYear, normalizedMonth, targetDay ? Math.min(targetDay, daysInMonth) : base.getUTCDate());
|
||||
break;
|
||||
}
|
||||
case "custom":
|
||||
base.setUTCDate(base.getUTCDate() + Math.max(1, Number(schedule.everyNDays || 0)));
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return dateStringToUTCMidnight(toDateString(base), timezone);
|
||||
}
|
||||
|
||||
const onEdit = (id: string, patch: Partial<FixedPlan>) => {
|
||||
setLocalPlans((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const next: LocalPlan = { ...p, ...patch };
|
||||
if (patch.frequency !== undefined && next.autoPayEnabled) {
|
||||
const schedule = next.paymentSchedule ?? buildDefaultSchedule(patch.frequency);
|
||||
next.paymentSchedule = {
|
||||
...schedule,
|
||||
frequency: mapScheduleFrequency(patch.frequency),
|
||||
};
|
||||
}
|
||||
if (patch.totalCents !== undefined) {
|
||||
next.totalCents = Math.max(0, Math.round(patch.totalCents));
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
next.priority = Math.max(0, Math.floor(patch.priority));
|
||||
}
|
||||
if (patch.autoPayEnabled !== undefined && !patch.autoPayEnabled) {
|
||||
next.nextPaymentDate = null;
|
||||
}
|
||||
if (next.autoPayEnabled && next.paymentSchedule) {
|
||||
const dueOnISO = patch.dueOn ?? next.dueOn;
|
||||
next.nextPaymentDate = calculateNextPaymentDate(
|
||||
dueOnISO,
|
||||
next.paymentSchedule,
|
||||
userTimezone
|
||||
);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
setLocalPlans((prev) =>
|
||||
prev
|
||||
.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
if (p._isNew) return { ...p, _isDeleted: true };
|
||||
return { ...p, _isDeleted: true };
|
||||
})
|
||||
.filter((p) => !(p._isNew && p._isDeleted))
|
||||
);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
resetToServer();
|
||||
push("ok", "Changes discarded");
|
||||
};
|
||||
|
||||
const onSave = useCallback(async (): Promise<boolean> => {
|
||||
const normalizedPriorityOrder = activePlans
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
.map((plan, index) => ({ id: plan.id, priority: index + 1 }));
|
||||
const priorityById = new Map(
|
||||
normalizedPriorityOrder.map((item) => [item.id, item.priority])
|
||||
);
|
||||
const normalizedPlans = localPlans.map((plan) =>
|
||||
plan._isDeleted ? plan : { ...plan, priority: priorityById.get(plan.id) ?? plan.priority }
|
||||
);
|
||||
|
||||
for (const plan of localPlans) {
|
||||
if (plan._isDeleted) continue;
|
||||
if (plan.totalCents < (plan.fundedCents ?? 0)) {
|
||||
push(
|
||||
"err",
|
||||
`Total for ${plan.name} cannot be less than funded amount.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const toDelete = normalizedPlans.filter((p) => p._isDeleted && !p._isNew);
|
||||
for (const plan of toDelete) {
|
||||
await fixedPlansApi.delete(plan.id);
|
||||
}
|
||||
|
||||
const toCreate = normalizedPlans.filter((p) => p._isNew && !p._isDeleted);
|
||||
for (const plan of toCreate) {
|
||||
const created = await fixedPlansApi.create({
|
||||
name: plan.name,
|
||||
totalCents: plan.totalCents,
|
||||
fundedCents: plan.fundedCents ?? 0,
|
||||
priority: plan.priority,
|
||||
dueOn: plan.dueOn,
|
||||
frequency: plan.frequency,
|
||||
autoPayEnabled: plan.autoPayEnabled ?? false,
|
||||
paymentSchedule: plan.paymentSchedule ?? undefined,
|
||||
nextPaymentDate: plan.nextPaymentDate ?? undefined,
|
||||
});
|
||||
if (plan.autoPayEnabled) {
|
||||
try {
|
||||
const res = await fixedPlansApi.fundFromAvailable(created.id);
|
||||
if (res.funded) {
|
||||
const dollars = (res.fundedAmountCents / 100).toFixed(2);
|
||||
push("ok", `Funded $${dollars} toward ${plan.name}.`);
|
||||
} else {
|
||||
push("err", `Not enough budget to fund ${plan.name}.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
push(
|
||||
"err",
|
||||
err?.message ?? `Funding ${plan.name} failed`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toUpdate = normalizedPlans.filter((p) => !p._isNew && !p._isDeleted);
|
||||
for (const local of toUpdate) {
|
||||
const server = plans.find((p) => p.id === local.id);
|
||||
if (!server) continue;
|
||||
|
||||
const patch: Partial<FixedPlan> = {};
|
||||
if (local.name !== server.name) patch.name = local.name;
|
||||
if (local.totalCents !== server.totalCents)
|
||||
patch.totalCents = local.totalCents;
|
||||
if (local.priority !== server.priority)
|
||||
patch.priority = local.priority;
|
||||
if (local.dueOn !== server.dueOn) patch.dueOn = local.dueOn;
|
||||
if ((local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false))
|
||||
patch.autoPayEnabled = local.autoPayEnabled;
|
||||
if (
|
||||
JSON.stringify(local.paymentSchedule ?? null) !==
|
||||
JSON.stringify(server.paymentSchedule ?? null)
|
||||
)
|
||||
patch.paymentSchedule = local.paymentSchedule;
|
||||
if ((local.frequency ?? null) !== (server.frequency ?? null))
|
||||
patch.frequency = local.frequency;
|
||||
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
|
||||
patch.nextPaymentDate = local.nextPaymentDate ?? null;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await fixedPlansApi.update(local.id, patch);
|
||||
}
|
||||
|
||||
const paymentPlanEnabled =
|
||||
!!local.autoPayEnabled && local.paymentSchedule !== null && local.paymentSchedule !== undefined;
|
||||
const amountChanged = local.totalCents !== server.totalCents;
|
||||
const dueChanged = local.dueOn !== server.dueOn;
|
||||
|
||||
if (paymentPlanEnabled && (amountChanged || dueChanged)) {
|
||||
try {
|
||||
const res = await fixedPlansApi.catchUpFunding(local.id);
|
||||
if (res.funded) {
|
||||
const dollars = (res.fundedAmountCents / 100).toFixed(2);
|
||||
push("ok", `Funded $${dollars} toward ${local.name}.`);
|
||||
} else if (res.message === "Insufficient available budget") {
|
||||
push("err", `Not enough budget to fund ${local.name}.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? `Funding ${local.name} failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push("ok", "Fixed expenses saved successfully");
|
||||
const refreshed = await refetch();
|
||||
const nextPlans = (refreshed.data?.fixedPlans ?? plans) as FixedPlan[];
|
||||
resetToServer(nextPlans);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Save failed");
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [localPlans, plans, refetch, resetToServer, push]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="muted">Loading fixed expenses...</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3">Couldn't load fixed expenses.</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 className="text-lg font-semibold">Fixed Expenses</h2>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm muted">
|
||||
Bills and recurring expenses that get funded over time until due.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={onAdd} className="settings-add-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Total $"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Due date"
|
||||
type="date"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={frequency || ""}
|
||||
onChange={(e) =>
|
||||
setFrequency((e.target.value || "") as "" | FixedPlan["frequency"])
|
||||
}
|
||||
>
|
||||
<option value="">Frequency</option>
|
||||
<option value="one-time">One-time</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
<label className="settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPayEnabled}
|
||||
onChange={(e) => setAutoPayEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>Auto-fund</span>
|
||||
</label>
|
||||
<button className="btn" disabled={addDisabled}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{activePlans.length === 0 ? (
|
||||
<div className="muted text-sm">No fixed expenses yet.</div>
|
||||
) : (
|
||||
<div className="settings-plans-list">
|
||||
{activePlans.map((plan) => {
|
||||
const aheadCents = getFundingAhead(plan);
|
||||
const fundedCents = plan.fundedCents ?? 0;
|
||||
const totalCents = plan.totalCents || 1; // Avoid division by zero
|
||||
const progressPercent = Math.min(100, (fundedCents / totalCents) * 100);
|
||||
return (
|
||||
<div key={plan.id} className={`settings-plan-card ${plan.autoPayEnabled ? 'auto-fund' : ''}`}>
|
||||
{/* Header row */}
|
||||
<div className="settings-plan-header">
|
||||
<div className="settings-plan-title">
|
||||
<InlineEditText
|
||||
value={plan.name}
|
||||
onChange={(v) => onEdit(plan.id, { name: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-plan-amount">
|
||||
<InlineEditMoney
|
||||
cents={plan.totalCents}
|
||||
onChange={(cents) => onEdit(plan.id, { totalCents: cents })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details row */}
|
||||
<div className="settings-plan-details">
|
||||
<div className="settings-plan-detail">
|
||||
<span className="label">Due</span>
|
||||
<InlineEditDate
|
||||
value={plan.dueOn}
|
||||
timezone={userTimezone}
|
||||
onChange={(iso) => onEdit(plan.id, { dueOn: iso })}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-plan-detail">
|
||||
<span className="label">Freq</span>
|
||||
<InlineEditSelect
|
||||
value={plan.frequency ?? ""}
|
||||
onChange={(v) =>
|
||||
onEdit(plan.id, {
|
||||
frequency: (v || undefined) as FixedPlan["frequency"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{aheadCents !== null && (
|
||||
<div className="settings-plan-badge ahead">
|
||||
+{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format((aheadCents ?? 0) / 100)} ahead
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="settings-plan-progress">
|
||||
<div className="settings-plan-progress-bar">
|
||||
<div
|
||||
className="settings-plan-progress-fill"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-plan-progress-text">
|
||||
<Money cents={plan.fundedCents} /> / <Money cents={plan.totalCents} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
<div className="settings-plan-actions">
|
||||
<label className="settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!plan.autoPayEnabled}
|
||||
onChange={(e) =>
|
||||
onEdit(plan.id, {
|
||||
autoPayEnabled: e.target.checked,
|
||||
paymentSchedule: e.target.checked
|
||||
? plan.paymentSchedule ?? buildDefaultSchedule(plan.frequency)
|
||||
: plan.paymentSchedule,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{incomeType === "regular" ? "Auto-fund" : "Payment plan"}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => setDeletePrompt(plan)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plan.autoPayEnabled && (
|
||||
<div className={`settings-plan-status ${incomeType === "regular" ? "funded" : "planned"}`}>
|
||||
{incomeType === "regular"
|
||||
? "Auto-funded each paycheck until fully funded"
|
||||
: "Prioritized in budget allocation"
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
{deletePrompt && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="card p-6 max-w-md space-y-4">
|
||||
<h3 className="text-lg font-semibold">Delete fixed expense?</h3>
|
||||
<p className="text-sm muted">
|
||||
Are you sure you want to delete {deletePrompt.name}? This action cannot be undone.
|
||||
</p>
|
||||
{!deletePrompt._isNew && (deletePrompt.fundedCents ?? 0) > 0 && (
|
||||
<div className="text-sm">
|
||||
Funded amount{" "}
|
||||
<span className="font-semibold">
|
||||
<Money cents={deletePrompt.fundedCents ?? 0} />
|
||||
</span>{" "}
|
||||
will be refunded to your available budget.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button className="btn" onClick={() => setDeletePrompt(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
onDelete(deletePrompt.id);
|
||||
setDeletePrompt(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<div className="flex gap-3 pt-4 border-t border-[--color-border]">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void onSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<button className="btn" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default PlansSettings;
|
||||
|
||||
// Inline editor components
|
||||
function InlineEditText({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [v, setV] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => setV(value), [value]);
|
||||
|
||||
const commit = () => {
|
||||
if (v !== value) onChange(v.trim());
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return editing ? (
|
||||
<input
|
||||
className="input"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link inline-flex items-center gap-1"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span>{value || placeholder}</span>
|
||||
<span className="text-[10px] opacity-60">Edit</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditMoney({
|
||||
cents,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
cents: number;
|
||||
onChange: (cents: number) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState((cents / 100).toFixed(2));
|
||||
|
||||
useEffect(() => setV((cents / 100).toFixed(2)), [cents]);
|
||||
|
||||
const commit = () => {
|
||||
const newCents = Math.max(0, Math.round((Number(v) || 0) * 100));
|
||||
if (newCents !== cents) onChange(newCents);
|
||||
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()}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Money cents={cents} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="input w-32 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="one-time">One-time</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditDate({
|
||||
value,
|
||||
timezone,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
timezone: string;
|
||||
onChange: (iso: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(isoToDateString(value, timezone));
|
||||
|
||||
useEffect(() => setV(isoToDateString(value, timezone)), [value, timezone]);
|
||||
|
||||
const commit = () => {
|
||||
if (v) {
|
||||
const nextISO = dateStringToUTCMidnight(v, timezone);
|
||||
if (nextISO !== value) onChange(nextISO);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return editing ? (
|
||||
<input
|
||||
className="input w-32"
|
||||
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>
|
||||
);
|
||||
}
|
||||
137
web/src/pages/settings/ReconcileSettings.tsx
Normal file
137
web/src/pages/settings/ReconcileSettings.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// web/src/pages/settings/ReconcileSettings.tsx
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import CurrencyInput from "../../components/CurrencyInput";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { budgetApi } from "../../api/budget";
|
||||
|
||||
export default function ReconcileSettings() {
|
||||
const { data, isLoading, isError } = useDashboard();
|
||||
const { push } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [bankTotalCents, setBankTotalCents] = useState<number | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bankTotalCents === null && data) {
|
||||
setBankTotalCents(data.totals.incomeCents);
|
||||
}
|
||||
}, [bankTotalCents, data]);
|
||||
|
||||
const currentTotalCents = data?.totals.incomeCents ?? 0;
|
||||
const fixedFundedCents = useMemo(
|
||||
() => data?.fixedPlans.reduce((sum, plan) => sum + (plan.fundedCents || 0), 0) ?? 0,
|
||||
[data]
|
||||
);
|
||||
const deltaCents =
|
||||
bankTotalCents === null ? 0 : bankTotalCents - currentTotalCents;
|
||||
const belowFixed = bankTotalCents !== null && bankTotalCents < fixedFundedCents;
|
||||
const isNoChange = bankTotalCents !== null && deltaCents === 0;
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (bankTotalCents === null || belowFixed || isNoChange) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await budgetApi.reconcile({
|
||||
bankTotalCents,
|
||||
});
|
||||
if (result.deltaCents === 0) {
|
||||
push("ok", "No changes needed.");
|
||||
} else {
|
||||
const direction = result.deltaCents > 0 ? "Added" : "Removed";
|
||||
push(
|
||||
"ok",
|
||||
`${direction} $${Math.abs(result.deltaCents / 100).toFixed(
|
||||
2
|
||||
)} to match your bank balance.`
|
||||
);
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Reconciliation failed");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="muted">Loading…</div>;
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <div className="muted">Unable to load reconciliation data.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Balance Reconciliation</h2>
|
||||
<p className="text-sm muted">
|
||||
Enter the combined total of all your bank accounts (checking + savings).
|
||||
We’ll adjust your available budget to match without affecting fixed plans.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="card">
|
||||
<div className="text-xs muted">SkyMoney Total</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
<Money cents={currentTotalCents} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-xs muted">Available Budget</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
<Money cents={data.totals.variableBalanceCents} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-xs muted">Funded Fixed Total</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
<Money cents={fixedFundedCents} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-4">
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Your bank total</span>
|
||||
<CurrencyInput
|
||||
valueCents={bankTotalCents ?? 0}
|
||||
onChange={setBankTotalCents}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{belowFixed && (
|
||||
<div className="alert alert-error">
|
||||
Bank total can’t be lower than funded fixed expenses.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!belowFixed && bankTotalCents !== null && (
|
||||
<div className="text-sm muted">
|
||||
{deltaCents > 0 && (
|
||||
<>This will add <strong>${(deltaCents / 100).toFixed(2)}</strong> to your available budget.</>
|
||||
)}
|
||||
{deltaCents < 0 && (
|
||||
<>This will remove <strong>${Math.abs(deltaCents / 100).toFixed(2)}</strong> from your available budget.</>
|
||||
)}
|
||||
{deltaCents === 0 && <>No adjustment needed.</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={onSubmit}
|
||||
disabled={pending || belowFixed || isNoChange}
|
||||
>
|
||||
{pending ? "Applying..." : "Apply Adjustment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
web/src/pages/settings/SettingsPage.tsx
Normal file
213
web/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// web/src/pages/settings/SettingsPage.tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useBlocker } from "react-router";
|
||||
|
||||
import CategoriesSettings, {
|
||||
type CategoriesSettingsHandle,
|
||||
} from "./CategoriesSettings";
|
||||
import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings";
|
||||
import AccountSettings from "./AccountSettings";
|
||||
import ThemeSettings from "./ThemeSettings";
|
||||
import ReconcileSettings from "./ReconcileSettings";
|
||||
|
||||
type Tab = "categories" | "plans" | "account" | "theme" | "reconcile";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const location = useLocation();
|
||||
|
||||
const getActiveTab = (): Tab => {
|
||||
if (location.pathname.includes("/settings/plans")) return "plans";
|
||||
if (location.pathname.includes("/settings/account")) return "account";
|
||||
if (location.pathname.includes("/settings/theme")) return "theme";
|
||||
if (location.pathname.includes("/settings/reconcile")) return "reconcile";
|
||||
return "categories";
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>(getActiveTab());
|
||||
|
||||
// Dirty / confirm state
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [pendingTab, setPendingTab] = useState<Tab | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// ref so the modal can "Save" for the current tab
|
||||
const categoriesRef = useRef<CategoriesSettingsHandle>(null);
|
||||
const plansRef = useRef<PlansSettingsHandle>(null);
|
||||
|
||||
// Block leaving /settings when dirty (Navbar links, back button, etc.)
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
|
||||
const leavingSettings =
|
||||
currentLocation.pathname.startsWith("/settings") &&
|
||||
nextLocation.pathname !== currentLocation.pathname;
|
||||
return isDirty && leavingSettings;
|
||||
});
|
||||
|
||||
// If router blocks, show modal
|
||||
useEffect(() => {
|
||||
if (blocker.state === "blocked") setShowConfirm(true);
|
||||
}, [blocker.state]);
|
||||
|
||||
// Sync tab with URL changes (e.g., direct navigation)
|
||||
useEffect(() => {
|
||||
setActiveTab(getActiveTab());
|
||||
}, [location.pathname]);
|
||||
|
||||
// Blocks refresh/close tab when dirty
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (!isDirty) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
const tabs = [
|
||||
{ id: "categories" as const, label: "Expenses" },
|
||||
{ id: "plans" as const, label: "Fixed Expenses" },
|
||||
{ id: "account" as const, label: "Account" },
|
||||
{ id: "theme" as const, label: "Theme" },
|
||||
{ id: "reconcile" as const, label: "Reconcile" },
|
||||
];
|
||||
|
||||
function requestTabChange(next: Tab) {
|
||||
if (next === activeTab) return;
|
||||
if (isDirty) {
|
||||
setPendingTab(next);
|
||||
setShowConfirm(true);
|
||||
return;
|
||||
}
|
||||
setActiveTab(next);
|
||||
}
|
||||
|
||||
function stayHere() {
|
||||
setPendingTab(null);
|
||||
setShowConfirm(false);
|
||||
if (blocker.state === "blocked") blocker.reset();
|
||||
}
|
||||
|
||||
function discardAndLeave() {
|
||||
setIsDirty(false);
|
||||
setShowConfirm(false);
|
||||
|
||||
if (pendingTab) {
|
||||
setActiveTab(pendingTab);
|
||||
setPendingTab(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blocker.state === "blocked") blocker.proceed();
|
||||
}
|
||||
|
||||
async function saveAndLeave() {
|
||||
let ok = true;
|
||||
|
||||
if (activeTab === "categories") {
|
||||
ok = (await categoriesRef.current?.save()) ?? false;
|
||||
} else if (activeTab === "plans") {
|
||||
ok = (await plansRef.current?.save()) ?? false;
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return;
|
||||
|
||||
setIsDirty(false);
|
||||
setShowConfirm(false);
|
||||
|
||||
if (pendingTab) {
|
||||
setActiveTab(pendingTab);
|
||||
setPendingTab(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blocker.state === "blocked") blocker.proceed();
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case "categories":
|
||||
return (
|
||||
<CategoriesSettings
|
||||
ref={categoriesRef}
|
||||
onDirtyChange={setIsDirty}
|
||||
/>
|
||||
);
|
||||
case "plans":
|
||||
return (
|
||||
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />
|
||||
);
|
||||
case "account":
|
||||
return <AccountSettings />;
|
||||
case "theme":
|
||||
return <ThemeSettings />;
|
||||
case "reconcile":
|
||||
return <ReconcileSettings />;
|
||||
default:
|
||||
return (
|
||||
<CategoriesSettings
|
||||
ref={categoriesRef}
|
||||
onDirtyChange={setIsDirty}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in container max-w-4xl mx-auto py-6">
|
||||
<header className="settings-header">
|
||||
<h1>Settings</h1>
|
||||
</header>
|
||||
|
||||
<div className="settings-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => requestTabChange(tab.id)}
|
||||
className={`settings-tab ${activeTab === tab.id ? "active" : ""}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[400px]">{renderTabContent()}</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="card p-6 max-w-md space-y-4">
|
||||
<h3 className="text-lg font-semibold">Unsaved Changes</h3>
|
||||
<p className="text-sm muted">
|
||||
You have unsaved changes. Do you want to save them before leaving, discard them, or stay here?
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button className="btn" onClick={stayHere}>
|
||||
Stay
|
||||
</button>
|
||||
|
||||
<button className="btn" onClick={discardAndLeave}>
|
||||
Discard
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={saveAndLeave}
|
||||
disabled={activeTab !== "categories" && activeTab !== "plans"}
|
||||
title={
|
||||
activeTab !== "categories" && activeTab !== "plans"
|
||||
? "Save-from-modal is wired for Expenses and Fixed Expenses right now."
|
||||
: ""
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
web/src/pages/settings/ThemeSettings.tsx
Normal file
145
web/src/pages/settings/ThemeSettings.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// web/src/pages/settings/ThemeSettings.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useTheme } from "../../theme/useTheme";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
type ColorScheme = "blue" | "green" | "purple" | "orange";
|
||||
|
||||
export default function ThemeSettings() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>("blue");
|
||||
const { push } = useToast();
|
||||
|
||||
// Load settings from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedColorScheme = localStorage.getItem("colorScheme") as ColorScheme;
|
||||
|
||||
if (savedColorScheme) setColorScheme(savedColorScheme);
|
||||
}, []);
|
||||
|
||||
// Apply color scheme changes
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove existing color scheme classes
|
||||
root.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange");
|
||||
|
||||
// Add new color scheme class
|
||||
root.classList.add(`scheme-${colorScheme}`);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem("colorScheme", colorScheme);
|
||||
}, [colorScheme]);
|
||||
|
||||
const handleResetSettings = () => {
|
||||
setTheme("system");
|
||||
setColorScheme("blue");
|
||||
localStorage.removeItem("theme");
|
||||
localStorage.removeItem("colorScheme");
|
||||
push("ok", "Theme settings reset to defaults");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Theme Selection */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Theme</h3>
|
||||
<p className="settings-section-desc">Choose your preferred color mode.</p>
|
||||
</div>
|
||||
<div className="settings-theme-grid">
|
||||
<label className={`settings-theme-card ${theme === "light" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value="light"
|
||||
checked={theme === "light"}
|
||||
onChange={(e) => setTheme(e.target.value as Theme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="settings-theme-icon light" />
|
||||
<div className="settings-theme-info">
|
||||
<div className="settings-theme-label">Light</div>
|
||||
<div className="settings-theme-desc">Clean and bright</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`settings-theme-card ${theme === "dark" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value="dark"
|
||||
checked={theme === "dark"}
|
||||
onChange={(e) => setTheme(e.target.value as Theme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="settings-theme-icon dark" />
|
||||
<div className="settings-theme-info">
|
||||
<div className="settings-theme-label">Dark</div>
|
||||
<div className="settings-theme-desc">Easy on the eyes</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`settings-theme-card ${theme === "system" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value="system"
|
||||
checked={theme === "system"}
|
||||
onChange={(e) => setTheme(e.target.value as Theme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="settings-theme-icon system" />
|
||||
<div className="settings-theme-info">
|
||||
<div className="settings-theme-label">System</div>
|
||||
<div className="settings-theme-desc">Match your OS</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Color Scheme */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Color Scheme</h3>
|
||||
<p className="settings-section-desc">Pick an accent color for the app.</p>
|
||||
</div>
|
||||
<div className="settings-color-grid">
|
||||
{(["blue", "green", "purple", "orange"] as const).map((scheme) => (
|
||||
<label
|
||||
key={scheme}
|
||||
className={`settings-color-card ${colorScheme === scheme ? "selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="colorScheme"
|
||||
value={scheme}
|
||||
checked={colorScheme === scheme}
|
||||
onChange={(e) => setColorScheme(e.target.value as ColorScheme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`settings-color-swatch ${scheme}`} />
|
||||
<div className="settings-color-label">{scheme}</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reset */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Reset</h3>
|
||||
<p className="settings-section-desc">Theme settings are saved automatically as you make changes.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={handleResetSettings}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,12 @@ export default function SettingsNav() {
|
||||
const link = (to: string, label: string) =>
|
||||
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
|
||||
return (
|
||||
<div className="row mb-3">
|
||||
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center">
|
||||
<h2 className="section-title m-0">Settings</h2>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{link("/settings/categories", "Categories")}
|
||||
{link("/settings/plans", "Fixed Plans")}
|
||||
<div className="flex flex-wrap gap-1 sm:ml-auto">
|
||||
{link("/settings/categories", "Expenses")}
|
||||
{link("/settings/plans", "Fixed Expenses")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user