feat(invoicing): add per-order color stripe on group sections
This commit is contained in:
parent
bbd1e1df7c
commit
ae66762b76
1 changed files with 191 additions and 36 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue