diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index ace5f99..04ce126 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -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 | 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) : 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>({}); const [searchText, setSearchText] = useState(""); const [printingGroupId, setPrintingGroupId] = useState(null); + const [bulkPrinting, setBulkPrinting] = useState(false); const [qbInFlight, setQbInFlight] = useState(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() { Invoice in QuickBooks (bulk) + {rows === null && ( @@ -1076,7 +1227,7 @@ export default function InvoicingPage() { const readyGroup = { orderNumber: card.orderNumber, rows: card.readyRows }; return ( -
+
{card.orderNumber || "No order number"}
@@ -1202,18 +1353,22 @@ export default function InvoicingPage() {
{invoicedGroups.map((group) => { const first = group.rows[0]; + const invoicePrintGroup = invoicePrintGroupMap.get((first?.invoiceNumber ?? "").trim()) ?? null; return ( -
+
{first?.invoiceNumber ?? "No invoice number"}
-
{group.orderNumber || "No order number"}
+
{first?.orderNumber || "No order number"}
{first?.companyName || "No company"}
{first?.invoiceDate ? (
{first.invoiceDate}
) : null}
+ + {invoicePrintGroup?.printed ? "Printed" : "Unprinted"} + {first?.qb_invoice_pdf ? (