mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 08:18:20 +00:00
get clearance fix
This commit is contained in:
@@ -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<unknown>) => {
|
||||||
|
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')]);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { PageContainer, PageHeader } from '@/components/page';
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
|
import { bookingsService } from '@/services/bookings.service';
|
||||||
import { warehouseService } from '@/services/warehouse.service';
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
type WarehouseInvoiceStatus,
|
type WarehouseInvoiceStatus,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||||
|
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
|
||||||
|
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||||
DRAFT: 'gray',
|
DRAFT: 'gray',
|
||||||
@@ -167,21 +170,26 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||||
|
|
||||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||||
try {
|
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
|
||||||
const response = await warehouseService.downloadInvoiceDocument(invoice.id);
|
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
|
||||||
openPdfBlob(response.data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
|
|
||||||
} catch (e) {
|
|
||||||
toast({ variant: 'destructive', title: 'Invoice download failed', description: (e as Error)?.message });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
||||||
try {
|
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
|
||||||
const response = await warehouseService.downloadInvoiceReceipt(invoice.id);
|
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
||||||
openPdfBlob(response.data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
};
|
||||||
} catch (e) {
|
|
||||||
toast({ variant: 'destructive', title: 'Receipt download failed', description: (e as Error)?.message });
|
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) => {
|
const handleGateClearance = async (invoice: WarehouseFeeInvoice) => {
|
||||||
@@ -196,17 +204,32 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
|
|
||||||
const pdfWindow = window.open('', '_blank');
|
const pdfWindow = window.open('', '_blank');
|
||||||
try {
|
try {
|
||||||
|
const releasedAt = new Date();
|
||||||
const releasedItem = await gateClear.mutateAsync(invoice.inventoryId);
|
const releasedItem = await gateClear.mutateAsync(invoice.inventoryId);
|
||||||
let documentResponse: Awaited<ReturnType<typeof warehouseService.downloadReleaseDocument>>;
|
let documentResponse: Awaited<ReturnType<typeof warehouseService.downloadReleaseDocument>>;
|
||||||
try {
|
try {
|
||||||
documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId);
|
documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId);
|
||||||
} catch (documentError) {
|
} catch (documentError) {
|
||||||
pdfWindow?.close();
|
const context = await getExitPaperContext(invoice);
|
||||||
toast({
|
const fallbackBlob = buildWarehouseExitPaperPdf({
|
||||||
variant: 'destructive',
|
invoice,
|
||||||
title: 'Exit paper failed',
|
releasedItem,
|
||||||
description: (documentError as Error)?.message,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`;
|
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`;
|
||||||
@@ -223,7 +246,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
|||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: 'Gate clearance failed',
|
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' });
|
toast({ title: 'Payment recorded' });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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' });
|
toast({ title: 'Invoice cancelled' });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
|
toast({ variant: 'destructive', title: 'Cancel failed', description: extractErrorMessage(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user