Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx

1890 lines
59 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Group,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Stepper,
Switch,
Text,
TextInput,
} from "@mantine/core";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import {
AlertTriangle,
CheckCircle2,
Clock,
FileText,
MessageSquareWarning,
PackageCheck,
Receipt,
ShieldAlert,
Ship,
Truck,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
import {
DoCollectionDateFields,
doDatesComplete,
toIsoDate,
useDoCollectionDates,
} from "@/components/contracts/DoCollectionDateFields";
import {
findWorkflowFile,
PhasedUploadedFileRow,
} from "@/components/contracts/PhasedUploadedFileRow";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type RoleMode = "ET" | "DJ" | "ALL";
export type ClearanceViewLike = Pick<
Freight.ContractClearanceView,
| "nextAction"
| "dutyRequired"
| "dutyAdvice"
| "dutyDispute"
| "transitAssignee"
| "roHold"
| "roHoldReason"
| "milestones"
| "bookingReady"
| "preClearanceFinalized"
| "exportClearanceFinalized"
| "allApproved"
| "t1"
| "train"
| "gatepassGranted"
| "gatepassAt"
| "t1Closed"
| "t1ClosedAt"
| "offloaded"
| "finalInvoice"
| "vesselDepartureDate"
| "vesselArrivalDate"
| "doCollectedDate"
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
| "riskHistory"
| "secondDuty"
| "importReleaseGranted"
> & { operationReady?: boolean };
export type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
export function isMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
const m = milestones?.find((x) => x.milestoneCode === code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
export function isBookingMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
const m = milestones?.find((x) => x.milestoneCode === code);
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
function computeImportActiveStep(
clearance: ClearanceViewLike,
bookingCreated: boolean,
bookingMilestones: MilestoneRow[],
t1Uploaded: boolean,
freightPaid: boolean,
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
if (
clearance.dutyRequired === null ||
clearance.dutyRequired === undefined ||
(clearance.dutyRequired && !isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED"))
) {
return 2;
}
if (
clearance.dutyRequired &&
!isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID")
) {
return 3;
}
if (!isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) return 4;
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
// The customer pays the train/freight charges on the booking. Until that
// settles the gate pass is not granted for this booking, so the flow stops here.
if (!freightPaid) return 8;
if (!clearance.gatepassGranted) return 9;
if (!t1Uploaded && !clearance.t1?.closed) return 10;
if (!clearance.t1?.closed) return 11;
// Risk is "assigned" when the booking milestone says so OR the clearance view
// already carries a riskLevel. The ET page derives its bookingMilestones from a
// separately-fetched booking id that can lag or mismatch the booking carrying
// the milestone — `clearance.riskLevel` is server truth and matches the badge.
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
if (!riskAssigned) return 12;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 13;
if (!clearance.importReleaseGranted) return 14;
return 15;
}
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(
workflowFiles: Freight.ClearanceWorkflowFile[],
): Array<{ code: string; label: string; file: { id: string; name: string } }> {
return workflowFiles
.filter(
(f) => f.category === "declaration" && f.code !== "import_release" && f.file,
)
.map((f) => ({
code: f.code,
label: f.label,
file: f.file!,
}));
}
function importTransitFilesFromWorkflow(
workflowFiles: Freight.ClearanceWorkflowFile[],
): Array<{ code: string; label: string; file: { id: string; name: string } }> {
return workflowFiles
.filter(
(f) =>
f.category === "transit" &&
f.code !== "export_transport_document" &&
f.file,
)
.map((f) => ({
code: f.code,
label: f.label,
file: f.file!,
}));
}
export function PhasedClearanceActionPanel({
contractId,
bookingId,
clearance,
tradeDirection,
workflowFiles = [],
roleMode = "ALL",
onChanged,
bookingCreateHref,
onViewFile,
onDownloadFile,
useUploadModals = false,
onUploadDoRequest,
onUploadRoRequest,
bookingCreated = false,
bookingMilestones = [],
}: {
contractId?: string;
bookingId?: string;
clearance: ClearanceViewLike;
tradeDirection: string;
workflowFiles?: Freight.ClearanceWorkflowFile[];
roleMode?: RoleMode;
onChanged?: () => void;
bookingCreateHref?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
/** GL Djibouti: open modal uploads instead of inline forms for DO/RO. */
useUploadModals?: boolean;
onUploadDoRequest?: () => void;
onUploadRoRequest?: () => void;
/** Hide create-booking CTA once GL has already created the shipment booking. */
bookingCreated?: boolean;
/** Post-booking milestones (export transit permit after wagon allocation). */
bookingMilestones?: MilestoneRow[];
}) {
const entityId = bookingId ?? contractId ?? "";
const isBooking = Boolean(bookingId);
const { user } = useAuth();
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
const canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
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);
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
const actionBookingId =
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0;
// Risk is assigned when the clearance view carries a riskLevel (server truth,
// drives the badge) OR the fetched booking milestone confirms it. Kept in sync
// with computeImportActiveStep so the stepper never freezes on a page whose
// bookingMilestones lag/mismatch the booking that holds the milestone.
const riskAssigned =
Boolean(clearance.riskLevel) ||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
// Freight (train + service) charges settled on the booking. The gate pass is
// only granted to a booking that has paid, so a granted gate pass is server
// proof of payment — it keeps the stepper moving on a page whose
// bookingMilestones have not loaded yet or point at a different booking.
const freightPaid =
isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
Boolean(clearance.gatepassGranted);
const activeStep = useMemo(
() =>
isImport
? computeImportActiveStep(
clearance,
effectiveBookingCreated,
bookingMilestones,
t1Uploaded,
freightPaid,
)
: 0,
[
clearance,
isImport,
effectiveBookingCreated,
bookingMilestones,
t1Uploaded,
freightPaid,
],
);
if (isImport) {
return (
<Stack gap="md">
{clearance.roHold && clearance.roHoldReason ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
{clearance.roHoldReason}
</Alert>
) : null}
{clearance.nextAction ? (
<Alert color="blue" variant="light" title="Next step">
<Text size="sm">
<strong>{clearance.nextAction.actor.replace("_", " ")}</strong> {" "}
{clearance.nextAction.action}
</Text>
</Alert>
) : null}
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="md">
Import pre-booking clearance
</Text>
<Stepper
active={activeStep}
orientation="vertical"
size="sm"
iconSize={26}
allowNextStepsSelect={false}
>
<Stepper.Step
label="Customer documents"
description="Review and approve in the panel on the left"
icon={
isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED") ? (
<CheckCircle2 size={14} />
) : undefined
}
>
<StepStatus
done={isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")}
pendingLabel="Approve every required customer document."
doneLabel="All required documents approved."
/>
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="Upload declaration documents"
icon={
isMilestoneDone(clearance.milestones, "DECLARED") ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
{/* Djibouti must name the transit officer first — the declaration
is filed against whoever handles the shipment there, and the
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
entityId={entityId}
isBooking={isBooking}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : showEt &&
canEt &&
!clearance.bookingReady &&
(activeStep >= 1 ||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={isMilestoneDone(clearance.milestones, "DECLARED")}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<Stack gap="sm">
{declarationFilesFromWorkflow(workflowFiles).map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
<StepStatus
done={isMilestoneDone(clearance.milestones, "DECLARED")}
pendingLabel="Waiting for GL Ethiopia to upload customs declaration documents."
doneLabel="Declaration uploaded."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Duty & tax"
description="Advise amount and attach notice"
icon={<Receipt size={14} />}
>
{showEt && canEt && activeStep === 2 ? (
<DutyStep
entityId={entityId}
isBooking={isBooking}
clearance={clearance}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<StepStatus
done={
clearance.dutyRequired === false ||
isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED")
}
pendingLabel="Waiting for GL Ethiopia to advise duty/tax."
doneLabel={
clearance.dutyRequired === false
? "Duty/tax not required."
: "Duty/tax advised to customer."
}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Customer payment"
description="Customer uploads payment slip"
icon={<Clock size={14} />}
>
{findWorkflowFile(workflowFiles, "duty_tax_receipt") ? (
<PhasedUploadedFileRow
label="Duty / Tax Payment Slip"
file={findWorkflowFile(workflowFiles, "duty_tax_receipt")!}
onView={onViewFile}
onDownload={onDownloadFile}
/>
) : (
<StepStatus
done={
clearance.dutyRequired === false ||
isMilestoneDone(clearance.milestones, "DUTY_TAX_PAID")
}
pendingLabel={
clearance.dutyRequired === false
? "Skipped — duty not required."
: "Waiting for the customer to upload the duty/tax payment slip in the portal."
}
doneLabel="Customer payment slip received."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Transit Permit"
description="Upload transit permit documents"
icon={<Upload size={14} />}
>
{showEt &&
canEt &&
!bookingCreated &&
(activeStep >= 4 ||
isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? (
<TransitPermitStep
entityId={entityId}
isBooking={isBooking}
workflowFiles={workflowFiles}
replaceMode={isMilestoneDone(
clearance.milestones,
"TRANSIT_PERMIT_UPLOADED",
)}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : importTransitFilesFromWorkflow(workflowFiles).length > 0 ? (
<Stack gap="sm">
<Text size="sm" fw={700}>
Transit Permit
</Text>
{importTransitFilesFromWorkflow(workflowFiles).map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
</Stack>
) : (
<StepStatus
done={isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")}
pendingLabel="Waiting for GL Ethiopia to upload the transit permit."
doneLabel="Transit permit uploaded."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Finalize pre-clearance"
description="Hand off to GL Djibouti"
icon={<PackageCheck size={14} />}
>
{showEt && canEt && activeStep === 5 ? (
<FinalizePreClearanceStep
entityId={entityId}
isBooking={isBooking}
onChanged={onChanged}
/>
) : (
<StepStatus
done={Boolean(clearance.preClearanceFinalized)}
pendingLabel="Waiting for GL Ethiopia to finalize pre-clearance."
doneLabel="Pre-clearance finalized — Djibouti may upload the DO."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Delivery Order"
description="GL Djibouti uploads DO"
icon={<Ship size={14} />}
>
{showDj && canDj && !useUploadModals ? (
<DeliveryOrderStep
entityId={entityId}
isBooking={isBooking}
workflowFiles={workflowFiles}
replaceMode={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
vesselArrivalDate={clearance.vesselArrivalDate}
doCollectedDate={clearance.doCollectedDate}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<Stack gap="sm">
{findWorkflowFile(workflowFiles, "delivery_order") ? (
<PhasedUploadedFileRow
label="Delivery Order"
file={findWorkflowFile(workflowFiles, "delivery_order")!}
onView={onViewFile}
onDownload={onDownloadFile}
/>
) : null}
<StepStatus
done={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
pendingLabel="Waiting for GL Djibouti to upload the Delivery Order (can be uploaded at any time)."
doneLabel="Delivery Order collected."
/>
{useUploadModals && showDj && canDj && onUploadDoRequest ? (
<Button
color="edr-green"
leftSection={<Upload size={16} />}
onClick={onUploadDoRequest}
>
{findWorkflowFile(workflowFiles, "delivery_order")
? "Replace DO"
: "Upload DO"}
</Button>
) : null}
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Create booking"
description="GL Ethiopia books for the customer"
icon={<Ship size={14} />}
>
{(clearance.bookingReady || clearance.operationReady) &&
bookingCreateHref &&
!bookingCreated &&
showEt &&
canEt ? (
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
<Text size="sm" c="dimmed" mb="sm">
Pre-booking clearance is complete. Create the shipment booking for the
customer.
</Text>
<Button component="a" href={bookingCreateHref} color="edr-green">
Create shipment booking
</Button>
</SectionCard>
) : (
<StepStatus
done={Boolean(
effectiveBookingCreated ||
clearance.bookingReady ||
clearance.operationReady,
)}
pendingLabel="Complete the Delivery Order step first."
doneLabel={
effectiveBookingCreated
? "Shipment booking created."
: "Ready — create the shipment booking."
}
/>
)}
</Stepper.Step>
<Stepper.Step
label="Freight payment"
description="Customer pays the train and service charges"
icon={freightPaid ? <CheckCircle2 size={14} /> : <Receipt size={14} />}
>
<StepStatus
done={freightPaid}
pendingLabel="Waiting for the customer to pay the train and service charges. The gate pass is not granted until this settles."
doneLabel="Train and service charges settled."
/>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after payment and wagon allocation"
icon={
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
}
>
<ImportGatepassStep clearance={clearance} freightPaid={freightPaid} />
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after the gate pass is secured"
icon={
t1Uploaded || clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<Truck size={14} />
)
}
>
<ImportT1UploadStep
t1={clearance.t1 ?? null}
gatepassGranted={Boolean(clearance.gatepassGranted)}
workflowFiles={workflowFiles}
canDjAct={showDj && canDj}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Close T1"
description="GL Ethiopia closes once the train arrives"
icon={
clearance.t1?.closed ? (
<CheckCircle2 size={14} />
) : (
<PackageCheck size={14} />
)
}
>
<ImportT1CloseStep
t1={clearance.t1 ?? null}
t1Uploaded={t1Uploaded}
canEtAct={showEt && canEt}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="Customs risk"
description="GL Ethiopia assigns Green / Yellow / Red"
icon={
riskAssigned ? (
<CheckCircle2 size={14} />
) : (
<ShieldAlert size={14} />
)
}
>
<RiskStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
done={riskAssigned}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="Additional duty & tax"
description="GL Ethiopia advises if more duty applies"
icon={<Receipt size={14} />}
>
<SecondDutyStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
riskAssigned={riskAssigned}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Import release"
description="GL Ethiopia uploads the release document"
icon={
clearance.importReleaseGranted ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
<ImportReleaseStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Paper>
</Stack>
);
}
return (
<ExportClearanceStepper
contractId={contractId}
bookingId={bookingId}
clearance={clearance}
workflowFiles={workflowFiles}
showEt={showEt}
canEt={canEt}
showDj={showDj}
canDj={canDj}
onChanged={onChanged}
bookingCreateHref={bookingCreateHref}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
useUploadModals={useUploadModals}
onUploadRoRequest={onUploadRoRequest}
bookingCreated={bookingCreated}
bookingMilestones={bookingMilestones}
/>
);
}
/**
* GL Djibouti uploads the T1 transport documents (multi-file) once the gate
* pass is secured on the train schedule; locked on departure or T1 close.
*/
function ImportT1UploadStep({
t1,
gatepassGranted,
workflowFiles = [],
canDjAct,
onChanged,
onViewFile,
onDownloadFile,
}: {
t1: Freight.ClearanceT1State | null;
gatepassGranted: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
canDjAct: 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 uploaded = t1FilesFromWorkflow(workflowFiles);
const replaceMode = uploaded.length > 0;
if (!t1) {
return (
<StepStatus
done={false}
pendingLabel="Available once the shipment booking is created."
doneLabel=""
/>
);
}
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
// closes/accepts the T1.
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !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 documents are final — closed by GL Ethiopia."
/>
) : !t1.wagonAllocated ? (
<StepStatus
done={false}
pendingLabel="Waiting for operations to allocate wagons."
doneLabel=""
/>
) : !gatepassGranted ? (
<StepStatus
done={false}
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
doneLabel=""
/>
) : uploaded.length === 0 && !canUpload ? (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
) : uploaded.length > 0 && !canUpload ? (
<StepStatus done pendingLabel="" doneLabel="T1 documents uploaded." />
) : 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}
</Stack>
);
}
/**
* GL Ethiopia closes (accepts) the T1 set with one click once the train has
* arrived. Separate step from the GL Djibouti upload.
*/
function ImportT1CloseStep({
t1,
t1Uploaded,
canEtAct,
onChanged,
}: {
t1: Freight.ClearanceT1State | null;
t1Uploaded: boolean;
canEtAct: boolean;
onChanged?: () => void;
}) {
const [closing, setClosing] = useState(false);
if (!t1) {
return (
<StepStatus
done={false}
pendingLabel="Available once the shipment booking is created."
doneLabel=""
/>
);
}
if (t1.closed) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`T1 accepted and closed by GL Ethiopia${
t1.closedAt ? ` · ${new Date(t1.closedAt).toLocaleString()}` : ""
}`}
/>
);
}
if (!t1Uploaded) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
doneLabel=""
/>
);
}
if (!t1.trainArrivedAt) {
return (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
{t1.trainDepartedAt
? "Train en route — T1 can be closed once it arrives in Ethiopia."
: "T1 can be closed once the train arrives in Ethiopia."}
</Alert>
);
}
if (!canEtAct) {
return (
<StepStatus
done={false}
pendingLabel="Train arrived — waiting for GL Ethiopia to close the T1."
doneLabel=""
/>
);
}
return (
<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}
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>
);
}
/**
* Gate pass status, read-only. Secured on the train schedule's "Save as
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
* The train may be secured while this booking still owes freight charges; the
* booking only picks the gate pass up once its payment settles.
*/
function ImportGatepassStep({
clearance,
freightPaid,
}: {
clearance: ClearanceViewLike;
freightPaid: boolean;
}) {
const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`Gate pass secured${
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
}`}
/>
);
}
if (!freightPaid) {
return (
<StepStatus
done={false}
pendingLabel="Blocked — the customer must pay the train and service charges before the gate pass is granted for this shipment."
doneLabel=""
/>
);
}
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
return (
<Stack gap="sm">
<StepStatus
done={false}
pendingLabel={
wagonAllocated
? "Wagons allocated — secure the gate pass on the train schedule."
: "Available once wagons are allocated."
}
doneLabel=""
/>
{scheduleId ? (
<Button
component="a"
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="light"
color="edr-green"
leftSection={<Truck size={16} />}
>
Secure gate pass on train schedule
</Button>
) : null}
</Stack>
);
}
const RISK_LEVEL_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/** GL ET assigns the customs examination risk (visible to the customer). */
function RiskStep({
bookingId,
clearance,
canAct,
done,
onChanged,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
done: boolean;
onChanged?: () => void;
}) {
const assigned = done || Boolean(clearance.riskLevel);
// Duty is advised off the risk level, so once that is done the decision is
// final. Until then a mis-assigned level must stay correctable — the server
// overwrites the milestone metadata on reassignment. Mirrors AssignRiskCard.
const locked = isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED");
const [level, setLevel] = useState<string>(clearance.riskLevel ?? "GREEN");
const [loading, setLoading] = useState(false);
// The clearance view loads (and refetches after a reassignment) after first
// render, so mirror the persisted level onto the control whenever it changes —
// otherwise reopening the step offers GREEN whatever is actually assigned.
useEffect(() => {
if (clearance.riskLevel) setLevel(clearance.riskLevel);
}, [clearance.riskLevel]);
// Only the decisions before the current one — the badge above already states
// the level in force, so repeating it as a trail entry reads as a duplicate.
const priorDecisions = (clearance.riskHistory ?? []).slice(0, -1);
const assignedSummary = assigned ? (
<Stack gap={6}>
<Group gap="sm">
<Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
variant="filled"
radius="sm"
>
{clearance.riskLevel ?? "Assigned"}
</Badge>
<Text size="sm" c="dimmed">
Customs risk assigned
{clearance.riskAssignedAt
? ` · ${new Date(clearance.riskAssignedAt).toLocaleString()}`
: ""}
. The customer can see this level.
</Text>
</Group>
{priorDecisions.length > 0 ? (
<Stack gap={2} pl="xs">
<Text size="xs" c="dimmed" fw={600}>
Previously
</Text>
{priorDecisions.map((entry, index) => (
<Text key={`${entry.assignedAt}-${index}`} size="xs" c="dimmed">
{entry.level}
{" · "}
{new Date(entry.assignedAt).toLocaleString()}
{entry.assignedBy ? ` · ${entry.assignedBy}` : ""}
{entry.note ? ` · ${entry.note}` : ""}
</Text>
))}
</Stack>
) : null}
</Stack>
) : null;
// Assigned and final: the badge is all that is left to show.
if (assigned && (locked || !canAct || !bookingId)) {
return assignedSummary;
}
// Customs cannot rate cargo still under transit — the server rejects the
// assignment until the T1 is closed, so do not offer the control yet. Skipped
// once a level exists: risk cannot have been assigned without a closed T1, so
// a still-open T1 here is stale data and must not hide the assigned badge.
if (!assigned && !clearance.t1?.closed) {
return (
<StepStatus
done={false}
pendingLabel="Available once the T1 is closed."
doneLabel=""
/>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for GL Ethiopia to assign the customs risk level."
doneLabel=""
/>
);
}
return (
<Stack gap="sm">
{assignedSummary}
<SegmentedControl
fullWidth
value={level}
onChange={setLevel}
data={[
{ label: "Green", value: "GREEN" },
{ label: "Yellow", value: "YELLOW" },
{ label: "Red", value: "RED" },
]}
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{assigned
? "Correctable until duty is advised. The customer sees the assigned risk level."
: "The customer sees the assigned risk level."}
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={loading}
disabled={assigned && level === clearance.riskLevel}
onClick={async () => {
setLoading(true);
try {
await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel,
});
toast.success(
assigned ? "Customs risk reassigned" : "Customs risk assigned",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
{assigned ? "Reassign risk" : "Assign risk"}
</Button>
</Group>
</Stack>
);
}
/** Optional post-arrival additional duty/tax round (GL ET advises; customer pays slip). */
function SecondDutyStep({
bookingId,
clearance,
canAct,
riskAssigned,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
riskAssigned: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [dutyRequired, setDutyRequired] = useState(true);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [serial, setSerial] = useState("");
const [attachment, setAttachment] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const duty = clearance.secondDuty ?? null;
if (duty?.skipped) {
return (
<StepStatus done pendingLabel="" doneLabel="No additional duty or tax applies." />
);
}
if (duty?.advised) {
return (
<Stack gap="sm">
<Text size="sm">
Additional duty advised:{" "}
<strong>
{duty.amount?.toLocaleString()} {duty.currency}
</strong>
{duty.declarationSerial ? ` · ${duty.declarationSerial}` : ""}
</Text>
{duty.noticeFile ? (
<PhasedUploadedFileRow
label="Additional Duty / Tax Notice"
file={duty.noticeFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{duty.slipFile ? (
<PhasedUploadedFileRow
label="Customer payment slip"
file={duty.slipFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
<StepStatus
done={duty.paid}
pendingLabel="Waiting for the customer to pay and attach the slip in the portal."
doneLabel="Additional duty paid — slip received."
/>
</Stack>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus
done={false}
pendingLabel="GL Ethiopia decides whether additional duty/tax applies."
doneLabel=""
/>
);
}
return (
<Stack gap="md">
{!riskAssigned ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Usually decided after the customs risk is assigned.
</Alert>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Switch
label="Additional duty/tax applies"
description="Turn off if no further duty or tax is due after arrival."
checked={dutyRequired}
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
/>
{dutyRequired ? (
<>
<Group grow align="flex-start">
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<TextInput
label="Declaration / payment code"
placeholder="Customs payment reference"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
<PhasedFileDropzone
label="Duty notice attachment"
description="Any file type — shown to the customer in the portal."
accept="*/*"
value={attachment}
onChange={setAttachment}
onPreview={onViewFile}
/>
</>
) : null}
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={dutyRequired && (amount === "" || Number(amount) <= 0 || !attachment)}
fullWidth
onClick={async () => {
setLoading(true);
try {
await contractsService.adviseSecondDuty(bookingId, {
dutyRequired,
amount: dutyRequired ? Number(amount) : undefined,
currency,
declarationSerial: serial || undefined,
attachment: dutyRequired ? attachment : null,
});
toast.success(
dutyRequired ? "Additional duty advised to customer" : "No additional duty recorded",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
{dutyRequired ? "Send to customer" : "No additional duty"}
</Button>
</Stack>
);
}
/** GL ET uploads the import release document (auto-completes IMPORT_RELEASE_GRANTED). */
function ImportReleaseStep({
bookingId,
clearance,
canAct,
workflowFiles = [],
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const releaseFile = findWorkflowFile(workflowFiles, "import_release");
return (
<Stack gap="sm">
{releaseFile ? (
<PhasedUploadedFileRow
label="Import Release"
file={releaseFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{clearance.importReleaseGranted ? (
<StepStatus done pendingLabel="" doneLabel="Import release granted." />
) : canAct && bookingId ? (
<>
<PhasedFileDropzone
label="Import release document"
description="Any file type."
accept="*/*"
value={file}
onChange={setFile}
replaceMode={Boolean(releaseFile)}
onPreview={onViewFile}
/>
<Button
color="edr-green"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadGlDocuments(bookingId, {
import_release: file,
});
setFile(null);
toast.success("Import release uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{releaseFile ? "Replace import release" : "Upload import release"}
</Button>
</>
) : (
<StepStatus
done={false}
pendingLabel="Waiting for GL Ethiopia to upload the import release document."
doneLabel=""
/>
)}
</Stack>
);
}
export function StepStatus({
done,
pendingLabel,
doneLabel,
}: {
done: boolean;
pendingLabel: string;
doneLabel: string;
}) {
return (
<Paper withBorder radius="md" p="sm" bg={done ? "var(--mantine-color-edr-green-0)" : undefined}>
<Group gap="xs" wrap="nowrap">
{done ? (
<Badge color="edr-green" variant="light" leftSection={<CheckCircle2 size={12} />}>
Done
</Badge>
) : (
<Badge color="gray" variant="light" leftSection={<Clock size={12} />}>
Pending
</Badge>
)}
<Text size="sm" c="dimmed">
{done ? doneLabel : pendingLabel}
</Text>
</Group>
</Paper>
);
}
export function DeclarationStep({
entityId,
isBooking,
onChanged,
replaceMode = false,
workflowFiles = [],
onViewFile,
onDownloadFile,
}: {
entityId: string;
isBooking: boolean;
onChanged?: () => void;
replaceMode?: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const uploaded = declarationFilesFromWorkflow(workflowFiles);
return (
<Stack gap="md">
{uploaded.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current file{uploaded.length > 1 ? "s" : ""}
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<PhasedMultiFileDropzone
label="Customs declaration documents"
description={
replaceMode
? "Replace declaration files — upload one or more documents."
: "Upload one or more customs declaration documents (PDF or image)."
}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={loading}
/>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={files.length === 0}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
const payload = Object.fromEntries(
files.map((file, index) => [`declaration_${index}`, file]),
) as Record<string, File>;
if (isBooking) {
await bookingsService.uploadDeclaration(entityId, payload);
} else {
await contractsService.uploadDeclaration(entityId, payload);
}
setFiles([]);
toast.success(replaceMode ? "Declaration updated" : "Declaration uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{replaceMode ? "Replace declaration" : "Submit declaration"}
</Button>
</Stack>
);
}
function DutyStep({
entityId,
isBooking,
clearance,
workflowFiles = [],
onChanged,
onViewFile,
onDownloadFile,
}: {
entityId: string;
isBooking: boolean;
clearance: ClearanceViewLike;
workflowFiles?: Freight.ClearanceWorkflowFile[];
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [dutyRequired, setDutyRequired] = useState(clearance.dutyRequired ?? true);
const [amount, setAmount] = useState<number | string>(
clearance.dutyAdvice?.amount ?? "",
);
const [currency, setCurrency] = useState(clearance.dutyAdvice?.currency ?? "ETB");
const [serial, setSerial] = useState(clearance.dutyAdvice?.declarationSerial ?? "");
const [attachment, setAttachment] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
const hasExistingNotice = Boolean(noticeFile);
const dispute = clearance.dutyDispute;
return (
<Stack gap="md">
{/* The customer rejected the last advice — their words drive the
correction, so they lead the step. */}
{dispute ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Customer asked for a correction (round ${dispute.rounds})`
: "Customer asked for a correction"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(dispute.raisedAt).toLocaleString()} re-advise
below to send a corrected notice.
</Text>
</Stack>
</Alert>
) : null}
{noticeFile ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current notice
</Text>
<PhasedUploadedFileRow
label="Duty / Tax Notice"
file={noticeFile}
onView={onViewFile}
onDownload={onDownloadFile}
/>
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Switch
label="Customer must pay duty/tax"
description="Turn off if no duty or tax applies to this shipment."
checked={dutyRequired}
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
/>
{dutyRequired ? (
<>
<Group grow align="flex-start">
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<TextInput
label="Declaration / payment code"
placeholder="Customs payment reference"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
<PhasedFileDropzone
label="Duty notice attachment"
description="Upload the official duty/tax notice — shown to the customer in the portal."
value={attachment}
onChange={setAttachment}
replaceMode={hasExistingNotice}
onPreview={onViewFile}
/>
</>
) : null}
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={
dutyRequired && (amount === "" || (!attachment && !hasExistingNotice))
}
fullWidth
onClick={async () => {
setLoading(true);
try {
const payload = {
dutyRequired,
amount: dutyRequired ? Number(amount) : undefined,
currency,
declarationSerial: serial || undefined,
attachment: dutyRequired ? attachment : undefined,
};
if (isBooking) {
await bookingsService.adviseDuty(entityId, payload);
} else {
await contractsService.adviseContractDuty(entityId, payload);
}
toast.success(dutyRequired ? "Duty advised" : "Duty step skipped");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Save duty settings
</Button>
</Stack>
);
}
function TransitPermitStep({
entityId,
isBooking,
onChanged,
workflowFiles = [],
replaceMode = false,
onViewFile,
onDownloadFile,
}: {
entityId: string;
isBooking: boolean;
onChanged?: () => void;
workflowFiles?: Freight.ClearanceWorkflowFile[];
replaceMode?: boolean;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const uploaded: TransitPermitUploadedRow[] = importTransitFilesFromWorkflow(workflowFiles);
return (
<TransitPermitMultiUpload
uploaded={uploaded}
replaceMode={replaceMode}
fileFieldPrefix="transit_permit"
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async (payload) => {
try {
if (isBooking) {
await bookingsService.uploadTransitPermit(entityId, payload);
} else {
await contractsService.uploadContractTransitPermit(entityId, payload);
}
toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
throw e;
}
}}
/>
);
}
function FinalizePreClearanceStep({
entityId,
isBooking,
onChanged,
}: {
entityId: string;
isBooking: boolean;
onChanged?: () => void;
}) {
const [loading, setLoading] = useState(false);
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
Confirm that Ethiopia-side pre-clearance is complete. This unlocks GL Djibouti to
upload the Delivery Order.
</Text>
<Button
color="edr-green"
loading={loading}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);
try {
if (isBooking) {
await bookingsService.finalizePreClearance(entityId);
} else {
await contractsService.finalizePreClearance(entityId);
}
toast.success("Pre-clearance finalized");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Finalize pre-clearance
</Button>
</Stack>
);
}
function DeliveryOrderStep({
entityId,
isBooking,
onChanged,
workflowFiles = [],
replaceMode = false,
vesselArrivalDate,
doCollectedDate,
onViewFile,
onDownloadFile,
}: {
entityId: string;
isBooking: boolean;
onChanged?: () => void;
workflowFiles?: Freight.ClearanceWorkflowFile[];
replaceMode?: boolean;
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<Record<string, File | null>>({
delivery_order: null,
});
const [doDates, setDoDates] = useDoCollectionDates({
vesselArrivalDate,
doCollectedDate,
});
const [loading, setLoading] = useState(false);
const hasFile = Boolean(files.delivery_order);
return (
<PhasedDocumentUploadField
fields={[{ key: "delivery_order", label: "Delivery Order" }]}
files={files}
onChange={(key, file) => setFiles((prev) => ({ ...prev, [key]: file }))}
workflowFiles={workflowFiles}
replaceMode={replaceMode}
loading={loading}
disabled={!hasFile || !doDatesComplete(doDates)}
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
extraFields={
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async () => {
const file = files.delivery_order;
if (!file || !doDatesComplete(doDates)) return;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
setLoading(true);
try {
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file, dates);
}
setFiles({ delivery_order: null });
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
/>
);
}