update import gl flow

This commit is contained in:
Marshal
2026-07-02 13:27:59 +00:00
parent fe40d9f4be
commit fec2a5d320
12 changed files with 452 additions and 252 deletions

View File

@@ -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}

View File

@@ -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 &amp; 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,