Files
SkyMoney/web/src/pages/settings/PlansSettings.tsx
Ricearoni1245 72334b2583
All checks were successful
Deploy / deploy (push) Successful in 2m2s
Security Tests / security-non-db (push) Successful in 20s
Security Tests / security-db (push) Successful in 24s
ui fixes, input fixes, better dev workflow
2026-03-10 23:01:44 -05:00

1055 lines
35 KiB
TypeScript

// web/src/pages/settings/PlansSettings.tsx
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
type FormEvent,
} from "react";
import { Money } from "../../components/ui";
import CurrencyInput from "../../components/CurrencyInput";
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;
amountMode?: "fixed" | "estimated";
estimatedCents?: number | null;
actualCents?: number | null;
actualRecordedAt?: string | null;
actualCycleDueOn?: string | null;
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 };
function parseCurrencyToCents(value: string): number {
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
const [whole, ...fractionParts] = cleaned.split(".");
const fraction = fractionParts.join("");
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 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);
const [actualInputs, setActualInputs] = useState<Record<string, string>>({});
const [applyingActualByPlan, setApplyingActualByPlan] = useState<Record<string, boolean>>({});
const [trueUpMessages, setTrueUpMessages] = useState<Record<string, string>>({});
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.amountMode ?? "fixed") !== (server.amountMode ?? "fixed") ||
(local.estimatedCents ?? null) !== (server.estimatedCents ?? null) ||
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 [totalInput, setTotalInput] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
const [amountMode, setAmountMode] = useState<"fixed" | "estimated">("fixed");
const totalCents = Math.max(0, parseCurrencyToCents(totalInput));
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(),
amountMode,
estimatedCents: amountMode === "estimated" ? totalCents : null,
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("");
setTotalInput("");
setPriority("");
setDue(getTodayInTimezone(userTimezone));
setFrequency("monthly");
setAutoPayEnabled(false);
setAmountMode("fixed");
};
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.amountMode !== undefined) {
const mode = patch.amountMode;
next.amountMode = mode;
if (mode === "fixed") {
next.estimatedCents = null;
} else {
next.estimatedCents = next.estimatedCents ?? next.totalCents;
}
}
if (patch.estimatedCents !== undefined) {
const nextEstimate = Math.max(0, Math.round(patch.estimatedCents ?? 0));
next.estimatedCents = nextEstimate;
const hasActualForCycle =
!!next.actualCycleDueOn && next.actualCycleDueOn === next.dueOn;
if ((next.amountMode ?? "fixed") === "estimated" && !hasActualForCycle) {
next.totalCents = nextEstimate;
}
}
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,
amountMode: plan.amountMode ?? "fixed",
estimatedCents:
(plan.amountMode ?? "fixed") === "estimated"
? (plan.estimatedCents ?? plan.totalCents)
: null,
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 ((local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed"))
patch.amountMode = local.amountMode ?? "fixed";
if ((local.estimatedCents ?? null) !== (server.estimatedCents ?? null))
patch.estimatedCents = local.estimatedCents ?? 0;
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]);
const onApplyActual = useCallback(
async (plan: LocalPlan) => {
if (plan._isNew) {
push("err", "Save this plan before applying an actual amount.");
return;
}
const raw = actualInputs[plan.id] ?? "";
const actualCents = Math.max(0, parseCurrencyToCents(raw));
setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: true }));
setTrueUpMessages((prev) => ({ ...prev, [plan.id]: "" }));
try {
const res = await fixedPlansApi.trueUpActual(plan.id, { actualCents });
setLocalPlans((prev) =>
prev.map((p) =>
p.id !== plan.id
? p
: {
...p,
totalCents: res.totalCents,
fundedCents: res.fundedCents,
actualCents: res.actualCents,
actualCycleDueOn: p.dueOn,
actualRecordedAt: new Date().toISOString(),
}
)
);
const summary =
res.deltaCents > 0
? `Actual is higher. Pulled $${(res.autoPulledCents / 100).toFixed(2)} from available budget. Remaining shortfall $${(res.remainingShortfallCents / 100).toFixed(2)}.`
: res.deltaCents < 0
? `Actual is lower. Returned $${(res.refundedCents / 100).toFixed(2)} to available budget.`
: "Actual matches estimate. No adjustment needed.";
setTrueUpMessages((prev) => ({ ...prev, [plan.id]: summary }));
push("ok", "Actual amount applied.");
} catch (err: any) {
push("err", err?.message ?? "Unable to apply actual amount.");
} finally {
setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: false }));
}
},
[actualInputs, 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">
<select
className="input"
value={amountMode}
onChange={(e) => setAmountMode((e.target.value as "fixed" | "estimated") || "fixed")}
>
<option value="fixed">Fixed amount</option>
<option value="estimated">Estimated bill</option>
</select>
<input
className="input"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<CurrencyInput
className="input"
placeholder={amountMode === "estimated" ? "Estimated amount" : "Total amount"}
value={totalInput}
onValue={setTotalInput}
/>
{amountMode === "estimated" && (
<div className="settings-add-form-tip">
Tip: For variable bills (utilities, usage-based charges), set a slightly higher estimate to avoid due-date shortfalls.
</div>
)}
<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">Bill Type</span>
<select
className="input w-36 text-sm"
value={plan.amountMode ?? "fixed"}
onChange={(e) =>
onEdit(plan.id, {
amountMode: (e.target.value as "fixed" | "estimated") ?? "fixed",
})
}
>
<option value="fixed">Fixed</option>
<option value="estimated">Estimated</option>
</select>
</div>
<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>
{(plan.amountMode ?? "fixed") === "estimated" && (
<div className="settings-plan-detail">
<span className="label">Estimate</span>
<InlineEditMoney
cents={plan.estimatedCents ?? plan.totalCents}
onChange={(cents) => onEdit(plan.id, { estimatedCents: cents })}
/>
</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>
{(plan.amountMode ?? "fixed") === "estimated" && (
<div className="settings-plan-status planned">
<div className="flex flex-wrap items-end gap-2">
<label className="stack gap-1">
<span className="label">Actual this cycle</span>
<CurrencyInput
className="input w-36"
value={actualInputs[plan.id] ?? ""}
onValue={(nextValue) =>
setActualInputs((prev) => ({ ...prev, [plan.id]: nextValue }))
}
placeholder={((plan.totalCents ?? 0) / 100).toFixed(2)}
/>
</label>
<button
className="btn btn-sm"
onClick={() => void onApplyActual(plan)}
disabled={isSaving || !!applyingActualByPlan[plan.id]}
type="button"
>
{applyingActualByPlan[plan.id] ? "Applying..." : "Apply actual"}
</button>
</div>
<div className="text-xs muted mt-2">
Keep this estimate slightly high for variable bills, then true-up with the actual amount when posted.
</div>
{trueUpMessages[plan.id] ? (
<div className="text-xs mt-2">{trueUpMessages[plan.id]}</div>
) : null}
</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, parseCurrencyToCents(v));
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>
);
}