mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
1890 lines
59 KiB
TypeScript
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 & 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);
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|