mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1075 lines
34 KiB
TypeScript
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 & 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;
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|