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, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts"; import type { MonthId, Purchase } from "../../domain/types.ts"; 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 [detachBill, setDetachBill] = 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"; const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft); function reset() { setDetachBill(false); setDraft( purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? "")); } // A write can be REFUSED, not merely fail on a full disk: editing the date // of a purchase that confirms a recurring occurrence is rejected when the // new date would stop the series advancing. 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. 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), ...(detachBill ? { source: undefined } : {}), }); else addPurchase(data, toPurchase(draft)); }).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 })); }} /> setDraft((d) => ({ ...d, date: d.date.slice(0, 7) }))} > 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 })); }} />
{purchase?.source && ( )} )} {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 ` ); }