Import operation gate pass

This commit is contained in:
hagiye
2026-06-27 21:04:27 +03:00
parent 3441ee4a0a
commit fee4365b70
37 changed files with 3084 additions and 116 deletions

View File

@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from "@/constants/apiConfig";
import { API_BASE_URL } from '@/constants/apiConfig';
interface Cargo {
id: string;

View File

@@ -88,9 +88,15 @@ interface TruckEntranceFormState {
interface LockedTruckEntranceFields {
ownerName?: boolean;
consigneeDetails?: boolean;
tin?: boolean;
edrDigitalBookingId?: boolean;
customerPhone?: boolean;
assignedEquipmentNumber?: boolean;
itemDescription?: boolean;
packagingType?: boolean;
unitCount?: boolean;
grossWeightKg?: boolean;
}
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
@@ -129,23 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
});
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
ownerName: form.ownerName.trim() || undefined,
consigneeDetails: form.consigneeDetails.trim() || undefined,
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
tin: form.tin.trim() || undefined,
customerPhone: form.customerPhone.trim() || undefined,
truckPlateNumber: form.truckPlateNumber.trim(),
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
customsSealNumber: form.customsSealNumber.trim() || undefined,
declarationNumber: form.declarationNumber.trim() || undefined,
incoterms: form.incoterms.trim() || undefined,
hsCodes: form.hsCodes.trim() || undefined,
itemCode: form.itemCode.trim() || undefined,
itemDescription: form.itemDescription.trim() || undefined,
packagingType: form.packagingType.trim() || undefined,
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg),
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
@@ -221,9 +217,15 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
},
lockedFields: {
ownerName: Boolean(ownerName),
consigneeDetails: Boolean(consigneeDetails),
tin: Boolean(tin),
edrDigitalBookingId: Boolean(edrDigitalBookingId),
customerPhone: Boolean(customerPhone),
assignedEquipmentNumber: Boolean(assignedEquipmentNumber),
itemDescription: Boolean(itemDescription),
packagingType: Boolean(packagingType),
unitCount: unitCount !== '',
grossWeightKg: grossWeightKg !== '',
},
packagingFreightType,
};
@@ -292,6 +294,7 @@ function TruckEntranceFields({
<TextInput
label="Consignee details"
value={value.consigneeDetails}
readOnly={lockedFields?.consigneeDetails}
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
/>
</Group>
@@ -334,6 +337,7 @@ function TruckEntranceFields({
<TextInput
label="Assigned wagon / container number"
value={value.assignedEquipmentNumber}
readOnly={lockedFields?.assignedEquipmentNumber}
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
/>
<TextInput
@@ -413,6 +417,7 @@ function TruckEntranceFields({
<TextInput
label="Item description"
value={value.itemDescription}
readOnly={lockedFields?.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
</Group>
@@ -422,6 +427,7 @@ function TruckEntranceFields({
data={packagingOptions}
clearable
searchable
readOnly={lockedFields?.packagingType}
value={value.packagingType}
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
/>
@@ -429,6 +435,7 @@ function TruckEntranceFields({
label={quantityLabel}
min={0}
value={value.unitCount}
readOnly={lockedFields?.unitCount}
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
/>
</Group>
@@ -437,6 +444,7 @@ function TruckEntranceFields({
label="Gross weight (kg)"
min={0}
value={value.grossWeightKg}
readOnly={lockedFields?.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
@@ -676,6 +684,14 @@ function EligibleTab({
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
const someSelected = selected.size > 0 && !allSelected;
const pendingReceiveRows = useMemo(
() =>
pendingReceiveIds
.map((id) => rows.find((item) => item.id === id))
.filter(Boolean) as EligibleBooking[],
[pendingReceiveIds, rows],
);
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -686,6 +702,29 @@ function EligibleTab({
return next;
});
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
try {
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds,
...(truckEntrance ? { truckEntrance } : {}),
});
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
};
const openTruckReceive = (bookingIds: string[]) => {
if (!locationReady) {
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
@@ -704,6 +743,10 @@ function EligibleTab({
const selectedRows = filteredIds
.map((id) => rows.find((item) => item.id === id))
.filter(Boolean) as EligibleBooking[];
if (direction === 'IMPORT') {
void receiveBookings(filteredIds);
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
setPendingReceiveIds(filteredIds);
setTruckForm(form);
@@ -717,26 +760,7 @@ function EligibleTab({
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
return;
}
try {
const r = await bulkReceive.mutateAsync({
direction,
...location,
bookingIds: pendingReceiveIds,
truckEntrance: toTruckEntrancePayload(truckForm),
});
toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
});
setSelected(new Set());
setTruckOpen(false);
setPendingReceiveIds([]);
setLockedTruckFields({});
setPackagingFreightType('MIXED');
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
}
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
};
const loadPassedExport = async () => {
@@ -940,11 +964,21 @@ function EligibleTab({
</Table.ScrollContainer>
)}
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
<Modal
opened={truckOpen}
onClose={() => setTruckOpen(false)}
title="Export Truck Arrival / First Mile Receive Form"
centered
size="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
</Text>
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
<Text size="sm">
{pendingHasFirstMile
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.'
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text>
</Alert>
<TruckEntranceFields
value={truckForm}
onChange={setTruckForm}
@@ -956,7 +990,7 @@ function EligibleTab({
Cancel
</Button>
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
Receive and Generate GRN
Register Arrival & Generate GRN
</Button>
</Group>
</Stack>
@@ -965,6 +999,161 @@ function EligibleTab({
);
}
/** Export received items awaiting inspection before loading. */
function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
const { data: rows = [], isLoading } = useQuery(
api.warehouses.receivedExport.queryOptions({ enabled }),
);
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id)));
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const markInspected = async () => {
if (selected.size === 0) {
toast({ variant: 'destructive', title: 'Select at least one item' });
return;
}
try {
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
toast({
title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
});
setSelected(new Set());
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
}
};
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Selected: <b>{selected.size}</b> / {pendingRows.length} received
</Text>
<Button
size="compact-sm"
color="indigo"
leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0}
loading={inspectMutation.isPending}
onClick={markInspected}
>
Mark Selected as Inspected
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : rows.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No received export items awaiting inspection.
</Text>
) : (
<Table.ScrollContainer minWidth={1700}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th w={40}>
<Checkbox
aria-label="Select all"
checked={allSelected}
indeterminate={someSelected}
onChange={toggleAll}
/>
</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Inspection</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r: ReadyToLoadRow) => {
const selectable = r.inspectionStatus !== 'PASSED';
return (
<Table.Tr key={r.id}>
<Table.Td>
<Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`}
checked={selected.has(r.id)}
disabled={!selectable}
onChange={() => toggleOne(r.id)}
/>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
<Table.Td>
{r.origin || r.destination ? `${r.origin ?? '?'}${r.destination ?? '?'}` : '—'}
</Table.Td>
<Table.Td>
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'orange'} variant="light" size="sm">
{r.inspectionStatus ?? 'PENDING'}
</Badge>
</Table.Td>
<Table.Td>
<Badge color="blue" variant="light" size="sm">
{r.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
<InspectionReportModal
inventoryId={inspectId}
opened={Boolean(inspectId)}
onClose={() => setInspectId(null)}
/>
</Stack>
);
}
/** Export items that passed inspection and are queued to be loaded onto a train. */
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
const { toast } = useToast();
@@ -1765,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
<Tabs defaultValue="receive-queue" mt="xs">
<Tabs.List>
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
<Tabs.Tab value="received">Received</Tabs.Tab>
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
@@ -1773,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
<Tabs.Panel value="receive-queue">
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="received">
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="ready-to-load">
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
@@ -1849,10 +2042,6 @@ function SingleBookingReceiveModal({
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
return;
}
if (form.quantity === '' || form.weight === '') {
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
return;
}
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
return;
@@ -1862,8 +2051,8 @@ function SingleBookingReceiveModal({
warehouseId: form.warehouseId,
yardId: form.yardId,
zoneId: form.zoneId,
quantity: Number(form.quantity),
weight: Number(form.weight),
quantity: 0,
weight: 0,
volume: form.volume === '' ? undefined : Number(form.volume),
notes: form.notes.trim() || undefined,
truckEntrance: toTruckEntrancePayload(truckForm),
@@ -1889,21 +2078,13 @@ function SingleBookingReceiveModal({
<LocationSelects value={location} onChange={(next) => setForm((f) => ({ ...f, ...next }))} />
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated.
</Text>
</Alert>
<Group grow>
<NumberInput
label="Quantity"
required
min={0}
value={form.quantity}
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Weight (kg)"
required
min={0}
value={form.weight}
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
/>
<NumberInput
label="Volume (m³)"
placeholder="Optional"

View File

@@ -192,6 +192,24 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/documents`,
IMPORT_DJIBOUTI_GATEPASS_GRANTED: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`,
IMPORT_DJIBOUTI_READY_FOR_LOADING: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/ready-for-loading`,
IMPORT_DJIBOUTI_LOADED_ON_TRAIN: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/loaded-on-train`,
IMPORT_DJIBOUTI_DEPART: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/depart`,
IMPORT_DJIBOUTI_LOAD_LIST: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/load-list`,
IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`,
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) =>
@@ -322,6 +340,7 @@ export const URL_CONSTANTS = {
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
@@ -377,6 +396,23 @@ export const URL_CONSTANTS = {
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
},
IMPORT_OPERATIONS: {
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`,
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,
},
VEHICLES: {
BASE: '/vehicles',
BY_ID: (id: string) => `/vehicles/${id}`,

View File

@@ -1,5 +1,4 @@
import type { FleetResourceConfig } from "./resources";
import { API_BASE_URL } from "@/constants/apiConfig";
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
@@ -93,4 +92,3 @@ export const vehiclesConfig: FleetResourceConfig = {
};
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
export { API_BASE_URL };

View File

@@ -19,6 +19,7 @@ import {
CheckCircle2,
Container as ContainerIcon,
Eye,
FileText,
LayoutGrid,
Navigation,
Package,
@@ -56,8 +57,10 @@ import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/s
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type {
ContainerPlacement,
@@ -124,6 +127,12 @@ export default function TrainScheduleV2DetailPage() {
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const downloadMarshalling = useMutation({
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
direction === "EXPORT"
? trainSchedulingService.downloadExportLoadListDocument(id)
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
});
const assignedIds = useMemo(
() => (schedule?.bookings ?? []).map((b) => b.id),
@@ -304,6 +313,33 @@ export default function TrainScheduleV2DetailPage() {
const canDispatch = schedule.status === "SCHEDULED";
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
const openMarshallingDocument = async () => {
const pdfWindow = window.open("", "_blank");
try {
const blob = await downloadMarshalling.mutateAsync({
id: scheduleId,
direction: schedule.direction,
});
const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling";
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
toast({
title: "Marshalling document ready",
description: opened
? "The PDF opened in a browser tab for printing or saving."
: "The browser blocked the preview tab, so the PDF was downloaded.",
});
} catch (error) {
pdfWindow?.close();
toast({
title: "Could not open marshalling document",
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
variant: "destructive",
});
}
};
const handleAssign = async () => {
if (!allSelectedIds.length) return;
@@ -766,6 +802,19 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Group>
<Group gap="sm">
{canPrintMarshalling ? (
<Button
variant="light"
color="edr-green"
radius="lg"
size="sm"
leftSection={<FileText size={16} />}
loading={downloadMarshalling.isPending}
onClick={() => void openMarshallingDocument()}
>
Marshalling PDF
</Button>
) : null}
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
<Button
component={Link}

View File

@@ -644,6 +644,13 @@ export const api = {
() => ["warehouse-inventory", "ready-to-load-export"],
),
receivedExport: endpoint<void, ReadyToLoadRow[]>(
"warehouse-inventory",
"received-export",
() => warehouseService.receivedExport().then((r) => r.data),
() => ["warehouse-inventory", "received-export"],
),
loadedExport: endpoint<void, ReadyToLoadRow[]>(
"warehouse-inventory",
"loaded-export",

View File

@@ -0,0 +1,136 @@
import { api as client } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import { unwrap } from '@/utils/endpoint';
import type {
AssignCustomsRiskPayload,
CreateDjiboutiIncidentPayload,
CreateEmptyContainerReturnPayload,
DjiboutiIncident,
EmptyContainerReturn,
ImportCustomsFinalization,
ImportOperationActionPayload,
RecordDeclarationPayload,
UpdateEmptyContainerReturnStatusPayload,
UploadImportCustomsDocumentPayload,
} from '@/types/importOperations';
export const importOperationsService = {
listIncidents: async (bookingId?: string): Promise<DjiboutiIncident[]> => {
const response = await client.get<DjiboutiIncident[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
{ params: { bookingId } },
);
return unwrap(response.data);
},
createIncident: async (
payload: CreateDjiboutiIncidentPayload,
): Promise<DjiboutiIncident> => {
const response = await client.post<DjiboutiIncident>(
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
payload,
);
return unwrap(response.data);
},
getCustoms: async (bookingId: string): Promise<ImportCustomsFinalization> => {
const response = await client.get<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS(bookingId),
);
return unwrap(response.data);
},
uploadCustomsDocument: async (
bookingId: string,
payload: UploadImportCustomsDocumentPayload,
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DOCUMENTS(bookingId),
payload,
);
return unwrap(response.data);
},
recordDeclaration: async (
bookingId: string,
payload: RecordDeclarationPayload,
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DECLARATION(bookingId),
payload,
);
return unwrap(response.data);
},
notifyDutiesTaxes: async (
bookingId: string,
payload: ImportOperationActionPayload = {},
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_NOTIFY_DUTIES_TAXES(bookingId),
payload,
);
return unwrap(response.data);
},
markDutiesTaxesPaid: async (
bookingId: string,
payload: ImportOperationActionPayload = {},
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DUTIES_TAXES_PAID(bookingId),
payload,
);
return unwrap(response.data);
},
assignRisk: async (
bookingId: string,
payload: AssignCustomsRiskPayload,
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RISK(bookingId),
payload,
);
return unwrap(response.data);
},
markReleasePermitted: async (
bookingId: string,
payload: ImportOperationActionPayload = {},
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RELEASE_PERMITTED(bookingId),
payload,
);
return unwrap(response.data);
},
listEmptyReturns: async (): Promise<EmptyContainerReturn[]> => {
const response = await client.get<EmptyContainerReturn[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
);
return unwrap(response.data);
},
createEmptyReturn: async (
payload: CreateEmptyContainerReturnPayload,
): Promise<EmptyContainerReturn> => {
const response = await client.post<EmptyContainerReturn>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
payload,
);
return unwrap(response.data);
},
updateEmptyReturnStatus: async (
id: string,
payload: UpdateEmptyContainerReturnStatusPayload,
): Promise<EmptyContainerReturn> => {
const response = await client.post<EmptyContainerReturn>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_STATUS(id),
payload,
);
return unwrap(response.data);
},
};

View File

@@ -11,6 +11,9 @@ import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
ImportDjiboutiActionPayload,
ImportDjiboutiLoadList,
ImportDjiboutiOperation,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -21,6 +24,7 @@ import type {
TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules,
TrainTrackResponse,
UploadImportDjiboutiDocumentPayload,
WagonAllocationAttemptResult,
YardOption,
} from "@/types/trainScheduling";
@@ -261,6 +265,101 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {
const response = await client.get<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI(scheduleId),
);
return unwrap(response.data);
},
uploadImportDjiboutiDocument: async (
scheduleId: string,
payload: UploadImportDjiboutiDocumentPayload,
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DOCUMENTS(scheduleId),
payload,
);
return unwrap(response.data);
},
grantImportDjiboutiGatepass: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_GATEPASS_GRANTED(scheduleId),
payload,
);
return unwrap(response.data);
},
markImportDjiboutiReadyForLoading: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_READY_FOR_LOADING(scheduleId),
payload,
);
return unwrap(response.data);
},
confirmImportDjiboutiLoadedOnTrain: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOADED_ON_TRAIN(scheduleId),
payload,
);
return unwrap(response.data);
},
departImportFromDjibouti: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DEPART(scheduleId),
payload,
);
return unwrap(response.data);
},
generateImportDjiboutiLoadList: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiLoadList> => {
const response = await client.post<ImportDjiboutiLoadList>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST(scheduleId),
payload,
);
return unwrap(response.data);
},
downloadImportDjiboutiLoadListDocument: async (
scheduleId: string,
): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT(scheduleId),
{ responseType: "blob" },
);
return response.data;
},
downloadExportLoadListDocument: async (
scheduleId: string,
): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.EXPORT_LOAD_LIST_DOCUMENT(scheduleId),
{ responseType: "blob" },
);
return response.data;
},
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
const response = await client.get<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),

View File

@@ -149,6 +149,8 @@ export const warehouseService = {
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
bulkMarkInspected: (payload: BulkInspectPayload) =>
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
receivedExport: () =>
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVED_EXPORT),
readyToLoadExport: () =>
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
loadedExport: () =>

View File

@@ -0,0 +1,124 @@
export type DjiboutiIncidentType =
| 'SEAL_BROKEN'
| 'CONTAINER_OPENED'
| 'CONTAINER_DAMAGED'
| 'FLUID_LEAKING'
| 'QUANTITY_MISMATCH'
| 'WEIGHT_MISMATCH'
| 'OTHER';
export interface DjiboutiIncident {
id: string;
bookingId: string;
containerNumber: string | null;
cargoId: string | null;
facility: string | null;
station: string | null;
incidentType: DjiboutiIncidentType;
description: string;
photos: string[];
reportedBy: string | null;
reportedAt: string;
}
export interface CreateDjiboutiIncidentPayload {
bookingId: string;
containerNumber?: string;
cargoId?: string;
facility?: string;
station?: string;
incidentType: DjiboutiIncidentType;
description: string;
photos?: string[];
reportedBy?: string;
reportedAt?: string;
}
export type ImportCustomsDocumentType =
| 'IM4'
| 'IM5'
| 'T1_CLOSURE_PROOF'
| 'TRANSIT_PERMIT_SCREENSHOT'
| 'CUSTOMER_PAYMENT_SLIP'
| 'IMPORT_RELEASE_PERMIT';
export type ImportCustomsRiskLevel = 'GREEN' | 'YELLOW' | 'BLUE' | 'RED';
export interface ImportCustomsFinalization {
id: string;
bookingId: string;
documents: Partial<Record<ImportCustomsDocumentType, string>>;
declarationSerialNumber: string | null;
dutiesTaxesNotifiedAt: string | null;
dutiesTaxesPaidAt: string | null;
customsRisk: ImportCustomsRiskLevel | null;
importReleasePermittedAt: string | null;
completedAt: string | null;
performedBy: string | null;
notes: string | null;
}
export interface ImportOperationActionPayload {
performedBy?: string;
notes?: string;
}
export interface UploadImportCustomsDocumentPayload {
documentType: ImportCustomsDocumentType;
fileId: string;
performedBy?: string;
}
export interface RecordDeclarationPayload {
declarationSerialNumber: string;
performedBy?: string;
}
export interface AssignCustomsRiskPayload {
risk: ImportCustomsRiskLevel;
performedBy?: string;
}
export type EmptyContainerReturnStatus =
| 'RETURNED'
| 'ASSIGNED_STORAGE'
| 'DOCUMENTATION_CLEARED'
| 'WAGON_ALLOCATED'
| 'TRANSPORTED_TO_DJIBOUTI'
| 'HANDOVER_ISSUED'
| 'COMPLETED';
export interface EmptyContainerReturn {
id: string;
containerNumber: string;
bookingId: string | null;
customerId: string | null;
returnDate: string;
facility: string | null;
yard: string | null;
zone: string | null;
condition: string | null;
handoverNote: string | null;
status: EmptyContainerReturnStatus;
wagonAllocationReference: string | null;
performedBy: string | null;
}
export interface CreateEmptyContainerReturnPayload {
containerNumber: string;
bookingId?: string;
customerId?: string;
returnDate?: string;
facility?: string;
yard?: string;
zone?: string;
condition?: string;
handoverNote?: string;
performedBy?: string;
}
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {
status: EmptyContainerReturnStatus;
wagonAllocationReference?: string;
handoverNote?: string;
}

View File

@@ -399,6 +399,81 @@ export interface TrainScheduleDetail {
warnings?: string[];
}
export type ImportDjiboutiDocumentType =
| "DELIVERY_ORDER"
| "PORT_INVOICE"
| "DJIBOUTI_T1"
| "ETHIOPIA_T1"
| "RAILWAY_BILL";
export interface ImportDjiboutiDocumentRecord {
fileId?: string | null;
fileUrl?: string | null;
reference?: string | null;
uploadedAt: string;
uploadedBy?: string | null;
notes?: string | null;
}
export interface ImportDjiboutiOperation {
trainScheduleId: string;
trainNumber: string | null;
direction: string | null;
status: {
documentsComplete: boolean;
missingDocuments: ImportDjiboutiDocumentType[];
gatepassGranted: boolean;
readyForLoading: boolean;
loadedOnTrain: boolean;
departedFromDjibouti: boolean;
loadListGenerated: boolean;
};
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
gatepassGrantedAt: string | null;
readyForLoadingAt: string | null;
loadedOnTrainAt: string | null;
departedFromDjiboutiAt: string | null;
loadListGeneratedAt: string | null;
performedBy: string | null;
notes: string | null;
}
export interface UploadImportDjiboutiDocumentPayload {
documentType: ImportDjiboutiDocumentType;
fileId?: string;
fileUrl?: string;
reference?: string;
notes?: string;
performedBy?: string;
}
export interface ImportDjiboutiActionPayload {
notes?: string;
performedBy?: string;
}
export interface ImportDjiboutiLoadList {
generatedAt: string;
trainScheduleId: string;
trainNumber: string | null;
route: string | null;
origin: string | null;
destination: string | null;
totalBookings: number;
wagons: Array<{
sequenceNo: number;
wagonNumber: string | null;
allocations: Array<{
bookingId: string;
bookingReference: string | null;
loadType: string | null;
allocatedWeightTons: number;
containerNumbers: string[];
}>;
}>;
operation: ImportDjiboutiOperation;
}
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
export interface TrackStation {

View File

@@ -403,7 +403,7 @@ export interface BulkReceivePayload {
yardId: string;
zoneId: string;
bookingIds: string[];
truckEntrance: TruckEntrancePayload;
truckEntrance?: TruckEntrancePayload;
}
export interface BulkReceiveResult {