finilize gl flow for export

This commit is contained in:
Marshal
2026-07-02 17:55:59 +00:00
parent f77e4d150b
commit dd47068a4b
24 changed files with 2462 additions and 598 deletions

View File

@@ -3,7 +3,6 @@ import {
Alert,
Badge,
Button,
FileInput,
Group,
NumberInput,
Paper,
@@ -19,7 +18,6 @@ import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import { DateInput } from "@mantine/dates";
import {
AlertTriangle,
CheckCircle2,
@@ -35,6 +33,7 @@ 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 {
findWorkflowFile,
@@ -47,7 +46,7 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type RoleMode = "ET" | "DJ" | "ALL";
type ClearanceViewLike = Pick<
export type ClearanceViewLike = Pick<
Freight.ContractClearanceView,
| "nextAction"
| "dutyRequired"
@@ -60,11 +59,20 @@ type ClearanceViewLike = Pick<
| "exportClearanceFinalized"
| "allApproved"
| "t1"
| "train"
| "gatepassGranted"
| "gatepassAt"
| "t1Closed"
| "t1ClosedAt"
| "offloaded"
| "finalInvoice"
| "vesselDepartureDate"
| "linkedBookingId"
> & { operationReady?: boolean };
type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
export type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
function isMilestoneDone(
export function isMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
@@ -72,7 +80,7 @@ function isMilestoneDone(
return m?.status === "COMPLETED" || m?.status === "SKIPPED";
}
function isBookingMilestoneDone(
export function isBookingMilestoneDone(
milestones: MilestoneRow[] | undefined,
code: string,
): boolean {
@@ -524,91 +532,24 @@ export function PhasedClearanceActionPanel({
}
return (
<Stack gap="md">
{clearance.roHold && clearance.roHoldReason ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} title="Release Order on hold">
{clearance.roHoldReason}
</Alert>
) : null}
{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}
/>
)
) : null}
{showEt && canEt ? (
<DeclarationStep
entityId={entityId}
isBooking={isBooking}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : null}
{showEt && canEt ? (
<ExportReleaseCard
entityId={entityId}
isBooking={isBooking}
clearance={clearance}
onChanged={onChanged}
/>
) : null}
{(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>
) : bookingCreated && showEt ? (
<SectionCard icon={Ship} title="Create booking" accent="edr-green">
<StepStatus
done
pendingLabel=""
doneLabel="Shipment booking has been created for this contract."
/>
</SectionCard>
) : null}
{bookingCreated && showEt && canEt && bookingId ? (
<ExportPostBookingSection
contractId={contractId}
bookingId={bookingId}
clearance={clearance}
bookingMilestones={bookingMilestones}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : null}
</Stack>
<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}
/>
);
}
@@ -776,7 +717,7 @@ function ImportT1Section({
);
}
function StepStatus({
export function StepStatus({
done,
pendingLabel,
doneLabel,
@@ -805,7 +746,7 @@ function StepStatus({
);
}
function DeclarationStep({
export function DeclarationStep({
entityId,
isBooking,
onChanged,
@@ -1171,411 +1112,3 @@ function DeliveryOrderStep({
);
}
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>
);
}
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>
);
}
function ExportReleaseCard({
entityId,
isBooking,
clearance,
onChanged,
}: {
entityId: string;
isBooking: boolean;
clearance: ClearanceViewLike;
onChanged?: () => void;
}) {
const [loading, setLoading] = useState(false);
const done = clearance.bookingReady || clearance.operationReady;
return (
<Paper withBorder radius="md" p="md">
<Text fw={600} size="sm" mb="sm">
Export release
</Text>
<Button
color="edr-green"
loading={loading}
disabled={Boolean(done)}
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>
</Paper>
);
}
function ExportPostBookingSection({
contractId,
bookingId,
clearance,
bookingMilestones,
workflowFiles = [],
onChanged,
onViewFile,
onDownloadFile,
}: {
contractId?: string;
bookingId: string;
clearance: ClearanceViewLike;
bookingMilestones: MilestoneRow[];
workflowFiles?: Freight.ClearanceWorkflowFile[];
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const wagonAllocated = isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED");
const paymentSettled = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED");
const transitUploaded = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED");
const finalized = Boolean(clearance.exportClearanceFinalized);
return (
<SectionCard icon={Truck} title="Post-booking clearance" accent="edr-green">
<Stack gap="md">
<Text size="sm" c="dimmed">
After the customer pays and operations allocates wagons, upload the transit
permit and finalize export clearance.
</Text>
{!paymentSettled ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Waiting for the customer to pay freight charges.
</Alert>
) : null}
{paymentSettled && !wagonAllocated ? (
<Alert color="yellow" variant="light" icon={<Clock size={16} />}>
Waiting for operations to allocate wagons before the transit permit can be
uploaded.
</Alert>
) : null}
{wagonAllocated && !transitUploaded ? (
<ExportTransitPermitStep
bookingId={bookingId}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
) : null}
{transitUploaded ? (
<StepStatus
done
pendingLabel=""
doneLabel="Transit permit uploaded."
/>
) : null}
{transitUploaded && !finalized && contractId ? (
<FinalizeExportClearanceStep contractId={contractId} onChanged={onChanged} />
) : null}
{finalized ? (
<StepStatus
done
pendingLabel=""
doneLabel="Export clearance finalized."
/>
) : null}
</Stack>
</SectionCard>
);
}
function exportTransitFilesFromWorkflow(
workflowFiles: Freight.ClearanceWorkflowFile[],
): TransitPermitUploadedRow[] {
return workflowFiles
.filter((f) => f.category === "transit" && f.file)
.map((f) => ({
code: f.code,
label: f.label,
file: f.file!,
}));
}
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}>
Transit Permit
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
);
}
return (
<TransitPermitMultiUpload
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 ? "Transit permit updated" : "Transit permit uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
throw e;
}
}}
/>
);
}
function FinalizeExportClearanceStep({
contractId,
onChanged,
}: {
contractId: string;
onChanged?: () => void;
}) {
const [loading, setLoading] = useState(false);
return (
<Stack gap="sm">
<Text size="sm" c="dimmed">
Confirm that export clearance is complete now that the transit permit is on file.
</Text>
<Button
color="edr-green"
loading={loading}
leftSection={<PackageCheck size={16} />}
onClick={async () => {
setLoading(true);
try {
await contractsService.finalizeExportClearance(contractId);
toast.success("Export clearance finalized");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Finalize clearance
</Button>
</Stack>
);
}