mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
finilize gl flow for export
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user