refactor(collection-confirm): extract ConfirmInvoiceDialog component
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
3f228808ea
commit
7360e14c58
2 changed files with 169 additions and 98 deletions
|
|
@ -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,
|
||||
|
|
|
|||
151
src/components/collection-confirm/confirm-invoice-dialog.tsx
Normal file
151
src/components/collection-confirm/confirm-invoice-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue