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:
parent
7320bfdce6
commit
08f6005fd5
1 changed files with 122 additions and 15 deletions
|
|
@ -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}` : ""}
|
||||
|
|
|
|||
Loading…
Reference in a new issue