mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export const QUERY_KEYS = {
|
||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||
clearanceHistory: (region?: string) =>
|
||||
["contracts", "clearance-history", region ?? "ET"] as const,
|
||||
djSchedules: ["contracts", "clearance-dj-schedules"] as const,
|
||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||
bookingMilestones: (bookingId: string) =>
|
||||
|
||||
@@ -215,6 +215,15 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/bookings/${bookingId}/t1-documents`,
|
||||
BOOKING_T1_CLOSE: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/t1-close`,
|
||||
CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
|
||||
CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
|
||||
`/contracts/clearance/schedules/${scheduleId}/gatepass`,
|
||||
BOOKING_GATEPASS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/gatepass`,
|
||||
BOOKING_FINAL_INVOICE: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/final-invoice`,
|
||||
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/final-invoice/confirm`,
|
||||
BOOKING_INCIDENTS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/incidents`,
|
||||
},
|
||||
|
||||
@@ -68,6 +68,15 @@ export function useDjClearanceQueue(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
||||
export function useDjClearanceSchedules(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
|
||||
queryFn: () => contractsService.getDjClearanceSchedules(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||
export function useOpsClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
@@ -85,6 +86,11 @@ export default function GlClearanceDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const linkedBookingId =
|
||||
data?.kind === "contract" ? (data.clearance.linkedBookingId ?? undefined) : undefined;
|
||||
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||
useBookingMilestones(linkedBookingId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -197,7 +203,13 @@ export default function GlClearanceDetailPage() {
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={data.kind === "contract" ? id : undefined}
|
||||
bookingId={data.kind === "booking" ? id : undefined}
|
||||
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
||||
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
|
||||
bookingMilestones={
|
||||
data.kind === "booking"
|
||||
? (data.clearance.milestones ?? [])
|
||||
: (bookingMilestones ?? [])
|
||||
}
|
||||
clearance={data.clearance}
|
||||
tradeDirection={data.tradeDirection}
|
||||
workflowFiles={workflowFiles}
|
||||
@@ -205,7 +217,10 @@ export default function GlClearanceDetailPage() {
|
||||
useUploadModals
|
||||
onUploadDoRequest={() => setUploadKind("do")}
|
||||
onUploadRoRequest={() => setUploadKind("ro")}
|
||||
onChanged={() => void refetch()}
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
|
||||
@@ -1,30 +1,172 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { ChevronRight, Container, Ship } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import {
|
||||
useDjClearanceQueue,
|
||||
useDjClearanceSchedules,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||
const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue();
|
||||
const schedulesQuery = useDjClearanceSchedules();
|
||||
|
||||
const contractItems = contractQueue?.items ?? [];
|
||||
const bookingItems = bookingQueue ?? [];
|
||||
const scheduleItems = schedulesQuery.data ?? [];
|
||||
|
||||
const [gatepassTarget, setGatepassTarget] =
|
||||
useState<Freight.DjClearanceSchedule | null>(null);
|
||||
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
|
||||
const [granting, setGranting] = useState(false);
|
||||
|
||||
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
|
||||
() => [
|
||||
{
|
||||
header: "Train",
|
||||
accessorKey: "trainNumber",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={700}>
|
||||
{row.original.trainNumber ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Route",
|
||||
id: "route",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.origin ?? "—"} → {row.original.destination ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Scheduled departure",
|
||||
id: "scheduled",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.scheduledDepartureDate
|
||||
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Departed",
|
||||
id: "departed",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.actualDepartureAt
|
||||
? new Date(row.original.actualDepartureAt).toLocaleString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Arrived",
|
||||
id: "arrived",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.actualArrivalAt
|
||||
? new Date(row.original.actualArrivalAt).toLocaleString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Customs bookings",
|
||||
id: "customs",
|
||||
cell: ({ row }) => {
|
||||
const bookings = row.original.customsBookings;
|
||||
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{bookings.length}
|
||||
</Badge>
|
||||
{directions.map((d) => (
|
||||
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
|
||||
{d}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Gate pass",
|
||||
id: "gatepass",
|
||||
cell: ({ row }) => {
|
||||
const bookings = row.original.customsBookings;
|
||||
const allGranted =
|
||||
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
|
||||
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
|
||||
if (allGranted) {
|
||||
return (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setGatepassAt(new Date());
|
||||
setGatepassTarget(row.original);
|
||||
}}
|
||||
>
|
||||
Gate pass
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
|
||||
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
|
||||
/>
|
||||
<Tabs defaultValue="contracts" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="bookings">Bookings ({bookingItems.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
|
||||
Schedules ({scheduleItems.length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="contracts">
|
||||
@@ -36,8 +178,7 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
<Stack gap="sm">
|
||||
{contractItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs contracts yet. Items appear here once Ethiopia-side
|
||||
pre-clearance is finalized.
|
||||
No Djibouti customs contracts yet.
|
||||
</Text>
|
||||
) : (
|
||||
contractItems.map((c) => (
|
||||
@@ -73,51 +214,113 @@ export default function GlDjiboutiClearanceListPage() {
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="bookings">
|
||||
{bookingsLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{bookingItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs bookings yet.
|
||||
</Text>
|
||||
) : (
|
||||
bookingItems.map((b) => (
|
||||
<Card
|
||||
key={b.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Container size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{b.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.tradeDirection} · {b.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="blue">
|
||||
Booking
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
<Tabs.Panel value="schedules">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={scheduleItems}
|
||||
status={
|
||||
schedulesQuery.isLoading
|
||||
? "loading"
|
||||
: schedulesQuery.isError
|
||||
? "error"
|
||||
: "success"
|
||||
}
|
||||
error={
|
||||
schedulesQuery.isError
|
||||
? {
|
||||
message: "Failed to load train schedules.",
|
||||
onRetry: () => void schedulesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage="No train schedules carry customs bookings yet."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Modal
|
||||
opened={gatepassTarget != null}
|
||||
onClose={() => setGatepassTarget(null)}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Truck size={18} />
|
||||
<Text fw={700}>
|
||||
Gate pass — train {gatepassTarget?.trainNumber ?? ""}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Grants the gate pass for all{" "}
|
||||
{gatepassTarget?.customsBookings.length ?? 0} customs booking
|
||||
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
|
||||
train.
|
||||
</Text>
|
||||
<DateTimePicker
|
||||
label="Gate pass time"
|
||||
value={gatepassAt}
|
||||
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setGatepassTarget(null)}
|
||||
disabled={granting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={granting}
|
||||
leftSection={<Truck size={16} />}
|
||||
onClick={async () => {
|
||||
if (!gatepassTarget) return;
|
||||
setGranting(true);
|
||||
try {
|
||||
const result = await contractsService.grantScheduleGatepass(
|
||||
gatepassTarget.id,
|
||||
(gatepassAt ?? new Date()).toISOString(),
|
||||
);
|
||||
if (result.skipped.length > 0) {
|
||||
toast.error(
|
||||
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
setGatepassTarget(null);
|
||||
void schedulesQuery.refetch();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setGranting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Grant gate pass
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "SCHEDULED":
|
||||
return "blue";
|
||||
case "DISPATCHED":
|
||||
return "yellow";
|
||||
case "ARRIVED":
|
||||
return "edr-green";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +354,59 @@ export const contractsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceT1State;
|
||||
},
|
||||
|
||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
||||
getDjClearanceSchedules: async (): Promise<Freight.DjClearanceSchedule[]> => {
|
||||
const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
|
||||
return unwrap(response.data) as Freight.DjClearanceSchedule[];
|
||||
},
|
||||
|
||||
/** Gate pass for every customs booking on a train schedule (captures time). */
|
||||
grantScheduleGatepass: async (
|
||||
scheduleId: string,
|
||||
gatepassAt?: string,
|
||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
|
||||
const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
|
||||
gatepassAt,
|
||||
});
|
||||
return unwrap(response.data) as {
|
||||
granted: number;
|
||||
skipped: Array<{ bookingId: string; error: string }>;
|
||||
};
|
||||
},
|
||||
|
||||
/** Gate pass for a single customs booking (captures time). */
|
||||
grantGatepass: async (
|
||||
bookingId: string,
|
||||
gatepassAt?: string,
|
||||
): Promise<{ bookingId: string; gatepassAt: string }> => {
|
||||
const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
|
||||
return unwrap(response.data) as { bookingId: string; gatepassAt: string };
|
||||
},
|
||||
|
||||
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
||||
sendFinalInvoice: async (
|
||||
bookingId: string,
|
||||
payload: { amount: number; currency: string; description?: string; file: File },
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
|
||||
const form = new FormData();
|
||||
form.append("amount", String(payload.amount));
|
||||
form.append("currency", payload.currency);
|
||||
if (payload.description) form.append("description", payload.description);
|
||||
form.append("file", payload.file);
|
||||
const response = await client.post(C.BOOKING_FINAL_INVOICE(bookingId), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
|
||||
},
|
||||
|
||||
/** GL (ET or DJ) confirms the payment slip — settles the final invoice. */
|
||||
confirmFinalInvoicePaid: async (
|
||||
bookingId: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> => {
|
||||
const response = await client.post(C.BOOKING_FINAL_INVOICE_CONFIRM(bookingId));
|
||||
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
|
||||
},
|
||||
|
||||
// ── Path A self-clearance (Operations review) ──
|
||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
|
||||
Reference in New Issue
Block a user