final touches for beta skymoney (at least i think)
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user