This commit is contained in:
hagiye
2026-06-25 11:00:40 +03:00
parent 503878110f
commit f4a90755c2
42 changed files with 2100 additions and 98 deletions

View File

@@ -12,7 +12,6 @@ import {
} from '@mantine/core';
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
VisualEmptyState,
@@ -79,17 +78,13 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
{item.bookingReference ?? item.bookingId.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>{item.customerName ?? '-'}</Table.Td>
<Table.Td>{item.containerNumber ?? '-'}</Table.Td>
<Table.Td>{item.cargoType ?? '-'}</Table.Td>
<Table.Td>{item.customerName ?? ''}</Table.Td>
<Table.Td>{item.containerNumber ?? ''}</Table.Td>
<Table.Td>{item.cargoType ?? ''}</Table.Td>
<Table.Td>{formatNumber(item.weight)}</Table.Td>
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
<Table.Td>
<Badge
variant="light"
color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'}
size="sm"
>
<Badge variant="light" color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'} size="sm">
{item.currentStatus ?? 'PENDING'}
</Badge>
</Table.Td>
@@ -112,9 +107,7 @@ export default function ArrivalQueuePage() {
const unloadTrain = async (train: ImportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
const res = (await autoUnload.mutateAsync(train.scheduleId)) as {
data: AutoUnloadArrivedResult;
};
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
const result = res.data;
const details = [
result.skippedCount ? `${result.skippedCount} skipped` : '',
@@ -143,11 +136,6 @@ export default function ArrivalQueuePage() {
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
<Stack gap="lg" mt="sm">
<PageHeader
title="Arrival / Unloading Queue"
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
/>
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
@@ -199,16 +187,16 @@ export default function ArrivalQueuePage() {
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={700}>
{train.trainNumber ?? '-'}
{train.trainNumber ?? ''}
</Text>
<Text size="xs" c="dimmed">
{train.scheduleId.slice(0, 8)}
</Text>
</Stack>
</Table.Td>
<Table.Td>{train.route ?? '-'}</Table.Td>
<Table.Td>{train.origin ?? '-'}</Table.Td>
<Table.Td>{train.destination ?? '-'}</Table.Td>
<Table.Td>{train.route ?? ''}</Table.Td>
<Table.Td>{train.origin ?? ''}</Table.Td>
<Table.Td>{train.destination ?? ''}</Table.Td>
<Table.Td>
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
</Table.Td>
@@ -225,9 +213,7 @@ export default function ArrivalQueuePage() {
<Button
size="compact-xs"
variant="light"
leftSection={
isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />
}
leftSection={isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
>
Open
@@ -235,13 +221,7 @@ export default function ArrivalQueuePage() {
<Button
size="compact-xs"
color="orange"
leftSection={
busyScheduleId === train.scheduleId ? (
<PackageOpen size={14} />
) : (
<Truck size={14} />
)
}
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
loading={busyScheduleId === train.scheduleId}
onClick={() => unloadTrain(train)}
>

View File

@@ -13,7 +13,7 @@ import {
Text,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronRight, Eye, History, PackageOpen, Truck } from 'lucide-react';
import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
@@ -30,6 +30,10 @@ import {
useExportDjiboutiArrivalQueue,
useExportDjiboutiTrainItems,
} from '@/hooks/useWarehouses';
import {
useGenerateInterchangeDocument,
useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast';
import type {
AutoUnloadExportDjiboutiResult,
@@ -162,13 +166,22 @@ function ExportTrainDetailRows({
}
export default function ExportDjiboutiUnloadingQueuePage() {
const navigate = useNavigate();
const { toast } = useToast();
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue();
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
const autoUnload = useAutoUnloadExportAtDjibouti();
const generateInterchange = useGenerateInterchangeDocument();
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(null);
const interchangeBySchedule = new Map(
interchangeDocuments
.filter((doc) => doc.scheduleId && doc.status !== 'CANCELLED')
.map((doc) => [doc.scheduleId as string, doc]),
);
const unloadTrain = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
@@ -179,6 +192,9 @@ export default function ExportDjiboutiUnloadingQueuePage() {
const details = [
result.skippedCount ? `${result.skippedCount} skipped` : '',
result.failedCount ? `${result.failedCount} failed` : '',
result.interchangeDocument
? `Interchange document ${result.interchangeDocument.documentNo} generated`
: '',
]
.filter(Boolean)
.join(', ');
@@ -198,6 +214,34 @@ export default function ExportDjiboutiUnloadingQueuePage() {
}
};
const generateInterchangeDocument = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
const res = await generateInterchange.mutateAsync({
scheduleId: train.scheduleId,
direction: 'EXPORT',
handoverLocation: train.destination ?? 'Djibouti Port',
handoverFrom: 'EDR',
handoverTo: 'Djibouti Port Operator',
portOperatorName: 'Doraleh Multipurpose Port',
remarks: 'Generated after export unloading at Djibouti Port',
});
toast({
title: 'Interchange document generated',
description: res.data.documentNo,
});
navigate('/dashboard/interchange-documents');
} catch (error) {
toast({
variant: 'destructive',
title: 'Interchange document generation failed',
description: getErrorMessage(error),
});
} finally {
setBusyScheduleId(null);
}
};
return (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Djibouti Arrival / Unloading Queue' }]} />
@@ -255,6 +299,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Table.Tbody>
{trains.map((train: ExportTrain) => {
const isOpen = openScheduleId === train.scheduleId;
const interchangeDocument = interchangeBySchedule.get(train.scheduleId);
return (
<Fragment key={train.scheduleId}>
<Table.Tr>
@@ -304,6 +349,28 @@ export default function ExportDjiboutiUnloadingQueuePage() {
>
Auto Unload Export Items
</Button>
{interchangeDocument ? (
<Button
size="compact-xs"
variant="light"
color="blue"
leftSection={<Eye size={14} />}
onClick={() => navigate('/dashboard/interchange-documents')}
>
View Document
</Button>
) : (
<Button
size="compact-xs"
variant="light"
color="green"
leftSection={<FileText size={14} />}
loading={busyScheduleId === train.scheduleId && generateInterchange.isPending}
onClick={() => generateInterchangeDocument(train)}
>
Generate Interchange Document
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>

View File

@@ -0,0 +1,315 @@
import { useMemo, useState } from 'react';
import {
Badge,
Button,
Card,
Group,
Loader,
Modal,
SimpleGrid,
Stack,
Table,
Text,
TextInput,
} from '@mantine/core';
import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react';
import type { ReactNode } from 'react';
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 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;
};
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="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 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(
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }),
'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');
};
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 ? (
<Group justify="center" py="xl">
<Loader />
</Group>
) : documents.length === 0 ? (
<VisualEmptyState
variant="container"
title="No interchange documents"
description="Generated freight handover documents appear here."
/>
) : (
<Table.ScrollContainer minWidth={1060}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Document No</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Train No / Schedule</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Handover Location</Table.Th>
<Table.Th>Handover From</Table.Th>
<Table.Th>Handover To</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Generated At</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{documents.map((document) => (
<Table.Tr key={document.id}>
<Table.Td>
<Text fw={700} size="sm">
{document.documentNo}
</Text>
</Table.Td>
<Table.Td>{document.direction}</Table.Td>
<Table.Td>
<Stack gap={0}>
<Text size="sm">{document.trainNo ?? '-'}</Text>
<Text size="xs" c="dimmed">
{document.scheduleId?.slice(0, 8) ?? '-'}
</Text>
</Stack>
</Table.Td>
<Table.Td>{document.routeId?.slice(0, 8) ?? '-'}</Table.Td>
<Table.Td>{document.handoverLocation}</Table.Td>
<Table.Td>{document.handoverFrom}</Table.Td>
<Table.Td>{document.handoverTo}</Table.Td>
<Table.Td>
<Badge variant="light" color={statusColor[document.status]}>
{document.status}
</Badge>
</Table.Td>
<Table.Td>{formatDate(document.generatedAt)}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
leftSection={<Eye size={14} />}
onClick={() => setViewId(document.id)}
>
View
</Button>
{document.status !== 'ACKNOWLEDGED' && document.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="green"
variant="light"
leftSection={<CheckCircle2 size={14} />}
onClick={() => acknowledgeDocument(document)}
>
Acknowledge
</Button>
) : null}
{document.status !== 'CANCELLED' ? (
<Button
size="compact-xs"
color="orange"
variant="light"
leftSection={<FileText size={14} />}
onClick={() => disputeDocument(document)}
>
Dispute
</Button>
) : null}
{document.status === 'DRAFT' || document.status === 'GENERATED' ? (
<Button
size="compact-xs"
color="red"
variant="light"
leftSection={<XCircle size={14} />}
onClick={() =>
run(() => cancel.mutateAsync(document.id), 'Interchange document cancelled')
}
>
Cancel
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
<Modal opened={Boolean(viewId)} onClose={() => setViewId(null)} title="Interchange Document" size="90%">
{viewId ? <InterchangeDocumentDetail id={viewId} /> : null}
</Modal>
</PageContainer>
);
}