import { useState } from "react"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; import { Label } from "@/components/ui/label"; import { Segmented } from "./Segmented.tsx"; import { SplitEditor } from "./SplitEditor.tsx"; import { PlanEditor } from "./PlanEditor.tsx"; import { BulkLines } from "./BulkLines.tsx"; import { emptyDraft, fromPurchase, toPurchase, validatePurchase, type PurchaseDraft, } from "../purchaseForm.ts"; import { emptyBulkDraft, toPurchases, validateBulk, type BulkDraft, } from "../bulkEntry.ts"; import { useDataset } from "../hooks/useDataset.ts"; import { parseMoneyInput } from "../moneyInput.ts"; import { useMutate } from "../hooks/useMutate.ts"; import { addPurchase, addRecurringCost, cancelScheduleFrom, updatePurchase, } from "../../store/actions.ts"; import { newCostOptionIsSelectable, offerableCosts, seedCostFromDraft, sourceFor, } from "../recurringForm.ts"; import { WEEKDAYS } from "../routes/settings/RecurringSection.tsx"; import type { Anchoring, MonthId, Purchase, Recurrence } from "../../domain/types.ts"; /** The "Recurring cost" select's own sentinel, alongside "none" and a cost id. */ const NEW_COST = "__new__"; interface Props { monthId: MonthId; /** Provide to edit an existing purchase; omit to add a new one. */ purchase?: Purchase; trigger: React.ReactNode; } export function PurchaseDialog({ monthId, purchase, trigger }: Props) { const dataset = useDataset(); const { mutate, error, clearError } = useMutate(); const activePosts = dataset.posts .filter((p) => !p.archived) .sort((a, b) => a.order - b.order); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(() => purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); // The amount field keeps its own text so a half-typed "30." or "30 " stays // exactly as typed; `draft.amount` only moves when the text parses. const [amountText, setAmountText] = useState(() => purchase ? String(purchase.total.amount) : "", ); // "many" is only meaningful when adding — editing an existing purchase edits // exactly one, so the toggle is hidden in that case. const [mode, setMode] = useState<"one" | "many">("one"); const [bulk, setBulk] = useState(() => emptyBulkDraft(monthId, activePosts[0]?.id ?? ""), ); const bulkMode = !purchase && mode === "many"; // "none" | a RecurringCostId | NEW_COST. Seeded from the purchase's own // association when editing, so the control shows what is already there. const [recurringSelection, setRecurringSelection] = useState( () => purchase?.source?.recurringId ?? "none", ); // The shapes NEW_COST reveals, same defaults RecurringSection's own // "Add recurring cost" quick-add uses. const [newRecurrence, setNewRecurrence] = useState({ kind: "everyNMonths", n: 1, }); const [newAnchoring, setNewAnchoring] = useState("calendar"); const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft); // The costs offerable for the date currently typed, plus whatever is // already selected — so picking a cost, then changing the date to one its // grid does not cover, does not silently drop the selection out of the // list out from under the owner. const offerable = offerableCosts(dataset, draft.date); const selected = dataset.recurring.find((cost) => cost.id === recurringSelection); const selectableCosts = selected && !offerable.some((cost) => cost.id === selected.id) ? [...offerable, selected] : offerable; function reset() { setDraft( purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? "")); setRecurringSelection(purchase?.source?.recurringId ?? "none"); setNewRecurrence({ kind: "everyNMonths", n: 1 }); setNewAnchoring("calendar"); } // A write can be REFUSED, not merely fail on a full disk. Tagging a // purchase to a cost anchored to its last charge is rejected when the // purchase's date carries no day, because that date is what the next charge // is measured from; a cost that has vanished since the dialog opened is // rejected below; and any of the store's other pairing rules can refuse // too. Closing the dialog regardless would show the user a save that // silently did nothing, so the dialog stays open and shows `error` instead. // `mutate` never rejects. function save() { if (errors.length > 0) return; // One mutate for the whole batch: a single write, one queue entry, and no // way to half-commit a shopping trip. Creating a NEW recurring cost is // part of that same batch — the cost and its first charge must land // together, or a failure could leave a cost with no first charge. const created = bulkMode ? toPurchases(bulk) : null; void mutate((data) => { if (created) { for (const p of created) addPurchase(data, p); return; } let source: Purchase["source"]; if (recurringSelection === NEW_COST) { const seeded = addRecurringCost( data, seedCostFromDraft(draft, newRecurrence, newAnchoring), ); source = sourceFor(data, seeded, draft.date); } else if (recurringSelection !== "none") { const cost = data.recurring.find((c) => c.id === recurringSelection); // Throw rather than save untagged. The cost can genuinely be gone — // deleted from Settings in another tab while this dialog sat open — // and dropping the association silently would save a purchase the // owner believes is a charge for a bill, leaving that bill still // showing as expected. `useMutate` catches this and the dialog stays // open with the message. if (!cost) { throw new Error( `The recurring cost this purchase was tagged to no longer exists. Choose another one ` + `(or "None") and save again.`, ); } source = sourceFor(data, cost, draft.date); } if (purchase) { // `source` is passed explicitly, even when undefined: updatePurchase // only touches keys present in `changes`, so omitting it entirely // when the owner picks "None" would leave a stored association // untouched instead of clearing it. updatePurchase(data, purchase.id, { ...toPurchase(draft), source }); } else { addPurchase(data, { ...toPurchase(draft), ...(source ? { source } : {}) }); } }).then((saved) => { if (!saved) return; setOpen(false); if (!purchase) reset(); }); } return ( { setOpen(next); if (next) { reset(); clearError(); } }} > {trigger} {purchase ? "Edit purchase" : bulkMode ? "Add many purchases" : "Add purchase"} {!purchase && ( )}
{bulkMode ? ( <>
7 ? "date" : "text"} readOnly={bulk.date.length <= 7} className={bulk.date.length > 7 ? undefined : "font-money"} value={bulk.date} onChange={(event) => { const date = event.target.value; setBulk((b) => ({ ...b, date })); }} /> setBulk((b) => ({ ...b, date: b.date.length > 7 ? b.date.slice(0, 7) : `${b.date}-01`, })) } > {bulk.date.length > 7 ? "use the month only" : "set exact date"}
{ const postId = event.target.value; setBulk((b) => ({ ...b, postId })); }} > {activePosts.map((post) => ( ))}
{ const currency = event.target.value as typeof bulk.currency; setBulk((b) => ({ ...b, currency })); }} > {dataset.currencies.map(({ code: currency }) => ( ))}

