merge conflict

This commit is contained in:
marshal
2026-06-29 12:45:40 +03:00
202 changed files with 16895 additions and 3799 deletions

View File

@@ -134,15 +134,23 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
const pdfWindow = window.open('', '_blank');
try {
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
try {
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
} catch (documentError) {
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
});
}
onClose();
} catch (error) {
pdfWindow?.close();

View File

@@ -69,6 +69,8 @@ export function InterchangeDocumentDetailPanel({ id }: { id: string }) {
/>
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
<DetailField label="Signed by EDR" value={document.generatedBy} />
<DetailField label="Signed by Djibouti Port" value={document.acknowledgedBy} />
<DetailField label="Customs Ref" value={document.customsReference} />
<DetailField label="Manifest Ref" value={document.manifestReference} />
</SimpleGrid>

View File

@@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
}
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-';
const inventorySummary = [
item?.status?.replace(/_/g, ' '),
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
]
.filter(Boolean)
.join(' / ');
return (
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
{!item ? (
@@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="lg" fw={800}>
{item.booking?.reference ?? item.bookingId ?? item.id}
{bookingReference}
</Text>
<Text size="sm" c="dimmed">
Inventory ID: {item.id}
{inventorySummary || 'Inventory information'}
</Text>
</Stack>
<InventoryStatusBadge status={item.status} />
@@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
@@ -17,23 +17,115 @@ interface ReleaseOrderModalProps {
item: WarehouseInventoryItem | null;
}
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
].map(([powerPlate, trailerPlate], index) => ({
value: powerPlate,
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
trailerPlate,
}));
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
const [driverName, setDriverName] = useState('');
const [driverLicense, setDriverLicense] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const [truckType, setTruckType] = useState('');
const [containerNumber, setContainerNumber] = useState('');
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
const [downloading, setDownloading] = useState(false);
useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? '');
if (opened) {
setReference(item?.releaseOrderReference ?? '');
setTruckPlateNumber('');
setTrailerPlateNumber('');
setDriverName('');
setDriverLicense('');
setDriverPhone('');
setTruckType('');
setContainerNumber('');
setGateInTime('');
setTareWeight('');
setGrossWeight('');
setNetWeight(item?.weight != null ? Number(item.weight) : '');
setGateOutTime('');
}
}, [opened, item]);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
const handleSubmit = async () => {
if (!item) return;
if (!truckPlateNumber.trim() || !driverName.trim()) {
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (tareWeight === '' || grossWeight === '') {
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
return;
}
if (weightMismatch) {
toast({
variant: 'destructive',
title: 'Weight mismatch',
description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.',
});
return;
}
const pdfWindow = window.open('', '_blank');
try {
const released = await releaseMutation.mutateAsync({
id: item.id,
payload: { reference: reference.trim() || undefined },
payload: {
reference: reference.trim() || undefined,
bookingId: item.bookingId ?? undefined,
customerId: undefined,
truckPlateNumber: truckPlateNumber.trim(),
trailerPlateNumber: trailerPlateNumber.trim() || undefined,
driverName: driverName.trim(),
driverLicense: driverLicense.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
truckType: truckType.trim() || undefined,
containerNumber: containerNumber.trim() || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: Number(grossWeight),
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
gateOutTime: toIsoDateTime(gateOutTime),
},
});
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
@@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
};
return (
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Creates the warehouse release document with booking, customer, cargo and location details. The
printed paper authorizes the goods to leave the warehouse gate.
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
recorded net weight does not equal gross weight minus tare weight.
</Text>
</Alert>
<TextInput
@@ -70,12 +162,69 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
/>
<Select
label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below"
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
}}
/>
<Group grow>
<TextInput
label="Truck plate number"
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
/>
<TextInput
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
</Group>
<Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
<Text size="sm">
Weight mismatch detected. Exit paper and gate clearance are blocked; use Store or Move to
reassign the item back to warehouse handling.
</Text>
</Alert>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Issue & view exit paper
Exit Inspection & View Exit Paper
</Button>
</Group>
</Stack>

View File

@@ -164,7 +164,7 @@ export function WarehouseInventoryTable({
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (

View File

@@ -181,7 +181,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro
</ActionIcon>
</Group>
),
},
},
];
return (

View File

@@ -0,0 +1,273 @@
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';
align?: 'left' | 'center' | 'right';
};
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');
const estimateTextWidth = (text: string, size: number) => text.length * size * 0.52;
const textX = (text: string, size: number, align: PdfLine['align'] = 'left', x?: number) => {
if (typeof x === 'number') return x;
if (align === 'center') return Math.max(36, (595 - estimateTextWidth(text, size)) / 2);
if (align === 'right') return Math.max(36, 535 - estimateTextWidth(text, size));
return 60;
};
const lineOp = (x1: number, y1: number, x2: number, y2: number, color = '0.65 0.7 0.76') =>
`q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
const textOp = (
text: string,
x: number,
y: number,
size = 10,
bold = false,
color = '0 0 0',
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
lineOp(60, 242, 535, 242),
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
buildCircularSeal(452, 155, label),
];
const buildWarehouseOfficerSealBand = () => [
lineOp(60, 218, 535, 218),
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
buildCircularSeal(170, 128, 'CLEARED'),
textOp('Officer in charge name / signature / date:', 292, 154, 10),
lineOp(292, 132, 535, 132, '0 0 0'),
textOp('Customer or driver name / signature / date:', 292, 94, 10),
lineOp(292, 72, 535, 72, '0 0 0'),
];
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${textX(line.text, size, line.align, line.x)} ${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 title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
const bookingReference = firstText(invoice.bookingReference);
const customerName = firstText(invoice.customerName);
const inventoryReference = firstText(invoice.inventoryReference);
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
const clearanceStatus = firstText(
invoice.clearanceStatus,
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
);
const lines: PdfLine[] = [
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
...(invoice.items ?? []).flatMap((item) => [
{ text: item.description, bold: true, align: 'center' as const },
{
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,
align: 'center' as const,
},
]),
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
];
const authorizationOps = [
...buildAuthorizationBand('PAID'),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
const invoiceOps = [
lineOp(60, 242, 535, 242),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
}
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 = firstText(
inventory?.releaseOrderReference,
releasedItem?.releaseOrderReference,
invoice.inventoryReference,
booking?.reference ? `REL-${booking.reference.replace(/^BK-?/i, '')}` : null,
);
const customerName = firstText(
booking?.company?.name,
booking?.company?.companyName,
booking?.company?.label,
booking?.company?.contactPersonName,
invoice.customerName,
);
const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight));
const inventoryInfo = firstText(
invoice.inventoryInfo,
invoice.containerNumber,
invoice.cargoDescription,
inventory?.status,
releasedItem?.status,
);
const bookingReference = firstText(
booking?.reference,
(inventory as unknown as { bookingReference?: string })?.bookingReference,
invoice.bookingReference,
releasedItem?.booking?.reference,
);
return buildSimplePdf([
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: 'Warehouse Release / Exit Paper', size: 23, bold: true, yGap: 28, align: 'center' },
{ text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
{ text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' },
{ text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
{ text: `Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code)}`, align: 'center' },
{ text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code)}`, align: 'center' },
{ text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code)}`, align: 'center' },
{ text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' },
{ text: `Weight: ${weightTons}`, align: 'center' },
{ text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' },
{ text: `Clearance: ${invoice.clearanceStatus ?? 'CLEARED FOR WAREHOUSE EXIT'}`, bold: true, color: 'green', align: 'center' },
{ text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' },
{ text: 'This sealed document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' },
], [
...buildWarehouseOfficerSealBand(),
]);
}