feat(ui): add fast entry for many purchases at once
A grocery trip is twenty lines, and opening the full dialog twenty times is the thing this exists to avoid. PurchaseDialog gains a One / Many toggle, shown only when adding — editing an existing purchase edits exactly one, so the toggle is hidden there. In Many mode the date, post and currency are set once at the top and each line is just a description and an amount; Enter on an amount starts the next line, so a receipt can be typed without leaving the keyboard. Blank trailing rows are ignored rather than flagged. The whole batch commits in a single mutate: one write, one queue entry, and no way to half-commit a shopping trip. Lines are deliberately simple. A line needing a split or a payment plan is saved plain and then edited from the month view — building per-line split and plan editors would have been most of the work for a case the grocery run never hits, and would have slowed the common path. Line logic lives in a pure module (src/ui/bulkEntry.ts) following the purchaseForm.ts pattern, so it is testable with no DOM: 20 tests covering blank-row handling, per-row validation messages, the running total, and that N lines become N purchases landing wholly in the shared post. Each line is built through the same toPurchase the single form uses, so there is one definition of what a purchase is. Validation mirrors validatePurchase in what it deliberately does NOT reject: a negative amount is a refund, not an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
417adb0d85
commit
da87bff9c3
@@ -0,0 +1,118 @@
|
||||
import { useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatMoney } from "../format.ts";
|
||||
import { bulkTotal, isBlankLine, type BulkDraft } from "../bulkEntry.ts";
|
||||
|
||||
interface Props {
|
||||
draft: BulkDraft;
|
||||
onChange: (next: BulkDraft) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row list for fast entry. Two fields per line is the whole point — Enter
|
||||
* in the amount field commits the row and focuses a fresh one, so a grocery
|
||||
* receipt can be typed without ever leaving the keyboard.
|
||||
*/
|
||||
export function BulkLines({ draft, onChange }: Props) {
|
||||
const amountRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
function updateLine(index: number, changes: Partial<BulkDraft["lines"][number]>) {
|
||||
onChange({
|
||||
...draft,
|
||||
lines: draft.lines.map((line, i) => (i === index ? { ...line, ...changes } : line)),
|
||||
});
|
||||
}
|
||||
|
||||
function addLine(focusIndex?: number) {
|
||||
onChange({ ...draft, lines: [...draft.lines, { description: "", amount: 0 }] });
|
||||
if (focusIndex !== undefined) {
|
||||
// Focus after React has rendered the new row.
|
||||
requestAnimationFrame(() => amountRefs.current[focusIndex]?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
const lines = draft.lines.filter((_, i) => i !== index);
|
||||
onChange({ ...draft, lines: lines.length > 0 ? lines : [{ description: "", amount: 0 }] });
|
||||
}
|
||||
|
||||
const filledCount = draft.lines.filter((line) => !isBlankLine(line)).length;
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Lines</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Enter on an amount starts the next line
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
|
||||
{draft.lines.map((line, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span className="w-5 shrink-0 text-right text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Input
|
||||
className="h-8 flex-1"
|
||||
placeholder="What was it?"
|
||||
value={line.description}
|
||||
onChange={(event) => {
|
||||
const description = event.target.value;
|
||||
updateLine(index, { description });
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
amountRefs.current[index]?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
amountRefs.current[index] = el;
|
||||
}}
|
||||
className="font-money h-8 w-28"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={line.amount === 0 ? "" : line.amount}
|
||||
onChange={(event) => {
|
||||
const amount = Number(event.target.value) || 0;
|
||||
updateLine(index, { amount });
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (index === draft.lines.length - 1) addLine(index + 1);
|
||||
else amountRefs.current[index + 1]?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Remove line ${index + 1}`}
|
||||
onClick={() => removeLine(index)}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => addLine(draft.lines.length)}>
|
||||
Add line
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{filledCount} line{filledCount === 1 ? "" : "s"} ·{" "}
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft), draft.currency)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SplitEditor } from "./SplitEditor.tsx";
|
||||
import { PlanEditor } from "./PlanEditor.tsx";
|
||||
import { BulkLines } from "./BulkLines.tsx";
|
||||
import {
|
||||
emptyDraft,
|
||||
fromPurchase,
|
||||
@@ -19,6 +20,12 @@ import {
|
||||
validatePurchase,
|
||||
type PurchaseDraft,
|
||||
} from "../purchaseForm.ts";
|
||||
import {
|
||||
emptyBulkDraft,
|
||||
toPurchases,
|
||||
validateBulk,
|
||||
type BulkDraft,
|
||||
} from "../bulkEntry.ts";
|
||||
import { useDataset } from "../hooks/useDataset.ts";
|
||||
import { useMutate } from "../hooks/useMutate.ts";
|
||||
import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts";
|
||||
@@ -42,20 +49,38 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
const [draft, setDraft] = useState<PurchaseDraft>(() =>
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
const errors = validatePurchase(draft);
|
||||
// "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<BulkDraft>(() =>
|
||||
emptyBulkDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
const bulkMode = !purchase && mode === "many";
|
||||
|
||||
const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft);
|
||||
|
||||
function reset() {
|
||||
setDraft(
|
||||
purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""),
|
||||
);
|
||||
setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? ""));
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (errors.length > 0) return;
|
||||
mutate((data) => {
|
||||
if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
|
||||
else addPurchase(data, toPurchase(draft));
|
||||
});
|
||||
if (bulkMode) {
|
||||
// One mutate for the whole batch: a single write, one queue entry, and no
|
||||
// way to half-commit a shopping trip.
|
||||
const created = toPurchases(bulk);
|
||||
mutate((data) => {
|
||||
for (const p of created) addPurchase(data, p);
|
||||
});
|
||||
} else {
|
||||
mutate((data) => {
|
||||
if (purchase) updatePurchase(data, purchase.id, toPurchase(draft));
|
||||
else addPurchase(data, toPurchase(draft));
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
if (!purchase) reset();
|
||||
}
|
||||
@@ -71,10 +96,92 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{purchase ? "Edit purchase" : "Add purchase"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{purchase ? "Edit purchase" : bulkMode ? "Add many purchases" : "Add purchase"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!purchase && (
|
||||
<div className="flex gap-1">
|
||||
{(["one", "many"] as const).map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={mode === option ? "default" : "outline"}
|
||||
onClick={() => setMode(option)}
|
||||
>
|
||||
{option === "one" ? "One purchase" : "Many lines"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{bulkMode ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bulk-date">Date</Label>
|
||||
<Input
|
||||
id="bulk-date"
|
||||
type="date"
|
||||
value={bulk.date}
|
||||
onChange={(event) => {
|
||||
const date = event.target.value;
|
||||
setBulk((b) => ({ ...b, date }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bulk-post">Post</Label>
|
||||
<select
|
||||
id="bulk-post"
|
||||
className="h-9 w-full rounded border bg-background px-2 text-sm"
|
||||
value={bulk.postId}
|
||||
onChange={(event) => {
|
||||
const postId = event.target.value;
|
||||
setBulk((b) => ({ ...b, postId }));
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a post…</option>
|
||||
{activePosts.map((post) => (
|
||||
<option key={post.id} value={post.id}>
|
||||
{post.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bulk-currency">Currency</Label>
|
||||
<select
|
||||
id="bulk-currency"
|
||||
className="h-9 w-full rounded border bg-background px-2 text-sm"
|
||||
value={bulk.currency}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value as typeof bulk.currency;
|
||||
setBulk((b) => ({ ...b, currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BulkLines draft={bulk} onChange={setBulk} />
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchase-date">Date</Label>
|
||||
@@ -130,6 +237,8 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user