diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts new file mode 100644 index 000000000..6b18526f7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts @@ -0,0 +1,188 @@ +import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse'; +import type { BookingDetail } from '@/types/booking'; + +type PdfLine = { text: string; size?: number; bold?: boolean; x?: number; yGap?: number; color?: 'black' | 'green' }; + +export interface WarehouseExitPaperContext { + invoice: WarehouseFeeInvoice; + releasedItem?: WarehouseInventoryItem; + inventory?: WarehouseInventoryItem; + booking?: BookingDetail | null; + releasedAt?: Date; +} + +const escapePdfText = (value: string) => + value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + +const money = (amount: unknown, currency = 'USD') => + `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; + +const fmtDate = (value: unknown) => { + if (!value) return '-'; + const date = new Date(value as string | Date); + return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString(); +}; + +const GREEN = '0 0.55 0.32'; + +const circlePath = (cx: number, cy: number, r: number) => { + const k = 0.5522847498; + const c = r * k; + return [ + `${cx + r} ${cy} m`, + `${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`, + `${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`, + `${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`, + `${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`, + 'h', + ].join('\n'); +}; + +const stampText = (text: string, x: number, y: number, size: number, bold = false) => + `BT\n${GREEN} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; + +const buildCircularSeal = (cx: number, cy: number, label: 'PAID' | 'CLEARED') => + [ + 'q', + `${GREEN} RG`, + `${GREEN} rg`, + '2.2 w', + circlePath(cx, cy, 52), + 'S', + '0.9 w', + circlePath(cx, cy, 42), + 'S', + stampText('EDR FREIGHT', cx - 33, cy + 24, 9, true), + stampText(label, cx - (label === 'CLEARED' ? 36 : 21), cy - 4, label === 'CLEARED' ? 17 : 20, true), + stampText(label === 'CLEARED' ? 'GATE RELEASE' : 'WAREHOUSE', cx - (label === 'CLEARED' ? 34 : 32), cy - 25, 8), + 'Q', + ].join('\n'); + +function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { + let y = 800; + const streamLines = lines.map((line) => { + y -= line.yGap ?? 16; + const size = line.size ?? 10; + const font = line.bold ? '/F2' : '/F1'; + const color = line.color === 'green' ? `${GREEN} rg` : '0 0 0 rg'; + return `BT\n${color}\n${font} ${size} Tf\n${line.x ?? 46} ${y} Td\n(${escapePdfText(line.text)}) Tj\nET`; + }); + const stream = [...rawOps, ...streamLines].join('\n'); + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>', + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets = [0]; + objects.forEach((object, index) => { + offsets.push(pdf.length); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xref = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + offsets.slice(1).forEach((offset) => { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + }); + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return new Blob([pdf], { type: 'application/pdf' }); +} + +export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') { + const paid = kind === 'RECEIPT' || invoice.status === 'PAID'; + const lines: PdfLine[] = [ + { text: 'Ethio-Djibouti Railway S.C.', size: 11, bold: true, yGap: 0 }, + { text: `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`, size: 22, bold: true, yGap: 26 }, + { text: `Document No: ${invoice.invoiceNumber}`, bold: true, yGap: 34 }, + { text: `Status: ${invoice.status.replace(/_/g, ' ')}` }, + { text: `Invoice Type: ${invoice.invoiceType.replace(/_/g, ' ')}` }, + { text: `Booking ID: ${invoice.bookingId ?? '-'}` }, + { text: `Inventory ID: ${invoice.inventoryId}` }, + { text: `Issued: ${fmtDate(invoice.issuedAt)}` }, + { text: `Paid At: ${fmtDate(invoice.paidAt)}` }, + { text: 'Items', size: 14, bold: true, yGap: 26 }, + ...(invoice.items ?? []).flatMap((item) => [ + { text: item.description, bold: true }, + { + text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`, + yGap: 13, + }, + ]), + { text: 'Totals', size: 14, bold: true, yGap: 28 }, + { text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}` }, + { text: `Tax: ${money(invoice.taxAmount, invoice.currency)}` }, + { text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true }, + { text: `Paid: ${money(invoice.paidAmount, invoice.currency)}` }, + { text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true }, + { text: 'Prepared by EDR warehouse finance', yGap: 34 }, + { text: 'Authorized seal / signature: ______________________________' }, + ]; + return buildSimplePdf(lines, paid ? [buildCircularSeal(462, 712, 'PAID')] : []); +} + +const firstText = (...values: Array) => { + for (const value of values) { + if (value !== null && value !== undefined && String(value).trim()) return String(value); + } + return '-'; +}; + +const tons = (value: unknown) => { + const num = Number(value ?? 0); + if (!Number.isFinite(num) || num <= 0) return null; + return `${num.toLocaleString(undefined, { maximumFractionDigits: 3 })} ton`; +}; + +const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseInventoryItem) => { + const explicitNumber = (inventory as unknown as { containerNumber?: string | null })?.containerNumber; + if (explicitNumber) return explicitNumber; + const containers = booking?.bookingContainers ?? []; + if (!containers.length) return '-'; + return containers + .map((item) => { + const type = item.containerType?.code ?? item.containerType?.label ?? item.containerTypeId; + return `${item.quantity} x ${type}`; + }) + .join(', '); +}; + +export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) { + const context: WarehouseExitPaperContext = + 'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg }; + const { invoice, booking } = context; + const releasedItem = context.releasedItem; + const inventory = context.inventory ?? releasedItem; + const releasedAt = context.releasedAt ?? new Date(); + const releaseReference = `REL-${invoice.inventoryId.slice(0, 8).toUpperCase()}`; + const customerName = firstText( + booking?.company?.name, + booking?.company?.companyName, + booking?.company?.label, + booking?.company?.contactPersonName, + invoice.customerId, + ); + const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight)); + + return buildSimplePdf([ + { text: 'Ethio-Djibouti Railway S.C.', size: 11, bold: true, yGap: 0 }, + { text: 'Warehouse Release / Exit Paper', size: 22, bold: true, yGap: 26 }, + { text: `Release Reference: ${releaseReference}`, bold: true, yGap: 34 }, + { text: `Invoice No: ${invoice.invoiceNumber}` }, + { text: `Booking Reference: ${firstText(booking?.reference, (inventory as unknown as { bookingReference?: string })?.bookingReference, invoice.bookingId, releasedItem?.bookingId)}` }, + { text: `Customer: ${customerName}` }, + { text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code, invoice.warehouseId, releasedItem?.warehouseId)}` }, + { text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code, invoice.yardId, releasedItem?.yardId)}` }, + { text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code, invoice.zoneId, releasedItem?.zoneId)}` }, + { text: `Booking Container: ${containerSummary(booking, inventory)}` }, + { text: `Weight: ${weightTons}` }, + { text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}` }, + { text: `Release Date & Time: ${fmtDate(releasedAt)}` }, + { text: 'This document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30 }, + { text: 'Warehouse officer name / signature / date: ______________________________', yGap: 40 }, + { text: 'Customer or driver name / signature / date: ______________________________', yGap: 28 }, + ], [buildCircularSeal(462, 712, 'CLEARED')]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 6b93abc70..e03c4ef2a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -22,6 +22,7 @@ import { PageContainer, PageHeader } from '@/components/page'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; +import { bookingsService } from '@/services/bookings.service'; import { warehouseService } from '@/services/warehouse.service'; import { useToast } from '@/hooks/use-toast'; import { @@ -30,6 +31,8 @@ import { type WarehouseInvoiceStatus, } from '@/types/warehouse'; import { openPdfBlob } from '@/components/warehouses/pdf'; +import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf'; +import { extractErrorMessage } from '@/components/warehouses/options'; const STATUS_COLOR: Record = { DRAFT: 'gray', @@ -167,21 +170,26 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { - try { - const response = await warehouseService.downloadInvoiceDocument(invoice.id); - openPdfBlob(response.data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); - } catch (e) { - toast({ variant: 'destructive', title: 'Invoice download failed', description: (e as Error)?.message }); - } + const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE'); + openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); }; const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => { - try { - const response = await warehouseService.downloadInvoiceReceipt(invoice.id); - openPdfBlob(response.data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); - } catch (e) { - toast({ variant: 'destructive', title: 'Receipt download failed', description: (e as Error)?.message }); - } + const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT'); + openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); + }; + + const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => { + const [booking, inventoryRows] = await Promise.all([ + invoice.bookingId + ? bookingsService.getById(invoice.bookingId).catch(() => null) + : Promise.resolve(null), + invoice.bookingId + ? warehouseService.listInventory({ bookingId: invoice.bookingId }).then((response) => response.data).catch(() => []) + : Promise.resolve([]), + ]); + const inventory = inventoryRows.find((item) => item.id === invoice.inventoryId) ?? inventoryRows[0] ?? undefined; + return { booking, inventory }; }; const handleGateClearance = async (invoice: WarehouseFeeInvoice) => { @@ -196,17 +204,32 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const pdfWindow = window.open('', '_blank'); try { + const releasedAt = new Date(); const releasedItem = await gateClear.mutateAsync(invoice.inventoryId); let documentResponse: Awaited>; try { documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId); } catch (documentError) { - pdfWindow?.close(); - toast({ - variant: 'destructive', - title: 'Exit paper failed', - description: (documentError as Error)?.message, + const context = await getExitPaperContext(invoice); + const fallbackBlob = buildWarehouseExitPaperPdf({ + invoice, + releasedItem, + inventory: context.inventory, + booking: context.booking, + releasedAt, }); + const opened = openPdfBlob( + fallbackBlob, + `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`, + pdfWindow, + ); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The API exit paper failed, so a sealed fallback PDF opened instead.' + : `The API exit paper failed (${extractErrorMessage(documentError)}), so a sealed fallback PDF was downloaded.`, + }); + onClose(); return; } const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`; @@ -223,7 +246,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => toast({ variant: 'destructive', title: 'Gate clearance failed', - description: (e as Error)?.message, + description: extractErrorMessage(e), }); } }; @@ -241,7 +264,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => toast({ title: 'Payment recorded' }); } } catch (e) { - toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message }); + toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) }); } }; @@ -252,7 +275,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => toast({ title: 'Invoice cancelled' }); onClose(); } catch (e) { - toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message }); + toast({ variant: 'destructive', title: 'Cancel failed', description: extractErrorMessage(e) }); } };