feat: support unassigned purchases and allocation status
Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import { store } from "../../store/index.ts";
|
||||
import { emptyDraft, type PurchaseDraft } from "../purchaseForm.ts";
|
||||
import { PlanEditor } from "./PlanEditor.tsx";
|
||||
|
||||
test("a purchase dated by month can start a finance plan in that month", () => {
|
||||
const original = { get: store.get, subscribe: store.subscribe };
|
||||
const dataset = createSeedDataset("2026-09");
|
||||
store.get = () => dataset;
|
||||
store.subscribe = () => () => {};
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
let draft: PurchaseDraft = { ...emptyDraft("2026-09", "food"), amount: 90 };
|
||||
const render = () => root.render(<PlanEditor draft={draft} onChange={(next) => { draft = next; render(); }} />);
|
||||
try {
|
||||
act(render);
|
||||
const button = container.querySelector("button")!;
|
||||
expect(button.disabled).toBe(false);
|
||||
act(() => button.click());
|
||||
expect(draft.plan?.slices.map((slice) => slice.month)).toEqual(["2026-09", "2026-10", "2026-11"]);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
Object.assign(store, original);
|
||||
}
|
||||
});
|
||||
|
||||
test("an incomplete purchase date cannot start a finance plan", () => {
|
||||
const original = { get: store.get, subscribe: store.subscribe };
|
||||
const dataset = createSeedDataset("2026-09");
|
||||
store.get = () => dataset;
|
||||
store.subscribe = () => () => {};
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
act(() => root.render(<PlanEditor draft={{ ...emptyDraft("2026-09", "food"), date: "" }} onChange={() => {}} />));
|
||||
expect(container.querySelector("button")!.disabled).toBe(true);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
Object.assign(store, original);
|
||||
}
|
||||
});
|
||||
@@ -17,15 +17,13 @@ interface Props {
|
||||
onChange: (next: PurchaseDraft) => void;
|
||||
}
|
||||
|
||||
// A native <input type="date"> reports "" until the user has filled in every
|
||||
// field, and monthOf() throws on anything that isn't a well-formed
|
||||
// YYYY-MM-DD. This is UI-level input validation, not a domain concern — the
|
||||
// domain is right to throw on malformed input; the caller just must not call
|
||||
// it with malformed input.
|
||||
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function hasValidDate(draft: PurchaseDraft): boolean {
|
||||
return ISO_DATE_PATTERN.test(draft.date);
|
||||
try {
|
||||
monthOf(draft.date);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlanEditor({ draft, onChange }: Props) {
|
||||
@@ -53,7 +51,7 @@ export function PlanEditor({ draft, onChange }: Props) {
|
||||
</Button>
|
||||
{!dateReady && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick a date for this purchase first.
|
||||
Pick a month or date for this purchase first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
import { expect, test } from "bun:test";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { createSeedDataset } from "../../domain/seed.ts";
|
||||
import { monthView } from "../../domain/views.ts";
|
||||
import { store } from "../../store/index.ts";
|
||||
import { PostTable } from "./PostTable.tsx";
|
||||
|
||||
test("an unbudgeted post shows no allocation or overspend status, unlike an explicit zero rule", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
const [unbudgeted, zero] = data.posts;
|
||||
unbudgeted!.name = "Unbudgeted";
|
||||
unbudgeted!.rules = [];
|
||||
zero!.name = "Zero rule";
|
||||
zero!.rules = [{ from: "2026-09", rule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } } }];
|
||||
data.purchases = [unbudgeted!, zero!].map((post, i) => ({
|
||||
id: `p${i}`, date: "2026-09", description: post.name,
|
||||
total: { amount: 25, currency: "DKK" }, splitMode: "percent" as const,
|
||||
splits: [{ postId: post.id, value: 100, absorbsRemainder: true }], schedule: null,
|
||||
}));
|
||||
const original = { get: store.get, subscribe: store.subscribe };
|
||||
store.get = () => data;
|
||||
store.subscribe = () => () => {};
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
act(() => root.render(<MemoryRouter><PostTable monthId="2026-09" rows={monthView(data, "2026-09").rows} /></MemoryRouter>));
|
||||
const noneRow = container.querySelector('tbody[data-budget-name="Unbudgeted"]')!;
|
||||
const zeroRow = container.querySelector('tbody[data-budget-name="Zero rule"]')!;
|
||||
expect(noneRow.querySelectorAll("td")[2]!.textContent).toBe("—");
|
||||
expect(noneRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(false);
|
||||
expect(zeroRow.querySelectorAll("td")[2]!.textContent).toContain("0");
|
||||
expect(zeroRow.querySelectorAll("td")[4]!.classList.contains("text-overspend")).toBe(true);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
Object.assign(store, original);
|
||||
}
|
||||
});
|
||||
@@ -50,21 +50,21 @@ interface RowCells {
|
||||
* plain function, not a component, and both table markups call it from inside
|
||||
* a map.
|
||||
*/
|
||||
function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells {
|
||||
function postRowCells({ figures, hasAllocationRule }: MonthPostRow, fmt: MoneyFormat): RowCells {
|
||||
return {
|
||||
carriedIn: fmt.signedAmount(figures.carriedIn),
|
||||
carriedInTone:
|
||||
figures.carriedIn > 0
|
||||
? "text-surplus"
|
||||
: figures.carriedIn < 0
|
||||
: hasAllocationRule && figures.carriedIn < 0
|
||||
? "text-overspend"
|
||||
: "",
|
||||
allocation: fmt.amount(figures.allocation),
|
||||
allocation: hasAllocationRule ? fmt.amount(figures.allocation) : "—",
|
||||
charges: fmt.amount(figures.charges),
|
||||
remaining: fmt.signedAmount(figures.remaining),
|
||||
remainingTone: figures.remaining < 0 ? "text-overspend" : "",
|
||||
remainingTone: hasAllocationRule && figures.remaining < 0 ? "text-overspend" : "",
|
||||
projected: fmt.signedAmount(figures.projected),
|
||||
segments: carryMeterSegments(figures),
|
||||
segments: carryMeterSegments(figures, hasAllocationRule),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [detachBill, setDetachBill] = useState(false);
|
||||
const [draft, setDraft] = useState<PurchaseDraft>(() =>
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
@@ -68,6 +69,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft);
|
||||
|
||||
function reset() {
|
||||
setDetachBill(false);
|
||||
setDraft(
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
@@ -86,7 +88,10 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const created = bulkMode ? toPurchases(bulk) : null;
|
||||
void mutate((data) => {
|
||||
if (created) for (const p of created) addPurchase(data, p);
|
||||
else if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
|
||||
else if (purchase) updatePurchase(data, purchase.id, {
|
||||
...toPurchase(draft),
|
||||
...(detachBill ? { source: undefined } : {}),
|
||||
});
|
||||
else addPurchase(data, toPurchase(draft));
|
||||
}).then((saved) => {
|
||||
if (!saved) return;
|
||||
@@ -166,7 +171,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
setBulk((b) => ({ ...b, postId }));
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a post…</option>
|
||||
<option value="">No post (unassigned)</option>
|
||||
{activePosts.map((post) => (
|
||||
<option key={post.id} value={post.id}>
|
||||
{post.name}
|
||||
@@ -301,6 +306,17 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
|
||||
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
|
||||
|
||||
{purchase?.source && (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={detachBill}
|
||||
onChange={(event) => setDetachBill(event.target.checked)}
|
||||
/>
|
||||
<span>Unlink from bill. The occurrence will show as expected again.</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<PlanEditor draft={draft} onChange={setDraft} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -53,8 +53,8 @@ export function RuleHistory({ post }: Props) {
|
||||
<div className="space-y-3 rounded-md border border-budget-rule p-3">
|
||||
{post.rules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Not budgeted. Until a rule starts, this post is allocated nothing —
|
||||
spending still records, and shows as overspend.
|
||||
Not budgeted. Until a rule starts, this post is allocated nothing.
|
||||
Spending still records without an overspend status.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
|
||||
@@ -39,6 +39,26 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.splits.length === 0}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...draft,
|
||||
splits: event.target.checked
|
||||
? []
|
||||
: posts[0]
|
||||
? [{ postId: posts[0].id, value: 100, absorbsRemainder: true }]
|
||||
: [],
|
||||
})
|
||||
}
|
||||
disabled={posts.length === 0 && draft.splits.length === 0}
|
||||
/>
|
||||
No post — leave this purchase unassigned
|
||||
</label>
|
||||
{draft.splits.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Split across posts</Label>
|
||||
<Segmented
|
||||
@@ -136,6 +156,8 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
|
||||
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function YearMatrix({ view, mode }: Props) {
|
||||
<td
|
||||
key={i}
|
||||
className={`font-money py-2 pl-4 text-right whitespace-nowrap ${
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
mode === "balance" && row.hasAllocationRuleByMonth[i] && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{fmt.amount(value)}
|
||||
@@ -68,13 +68,22 @@ export function YearMatrix({ view, mode }: Props) {
|
||||
})}
|
||||
<td
|
||||
className={`font-money py-2 pl-4 text-right font-medium whitespace-nowrap ${
|
||||
mode === "balance" && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
mode === "balance" && row.hasAllocationRuleByMonth[11] && row.closingBalance < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{fmt.amount(mode === "spend" ? row.totalCharges : row.closingBalance)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{mode === "spend" && view.totalLooseCharges !== 0 && (
|
||||
<tr className="border-b border-budget-rule text-budget-ink-muted">
|
||||
<td className="py-2">Unassigned</td>
|
||||
{view.looseByMonth.map((amount, i) => (
|
||||
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(amount)}</td>
|
||||
))}
|
||||
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">{fmt.amount(view.totalLooseCharges)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user