feat(invoicing): add per-order color stripe on group sections

This commit is contained in:
SJCoetz 2026-07-10 02:09:29 +02:00
parent bbd1e1df7c
commit ae66762b76

View file

@ -10,14 +10,12 @@ import { Badge } from "@/components/ui/badge";
import { ModalShell } from "@/components/ui/modal-shell";
import { getPocketBase } from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
import { colorForOrder } from "@/lib/order-color";
import { fetchCustomers, type QbCustomer } from "@/app/api/invoicing/customers";
import {
buildPickingSlipPdf,
combineInvoiceAndPickingSlip,
downloadQbInvoicePdf,
} from "./print";
import {
buildInvoicePrintGroups,
buildTodayUnprintedInvoiceGroups,
buildBulkInvoiceRows,
buildQbInvoicePayload,
buildOrderInvoiceCards,
@ -80,17 +78,65 @@ function orderNumber(row: RawInvoicingRow): string {
return row.expand?.order?.order_number ?? "";
}
function readInvoiceSummary(raw: RawInvoicingRow): { date: string; number: string } | null {
function readOrderItemDetails(raw: RawInvoicingRow): Record<string, unknown> | null {
const details = raw.order_item_details;
if (!details || typeof details !== "object") return null;
if (!details) return null;
if (typeof details === "string") {
try {
const parsed: unknown = JSON.parse(details);
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
return typeof details === "object" ? details : null;
}
function readInvoiceSummary(raw: RawInvoicingRow): { date: string; number: string } | null {
const details = readOrderItemDetails(raw);
if (!details) return null;
const candidate = (details as { invoice?: { date?: string; number?: string } }).invoice;
if (!candidate || typeof candidate !== "object") return null;
if (typeof candidate.date !== "string" || typeof candidate.number !== "string") return null;
return { date: candidate.date, number: candidate.number };
}
function readPrintSummary(raw: RawInvoicingRow): {
printed: boolean;
printedAt: string | null;
printedBy: string | null;
printedByName: string | null;
printCount: number;
} {
const details = readOrderItemDetails(raw);
if (!details) {
return { printed: false, printedAt: null, printedBy: null, printedByName: null, printCount: 0 };
}
const candidate = (details as {
print?: {
printed?: unknown;
printedAt?: unknown;
printedBy?: unknown;
printedByName?: unknown;
printCount?: unknown;
};
}).print;
return {
printed: candidate?.printed === true,
printedAt: typeof candidate?.printedAt === "string" ? candidate.printedAt : null,
printedBy: typeof candidate?.printedBy === "string" ? candidate.printedBy : null,
printedByName: typeof candidate?.printedByName === "string" ? candidate.printedByName : null,
printCount:
typeof candidate?.printCount === "number" && Number.isFinite(candidate.printCount)
? candidate.printCount
: 0,
};
}
function toInvoicingRow(row: RawInvoicingRow): InvoicingRow {
const invoice = readInvoiceSummary(row);
const print = readPrintSummary(row);
return {
id: row.id,
collectionId: row.collectionId ?? "",
@ -113,6 +159,11 @@ function toInvoicingRow(row: RawInvoicingRow): InvoicingRow {
orderQty: row.order_qty ?? null,
qtyCollected: row.qty_collected ?? null,
sellingPrice: row.selling_price ?? null,
printed: print.printed,
printedAt: print.printedAt,
printedBy: print.printedBy,
printedByName: print.printedByName,
printCount: print.printCount,
};
}
@ -676,6 +727,7 @@ export default function InvoicingPage() {
const [showOutstanding, setShowOutstanding] = useState<Record<string, boolean>>({});
const [searchText, setSearchText] = useState<string>("");
const [printingGroupId, setPrintingGroupId] = useState<string | null>(null);
const [bulkPrinting, setBulkPrinting] = useState(false);
const [qbInFlight, setQbInFlight] = useState<string | null>(null);
// Customer lists are cached per match string so the n8n webhook receives
// the right `customer_query` / `billing_company` for whichever modal opens
@ -800,6 +852,19 @@ export default function InvoicingPage() {
const rowsView = useMemo(() => rows ?? [], [rows]);
const invoicingRows = useMemo(() => rowsView.map(toInvoicingRow), [rowsView]);
const recentInvoiceRange = useMemo(() => buildRecentInvoiceRange(), []);
const todayIso = useMemo(() => new Date().toISOString().slice(0, 10), []);
const invoicePrintGroups = useMemo(
() => buildInvoicePrintGroups(invoicingRows, todayIso),
[invoicingRows, todayIso],
);
const todaysUnprintedGroups = useMemo(
() => buildTodayUnprintedInvoiceGroups(invoicingRows, todayIso),
[invoicingRows, todayIso],
);
const invoicePrintGroupMap = useMemo(
() => new Map(invoicePrintGroups.map((group) => [group.invoiceNumber, group])),
[invoicePrintGroups],
);
const orderCards = useMemo(
() => buildOrderInvoiceCards(invoicingRows),
@ -835,49 +900,126 @@ export default function InvoicingPage() {
[toInvoiceCards],
);
const markInvoicePrinted = useCallback(async (invoiceNumber: string) => {
const authStore = getPocketBase().authStore as unknown as {
model?: {
id?: string;
name?: string;
email?: string;
} | null;
};
const user = authStore.model ?? null;
const printedByName = user?.name?.trim() || user?.email?.trim() || "Unknown user";
const response = await fetch("/api/invoicing/print-tracking", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
invoiceNumber,
printedAt: new Date().toISOString(),
printedBy: user?.id ?? "",
printedByName,
}),
});
if (!response.ok) {
let message = `Tracking failed (${response.status})`;
try {
const data: unknown = await response.json();
if (
data &&
typeof data === "object" &&
"error" in data &&
typeof (data as { error?: unknown }).error === "string"
) {
message = `Tracking failed: ${(data as { error: string }).error}`;
}
} catch {
// ignore non-json body
}
throw new Error(message);
}
}, []);
const handlePrint = useCallback(
async (group: QbGroup) => {
if (printingGroupId) return;
const first = group.rows[0];
if (!first?.qb_invoice_pdf) {
toast.error("QuickBooks invoice PDF is missing for this group.");
return;
if (printingGroupId) return false;
const invoiceNumber = (group.rows[0]?.invoiceNumber ?? "").trim();
if (!invoiceNumber) {
toast.error("Invoice number is missing for this group.");
return false;
}
setPrintingGroupId(group.orderNumber);
try {
const invoice = await downloadQbInvoicePdf(first.qb_invoice_pdf);
const picking = buildPickingSlipPdf({
orderNumber: first.orderNumber ?? "",
customerName: first.customerName ?? "",
companyName: first.companyName ?? "",
supplierName: first.supplierName ?? "",
poNumber: first.poNumber ?? "",
invoiceNumber: first.invoiceNumber ?? "",
invoiceDate: first.invoiceDate ?? "",
items: group.rows.map((row) => ({
description: row.description ?? "(no description)",
qty: 1,
supplierName: row.supplierName ?? "",
poNumber: row.poNumber ?? "",
})),
});
const combined = combineInvoiceAndPickingSlip([invoice, picking], { invoiceCopies: 2 });
const buffer = new ArrayBuffer(combined.byteLength);
new Uint8Array(buffer).set(combined);
const blob = new Blob([buffer], { type: "application/pdf" });
const response = await fetch(
`/api/invoicing/print-bundle?invoiceNumber=${encodeURIComponent(invoiceNumber)}`,
);
if (!response.ok) {
let message = `Print failed (${response.status})`;
try {
const data: unknown = await response.json();
if (
data &&
typeof data === "object" &&
"error" in data &&
typeof (data as { error?: unknown }).error === "string"
) {
message = `Print failed: ${(data as { error: string }).error}`;
}
} catch {
// ignore non-json body
}
toast.error(message);
return false;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
await markInvoicePrinted(invoiceNumber);
await load();
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(`Print failed: ${message}`);
return false;
} finally {
setPrintingGroupId(null);
}
},
[printingGroupId],
[load, markInvoicePrinted, printingGroupId],
);
const handleBulkPrintToday = useCallback(async () => {
if (bulkPrinting || todaysUnprintedGroups.length === 0) return;
setBulkPrinting(true);
let successCount = 0;
let failCount = 0;
try {
for (const invoiceGroup of todaysUnprintedGroups) {
const success = await handlePrint({
orderNumber: invoiceGroup.rows[0]?.orderNumber ?? invoiceGroup.invoiceNumber,
rows: invoiceGroup.rows,
});
if (success) {
successCount += 1;
} else {
failCount += 1;
}
}
if (successCount > 0) {
toast.success(`Printed ${successCount} invoice${successCount === 1 ? "" : "s"}`);
}
if (failCount > 0) {
toast.error(`${failCount} invoice${failCount === 1 ? "" : "s"} failed to print`);
}
} finally {
setBulkPrinting(false);
}
}, [bulkPrinting, handlePrint, todaysUnprintedGroups]);
// The group object passed to the single-order modal needs a stable
// reference across parent re-renders. If we just did `setQbModal({ group })`
// from a parent render, every parent re-render would create a new object
@ -1047,6 +1189,15 @@ export default function InvoicingPage() {
<Zap className="mr-1 h-3.5 w-3.5" />
Invoice in QuickBooks (bulk)
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleBulkPrintToday()}
disabled={activeTab !== "invoiced" || bulkPrinting || todaysUnprintedGroups.length === 0}
>
<Printer className="mr-1 h-3.5 w-3.5" />
{bulkPrinting ? "Printing today's invoices..." : "Print Today's Unprinted Invoices"}
</Button>
</div>
{rows === null && (
@ -1076,7 +1227,7 @@ export default function InvoicingPage() {
const readyGroup = { orderNumber: card.orderNumber, rows: card.readyRows };
return (
<section key={card.orderNumber || "unknown"} className="rounded-xl border bg-white p-4 shadow-sm">
<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)}>
<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>
@ -1202,18 +1353,22 @@ export default function InvoicingPage() {
<div className="space-y-3">
{invoicedGroups.map((group) => {
const first = group.rows[0];
const invoicePrintGroup = invoicePrintGroupMap.get((first?.invoiceNumber ?? "").trim()) ?? null;
return (
<div key={group.orderNumber || "unknown"} className="rounded-xl border bg-white p-4 shadow-sm">
<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 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">{group.orderNumber || "No order 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>
{first?.invoiceDate ? (
<div className="text-xs text-slate-500">{first.invoiceDate}</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={invoicePrintGroup?.printed ? "default" : "secondary"}>
{invoicePrintGroup?.printed ? "Printed" : "Unprinted"}
</Badge>
{first?.qb_invoice_pdf ? (
<Button
size="xs"