feat: added estimate fixed expenses
This commit is contained in:
@@ -23,6 +23,11 @@ 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;
|
||||
@@ -61,6 +66,9 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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) {
|
||||
@@ -98,6 +106,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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) ||
|
||||
@@ -127,6 +137,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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, Math.round((Number(total) || 0) * 100));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
@@ -162,6 +173,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
const newPlan: LocalPlan = {
|
||||
id: tempId,
|
||||
name: name.trim(),
|
||||
amountMode,
|
||||
estimatedCents: amountMode === "estimated" ? totalCents : null,
|
||||
totalCents,
|
||||
fundedCents: 0,
|
||||
priority: parsedPriority || localPlans.length + 1,
|
||||
@@ -180,6 +193,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
setDue(getTodayInTimezone(userTimezone));
|
||||
setFrequency("monthly");
|
||||
setAutoPayEnabled(false);
|
||||
setAmountMode("fixed");
|
||||
};
|
||||
|
||||
function toUserMidnight(iso: string, timezone: string) {
|
||||
@@ -338,6 +352,24 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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;
|
||||
}
|
||||
@@ -407,6 +439,11 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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,
|
||||
@@ -455,6 +492,10 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
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);
|
||||
@@ -493,6 +534,49 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
}
|
||||
}, [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, Math.round((Number(raw) || 0) * 100));
|
||||
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)
|
||||
@@ -531,6 +615,14 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
|
||||
{/* 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"
|
||||
@@ -539,13 +631,18 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Total $"
|
||||
placeholder={amountMode === "estimated" ? "Estimated $" : "Total $"}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value)}
|
||||
/>
|
||||
{amountMode === "estimated" && (
|
||||
<div className="text-xs muted col-span-full">
|
||||
Tip: Always over-estimate variable bills to avoid due-date shortfalls.
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Due date"
|
||||
@@ -608,6 +705,21 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
|
||||
{/* 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
|
||||
@@ -627,6 +739,15 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
}
|
||||
/>
|
||||
</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", {
|
||||
@@ -651,6 +772,41 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
</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>
|
||||
<input
|
||||
className="input w-36"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={actualInputs[plan.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setActualInputs((prev) => ({ ...prev, [plan.id]: e.target.value }))
|
||||
}
|
||||
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">
|
||||
Using a slightly higher estimate helps prevent last-minute shortages.
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user