mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
495 lines
19 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
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>
|
|
);
|
|
}
|