feat(ui): add finance plan editor
Plans default to an equal split, each slice is individually editable, and plans compose with splits. Cancelling keeps past slices. planBalance delegates to domain slicesBalance rather than recomputing the same total-minus-slices arithmetic, per the project's one-implementation rule for shared math.
This commit is contained in:
1 parent
5a11941d39
commit
5b879809d2
4 files changed
+235
-1
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatAmount } from "../format.ts";
|
||||
import {
|
||||
planBalance,
|
||||
setSliceAmount,
|
||||
withPlan,
|
||||
withoutPlan,
|
||||
type PurchaseDraft,
|
||||
} from "../purchaseForm.ts";
|
||||
import { monthOf } from "../../domain/months.ts";
|
||||
|
||||
interface Props {
|
||||
draft: PurchaseDraft;
|
||||
onChange: (next: PurchaseDraft) => void;
|
||||
}
|
||||
|
||||
export function PlanEditor({ draft, onChange }: Props) {
|
||||
const balance = planBalance(draft);
|
||||
|
||||
if (!draft.plan) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(withPlan(draft, monthOf(draft.date), 3))}
|
||||
>
|
||||
Spread this over several months
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-3 rounded border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Finance plan</Label>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">months</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-8 w-16 tabular-nums"
|
||||
value={draft.plan.slices.length}
|
||||
onChange={(event) => {
|
||||
const months = Number(event.target.value);
|
||||
if (months >= 1) {
|
||||
onChange(withPlan(draft, draft.plan!.startMonth, months));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(withoutPlan(draft))}>
|
||||
remove plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{draft.plan.slices.map((slice, index) => (
|
||||
<div key={slice.month} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-20 text-muted-foreground">{slice.month}</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="h-8 w-32 font-money"
|
||||
value={slice.amount.amount}
|
||||
onChange={(event) =>
|
||||
onChange(setSliceAmount(draft, index, Number(event.target.value) || 0))
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{draft.currency}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className={`text-xs ${balance === 0 ? "text-muted-foreground" : ""}`}>
|
||||
{balance === 0
|
||||
? `Slices total ${formatAmount(draft.amount)} ${draft.currency}, matching the purchase.`
|
||||
: `Slices are ${formatAmount(Math.abs(balance))} ${draft.currency} ${
|
||||
balance > 0 ? "short of" : "over"
|
||||
} the purchase total.`}
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SplitEditor } from "./SplitEditor.tsx";
|
||||
import { PlanEditor } from "./PlanEditor.tsx";
|
||||
import {
|
||||
emptyDraft,
|
||||
fromPurchase,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
} from "../purchaseForm.ts";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { addPurchase, updatePurchase } from "../../store/actions.ts";
|
||||
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
|
||||
import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts";
|
||||
|
||||
interface Props {
|
||||
@@ -128,6 +129,22 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
|
||||
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
|
||||
|
||||
<PlanEditor draft={draft} onChange={setDraft} />
|
||||
|
||||
{purchase?.schedule && !purchase.schedule.cancelledFromMonth && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
mutate((data) => cancelScheduleFrom(data, purchase.id, monthId));
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel remaining slices from {monthId}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{errors.length > 0 && (
|
||||
<ul className="space-y-1 text-sm text-destructive">
|
||||
{errors.map((message) => (
|
||||
|
||||
Reference in new issue
Block a user