feat(invoicing): muted rendering + billable-submit guard for non-invoiceable rows

Task 6 of the non-invoiceable-order-extras plan.

Both modals (QB submit + delivery note submit) — same shape change:

- Excluded rows render muted with a 'Not invoiced' chip in the
  row metadata. The muted state overrides 'bg-card' even when
  included, so the operator doesn't mistake them for billable.
- Their include-checkbox is checked and disabled — the row must
  ride along for the n8n status flip, and the operator must not
  be able to remove it or add it to the bill. onChange defensively
  bails when the flag is set, in addition to the disabled prop.
- New 'billableCount' guard: when every billable row has been
  unchecked, submit is replaced with 'No billable lines' label
  and the click handler toasts 'This order has no billable lines.'
  The existing 'noneIncluded' check still catches the operator
  unchecking everything (including the excluded rows the disabled
  checkbox blocks them from unchecking in practice); the new guard
  is the per-mode subset check.

The existing 'noneIncluded' check isn't sufficient because excluded
rows are always in 'includedRowIds' (they have to ride along), so
includedRowIds.size === 0 is unreachable in practice — the billable
subset can still be empty.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 17:30:19 +02:00
parent 7320bfdce6
commit 08f6005fd5

View file

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Search, Building2, UserRound, FileText, ExternalLink, RefreshCw, Printer, Zap, Loader2, Truck, Boxes, Check } from "lucide-react";
import { Search, Building2, UserRound, FileText, ExternalLink, RefreshCw, Printer, Zap, Loader2, Truck, Boxes, Check, Receipt } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@ -746,6 +746,20 @@ function SingleOrderQbModal({
const allIncluded =
group.rows.length > 0 && group.rows.every((row) => includedRowIds.has(row.id));
const noneIncluded = includedRowIds.size === 0;
// Task 6 of the non-invoiceable-order-extras plan. The billable
// subset is the rows that actually go on the customer invoice —
// excluded rows ride along for the status flip but don't bill.
// When the operator has unchecked every billable row, there's
// nothing to send to QuickBooks. The existing `noneIncluded`
// check is about manual unchecking of the lot (it counts excluded
// rows as included); the new guard catches the subset-empty case
// and blocks the submit + shows a descriptive toast.
const billableCount = group
? group.rows.filter(
(row) => includedRowIds.has(row.id) && row.excludeFromInvoice !== true,
).length
: 0;
const noBillable = billableCount === 0;
const itemLabel = (row: InvoicingRow): string =>
row.description?.trim() || row.supplierName || `Item ${row.id}`;
return (
@ -767,12 +781,26 @@ function SingleOrderQbModal({
type="button"
disabled={!customer || 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())}
onClick={() => {
// Belt-and-braces: if the operator somehow gets past
// a stale `noBillable=true` (e.g. they unchecked a
// billable row in the same tick as submit), the toast
// names the actual problem rather than letting a
// zero-line invoice reach n8n.
if (noBillable) {
toast.error("This order has no billable lines.");
return;
}
if (!customer || !qbCompany) return;
void onSubmit(group, customer, qbCompany, submitOverrides).then((ok) => ok && onClose());
}}
>
{busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
{noneIncluded
? "Pick at least one item"
: `Send ${includedRowIdList.length} item${includedRowIdList.length === 1 ? "" : "s"} to QuickBooks`}
: noBillable
? "No billable lines"
: `Send ${billableCount} item${billableCount === 1 ? "" : "s"} to QuickBooks`}
</Button>
)}
</div>
@ -881,6 +909,14 @@ function SingleOrderQbModal({
const qtyId = `invoice-qty-${row.id}`;
const priceId = `invoice-price-${row.id}`;
const included = includedRowIds.has(row.id);
// Task 6 of the non-invoiceable-order-extras plan.
// Excluded rows are forced included (they ride along for
// the n8n status flip — see Task 5) and forced checked
// + disabled (the operator must not be able to remove
// them or add them to the bill). Their row is muted
// with a 'Not invoiced' chip so the operator can tell
// which lines are billable at a glance.
const isExcluded = row.excludeFromInvoice === true;
const label = itemLabel(row);
const surplus = surplusQty(row);
return (
@ -888,20 +924,39 @@ function SingleOrderQbModal({
key={row.id}
className={cn(
"grid gap-3 rounded-md border p-3 transition-colors",
included ? "bg-card" : "bg-muted opacity-60",
// Excluded rows are always muted, even when
// checked, so the operator doesn't mistake them
// for billable rows. Suppressed at the CSS level
// (bg-muted) rather than via `disabled` so the row
// stays readable.
included && !isExcluded ? "bg-card" : "bg-muted opacity-70",
"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"}
title={
isExcluded
? "Always included — this line never reaches the customer invoice"
: included
? "Remove from invoice"
: "Add to invoice"
}
>
<input
id={includeId}
type="checkbox"
checked={included}
disabled={isExcluded}
onChange={(event) => {
if (isExcluded) return; // belt-and-braces — the
// disabled prop already
// prevents this, but
// ignore the change
// defensively so an
// excluded row can
// never be unchecked.
const next = new Set(includedRowIds);
if (event.currentTarget.checked) {
next.add(row.id);
@ -910,11 +965,23 @@ function SingleOrderQbModal({
}
setIncludedRowIds(next);
}}
className="mt-1 h-4 w-4 accent-success"
className="mt-1 h-4 w-4 accent-success disabled:opacity-60"
/>
</label>
<div className="min-w-0 text-sm">
<div className="truncate font-medium" title={label}>{label}</div>
<div className="flex items-center gap-2">
<span className="truncate font-medium" title={label}>{label}</span>
{isExcluded && (
<Badge
variant="outline"
className="shrink-0 border-muted-foreground/40 text-muted-foreground text-[10px]"
title="Counts for the order and gets collected, but never appears on the customer's invoice or delivery note."
>
<Receipt className="h-3 w-3 mr-1" />
Not invoiced
</Badge>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.supplierName ?? "Unknown supplier"}
{row.poNumber ? ` · ${row.poNumber}` : ""}
@ -1065,6 +1132,19 @@ function SingleDeliveryNoteModal({
const allIncluded =
group.rows.length > 0 && group.rows.every((row) => includedRowIds.has(row.id));
const noneIncluded = includedRowIds.size === 0;
// Task 6 — billable-subset guard, mirrors the QB modal above. A
// delivery note has no billable lines if every row is excluded —
// Task 7's route 400s on that case anyway, but the UI guard
// stops the operator from clicking through a guaranteed-to-fail
// submit. Excluded rows still ride along for the status flip
// because the status-flip loop in the route (Task 7) is left
// untouched.
const billableCount = group
? group.rows.filter(
(row) => includedRowIds.has(row.id) && row.excludeFromInvoice !== true,
).length
: 0;
const noBillable = billableCount === 0;
const itemLabel = (row: InvoicingRow): string =>
row.description?.trim() || row.supplierName || `Item ${row.id}`;
return (
@ -1081,14 +1161,20 @@ function SingleDeliveryNoteModal({
type="button"
disabled={busy || noneIncluded}
className="border-0 bg-info text-info-foreground shadow-sm shadow-info/20 hover:bg-info/90 disabled:opacity-50"
onClick={() =>
void onSubmit(group, submitPayload).then((ok) => ok && onClose())
}
onClick={() => {
if (noBillable) {
toast.error("This delivery note has no billable lines.");
return;
}
void onSubmit(group, submitPayload).then((ok) => ok && onClose());
}}
>
{busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Truck className="mr-1 h-3.5 w-3.5" />}
{noneIncluded
? "Pick at least one item"
: `Mark ${includedRowIdList.length} item${includedRowIdList.length === 1 ? "" : "s"} as delivery note`}
: noBillable
? "No billable lines"
: `Mark ${billableCount} item${billableCount === 1 ? "" : "s"} as delivery note`}
</Button>
</div>
}
@ -1148,6 +1234,7 @@ function SingleDeliveryNoteModal({
const qtyId = `dn-qty-${row.id}`;
const priceId = `dn-price-${row.id}`;
const included = includedRowIds.has(row.id);
const isExcluded = row.excludeFromInvoice === true;
const label = itemLabel(row);
const surplus = surplusQty(row);
return (
@ -1155,20 +1242,28 @@ function SingleDeliveryNoteModal({
key={row.id}
className={cn(
"grid gap-3 rounded-md border p-3 transition-colors",
included ? "bg-card" : "bg-muted opacity-60",
included && !isExcluded ? "bg-card" : "bg-muted opacity-70",
"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 delivery note" : "Add to delivery note"}
title={
isExcluded
? "Always included — this line never reaches the customer delivery note"
: included
? "Remove from delivery note"
: "Add to delivery note"
}
>
<input
id={includeId}
type="checkbox"
checked={included}
disabled={isExcluded}
onChange={(event) => {
if (isExcluded) return;
const next = new Set(includedRowIds);
if (event.currentTarget.checked) {
next.add(row.id);
@ -1177,11 +1272,23 @@ function SingleDeliveryNoteModal({
}
setIncludedRowIds(next);
}}
className="mt-1 h-4 w-4 accent-info"
className="mt-1 h-4 w-4 accent-info disabled:opacity-60"
/>
</label>
<div className="min-w-0 text-sm">
<div className="truncate font-medium" title={label}>{label}</div>
<div className="flex items-center gap-2">
<span className="truncate font-medium" title={label}>{label}</span>
{isExcluded && (
<Badge
variant="outline"
className="shrink-0 border-muted-foreground/40 text-muted-foreground text-[10px]"
title="Counts for the order and gets collected, but never appears on the customer's invoice or delivery note."
>
<Receipt className="h-3 w-3 mr-1" />
Not invoiced
</Badge>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.supplierName ?? "Unknown supplier"}
{row.poNumber ? ` · ${row.poNumber}` : ""}