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

1075 lines
34 KiB
TypeScript

import { useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
FileInput,
Group,
Modal,
NumberInput,
Paper,
Select,
Stack,
Stepper,
Text,
Textarea,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
AlertTriangle,
CheckCircle2,
FileText,
PackageCheck,
Receipt,
Ship,
Train,
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 { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import {
findWorkflowFile,
PhasedUploadedFileRow,
} from "@/components/contracts/PhasedUploadedFileRow";
import {
DeclarationStep,
StepStatus,
isBookingMilestoneDone,
isMilestoneDone,
type ClearanceViewLike,
type MilestoneRow,
} from "@/components/contracts/PhasedClearanceActionPanel";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
/**
* Export customs flow, ordered per the stakeholder process:
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
* → final invoice (DJ) + customer slip + GL confirm.
*/
export function computeExportActiveStep(
clearance: ClearanceViewLike,
bookingMilestones: MilestoneRow[],
bookingCreated: boolean,
): number {
const released = Boolean(clearance.bookingReady || clearance.operationReady);
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1;
if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2;
if (!bookingCreated) return 3;
if (
!isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") ||
!(
isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") ||
clearance.train?.wagonAllocated
)
) {
return 4;
}
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
if (!clearance.train?.arrivedAt) return 6;
if (!clearance.t1Closed) return 7;
if (!clearance.gatepassGranted) return 8;
if (clearance.finalInvoice?.status !== "PAID") return 9;
return 10;
}
export function exportTransitFilesFromWorkflow(
workflowFiles: Freight.ClearanceWorkflowFile[],
): TransitPermitUploadedRow[] {
return workflowFiles
.filter(
(f) =>
f.category === "transit" &&
f.code.toLowerCase().startsWith("export_transport_document") &&
f.file,
)
.map((f) => ({
code: f.code,
label: f.label,
file: f.file!,
}));
}
export function ExportClearanceStepper({
contractId,
bookingId,
clearance,
workflowFiles = [],
showEt,
canEt,
showDj,
canDj,
onChanged,
bookingCreateHref,
onViewFile,
onDownloadFile,
useUploadModals = false,
onUploadRoRequest,
bookingCreated = false,
bookingMilestones = [],
}: {
contractId?: string;
bookingId?: string;
clearance: ClearanceViewLike;
workflowFiles?: Freight.ClearanceWorkflowFile[];
showEt: boolean;
canEt: boolean;
showDj: boolean;
canDj: boolean;
onChanged?: () => void;
bookingCreateHref?: string;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
useUploadModals?: boolean;
onUploadRoRequest?: () => void;
bookingCreated?: boolean;
bookingMilestones?: MilestoneRow[];
}) {
// Pre-booking actions (RO, declaration, release) target the contract when one
// is present; GENERAL customs bookings run the same flow keyed on the booking.
const isBooking = Boolean(bookingId) && !contractId;
const entityId = contractId ?? bookingId ?? "";
// The booking that carries the post-booking steps (gate pass, T1, invoice).
const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null;
const effectiveBookingCreated =
bookingCreated || Boolean(clearance.linkedBookingId) || isBooking;
const activeStep = useMemo(
() => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated),
[clearance, bookingMilestones, effectiveBookingCreated],
);
const released = Boolean(clearance.bookingReady || clearance.operationReady);
const declared = isMilestoneDone(clearance.milestones, "DECLARED");
const paymentSettled = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED");
const wagonAllocated =
isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") ||
Boolean(clearance.train?.wagonAllocated);
const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
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">
Export customs 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="Release Order"
description="GL Djibouti uploads RO + vessel date"
icon={<Ship size={14} />}
>
{showDj && canDj ? (
useUploadModals ? (
<ReleaseOrderActions
entityId={entityId}
isBooking={isBooking}
clearance={clearance}
workflowFiles={workflowFiles}
onUploadRoRequest={onUploadRoRequest}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<ReleaseOrderCard
entityId={entityId}
isBooking={isBooking}
clearance={clearance}
onChanged={onChanged}
/>
)
) : (
<Stack gap="sm">
{findWorkflowFile(workflowFiles, "release_order") ? (
<PhasedUploadedFileRow
label="Release Order"
file={findWorkflowFile(workflowFiles, "release_order")!}
onView={onViewFile}
onDownload={onDownloadFile}
/>
) : null}
{clearance.vesselDepartureDate ? (
<Text size="sm" c="dimmed">
Vessel departure:{" "}
{new Date(clearance.vesselDepartureDate).toLocaleDateString()}
</Text>
) : null}
<StepStatus
done={isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")}
pendingLabel="Waiting for GL Djibouti to upload the Release Order."
doneLabel="Release Order secured."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Customs declaration"
description="GL Ethiopia uploads — releases the export"
icon={declared ? <CheckCircle2 size={14} /> : <FileText size={14} />}
>
{showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? (
<Stack gap="sm">
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
replaceMode={declared}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
{declared && !released ? (
<ConfirmExportReleaseFallback
entityId={entityId}
isBooking={isBooking}
onChanged={onChanged}
/>
) : null}
</Stack>
) : (
<StepStatus
done={declared && released}
pendingLabel="Waiting for GL Ethiopia to upload the customs declaration."
doneLabel="Declaration uploaded — export released."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Create booking"
description="GL Ethiopia books for the customer"
icon={<Ship size={14} />}
>
{released && bookingCreateHref && !effectiveBookingCreated && showEt && canEt ? (
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
<Text size="sm" c="dimmed" mb="sm">
Export is released. Create the shipment booking for the customer.
</Text>
<Button component="a" href={bookingCreateHref} color="edr-green">
Create shipment booking
</Button>
</SectionCard>
) : (
<StepStatus
done={effectiveBookingCreated}
pendingLabel={
released
? "Ready — GL Ethiopia creates the shipment booking."
: "Complete the declaration step first."
}
doneLabel="Shipment booking created."
/>
)}
</Stepper.Step>
<Stepper.Step
label="Payment & wagon allocation"
description="Customer pays; operations allocates wagons"
icon={<Receipt size={14} />}
>
<Stack gap="xs">
<StepStatus
done={paymentSettled}
pendingLabel="Waiting for the customer to pay freight charges."
doneLabel="Freight payment settled."
/>
<StepStatus
done={wagonAllocated}
pendingLabel="Waiting for operations to allocate wagons."
doneLabel="Wagons allocated."
/>
</Stack>
</Stepper.Step>
<Stepper.Step
label="Transport document"
description="GL Ethiopia uploads after wagon allocation"
icon={transportIssued ? <CheckCircle2 size={14} /> : <Upload size={14} />}
>
{showEt && canEt && actionBookingId && wagonAllocated ? (
<ExportTransitPermitStep
bookingId={actionBookingId}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : (
<Stack gap="sm">
{exportTransitFilesFromWorkflow(workflowFiles).map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
<StepStatus
done={transportIssued}
pendingLabel={
wagonAllocated
? "Waiting for GL Ethiopia to upload the transport document."
: "Available once wagons are allocated."
}
doneLabel="Transport document uploaded."
/>
</Stack>
)}
</Stepper.Step>
<Stepper.Step
label="Train to Djibouti"
description="Departure and arrival"
icon={<Train size={14} />}
>
<Stack gap="xs">
<StepStatus
done={Boolean(clearance.train?.departedAt)}
pendingLabel="Waiting for the train to depart."
doneLabel={`Departed ${
clearance.train?.departedAt
? new Date(clearance.train.departedAt).toLocaleString()
: ""
}`}
/>
<StepStatus
done={Boolean(clearance.train?.arrivedAt)}
pendingLabel="Waiting for arrival at Djibouti."
doneLabel={`Arrived ${
clearance.train?.arrivedAt
? new Date(clearance.train.arrivedAt).toLocaleString()
: ""
}`}
/>
</Stack>
</Stepper.Step>
<Stepper.Step
label="Accept T1"
description="GL Djibouti closes once the train arrives"
icon={clearance.t1Closed ? <CheckCircle2 size={14} /> : <PackageCheck size={14} />}
>
<AcceptT1Step
bookingId={actionBookingId}
clearance={clearance}
transportIssued={transportIssued}
workflowFiles={workflowFiles}
canAct={showDj && canDj}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
label="Final invoice & payment"
description="GL Djibouti invoices after offload; customer pays"
icon={
clearance.finalInvoice?.status === "PAID" ? (
<CheckCircle2 size={14} />
) : (
<Receipt size={14} />
)
}
>
<FinalInvoiceStep
bookingId={actionBookingId}
clearance={clearance}
canDjAct={showDj && canDj}
canConfirm={(showDj && canDj) || (showEt && canEt)}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Paper>
</Stack>
);
}
/** Legacy in-flight contracts: declaration done before auto-release existed. */
function ConfirmExportReleaseFallback({
entityId,
isBooking,
onChanged,
}: {
entityId: string;
isBooking: boolean;
onChanged?: () => void;
}) {
const [loading, setLoading] = useState(false);
return (
<Button
variant="light"
color="edr-green"
loading={loading}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);
try {
if (isBooking) {
await bookingsService.confirmExportRelease(entityId);
} else {
await contractsService.confirmExportRelease(entityId);
}
toast.success("Export released — ready for booking");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Confirm export release
</Button>
);
}
/**
* Gate pass status, read-only. Secured on the train schedule's "Save as
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
*/
function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`Gate pass secured${
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
}`}
/>
);
}
const arrived = Boolean(clearance.train?.arrivedAt);
return (
<Stack gap="sm">
<StepStatus
done={false}
pendingLabel={
arrived
? "Train arrived — secure the gate pass on the train schedule."
: "Available once the train arrives at Djibouti."
}
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>
);
}
function AcceptT1Step({
bookingId,
clearance,
transportIssued,
workflowFiles = [],
canAct,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
transportIssued: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
canAct: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [loading, setLoading] = useState(false);
const files = exportTransitFilesFromWorkflow(workflowFiles);
const arrived = Boolean(clearance.train?.arrivedAt);
return (
<Stack gap="sm">
{files.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
))}
{clearance.t1Closed ? (
<StepStatus
done
pendingLabel=""
doneLabel={`T1 accepted and closed${
clearance.t1ClosedAt ? ` · ${new Date(clearance.t1ClosedAt).toLocaleString()}` : ""
}`}
/>
) : (
<>
<StepStatus
done={false}
pendingLabel={
!transportIssued
? "Waiting for the transport document."
: arrived
? "Train arrived — GL Djibouti accepts (closes) the T1."
: "Available once the train arrives at Djibouti."
}
doneLabel=""
/>
{canAct && bookingId ? (
<Button
color="edr-green"
loading={loading}
disabled={!transportIssued || !arrived}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);
try {
await contractsService.closeT1(bookingId);
toast.success("T1 accepted and closed");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Accept &amp; close T1
</Button>
) : null}
</>
)}
</Stack>
);
}
function FinalInvoiceStep({
bookingId,
clearance,
canDjAct,
canConfirm,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canDjAct: boolean;
canConfirm: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [opened, setOpened] = useState(false);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [description, setDescription] = useState("");
const [file, setFile] = useState<File | null>(null);
const [sending, setSending] = useState(false);
const [confirming, setConfirming] = useState(false);
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for cargo offload (handled in operations)."
doneLabel=""
/>
);
}
return (
<Stack gap="sm">
{invoice ? (
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<div>
<Text fw={700} size="sm">
{invoice.invoiceNumber}
</Text>
<Text size="sm" c="dimmed">
{invoice.totalAmount.toLocaleString()} {invoice.currency}
{invoice.description ? `${invoice.description}` : ""}
</Text>
</div>
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
{invoice.status}
</Badge>
</Group>
</Paper>
) : null}
{invoice?.invoiceFile ? (
<PhasedUploadedFileRow
label="Final Invoice"
file={invoice.invoiceFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{invoice?.slipFile ? (
<PhasedUploadedFileRow
label="Customer payment slip"
file={invoice.slipFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{paid ? (
<StepStatus
done
pendingLabel=""
doneLabel={`Payment confirmed${
invoice?.confirmedAt ? ` · ${new Date(invoice.confirmedAt).toLocaleString()}` : ""
}`}
/>
) : invoice ? (
<>
<StepStatus
done={false}
pendingLabel={
invoice.slipFile
? "Payment slip attached — confirm to settle the invoice."
: "Waiting for the customer to pay and attach the payment slip."
}
doneLabel=""
/>
{canConfirm && bookingId && invoice.slipFile ? (
<Button
color="edr-green"
loading={confirming}
leftSection={<CheckCircle2 size={16} />}
onClick={async () => {
setConfirming(true);
try {
await contractsService.confirmFinalInvoicePaid(bookingId);
toast.success("Payment confirmed — invoice settled");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setConfirming(false);
}
}}
>
Confirm payment received
</Button>
) : null}
</>
) : canDjAct && bookingId ? (
<>
<Text size="sm" c="dimmed">
Send the final invoice to the customer if post-arrival charges apply (optional).
</Text>
<Button
color="edr-green"
leftSection={<Receipt size={16} />}
onClick={() => setOpened(true)}
>
Send invoice
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={<Text fw={700}>Send final invoice</Text>}
radius="md"
size="md"
>
<Stack gap="md">
<Group grow align="flex-start">
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
thousandSeparator=","
required
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
/>
</Group>
<Textarea
label="Description"
placeholder="What the invoice bills for"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
minRows={2}
/>
<PhasedFileDropzone
label="Invoice document"
description="Any file type."
accept="*/*"
value={file}
onChange={setFile}
onPreview={onViewFile}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)} disabled={sending}>
Cancel
</Button>
<Button
color="edr-green"
loading={sending}
disabled={amount === "" || Number(amount) <= 0 || !file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setSending(true);
try {
await contractsService.sendFinalInvoice(bookingId, {
amount: Number(amount),
currency,
description: description.trim() || undefined,
file,
});
toast.success("Final invoice sent to the customer");
setOpened(false);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setSending(false);
}
}}
>
Send invoice
</Button>
</Group>
</Stack>
</Modal>
</>
) : (
<StepStatus
done={false}
pendingLabel="Waiting for GL Djibouti to send the final invoice."
doneLabel=""
/>
)}
</Stack>
);
}
export function ReleaseOrderActions({
entityId,
isBooking,
clearance,
workflowFiles = [],
onUploadRoRequest,
onChanged,
onViewFile,
onDownloadFile,
}: {
entityId: string;
isBooking: boolean;
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
workflowFiles?: Freight.ClearanceWorkflowFile[];
onUploadRoRequest?: () => void;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [amendLoading, setAmendLoading] = useState(false);
const roFile = findWorkflowFile(workflowFiles, "release_order");
return (
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="sm">
Release Order
</Text>
<Stack gap="sm">
{roFile ? (
<PhasedUploadedFileRow
label="Release Order"
file={roFile}
onView={onViewFile}
onDownload={onDownloadFile}
/>
) : null}
{clearance.vesselDepartureDate ? (
<Text size="sm" c="dimmed">
Vessel departure:{" "}
{new Date(clearance.vesselDepartureDate).toLocaleDateString()}
</Text>
) : null}
<Group>
{onUploadRoRequest ? (
<Button color="edr-green" leftSection={<Upload size={16} />} onClick={onUploadRoRequest}>
{roFile ? "Replace RO" : "Upload RO"}
</Button>
) : null}
<Button
variant="light"
color="orange"
loading={amendLoading}
onClick={async () => {
setAmendLoading(true);
try {
if (isBooking) {
await bookingsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
} else {
await contractsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
}
toast.success("Amendment request recorded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setAmendLoading(false);
}
}}
>
Request amendment
</Button>
</Group>
</Stack>
</Paper>
);
}
export function ReleaseOrderCard({
entityId,
isBooking,
clearance,
onChanged,
}: {
entityId: string;
isBooking: boolean;
clearance: ClearanceViewLike & { vesselDepartureDate?: string | null };
onChanged?: () => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [vesselDate, setVesselDate] = useState<Date | null>(
clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null,
);
const [loading, setLoading] = useState(false);
const [amendLoading, setAmendLoading] = useState(false);
return (
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="sm">
Release Order
</Text>
<Stack gap="sm">
<FileInput label="Release Order" value={file} onChange={setFile} size="sm" />
<DateInput
label="Vessel departure date"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"
/>
<Group>
<Button
color="edr-green"
loading={loading}
disabled={!file || !vesselDate}
onClick={async () => {
if (!file || !vesselDate) return;
setLoading(true);
try {
const iso = vesselDate.toISOString().slice(0, 10);
const result = isBooking
? await bookingsService.uploadReleaseOrder(entityId, file, iso)
: await contractsService.uploadReleaseOrder(entityId, file, iso);
if (result.hold) {
toast.error(result.holdReason ?? "Vessel date too soon");
} else {
toast.success("Release Order accepted");
}
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
Upload RO
</Button>
<Button
variant="light"
color="orange"
loading={amendLoading}
onClick={async () => {
setAmendLoading(true);
try {
if (isBooking) {
await bookingsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
} else {
await contractsService.requestRoAmendment(
entityId,
"Port amendment requested — vessel window too short.",
);
}
toast.success("Amendment request recorded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setAmendLoading(false);
}
}}
>
Request amendment
</Button>
</Group>
</Stack>
</Paper>
);
}
export function ExportTransitPermitStep({
bookingId,
workflowFiles = [],
replaceMode = false,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string;
workflowFiles?: Freight.ClearanceWorkflowFile[];
replaceMode?: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const uploaded = exportTransitFilesFromWorkflow(workflowFiles);
const hasUploaded = uploaded.length > 0;
if (hasUploaded && !replaceMode) {
return (
<Stack gap="sm">
<Text size="sm" fw={700}>
Transport document
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
);
}
return (
<TransitPermitMultiUpload
title="Transport document"
replaceMode={replaceMode || hasUploaded}
uploaded={uploaded}
fileFieldPrefix="export_transport_document"
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async (payload) => {
try {
await contractsService.uploadTransportDocument(bookingId, payload);
toast.success(replaceMode ? "Transport document updated" : "Transport document uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
throw e;
}
}}
/>
);
}