refactor(collection-confirm): extract ConfirmInvoiceDialog component

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 01:09:37 +02:00
parent 3f228808ea
commit 7360e14c58
2 changed files with 169 additions and 98 deletions

View file

@ -18,7 +18,6 @@ import type { StockItem } from "@/lib/types";
import {
appendConfirmingNote,
appendNote,
buildConfirmNote,
buildConfirmPatch,
buildReturnPatch,
canConfirmRow,
@ -30,6 +29,8 @@ import {
type NoteEntry,
} from "./view-model";
import { ConfirmInvoiceDialog } from "@/components/collection-confirm/confirm-invoice-dialog";
/**
* Where the row came from. The collection-confirm / collection-pool
* pages now read from BOTH `order_items` (customer deliveries) and
@ -1459,15 +1460,25 @@ function ConfirmCard({
<ConfirmInvoiceDialog
open={isConfirmingThis}
row={row}
eyebrow={row.expand?.purchase_order?.po_number ?? "Item"}
busy={busy}
bulkRows={confirmModal?.bulkRows}
onClose={onCloseConfirm}
onSubmit={async (invoice) => {
rowCount={
confirmModal?.bulkRows && confirmModal.bulkRows.length > 0
? confirmModal.bulkRows.length
: 1
}
defaultValues={
invoice ? { date: invoice.date, number: invoice.number } : undefined
}
costOverrides={{}}
onOpenChange={(state) => {
if (!state) onCloseConfirm();
}}
onSubmit={async ({ invoice: submittedInvoice }) => {
if (confirmModal?.bulkRows && confirmModal.bulkRows.length > 0) {
await onSubmitBulkConfirm(confirmModal.bulkRows, invoice);
await onSubmitBulkConfirm(confirmModal.bulkRows, submittedInvoice);
} else {
await onSubmitConfirm(row, invoice);
await onSubmitConfirm(row, submittedInvoice);
}
}}
/>
@ -1676,97 +1687,6 @@ function AttachDialog({
);
}
function ConfirmInvoiceDialog({
open,
row,
busy,
bulkRows,
onClose,
onSubmit,
}: {
open: boolean;
row: ConfirmRow;
busy: boolean;
bulkRows?: ConfirmRow[];
onClose: () => void;
onSubmit: (invoice: { date: string; number: string }) => Promise<void>;
}) {
const today = new Date().toISOString().slice(0, 10);
const existing = readInvoiceDetails(row.order_item_details);
const [date, setDate] = useState<string>(existing?.date ?? today);
const [number, setNumber] = useState<string>(existing?.number ?? "");
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset modal state when (re)opened
setDate(existing?.date ?? today);
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset modal state when (re)opened
setNumber(existing?.number ?? "");
}
}, [open, existing?.date, existing?.number, today]);
const trimmedNumber = number.trim();
const autoNote = buildConfirmNote({ date, number: trimmedNumber });
const valid = date.length === 10 && trimmedNumber.length > 0 && !busy;
const bulkCount = bulkRows?.length ?? 0;
return (
<ModalShell.Dialog
open={open}
onOpenChange={(state) => {
if (!state) onClose();
}}
size="md"
eyebrow={`${row.expand?.purchase_order?.po_number ?? "Item"}`}
title="Confirm for invoicing"
description="Capture the supplier invoice details so invoicing can pick the row up."
footer={
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="button" disabled={!valid} onClick={() => void onSubmit({ date, number: trimmedNumber })}>
{busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
{bulkCount > 1 ? `Confirm ${bulkCount} items` : "Confirm"}
</Button>
</div>
}
>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-muted-foreground" htmlFor="confirm-date">Supplier invoice date</label>
<input
id="confirm-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground" htmlFor="confirm-number">Supplier invoice number</label>
<input
id="confirm-number"
type="text"
value={number}
onChange={(e) => setNumber(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
placeholder="INV-..."
/>
</div>
<p className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Audit note: <span className="font-mono text-foreground">{autoNote}</span>
</p>
{bulkCount > 1 && (
<p className="text-[11px] text-muted-foreground">
This will confirm {bulkCount} items in this PO with the invoice details above.
</p>
)}
</div>
</ModalShell.Dialog>
);
}
function ReturnToPoolDialog({
open,
row,

View file

@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { ModalShell } from "@/components/ui/modal-shell";
import { Button } from "@/components/ui/button";
type ConfirmInvoiceDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/**
* Optional pre-filled invoice details. The parent typically derives
* these from the row being confirmed (e.g. via
* `readInvoiceDetails(row.order_item_details)`); when omitted, the
* date defaults to today and the number is left blank.
*/
defaultValues?: {
date?: string;
number?: string;
};
/**
* Map of `itemId → new cost`. Surfaced in the summary line at the
* bottom of the dialog body so the operator can see at a glance
* whether the confirm patch will change any `cost_price` values.
* Tasks 10–12 will populate this from the surrounding flows; the
* dialog itself just reads it for display.
*/
costOverrides?: Record<string, number>;
/**
* Number of rows this confirm covers. Drives the button label
* ("Confirm" vs "Confirm N items") and the summary line plural.
* Defaults to 1 (single-row confirm).
*/
rowCount?: number;
/** Small label above the title (e.g. the PO number). */
eyebrow?: React.ReactNode;
/** Disables the submit button and shows a spinner while the parent persists. */
busy?: boolean;
/**
* Called when the operator clicks Confirm. Receives the trimmed
* invoice date/number plus the cost-overrides map so callers can
* stitch them into the confirm patch.
*/
onSubmit: (args: {
invoice: { date: string; number: string };
costOverrides: Record<string, number>;
}) => Promise<void> | void;
};
export function ConfirmInvoiceDialog({
open,
onOpenChange,
defaultValues,
costOverrides = {},
rowCount = 1,
eyebrow,
busy = false,
onSubmit,
}: ConfirmInvoiceDialogProps) {
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState<string>(defaultValues?.date ?? today);
const [number, setNumber] = useState<string>(defaultValues?.number ?? "");
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset modal state when (re)opened
setDate(defaultValues?.date ?? today);
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset modal state when (re)opened
setNumber(defaultValues?.number ?? "");
}
}, [open, defaultValues?.date, defaultValues?.number, today]);
const trimmedNumber = number.trim();
const valid = date.length === 10 && trimmedNumber.length > 0 && !busy;
const overridesCount = Object.keys(costOverrides).length;
return (
<ModalShell.Dialog
open={open}
onOpenChange={onOpenChange}
size="md"
eyebrow={eyebrow}
title="Confirm for invoicing"
description="Capture the supplier invoice details so invoicing can pick the row up."
footer={
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={busy}
>
Cancel
</Button>
<Button
type="button"
disabled={!valid}
onClick={() =>
void onSubmit({
invoice: { date, number: trimmedNumber },
costOverrides,
})
}
>
{busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
{rowCount > 1 ? `Confirm ${rowCount} items` : "Confirm"}
</Button>
</div>
}
>
<div className="space-y-3">
<div>
<label
className="block text-xs font-medium text-muted-foreground"
htmlFor="confirm-date"
>
Supplier invoice date
</label>
<input
id="confirm-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<label
className="block text-xs font-medium text-muted-foreground"
htmlFor="confirm-number"
>
Supplier invoice number
</label>
<input
id="confirm-number"
type="text"
value={number}
onChange={(e) => setNumber(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
placeholder="INV-..."
/>
</div>
<p className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Confirming {rowCount} row{rowCount === 1 ? "" : "s"} ·{" "}
{overridesCount} cost override{overridesCount === 1 ? "" : "s"} applied
</p>
</div>
</ModalShell.Dialog>
);
}