1055 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|