Each line is saved as its own purchase in the post above. Need a split or a payment plan on one of them? Save it here, then open it from the month view.

) : ( <>
{draft.date.length > 7 ? ( <> { const date = event.target.value; setDraft((d) => ({ ...d, date })); }} /> { const monthOnly = draft.date.slice(0, 7); setDraft((d) => ({ ...d, date: monthOnly })); // Dropping to month-only can strand a "+ New recurring // cost…" selection that needed the day just removed // (e.g. "days", or "months" anchored to the last // charge) — its option would show `disabled` below but // stay selected, so reset to the one pairing that is // always valid rather than leave state pointing at a // choice the owner can no longer make. if (!newCostOptionIsSelectable(newRecurrence.kind, newAnchoring, monthOnly)) { setNewRecurrence((r) => ({ kind: "everyNMonths", n: r.n })); setNewAnchoring("calendar"); } }} > use the month only ) : ( <> setDraft((d) => ({ ...d, date: `${d.date}-01` }))} > set exact date )}
setDraft({ ...draft, description: event.target.value }) } />
{/* type="text", not "number": a number input silently discards anything non-numeric, so "30$" would never reach the parser. Entry speed is the point — the currency is set from what was typed rather than from a second control. */} { const typed = event.target.value; setAmountText(typed); const parsed = parseMoneyInput(typed, dataset.currencies, draft.currency); if (parsed) { setDraft({ ...draft, amount: parsed.amount, currency: parsed.currency }); } }} />
setDraft({ ...draft, currency: event.target.value as typeof draft.currency }) } > {dataset.currencies.map(({ code: currency }) => ( ))}
{ const note = event.target.value; setDraft((d) => ({ ...d, note })); }} />
{ const value = event.target.value; setRecurringSelection(value); }} > {selectableCosts.map((cost) => ( ))}

Tag this purchase as a charge for one of your bills: for one measured from the last charge, that also restarts its cycle from today.

{/* An anchored cost stays SELECTED when the date drops to month-only (the selection is never taken away out from under the owner), but the store will refuse the save: that date is what the bill's next charge is measured from. Say so here rather than letting Save be the first place it comes up. */} {selected?.anchoring === "lastCharge" && draft.date.length <= 7 && (

“{selected.name}” is measured from its last charge, so this purchase needs an exact date — use “set exact date” above.

)}
{recurringSelection === NEW_COST && (
New recurring cost
{ // Captured before setState, same discipline mutate // callbacks need — this one is not deferred, but there // is no reason to read the live DOM node twice. const typed = event.target.value; const n = Number(typed); if (!Number.isInteger(n) || n < 1) return; setNewRecurrence((r) => ({ ...r, n })); }} />
{ const kind = event.target.value as Recurrence["kind"]; setNewRecurrence((r) => kind === "everyNWeeks" ? { kind, n: r.n, weekday: 1 } : { kind, n: r.n }, ); }} >
{newRecurrence.kind === "everyNWeeks" && (
{ const weekday = Number(event.target.value); setNewRecurrence((r) => r.kind === "everyNWeeks" ? { ...r, weekday } : r, ); }} > {WEEKDAYS.map((day, index) => ( ))}
)}
{ const anchoring = event.target.value as Anchoring; setNewAnchoring(anchoring); }} >
{draft.date.length <= 7 && (

Days, weeks, and "the last charge" need an exact date — use "set exact date" next to the date above, then pick the unit or anchoring you want.

)}

This purchase becomes its first charge. Anchored to the last charge, its cycle restarts from today rather than from a fixed grid.

)} )} {purchase?.schedule && !purchase.schedule.cancelledFromMonth && ( )} {errors.length > 0 && (
    {errors.map((message) => (
  • {message}
  • ))}
)} {error && (

Could not save: {error}

)}
); } /** * "set exact date" / "use the month only", under the date field. * * Was a bare ` ); }