diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx
index 913f623..75e71b8 100644
--- a/src/app/(app)/invoicing/page.tsx
+++ b/src/app/(app)/invoicing/page.tsx
@@ -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 ? : 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`}
)}
@@ -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)]",
)}
>