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:
SJCoetz 2026-07-14 12:34:47 +02:00
parent 046683c506
commit 7ebfb8b86b
4 changed files with 368 additions and 102 deletions

View file

@ -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,
},
},
}),

View file

@ -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&apos;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">

View file

@ -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;

View file

@ -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
)}
>