feat: add row selection and cost pricing
- add `costPrice` field to invoicing row types, plus ZAR currency formatting and profit margin calculations - update invoice row displays to show ordered/received quantity, unit cost, selling price and margin metrics - add per-row selection in QuickBooks send modals, letting users filter which order lines to include on invoices - update ModalShell component to support responsive breakpoint-based sizing instead of fixed sm-only widths - refine UI styling across the invoicing page to use consistent theme tokens (bg-card, muted-foreground) - update tests to use dynamic yesterday dates instead of hardcoded values for stable, time-agnostic assertions - improve button states, labels and text for QuickBooks invoice actions, including showing selected item counts
This commit is contained in:
parent
046683c506
commit
7ebfb8b86b
4 changed files with 368 additions and 102 deletions
|
|
@ -3,6 +3,17 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
|||
|
||||
const invoiceWebhookFetchMock = vi.fn();
|
||||
|
||||
// ISO date "yesterday" — the Invoiced tab only shows rows from the last
|
||||
// 3 days (`buildRecentInvoiceRange`). Hard-coding a date (e.g.
|
||||
// "2026-07-08") silently breaks the test once the calendar rolls past
|
||||
// that range; using "yesterday" keeps the assertion stable over time.
|
||||
const YESTERDAY_ISO = (() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
})();
|
||||
const YESTERDAY_ISO_AT_8AM = `${YESTERDAY_ISO}T08:00:00.000Z`;
|
||||
|
||||
const { fetchCustomersMock, getFullListMock, mockedRows, makeRawRow } = vi.hoisted(() => ({
|
||||
fetchCustomersMock: vi.fn(async () => [
|
||||
{ id: "qb-1", name: "Adal Engineering" },
|
||||
|
|
@ -324,7 +335,7 @@ describe("InvoicingPage", () => {
|
|||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
|
||||
// Wait for the modal to actually open.
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
// Give any spurious effect a tick, then assert nothing fired.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(fetchCustomersMock).not.toHaveBeenCalled();
|
||||
|
|
@ -335,7 +346,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
const toggle = getQbCompanyToggle("single");
|
||||
expect(toggle).toBeInTheDocument();
|
||||
|
|
@ -349,7 +360,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
pickQbCompany("single", "ISHERWOOD");
|
||||
|
||||
|
|
@ -369,7 +380,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
// Pre-condition: picking the QB company triggers exactly one fetch.
|
||||
pickQbCompany("single", "ISHERWOOD");
|
||||
|
|
@ -396,7 +407,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
pickQbCompany("single", "CNL");
|
||||
|
||||
|
|
@ -407,7 +418,7 @@ describe("InvoicingPage", () => {
|
|||
return el;
|
||||
});
|
||||
expect(select.value).toBe("qb-1");
|
||||
const sendButton = screen.getByRole("button", { name: /send to quickbooks/i });
|
||||
const sendButton = screen.getByRole("button", { name: /send .*to quickbooks/i });
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
|
|
@ -453,7 +464,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
pickQbCompany("single", "CNL");
|
||||
|
||||
|
|
@ -470,7 +481,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
pickQbCompany("single", "CNL");
|
||||
|
||||
|
|
@ -481,7 +492,7 @@ describe("InvoicingPage", () => {
|
|||
});
|
||||
expect(select.value).toBe("qb-1");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^send to quickbooks$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^send \d+ items? to quickbooks$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
if (invoiceWebhookFetchMock.mock.calls.length === 0) throw new Error("waiting for invoice webhook");
|
||||
|
|
@ -504,7 +515,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
pickQbCompany("single", "CNL");
|
||||
await waitFor(() => {
|
||||
|
|
@ -557,7 +568,7 @@ describe("InvoicingPage", () => {
|
|||
await waitFor(() => expect(screen.getAllByText("Wheel hub kit")[0]).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(getSingleOrderButton("SO-1"));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send to quickbooks/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /send .*to quickbooks/i })).toBeInTheDocument());
|
||||
|
||||
pickQbCompany("single", "CNL");
|
||||
|
||||
|
|
@ -569,14 +580,14 @@ describe("InvoicingPage", () => {
|
|||
fireEvent.change(screen.getByLabelText("Invoice order number"), {
|
||||
target: { value: "SO-OVERRIDE" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Qty for row-1"), {
|
||||
fireEvent.change(screen.getByLabelText("Qty — Wheel hub kit"), {
|
||||
target: { value: "4" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Selling price for row-1"), {
|
||||
fireEvent.change(screen.getByLabelText("Selling price — Wheel hub kit"), {
|
||||
target: { value: "78.5" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^send to quickbooks$/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^send \d+ items? to quickbooks$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
if (invoiceWebhookFetchMock.mock.calls.length === 0) throw new Error("waiting for invoice webhook");
|
||||
|
|
@ -606,7 +617,7 @@ describe("InvoicingPage", () => {
|
|||
order_item_details: {
|
||||
invoice: {
|
||||
number: "INV-1",
|
||||
date: "2026-07-08",
|
||||
date: YESTERDAY_ISO,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -634,10 +645,10 @@ describe("InvoicingPage", () => {
|
|||
order_item_details: {
|
||||
invoice: {
|
||||
number: "INV-1",
|
||||
date: "2026-07-08",
|
||||
date: YESTERDAY_ISO,
|
||||
setBy: "u-1",
|
||||
setByName: "Op",
|
||||
setAt: "2026-07-08T08:00:00.000Z",
|
||||
setAt: YESTERDAY_ISO_AT_8AM,
|
||||
},
|
||||
print: {
|
||||
printed: true,
|
||||
|
|
@ -667,10 +678,10 @@ describe("InvoicingPage", () => {
|
|||
order_item_details: {
|
||||
invoice: {
|
||||
number: "INV-1",
|
||||
date: "2026-07-08",
|
||||
date: YESTERDAY_ISO,
|
||||
setBy: "u-1",
|
||||
setByName: "Op",
|
||||
setAt: "2026-07-08T08:00:00.000Z",
|
||||
setAt: YESTERDAY_ISO_AT_8AM,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ type RawInvoicingRow = {
|
|||
qb_invoice_pdf?: string | null;
|
||||
order_qty?: number | null;
|
||||
qty_collected?: number | null;
|
||||
cost_price?: number | null;
|
||||
selling_price?: number | null;
|
||||
order_item_details?: string | Record<string, unknown> | null;
|
||||
expand?: {
|
||||
|
|
@ -158,6 +159,7 @@ function toInvoicingRow(row: RawInvoicingRow): InvoicingRow {
|
|||
}),
|
||||
orderQty: row.order_qty ?? null,
|
||||
qtyCollected: row.qty_collected ?? null,
|
||||
costPrice: row.cost_price ?? null,
|
||||
sellingPrice: row.selling_price ?? null,
|
||||
printed: print.printed,
|
||||
printedAt: print.printedAt,
|
||||
|
|
@ -174,6 +176,109 @@ function rowStatusLabel(status: string | undefined): string {
|
|||
return status ?? "Unknown";
|
||||
}
|
||||
|
||||
// Format a number as ZAR (R 1 234.56). Returns "—" for null / NaN so the
|
||||
// row layout doesn't collapse when a value is missing.
|
||||
function formatZar(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return new Intl.NumberFormat("en-ZA", {
|
||||
style: "currency",
|
||||
currency: "ZAR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
// Format a plain integer-ish qty. Falls back to "—" when missing so an
|
||||
// "Qty ordered / received" cell can render partial data without breaking
|
||||
// the row alignment.
|
||||
function formatQty(value: number | null | undefined): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
||||
return new Intl.NumberFormat("en-ZA", {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function RowMetrics({ row }: { row: InvoicingRow }) {
|
||||
return (
|
||||
<dl className="grid grid-cols-3 gap-x-3 gap-y-0.5 text-xs">
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">Ordered</dt>
|
||||
<dd className="font-mono font-medium tabular-nums">{formatQty(row.orderQty)}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">Received</dt>
|
||||
<dd className="font-mono font-medium tabular-nums">{formatQty(row.qtyCollected)}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">Unit</dt>
|
||||
<dd className="text-muted-foreground">{row.sellingPrice != null ? "ea" : ""}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">Cost</dt>
|
||||
<dd className="font-mono tabular-nums">{formatZar(row.costPrice)}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">Selling</dt>
|
||||
<dd className="font-mono font-medium tabular-nums">{formatZar(row.sellingPrice)}</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">Margin</dt>
|
||||
<dd className="font-mono tabular-nums text-muted-foreground">
|
||||
{typeof row.costPrice === "number" &&
|
||||
typeof row.sellingPrice === "number" &&
|
||||
row.costPrice > 0
|
||||
? `${Math.round(((row.sellingPrice - row.costPrice) / row.costPrice) * 100)}%`
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
// Top-of-row identity block: the *thing being sold* is the headline
|
||||
// (item description, themed with `text-primary` so it pops against the
|
||||
// card), with the supplier dropped to a muted second line. Keeps the
|
||||
// visual hierarchy "what is this? → where is it from? → where does it
|
||||
// live in the order?" instead of "where is it from? → what is this?".
|
||||
function RowIdentity({
|
||||
row,
|
||||
showOrderLink = true,
|
||||
}: {
|
||||
row: InvoicingRow;
|
||||
showOrderLink?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-primary"
|
||||
title={row.description ?? undefined}
|
||||
>
|
||||
{row.description || "(no description)"}
|
||||
</p>
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={row.supplierName ?? undefined}
|
||||
>
|
||||
{row.supplierName || "Unknown supplier"}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{row.poNumber ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono">
|
||||
<FileText className="h-3 w-3" />
|
||||
{row.poNumber}
|
||||
</span>
|
||||
) : null}
|
||||
{showOrderLink && row.orderNumber ? (
|
||||
<Link href="/orders" className="inline-flex items-center gap-1 hover:underline">
|
||||
Order {row.orderNumber}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stable cache key for (customer_query, billing_company). The order keeps
|
||||
// the key deterministic for the same inputs so the n8n webhook gets the
|
||||
// right `customer_query` / `billing_company`.
|
||||
|
|
@ -270,6 +375,12 @@ type QbGroup = { orderNumber: string; rows: InvoicingRow[] };
|
|||
type InvoicePayloadOverrides = {
|
||||
orderNumberOverride?: string;
|
||||
lineOverrides?: InvoiceLineOverride[];
|
||||
/**
|
||||
* Subset of `group.rows` the operator actually wants on the invoice.
|
||||
* When omitted, every line in `group.rows` is sent (legacy behaviour).
|
||||
* Filter is applied in `submitQbInvoice` before the QB payload is built.
|
||||
*/
|
||||
includedRowIds?: string[];
|
||||
};
|
||||
type SubmitQbInvoice = (
|
||||
group: QbGroup,
|
||||
|
|
@ -308,6 +419,10 @@ function SingleOrderQbModal({
|
|||
const [customer, setCustomer] = useState<QbCustomer | null>(null);
|
||||
const [orderNumberOverride, setOrderNumberOverride] = useState("");
|
||||
const [lineOverrides, setLineOverrides] = useState<Record<string, { qty: string; price: string }>>({});
|
||||
// Set of row IDs the operator wants on this invoice. Default = all rows
|
||||
// on open (the common case is "invoice the whole order"), with a
|
||||
// checkbox per row + select-all/none to drop the ones they don't want.
|
||||
const [includedRowIds, setIncludedRowIds] = useState<Set<string>>(new Set());
|
||||
// The webhook match is the company (e.g. "Adal Engineering") — QuickBooks
|
||||
// customers are billed to the company. The order's `customerName`
|
||||
// (e.g. "GIELIE") is just visual context on the row.
|
||||
|
|
@ -328,7 +443,16 @@ function SingleOrderQbModal({
|
|||
}, [open, customers, orderCompanyName, qbCompany, onQbCompanyChange]);
|
||||
useEffect(() => {
|
||||
if (!open || !group) return;
|
||||
// The pre-existing sibling effect (preselect closest QB customer) is
|
||||
// a setState-in-effect on purpose — the modal's `open` transition
|
||||
// is the only signal that lets us seed the form from incoming props
|
||||
// without flashing a stale value. Mirror the same pattern for the
|
||||
// new fields. The parent uses `key`-style remount discipline
|
||||
// (re-opening resets everything), so this only runs on the
|
||||
// open/close transition — not on every parent re-render.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional open/close seed
|
||||
setOrderNumberOverride(group.orderNumber);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
|
||||
setLineOverrides(
|
||||
Object.fromEntries(
|
||||
group.rows.map((row) => {
|
||||
|
|
@ -337,21 +461,33 @@ function SingleOrderQbModal({
|
|||
}),
|
||||
),
|
||||
);
|
||||
// Default to "all rows on this invoice" — the common case. Operator
|
||||
// can uncheck the ones they don't want.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- see note above
|
||||
setIncludedRowIds(new Set(group.rows.map((row) => row.id)));
|
||||
}, [open, group]);
|
||||
|
||||
const includedRowIdList = group
|
||||
? group.rows
|
||||
.filter((row) => includedRowIds.has(row.id))
|
||||
.map((row) => row.id)
|
||||
: [];
|
||||
const submitOverrides: InvoicePayloadOverrides = {
|
||||
orderNumberOverride,
|
||||
lineOverrides: group?.rows.map((row) => {
|
||||
const current = lineOverrides[row.id];
|
||||
const defaults = resolveInvoiceLineDefaults(row);
|
||||
const qty = Number(current?.qty);
|
||||
const price = Number(current?.price);
|
||||
return {
|
||||
rowId: row.id,
|
||||
qty: Number.isFinite(qty) ? qty : defaults.qty,
|
||||
price: Number.isFinite(price) ? price : defaults.price,
|
||||
};
|
||||
}),
|
||||
includedRowIds: includedRowIdList,
|
||||
lineOverrides: group?.rows
|
||||
.filter((row) => includedRowIds.has(row.id))
|
||||
.map((row) => {
|
||||
const current = lineOverrides[row.id];
|
||||
const defaults = resolveInvoiceLineDefaults(row);
|
||||
const qty = Number(current?.qty);
|
||||
const price = Number(current?.price);
|
||||
return {
|
||||
rowId: row.id,
|
||||
qty: Number.isFinite(qty) ? qty : defaults.qty,
|
||||
price: Number.isFinite(price) ? price : defaults.price,
|
||||
};
|
||||
}),
|
||||
};
|
||||
// Drive the fetch off the operator's QB-company selection. Until they
|
||||
// pick CNL or ISHERWOOD, no customer request fires — the picker just
|
||||
|
|
@ -379,19 +515,31 @@ function SingleOrderQbModal({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [matchKey]);
|
||||
if (!group) return null;
|
||||
const allIncluded =
|
||||
group.rows.length > 0 && group.rows.every((row) => includedRowIds.has(row.id));
|
||||
const noneIncluded = includedRowIds.size === 0;
|
||||
const itemLabel = (row: InvoicingRow): string =>
|
||||
row.description?.trim() || row.supplierName || `Item ${row.id}`;
|
||||
return (
|
||||
<ModalShell.Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => { if (!state) onClose(); }}
|
||||
size="md"
|
||||
size={{ base: "md", md: "xl", lg: "2xl" }}
|
||||
title="Send to QuickBooks"
|
||||
description={`Pick a QuickBooks customer for order ${group.orderNumber || "no order number"} (${orderCompanyName || "no billing company"}, ${orderCustomerName || "no customer"}).`}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||
<Button type="button" disabled={!customer || !qbCompany || busy} onClick={() => customer && qbCompany && void onSubmit(group, customer, qbCompany, submitOverrides).then((ok) => ok && onClose())}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!customer || !qbCompany || busy || noneIncluded}
|
||||
className="border-0 bg-success text-success-foreground shadow-sm shadow-success/20 hover:bg-success/90 disabled:opacity-50"
|
||||
onClick={() => customer && qbCompany && void onSubmit(group, customer, qbCompany, submitOverrides).then((ok) => ok && onClose())}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
Send to QuickBooks
|
||||
{noneIncluded
|
||||
? "Pick at least one item"
|
||||
: `Send ${includedRowIdList.length} item${includedRowIdList.length === 1 ? "" : "s"} to QuickBooks`}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -418,11 +566,33 @@ function SingleOrderQbModal({
|
|||
<QbCustomerPicker value={customer} onChange={setCustomer} customers={customers} />
|
||||
)}
|
||||
<div className="mt-4 space-y-3 border-t pt-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Invoice overrides</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These values change only the QuickBooks payload for this send.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Invoice lines</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uncheck any line you don't want on this invoice. The override
|
||||
fields change only the QuickBooks payload for this send.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIncludedRowIds(new Set(group.rows.map((r) => r.id)))}
|
||||
disabled={allIncluded}
|
||||
className="text-info hover:underline disabled:text-muted-foreground disabled:no-underline"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIncludedRowIds(new Set())}
|
||||
disabled={noneIncluded}
|
||||
className="text-info hover:underline disabled:text-muted-foreground disabled:no-underline"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="space-y-1 text-sm" htmlFor="invoice-order-number">
|
||||
<span>Invoice order number</span>
|
||||
|
|
@ -436,16 +606,50 @@ function SingleOrderQbModal({
|
|||
<div className="space-y-3">
|
||||
{group.rows.map((row) => {
|
||||
const current = lineOverrides[row.id] ?? { qty: "", price: "" };
|
||||
const includeId = `invoice-include-${row.id}`;
|
||||
const qtyId = `invoice-qty-${row.id}`;
|
||||
const priceId = `invoice-price-${row.id}`;
|
||||
const included = includedRowIds.has(row.id);
|
||||
const label = itemLabel(row);
|
||||
return (
|
||||
<div key={row.id} className="grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"grid gap-3 rounded-md border p-3 transition-colors",
|
||||
included ? "bg-card" : "bg-muted opacity-60",
|
||||
"md:grid-cols-[auto_minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
<label
|
||||
htmlFor={includeId}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
title={included ? "Remove from invoice" : "Add to invoice"}
|
||||
>
|
||||
<input
|
||||
id={includeId}
|
||||
type="checkbox"
|
||||
checked={included}
|
||||
onChange={(event) => {
|
||||
const next = new Set(includedRowIds);
|
||||
if (event.currentTarget.checked) {
|
||||
next.add(row.id);
|
||||
} else {
|
||||
next.delete(row.id);
|
||||
}
|
||||
setIncludedRowIds(next);
|
||||
}}
|
||||
className="mt-1 h-4 w-4 accent-success"
|
||||
/>
|
||||
</label>
|
||||
<div className="min-w-0 text-sm">
|
||||
<div className="font-medium">{row.description ?? "(no description)"}</div>
|
||||
<div className="text-xs text-muted-foreground">{row.supplierName ?? "Unknown supplier"}</div>
|
||||
<div className="truncate font-medium" title={label}>{label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{row.supplierName ?? "Unknown supplier"}
|
||||
{row.poNumber ? ` · ${row.poNumber}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<label className="space-y-1 text-sm" htmlFor={qtyId}>
|
||||
<span>{`Qty for ${row.id}`}</span>
|
||||
<span>{`Qty — ${label}`}</span>
|
||||
<input
|
||||
id={qtyId}
|
||||
type="number"
|
||||
|
|
@ -461,11 +665,12 @@ function SingleOrderQbModal({
|
|||
},
|
||||
}));
|
||||
}}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
disabled={!included}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-muted"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm" htmlFor={priceId}>
|
||||
<span>{`Selling price for ${row.id}`}</span>
|
||||
<span>{`Selling price — ${label}`}</span>
|
||||
<input
|
||||
id={priceId}
|
||||
type="number"
|
||||
|
|
@ -482,7 +687,8 @@ function SingleOrderQbModal({
|
|||
},
|
||||
}));
|
||||
}}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||
disabled={!included}
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-muted"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -603,7 +809,7 @@ function BulkQbModal({
|
|||
<ModalShell.Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => { if (!state) onClose(); }}
|
||||
size="lg"
|
||||
size={{ base: "md", md: "xl" }}
|
||||
title="Send all to QuickBooks"
|
||||
description="Pick a QuickBooks customer for each order."
|
||||
footer={
|
||||
|
|
@ -654,8 +860,8 @@ function BulkQbModal({
|
|||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{row.orderNumber || "No order number"}</div>
|
||||
<div className="truncate text-sm text-slate-600">{row.companyName || "No company"}</div>
|
||||
<div className="truncate text-sm text-slate-500">{row.customerName || "No customer"}</div>
|
||||
<div className="truncate text-sm text-muted-foreground">{row.companyName || "No company"}</div>
|
||||
<div className="truncate text-sm text-muted-foreground/80">{row.customerName || "No customer"}</div>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1 text-sm">
|
||||
|
|
@ -1053,7 +1259,22 @@ export default function InvoicingPage() {
|
|||
}
|
||||
setQbInFlight(group.orderNumber);
|
||||
try {
|
||||
const first = group.rows[0];
|
||||
// Apply the operator's per-row include filter (the single-order
|
||||
// modal lets them uncheck rows they don't want on the invoice).
|
||||
// Legacy calls and the bulk modal pass `undefined` and fall back
|
||||
// to every row in the group.
|
||||
const includedSet =
|
||||
overrides?.includedRowIds && overrides.includedRowIds.length > 0
|
||||
? new Set(overrides.includedRowIds)
|
||||
: null;
|
||||
const rows = includedSet
|
||||
? group.rows.filter((row) => includedSet.has(row.id))
|
||||
: group.rows;
|
||||
if (rows.length === 0) {
|
||||
toast.error("Pick at least one item before sending to QuickBooks.");
|
||||
return false;
|
||||
}
|
||||
const first = rows[0];
|
||||
const authStore = getPocketBase().authStore as unknown as {
|
||||
model?: {
|
||||
id?: string;
|
||||
|
|
@ -1068,7 +1289,7 @@ export default function InvoicingPage() {
|
|||
billingCompany: qbCompany,
|
||||
invoiceCompany: first.companyName ?? "",
|
||||
customer,
|
||||
rows: group.rows,
|
||||
rows,
|
||||
orderNumberOverride: overrides?.orderNumberOverride,
|
||||
lineOverrides: overrides?.lineOverrides,
|
||||
sentById: sender?.id ?? "",
|
||||
|
|
@ -1145,7 +1366,7 @@ export default function InvoicingPage() {
|
|||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div role="tablist" aria-label="Invoicing views" className="inline-flex rounded-lg border bg-white p-1 shadow-sm">
|
||||
<div role="tablist" aria-label="Invoicing views" className="inline-flex rounded-lg border bg-card p-1 shadow-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant={activeTab === "to_invoice" ? "default" : "ghost"}
|
||||
|
|
@ -1181,10 +1402,10 @@ export default function InvoicingPage() {
|
|||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openQbBulk}
|
||||
disabled={activeTab !== "to_invoice" || bulkRows.length === 0}
|
||||
className="border-0 bg-info text-info-foreground shadow-sm shadow-info/20 hover:bg-info/90"
|
||||
>
|
||||
<Zap className="mr-1 h-3.5 w-3.5" />
|
||||
Invoice in QuickBooks (bulk)
|
||||
|
|
@ -1227,11 +1448,11 @@ export default function InvoicingPage() {
|
|||
const readyGroup = { orderNumber: card.orderNumber, rows: card.readyRows };
|
||||
|
||||
return (
|
||||
<section key={card.orderNumber || "unknown"} className={cn("rounded-xl border bg-white p-4 shadow-sm border-l-4 pl-4", colorForOrder(card.orderNumber).borderClass)}>
|
||||
<section key={card.orderNumber || "unknown"} className={cn("rounded-xl border bg-card p-4 shadow-sm border-l-4 pl-4", colorForOrder(card.orderNumber).borderClass)}>
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold">{card.orderNumber || "No order number"}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-slate-600">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
|
||||
{card.companyName ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
|
|
@ -1239,7 +1460,7 @@ export default function InvoicingPage() {
|
|||
</span>
|
||||
) : null}
|
||||
{card.companyName && card.customerName ? (
|
||||
<span aria-hidden="true" className="text-slate-300">·</span>
|
||||
<span aria-hidden="true" className="text-muted-foreground/50">·</span>
|
||||
) : null}
|
||||
{card.customerName ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
|
|
@ -1271,9 +1492,9 @@ export default function InvoicingPage() {
|
|||
) : null}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={!isGroupReadyForQb(readyGroup) || qbInFlight === readyGroup.orderNumber}
|
||||
onClick={() => openQbSingle(readyGroup)}
|
||||
className="border-0 bg-success text-success-foreground shadow-sm shadow-success/20 hover:bg-success/90"
|
||||
>
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
{qbInFlight === readyGroup.orderNumber ? "Sending…" : "Invoice in QuickBooks"}
|
||||
|
|
@ -1283,31 +1504,14 @@ export default function InvoicingPage() {
|
|||
|
||||
<div className="mt-4 space-y-2">
|
||||
{card.readyRows.map((row) => (
|
||||
<div key={row.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto]">
|
||||
<div key={row.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1.1fr)_auto]">
|
||||
<RowIdentity row={row} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{row.supplierName || "Unknown supplier"}</p>
|
||||
<p className="truncate text-sm text-foreground">{row.description || "(no description)"}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{row.poNumber ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono">
|
||||
<FileText className="h-3 w-3" />
|
||||
{row.poNumber}
|
||||
</span>
|
||||
) : null}
|
||||
{row.orderNumber ? (
|
||||
<Link href={`/orders`} className="inline-flex items-center gap-1 hover:underline">
|
||||
Order {row.orderNumber}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<RowMetrics row={row} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1 text-xs text-muted-foreground">
|
||||
<div>Status: {rowStatusLabel(row.item_status)}</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Ready
|
||||
{rowStatusLabel(row.item_status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1317,26 +1521,18 @@ export default function InvoicingPage() {
|
|||
{expanded && card.outstandingRows.length > 0 ? (
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
{card.outstandingRows.map((row) => (
|
||||
<div key={row.id} className="grid gap-3 rounded-lg bg-slate-50 p-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto]">
|
||||
<div key={row.id} className="grid gap-3 rounded-lg bg-muted p-3 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1.1fr)_auto]">
|
||||
<RowIdentity row={row} showOrderLink={false} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{row.supplierName || "Unknown supplier"}</p>
|
||||
<p className="truncate text-sm text-foreground">{row.description || "(no description)"}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{row.poNumber ? (
|
||||
<span className="inline-flex items-center gap-1 font-mono">
|
||||
<FileText className="h-3 w-3" />
|
||||
{row.poNumber}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<RowMetrics row={row} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1 text-xs text-muted-foreground">
|
||||
<div>Status: {rowStatusLabel(row.item_status)}</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Outstanding
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{rowStatusLabel(row.item_status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1350,7 +1546,7 @@ export default function InvoicingPage() {
|
|||
|
||||
{rows !== null && activeTab === "invoiced" && invoicedGroups.length > 0 && (
|
||||
<section className="space-y-4">
|
||||
<div className="text-sm text-slate-600">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{`Showing invoices from ${recentInvoiceRange.from} to ${recentInvoiceRange.to}`}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -1358,14 +1554,14 @@ export default function InvoicingPage() {
|
|||
const first = group.rows[0];
|
||||
const invoicePrintGroup = invoicePrintGroupMap.get((first?.invoiceNumber ?? "").trim()) ?? null;
|
||||
return (
|
||||
<div key={group.orderNumber || "unknown"} className={cn("rounded-xl border bg-white p-4 shadow-sm border-l-4 pl-4", colorForOrder(group.orderNumber).borderClass)}>
|
||||
<div key={group.orderNumber || "unknown"} className={cn("rounded-xl border bg-card p-4 shadow-sm border-l-4 pl-4", colorForOrder(group.orderNumber).borderClass)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">{first?.invoiceNumber ?? "No invoice number"}</div>
|
||||
<div className="text-sm text-slate-600">{first?.orderNumber || "No order number"}</div>
|
||||
<div className="text-sm text-slate-500">{first?.companyName || "No company"}</div>
|
||||
<div className="text-sm text-muted-foreground">{first?.orderNumber || "No order number"}</div>
|
||||
<div className="text-sm text-muted-foreground/80">{first?.companyName || "No company"}</div>
|
||||
{first?.invoiceDate ? (
|
||||
<div className="text-xs text-slate-500">{first.invoiceDate}</div>
|
||||
<div className="text-xs text-muted-foreground/80">{first.invoiceDate}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type InvoicingRow = {
|
|||
qb_invoice_pdf?: string | null;
|
||||
orderQty?: number | null;
|
||||
qtyCollected?: number | null;
|
||||
costPrice?: number | null;
|
||||
sellingPrice?: number | null;
|
||||
printed?: boolean;
|
||||
printedAt?: string | null;
|
||||
|
|
|
|||
|
|
@ -35,10 +35,29 @@ import { cn } from "@/lib/utils";
|
|||
* <ModalShell.Body> — scrollable content area
|
||||
* <ModalShell.Footer split> — bordered action row
|
||||
*
|
||||
* Width sizes: sm=384 / md=448 / lg=560 / xl=720 / 2xl=896 / full=full
|
||||
* Width sizes: sm=384 / md=448 / lg=512 / xl=672 / 2xl=896 / full=full
|
||||
*
|
||||
* `size` accepts a Size string (uses Tailwind's `sm:` breakpoint) OR a
|
||||
* ResponsiveSize record keyed by `base | sm | md | lg | xl | 2xl`. The
|
||||
* responsive form is what you want when the modal hosts multi-column
|
||||
* content — small screens stay narrow, large screens get a real
|
||||
* 2xl-class canvas so the row layout can breathe horizontally instead
|
||||
* of stacking into a tall, vertical wall.
|
||||
* ========================================================================= */
|
||||
|
||||
type Size = "sm" | "md" | "lg" | "xl" | "2xl" | "full";
|
||||
type Breakpoint = "base" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
type ResponsiveSize = Partial<Record<Breakpoint, Size>>;
|
||||
|
||||
/** Raw Tailwind class for a size, no breakpoint prefix. */
|
||||
const RAW_SIZE: Record<Size, string> = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-2xl",
|
||||
"2xl": "max-w-4xl",
|
||||
full: "max-w-[calc(100%-2rem)]",
|
||||
};
|
||||
|
||||
const SIZE_DIALOG: Record<Size, string> = {
|
||||
sm: "sm:max-w-sm",
|
||||
|
|
@ -58,10 +77,47 @@ const SIZE_SHEET: Record<Size, string> = {
|
|||
full: "sm:max-w-[calc(100%-2rem)]",
|
||||
};
|
||||
|
||||
const BREAKPOINT_PREFIX: Record<Breakpoint, string> = {
|
||||
base: "",
|
||||
sm: "sm:",
|
||||
md: "md:",
|
||||
lg: "lg:",
|
||||
xl: "xl:",
|
||||
"2xl": "2xl:",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the Tailwind class string for a Size | ResponsiveSize. Strings
|
||||
* preserve the legacy "applies at sm+ only" behaviour (dialogs naturally
|
||||
* take the full screen on mobile, so the sm+ min-width is intentional).
|
||||
* Records give per-breakpoint control, with `base` being the no-prefix
|
||||
* mobile class — omit it if you want the dialog to stay edge-to-edge
|
||||
* on mobile and only widen from your chosen breakpoint up.
|
||||
*/
|
||||
function buildMaxWidthClass(
|
||||
size: Size | ResponsiveSize | undefined,
|
||||
legacy: Record<Size, string>,
|
||||
): string {
|
||||
if (size === undefined) return legacy.md;
|
||||
if (typeof size === "string") return legacy[size];
|
||||
const parts: string[] = [];
|
||||
for (const bp of ["base", "sm", "md", "lg", "xl", "2xl"] as const) {
|
||||
const value = size[bp];
|
||||
if (!value) continue;
|
||||
parts.push(`${BREAKPOINT_PREFIX[bp]}${RAW_SIZE[value]}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") : legacy.md;
|
||||
}
|
||||
|
||||
type CommonProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
size?: Size;
|
||||
/**
|
||||
* Width: a single Size string (legacy behaviour, sm+ breakpoint only)
|
||||
* or a ResponsiveSize record (per-breakpoint). Responsive is what
|
||||
* you want for modals that host multi-column content.
|
||||
*/
|
||||
size?: Size | ResponsiveSize;
|
||||
/** Optional small label above the title (e.g. "Quote #Q-1024") */
|
||||
eyebrow?: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
|
|
@ -89,12 +145,13 @@ function DialogShell({
|
|||
footerSplit,
|
||||
className,
|
||||
}: CommonProps) {
|
||||
const maxWidthClass = buildMaxWidthClass(size, SIZE_DIALOG);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"modal-shell p-0",
|
||||
SIZE_DIALOG[size],
|
||||
maxWidthClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -147,13 +204,14 @@ function SheetShell({
|
|||
footerSplit,
|
||||
className,
|
||||
}: CommonProps) {
|
||||
const maxWidthClass = buildMaxWidthClass(size, SIZE_SHEET);
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className={cn(
|
||||
"modal-shell modal-shell--sheet gap-0 p-0",
|
||||
SIZE_SHEET[size],
|
||||
maxWidthClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
Loading…
Reference in a new issue