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)]", )} >
-
{label}
+
+ {label} + {isExcluded && ( + + + Not invoiced + + )} +
{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 ? : } {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`}
} @@ -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}
+
+ {label} + {isExcluded && ( + + + Not invoiced + + )} +
{row.supplierName ?? "Unknown supplier"} {row.poNumber ? ` · ${row.poNumber}` : ""}