Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx

495 lines
19 KiB
TypeScript

import { useMemo, useState } from 'react';
import {
Badge,
Button,
Card,
Group,
Loader,
Modal,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
import type { ReactNode } from 'react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import { PageContainer, PageHeader } from '@/components/page';
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
import {
useAcknowledgeInterchangeDocument,
useCancelInterchangeDocument,
useDisputeInterchangeDocument,
useInterchangeDocument,
useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast';
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
const statusColor: Record<InterchangeDocumentStatus, string> = {
DRAFT: 'gray',
GENERATED: 'blue',
ACKNOWLEDGED: 'green',
DISPUTED: 'red',
CANCELLED: 'gray',
};
const getErrorMessage = (error: unknown) => {
if (error && typeof error === 'object' && 'response' in error) {
const response = (error as { response?: { data?: { message?: unknown } } }).response;
const message = response?.data?.message;
if (Array.isArray(message)) return message.join(', ');
if (typeof message === 'string') return message;
}
return error instanceof Error ? error.message : undefined;
};
const escapeHtml = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const filenameFor = (document: InterchangeDocument) =>
`${document.documentNo || document.id}-interchange-document.html`.replace(/[\\/:*?"<>|]/g, '-');
const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
const items = document.items ?? [];
const rows = items
.map(
(item, index) => `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
<td>${escapeHtml(item.itemType)}</td>
<td>${escapeHtml(item.containerNumber)}</td>
<td>${escapeHtml(item.sealNumber)}</td>
<td>${escapeHtml(item.cargoType ?? item.cargoDescription)}</td>
<td>${escapeHtml(formatNumber(item.weight))}</td>
<td>${escapeHtml(formatNumber(item.quantity))}</td>
<td>${escapeHtml(item.wagonNumber)}</td>
<td>${escapeHtml(item.conditionStatus)}</td>
<td>${escapeHtml(item.damageDescription)}</td>
</tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(document.documentNo)} Interchange Document</title>
<style>
@page { size: A4 landscape; margin: 14mm; }
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; color: #111827; margin: 0; }
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid #111827; padding-bottom: 14px; }
h1 { margin: 0; font-size: 24px; }
.muted { color: #4b5563; font-size: 12px; }
.stamp { border: 2px solid #15803d; color: #15803d; border-radius: 999px; padding: 14px 18px; text-align: center; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 18px; margin: 18px 0; }
.field { border-bottom: 1px solid #d1d5db; padding-bottom: 6px; }
.label { color: #6b7280; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }
.value { font-size: 13px; font-weight: 700; margin-top: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 11px; }
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; vertical-align: top; }
th { background: #f3f4f6; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 34px; }
.signature { border-top: 1px solid #111827; padding-top: 8px; min-height: 48px; }
.footer { margin-top: 16px; font-size: 10px; color: #6b7280; }
</style>
</head>
<body>
<div class="top">
<div>
<h1>EDR / Djibouti Port Interchange Document</h1>
<div class="muted">Official export handover document</div>
<div class="muted">Document No: ${escapeHtml(document.documentNo)}</div>
</div>
<div class="stamp">${escapeHtml(document.status)}<br/>SIGNED HANDOVER</div>
</div>
<div class="grid">
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
<div class="field"><div class="label">Generated At</div><div class="value">${escapeHtml(formatDate(document.generatedAt))}</div></div>
<div class="field"><div class="label">Acknowledged At</div><div class="value">${escapeHtml(formatDate(document.acknowledgedAt))}</div></div>
<div class="field"><div class="label">Signed by EDR</div><div class="value">${escapeHtml(document.generatedBy)}</div></div>
<div class="field"><div class="label">Signed by Djibouti Port</div><div class="value">${escapeHtml(document.acknowledgedBy)}</div></div>
<div class="field"><div class="label">Port Operator</div><div class="value">${escapeHtml(document.portOperatorName)}</div></div>
<div class="field"><div class="label">Manifest Ref</div><div class="value">${escapeHtml(document.manifestReference)}</div></div>
</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Booking</th>
<th>Type</th>
<th>Container</th>
<th>Seal</th>
<th>Cargo</th>
<th>Weight</th>
<th>Qty</th>
<th>Wagon</th>
<th>Condition</th>
<th>Damage / Notes</th>
</tr>
</thead>
<tbody>${rows || '<tr><td colspan="11">No items</td></tr>'}</tbody>
</table>
<div class="signatures">
<div class="signature">EDR Representative: ${escapeHtml(document.generatedBy)}</div>
<div class="signature">Djibouti Port Operator: ${escapeHtml(document.acknowledgedBy)}</div>
</div>
<div class="footer">Generated from EDR Freight Management System. Printed on ${escapeHtml(new Date().toLocaleString())}.</div>
</body>
</html>`;
};
function DetailField({ label, value }: { label: string; value: ReactNode }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value || '-'}
</Text>
</Stack>
);
}
function InterchangeDocumentDetail({ id }: { id: string }) {
const { data: document, isLoading } = useInterchangeDocument(id);
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
if (!document) return null;
const items = document.items ?? [];
return (
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<DetailField label="Document No" value={document.documentNo} />
<DetailField label="Direction" value={document.direction} />
<DetailField label="Schedule" value={document.scheduleId?.slice(0, 8)} />
<DetailField label="Train No" value={document.trainNo} />
<DetailField label="Handover Location" value={document.handoverLocation} />
<DetailField label="Handover From" value={document.handoverFrom} />
<DetailField label="Handover To" value={document.handoverTo} />
<DetailField
label="Status"
value={
<Badge variant="light" color={statusColor[document.status]}>
{document.status}
</Badge>
}
/>
<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>
<Table.ScrollContainer minWidth={980}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking Reference</Table.Th>
<Table.Th>Item Type</Table.Th>
<Table.Th>Container Number</Table.Th>
<Table.Th>Seal Number</Table.Th>
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Quantity</Table.Th>
<Table.Th>Wagon Number</Table.Th>
<Table.Th>Condition</Table.Th>
<Table.Th>Damage Description</Table.Th>
<Table.Th>Remarks</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>{item.bookingReference ?? item.bookingId?.slice(0, 8) ?? '-'}</Table.Td>
<Table.Td>{item.itemType}</Table.Td>
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
<Table.Td>{item.sealNumber ?? '-'}</Table.Td>
<Table.Td>{item.cargoType ?? item.cargoDescription ?? '-'}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>{formatNumber(item.quantity)}</Table.Td>
<Table.Td>{item.wagonNumber ?? '-'}</Table.Td>
<Table.Td>
<Badge
variant="light"
color={item.conditionStatus === 'GOOD' ? 'green' : item.conditionStatus === 'UNKNOWN' ? 'gray' : 'orange'}
size="sm"
>
{item.conditionStatus}
</Badge>
</Table.Td>
<Table.Td>{item.damageDescription ?? '-'}</Table.Td>
<Table.Td>{item.remarks ?? '-'}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
);
}
export default function InterchangeDocumentsPage() {
const { toast } = useToast();
const [search, setSearch] = useState('');
const [viewId, setViewId] = useState<string | null>(null);
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
const acknowledge = useAcknowledgeInterchangeDocument();
const dispute = useDisputeInterchangeDocument();
const cancel = useCancelInterchangeDocument();
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
if (interchangeDocument.items?.length) return interchangeDocument;
return interchangeDocumentsService.getById(interchangeDocument.id).then((response) => response.data);
};
const printDocument = async (interchangeDocument: InterchangeDocument) => {
const fullDocument = await getPrintableDocument(interchangeDocument);
const win = window.open('', '_blank');
if (!win) {
toast({ variant: 'destructive', title: 'Pop-up blocked', description: 'Allow pop-ups to print the document.' });
return;
}
win.document.write(buildPrintableInterchangeHtml(fullDocument));
win.document.close();
win.focus();
setTimeout(() => win.print(), 250);
};
const downloadDocument = async (interchangeDocument: InterchangeDocument) => {
const fullDocument = await getPrintableDocument(interchangeDocument);
const blob = new Blob([buildPrintableInterchangeHtml(fullDocument)], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(fullDocument);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const run = async (fn: () => Promise<unknown>, title: string) => {
try {
await fn();
toast({ title });
} catch (error) {
toast({ variant: 'destructive', title: 'Action failed', description: getErrorMessage(error) });
}
};
const acknowledgeDocument = (document: InterchangeDocument) => {
const acknowledgedBy = window.prompt('Acknowledged by');
if (!acknowledgedBy) return;
run(async () => {
const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy });
await printDocument(response.data);
}, 'Interchange document acknowledged');
};
const disputeDocument = (document: InterchangeDocument) => {
const remarks = window.prompt('Dispute reason');
if (!remarks) return;
run(() => dispute.mutateAsync({ id: document.id, remarks }), 'Interchange document disputed');
};
const documentColumns: ColumnDef<InterchangeDocument>[] = [
{
id: 'documentNo',
header: 'Document No',
cell: ({ row }) => (
<Text fw={700} size="sm">
{row.original.documentNo}
</Text>
),
},
{ id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction },
{
id: 'train',
header: 'Train No / Schedule',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.trainNo ?? '-'}</Text>
<Text size="xs" c="dimmed">
{row.original.scheduleId?.slice(0, 8) ?? '-'}
</Text>
</Stack>
),
},
{ id: 'route', header: 'Route', cell: ({ row }) => row.original.routeId?.slice(0, 8) ?? '-' },
{ id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation },
{ id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom },
{ id: 'handoverTo', header: 'Handover To', cell: ({ row }) => row.original.handoverTo },
{
id: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant="light" color={statusColor[row.original.status]}>
{row.original.status}
</Badge>
),
},
{
id: 'signedBy',
header: 'Signed By',
cell: ({ row }) => (
<Stack gap={0}>
<Text size="sm">{row.original.generatedBy ?? '-'}</Text>
<Text size="xs" c="dimmed">
{row.original.acknowledgedBy ?? 'Awaiting Djibouti Port'}
</Text>
</Stack>
),
},
{ id: 'generatedAt', header: 'Generated At', cell: ({ row }) => formatDate(row.original.generatedAt) },
{
id: 'actions',
header: '',
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
cell: ({ row }) => {
const doc = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
leftSection={<Eye size={14} />}
onClick={() => setViewId(doc.id)}
>
View
</Button>
{doc.status !== 'ACKNOWLEDGED' && doc.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="green"
variant="light"
leftSection={<CheckCircle2 size={14} />}
onClick={() => acknowledgeDocument(doc)}
>
Acknowledge
</Button>
) : null}
{doc.status === 'ACKNOWLEDGED' ? (
<>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Printer size={14} />}
onClick={() => run(() => printDocument(doc), 'Print view opened')}
>
Print
</Button>
<Button
size="compact-xs"
color="blue"
variant="light"
leftSection={<Download size={14} />}
onClick={() => run(() => downloadDocument(doc), 'Document downloaded')}
>
Download
</Button>
</>
) : null}
{doc.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="orange"
variant="light"
leftSection={<FileText size={14} />}
onClick={() => disputeDocument(doc)}
>
Dispute
</Button>
) : null}
{doc.status === 'DRAFT' || doc.status === 'GENERATED' ? (
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<XCircle size={14} />}
onClick={() => run(() => cancel.mutateAsync(doc.id), 'Interchange document cancelled')}
>
Cancel
</Button>
) : null}
</Group>
);
},
},
];
return (
<PageContainer>
<PageHeader
title="Interchange Documents"
subtitle="Official freight handover documents with booking, container and cargo line items."
/>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{documents.length} document(s)</Text>
<TextInput
w={{ base: '100%', sm: 320 }}
leftSection={<Search size={16} />}
placeholder="Search documents"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
/>
</Group>
{!isLoading && documents.length === 0 ? (
<VisualEmptyState
variant="container"
title="No interchange documents"
description="Generated freight handover documents appear here."
/>
) : (
<DataTable
columns={documentColumns}
data={documents}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
)}
</Card>
<Modal opened={Boolean(viewId)} onClose={() => setViewId(null)} title="Interchange Document" size="90%">
{viewId ? <InterchangeDocumentDetail id={viewId} /> : null}
</Modal>
</PageContainer>
);
}