mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
update import gl flow
This commit is contained in:
@@ -121,7 +121,8 @@ export function GlClearanceUploadModal({
|
||||
|
||||
<PhasedFileDropzone
|
||||
label={isDo ? "Delivery Order file" : "Release Order file"}
|
||||
description="PDF or image."
|
||||
description={isDo ? "Any file type." : "PDF or image."}
|
||||
accept={isDo ? "*/*" : undefined}
|
||||
value={file}
|
||||
onChange={setFile}
|
||||
replaceMode={replaceMode}
|
||||
|
||||
@@ -59,6 +59,7 @@ type ClearanceViewLike = Pick<
|
||||
| "preClearanceFinalized"
|
||||
| "exportClearanceFinalized"
|
||||
| "allApproved"
|
||||
| "t1"
|
||||
> & { operationReady?: boolean };
|
||||
|
||||
type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
|
||||
@@ -79,7 +80,10 @@ function isBookingMilestoneDone(
|
||||
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
|
||||
}
|
||||
|
||||
function computeImportActiveStep(clearance: ClearanceViewLike): number {
|
||||
function computeImportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
bookingCreated: boolean,
|
||||
): number {
|
||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||
if (
|
||||
@@ -98,7 +102,23 @@ function computeImportActiveStep(clearance: ClearanceViewLike): number {
|
||||
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
|
||||
if (!clearance.preClearanceFinalized) return 5;
|
||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||
return 7;
|
||||
if (!bookingCreated) return 7;
|
||||
if (!clearance.t1?.closed) return 8;
|
||||
return 9;
|
||||
}
|
||||
|
||||
function t1FilesFromWorkflow(
|
||||
workflowFiles: Freight.ClearanceWorkflowFile[],
|
||||
): Array<{ code: string; label: string; file: { id: string; name: string } }> {
|
||||
return workflowFiles
|
||||
.filter(
|
||||
(f) => f.code.toLowerCase().startsWith("t1_transport_document") && f.file,
|
||||
)
|
||||
.map((f) => ({
|
||||
code: f.code,
|
||||
label: f.label,
|
||||
file: f.file!,
|
||||
}));
|
||||
}
|
||||
|
||||
function declarationFilesFromWorkflow(
|
||||
@@ -174,9 +194,12 @@ export function PhasedClearanceActionPanel({
|
||||
const showEt = roleMode === "ET" || roleMode === "ALL";
|
||||
const showDj = roleMode === "DJ" || roleMode === "ALL";
|
||||
const isImport = tradeDirection === "IMPORT";
|
||||
// The server only builds the t1 block once a booking is linked — use it as the
|
||||
// booking-created signal on pages that don't pass bookingCreated (GL DJ detail).
|
||||
const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1);
|
||||
const activeStep = useMemo(
|
||||
() => (isImport ? computeImportActiveStep(clearance) : 0),
|
||||
[clearance, isImport],
|
||||
() => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0),
|
||||
[clearance, isImport, effectiveBookingCreated],
|
||||
);
|
||||
|
||||
if (isImport) {
|
||||
@@ -401,11 +424,7 @@ export function PhasedClearanceActionPanel({
|
||||
description="GL Djibouti uploads DO"
|
||||
icon={<Ship size={14} />}
|
||||
>
|
||||
{showDj &&
|
||||
canDj &&
|
||||
!useUploadModals &&
|
||||
(activeStep >= 6 ||
|
||||
isMilestoneDone(clearance.milestones, "DO_COLLECTED")) ? (
|
||||
{showDj && canDj && !useUploadModals ? (
|
||||
<DeliveryOrderStep
|
||||
entityId={entityId}
|
||||
isBooking={isBooking}
|
||||
@@ -427,11 +446,7 @@ export function PhasedClearanceActionPanel({
|
||||
) : null}
|
||||
<StepStatus
|
||||
done={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
|
||||
pendingLabel={
|
||||
!clearance.preClearanceFinalized
|
||||
? "Blocked until GL Ethiopia finalizes pre-clearance."
|
||||
: "Waiting for GL Djibouti to upload the Delivery Order."
|
||||
}
|
||||
pendingLabel="Waiting for GL Djibouti to upload the Delivery Order (can be uploaded at any time)."
|
||||
doneLabel="Delivery Order collected."
|
||||
/>
|
||||
{useUploadModals && showDj && canDj && onUploadDoRequest ? (
|
||||
@@ -439,7 +454,6 @@ export function PhasedClearanceActionPanel({
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={onUploadDoRequest}
|
||||
disabled={!clearance.preClearanceFinalized && !findWorkflowFile(workflowFiles, "delivery_order")}
|
||||
>
|
||||
{findWorkflowFile(workflowFiles, "delivery_order")
|
||||
? "Replace DO"
|
||||
@@ -472,17 +486,37 @@ export function PhasedClearanceActionPanel({
|
||||
) : (
|
||||
<StepStatus
|
||||
done={Boolean(
|
||||
bookingCreated || clearance.bookingReady || clearance.operationReady,
|
||||
effectiveBookingCreated ||
|
||||
clearance.bookingReady ||
|
||||
clearance.operationReady,
|
||||
)}
|
||||
pendingLabel="Complete the Delivery Order step first."
|
||||
doneLabel={
|
||||
bookingCreated
|
||||
effectiveBookingCreated
|
||||
? "Shipment booking created."
|
||||
: "Ready — create the shipment booking."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="T1 transport documents"
|
||||
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
|
||||
icon={
|
||||
clearance.t1?.closed ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||
}
|
||||
>
|
||||
<ImportT1Section
|
||||
t1={clearance.t1 ?? null}
|
||||
workflowFiles={workflowFiles}
|
||||
canDjAct={showDj && canDj}
|
||||
canEtAct={showEt && canEt}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Paper>
|
||||
</Stack>
|
||||
@@ -578,6 +612,170 @@ export function PhasedClearanceActionPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportT1Section({
|
||||
t1,
|
||||
workflowFiles = [],
|
||||
canDjAct,
|
||||
canEtAct,
|
||||
onChanged,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
t1: Freight.ClearanceT1State | null;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
canDjAct: boolean;
|
||||
canEtAct: boolean;
|
||||
onChanged?: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const uploaded = t1FilesFromWorkflow(workflowFiles);
|
||||
const replaceMode = uploaded.length > 0;
|
||||
|
||||
if (!t1) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Available once the shipment booking is created."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const departed = Boolean(t1.trainDepartedAt);
|
||||
const arrived = Boolean(t1.trainArrivedAt);
|
||||
const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{uploaded.length > 0 ? (
|
||||
<Stack gap={8}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
|
||||
T1 document{uploaded.length > 1 ? "s" : ""}
|
||||
</Text>
|
||||
{uploaded.map((row) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={row.code}
|
||||
label={row.label}
|
||||
file={row.file}
|
||||
onView={onViewFile}
|
||||
onDownload={onDownloadFile}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{t1.closed ? (
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel="T1 accepted and closed by GL Ethiopia — documents are final."
|
||||
/>
|
||||
) : !t1.wagonAllocated ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for operations to allocate wagons."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : departed ? (
|
||||
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
|
||||
The train has departed — T1 documents are locked and can no longer be changed.
|
||||
</Alert>
|
||||
) : uploaded.length === 0 && !canUpload ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{canUpload ? (
|
||||
<>
|
||||
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
|
||||
<PhasedMultiFileDropzone
|
||||
label="T1 transport documents"
|
||||
description={
|
||||
replaceMode
|
||||
? "Replace T1 files — upload one or more documents (any file type)."
|
||||
: "Upload one or more T1 transport documents (any file type)."
|
||||
}
|
||||
accept="*/*"
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
replaceMode={replaceMode}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</Paper>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={uploading}
|
||||
disabled={files.length === 0}
|
||||
leftSection={<Upload size={16} />}
|
||||
fullWidth
|
||||
onClick={async () => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const payload = Object.fromEntries(
|
||||
files.map((file, index) => [`t1_transport_document_${index}`, file]),
|
||||
) as Record<string, File>;
|
||||
await contractsService.uploadT1Documents(t1.bookingId, payload);
|
||||
setFiles([]);
|
||||
toast.success(replaceMode ? "T1 documents updated" : "T1 documents uploaded");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{replaceMode ? "Replace T1 documents" : "Upload T1 documents"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canEtAct && !t1.closed ? (
|
||||
arrived ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
The train has arrived — review the T1 documents and close (accept) them.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={closing}
|
||||
disabled={uploaded.length === 0}
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
onClick={async () => {
|
||||
setClosing(true);
|
||||
try {
|
||||
await contractsService.closeT1(t1.bookingId);
|
||||
toast.success("T1 closed");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept & close T1
|
||||
</Button>
|
||||
</Stack>
|
||||
) : departed ? (
|
||||
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
|
||||
Train en route — T1 can be closed once it arrives in Ethiopia.
|
||||
</Alert>
|
||||
) : null
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function StepStatus({
|
||||
done,
|
||||
pendingLabel,
|
||||
|
||||
@@ -211,6 +211,10 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/bookings/${bookingId}/documents`,
|
||||
BOOKING_TRANSPORT_DOCUMENT: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/transport-document`,
|
||||
BOOKING_T1_DOCUMENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/t1-documents`,
|
||||
BOOKING_T1_CLOSE: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/t1-close`,
|
||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/incidents`,
|
||||
},
|
||||
|
||||
@@ -112,7 +112,8 @@ export default function GlClearanceDetailPage() {
|
||||
const isImport = data.tradeDirection === "IMPORT";
|
||||
const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order"));
|
||||
const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||
const canUploadDo = isImport && Boolean(data.clearance.preClearanceFinalized || hasDo);
|
||||
// DO upload is un-gated — Djibouti GL may attach it at any point, any file type.
|
||||
const canUploadDo = isImport;
|
||||
const vesselDepartureDate =
|
||||
"vesselDepartureDate" in data.clearance
|
||||
? (data.clearance.vesselDepartureDate ?? null)
|
||||
|
||||
@@ -333,6 +333,27 @@ export const contractsService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** GL Djibouti uploads T1 transit documents (multi-file, post wagon allocation). */
|
||||
uploadT1Documents: async (
|
||||
bookingId: string,
|
||||
files: Record<string, File | null>,
|
||||
) => {
|
||||
const form = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(C.BOOKING_T1_DOCUMENTS(bookingId), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** GL Ethiopia closes (accepts) the T1 document set after the train arrives. */
|
||||
closeT1: async (bookingId: string): Promise<Freight.ClearanceT1State> => {
|
||||
const response = await client.post(C.BOOKING_T1_CLOSE(bookingId));
|
||||
return unwrap(response.data) as Freight.ClearanceT1State;
|
||||
},
|
||||
|
||||
// ── Path A self-clearance (Operations review) ──
|
||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
|
||||
Reference in New Issue
Block a user