Merge branch 'contrat-backup2' of github.com:Tria-plc/edr-platform into contrat-backup2

This commit is contained in:
marshal
2026-07-02 21:15:45 +03:00
17 changed files with 1084 additions and 11 deletions

View File

@@ -62,10 +62,11 @@ export function GlClearanceUploadModal({
setLoading(true);
try {
if (isDo) {
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file);
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
} else {
await contractsService.uploadDeliveryOrder(entityId, file);
await contractsService.uploadDeliveryOrder(entityId, file, iso);
}
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
} else {
@@ -117,7 +118,15 @@ export function GlClearanceUploadModal({
size="sm"
required
/>
) : null}
) : (
<DateInput
label="Vessel departure date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"
clearable
/>
)}
<PhasedFileDropzone
label={isDo ? "Delivery Order file" : "Release Order file"}

View File

@@ -4,8 +4,10 @@ import {
Badge,
Button,
Group,
Modal,
NumberInput,
Paper,
SegmentedControl,
Select,
Stack,
Stepper,
@@ -13,6 +15,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import {
TransitPermitMultiUpload,
@@ -25,6 +28,7 @@ import {
FileText,
PackageCheck,
Receipt,
ShieldAlert,
Ship,
Truck,
Upload,
@@ -68,6 +72,10 @@ export type ClearanceViewLike = Pick<
| "finalInvoice"
| "vesselDepartureDate"
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
| "secondDuty"
| "importReleaseGranted"
> & { operationReady?: boolean };
export type MilestoneRow = NonNullable<ClearanceViewLike["milestones"]>[number];
@@ -91,6 +99,7 @@ export function isBookingMilestoneDone(
function computeImportActiveStep(
clearance: ClearanceViewLike,
bookingCreated: boolean,
bookingMilestones: MilestoneRow[],
): number {
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
@@ -111,8 +120,17 @@ function computeImportActiveStep(
if (!clearance.preClearanceFinalized) return 5;
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
if (!bookingCreated) return 7;
if (!clearance.t1?.closed) return 8;
return 9;
if (!clearance.gatepassGranted) return 8;
if (!clearance.t1?.closed) return 9;
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10;
// Additional duty round is optional — resolved once skipped or paid.
const secondDutyResolved =
clearance.secondDuty?.skipped ||
clearance.secondDuty?.paid ||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
if (!secondDutyResolved) return 11;
if (!clearance.importReleaseGranted) return 12;
return 13;
}
function t1FilesFromWorkflow(
@@ -133,7 +151,9 @@ function declarationFilesFromWorkflow(
workflowFiles: Freight.ClearanceWorkflowFile[],
): Array<{ code: string; label: string; file: { id: string; name: string } }> {
return workflowFiles
.filter((f) => f.category === "declaration" && f.file)
.filter(
(f) => f.category === "declaration" && f.code !== "import_release" && f.file,
)
.map((f) => ({
code: f.code,
label: f.label,
@@ -205,9 +225,15 @@ export function PhasedClearanceActionPanel({
// The server only builds the t1 block once a booking is linked — use it as the
// booking-created signal on pages that don't pass bookingCreated (GL DJ detail).
const effectiveBookingCreated = bookingCreated || Boolean(clearance.t1);
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
const actionBookingId =
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
const activeStep = useMemo(
() => (isImport ? computeImportActiveStep(clearance, effectiveBookingCreated) : 0),
[clearance, isImport, effectiveBookingCreated],
() =>
isImport
? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones)
: 0,
[clearance, isImport, effectiveBookingCreated, bookingMilestones],
);
if (isImport) {
@@ -508,6 +534,21 @@ export function PhasedClearanceActionPanel({
)}
</Stepper.Step>
<Stepper.Step
label="Gate pass"
description="GL Djibouti grants after wagon allocation"
icon={
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
}
>
<ImportGatepassStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showDj && canDj}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="T1 transport documents"
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
@@ -525,6 +566,64 @@ export function PhasedClearanceActionPanel({
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Customs risk"
description="GL Ethiopia assigns Green / Yellow / Red"
icon={
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? (
<CheckCircle2 size={14} />
) : (
<ShieldAlert size={14} />
)
}
>
<RiskStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
onChanged={onChanged}
/>
</Stepper.Step>
<Stepper.Step
label="Additional duty & tax"
description="GL Ethiopia advises if more duty applies"
icon={<Receipt size={14} />}
>
<SecondDutyStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
<Stepper.Step
label="Import release"
description="GL Ethiopia uploads the release document"
icon={
clearance.importReleaseGranted ? (
<CheckCircle2 size={14} />
) : (
<FileText size={14} />
)
}
>
<ImportReleaseStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showEt && canEt}
workflowFiles={workflowFiles}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
/>
</Stepper.Step>
</Stepper>
</Paper>
</Stack>
@@ -717,6 +816,453 @@ function ImportT1Section({
);
}
/** GL DJ grants the import gate pass once wagons are allocated (captures time). */
function ImportGatepassStep({
bookingId,
clearance,
canAct,
onChanged,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
onChanged?: () => void;
}) {
const [opened, setOpened] = useState(false);
const [at, setAt] = useState<Date | null>(new Date());
const [loading, setLoading] = useState(false);
if (clearance.gatepassGranted) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`Gate pass granted${
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
}`}
/>
);
}
const wagonAllocated = Boolean(clearance.train?.wagonAllocated);
return (
<Stack gap="sm">
<StepStatus
done={false}
pendingLabel={
wagonAllocated
? "Wagons allocated — GL Djibouti can grant the gate pass."
: "Available once wagons are allocated."
}
doneLabel=""
/>
{canAct && bookingId ? (
<>
<Button
color="edr-green"
leftSection={<Truck size={16} />}
disabled={!wagonAllocated}
onClick={() => {
setAt(new Date());
setOpened(true);
}}
>
Grant gate pass
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={<Text fw={700}>Grant gate pass</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<DateTimePicker
label="Gate pass time"
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
Cancel
</Button>
<Button
color="edr-green"
loading={loading}
onClick={async () => {
setLoading(true);
try {
await contractsService.grantGatepass(
bookingId,
(at ?? new Date()).toISOString(),
);
toast.success("Gate pass granted");
setOpened(false);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Grant
</Button>
</Group>
</Stack>
</Modal>
</>
) : null}
</Stack>
);
}
const RISK_LEVEL_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/** GL ET assigns the customs examination risk (visible to the customer). */
function RiskStep({
bookingId,
clearance,
canAct,
done,
onChanged,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
done: boolean;
onChanged?: () => void;
}) {
const [level, setLevel] = useState<string>("GREEN");
const [loading, setLoading] = useState(false);
if (done || clearance.riskLevel) {
return (
<Group gap="sm">
<Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
variant="filled"
radius="sm"
>
{clearance.riskLevel ?? "Assigned"}
</Badge>
<Text size="sm" c="dimmed">
Customs risk assigned
{clearance.riskAssignedAt
? ` · ${new Date(clearance.riskAssignedAt).toLocaleString()}`
: ""}
. The customer can see this level.
</Text>
</Group>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus
done={false}
pendingLabel="Waiting for GL Ethiopia to assign the customs risk level."
doneLabel=""
/>
);
}
return (
<Stack gap="sm">
<SegmentedControl
fullWidth
value={level}
onChange={setLevel}
data={[
{ label: "Green", value: "GREEN" },
{ label: "Yellow", value: "YELLOW" },
{ label: "Red", value: "RED" },
]}
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
The customer sees the assigned risk level.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={loading}
onClick={async () => {
setLoading(true);
try {
await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel,
});
toast.success("Customs risk assigned");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Assign risk
</Button>
</Group>
</Stack>
);
}
/** Optional post-arrival additional duty/tax round (GL ET advises; customer pays slip). */
function SecondDutyStep({
bookingId,
clearance,
canAct,
riskAssigned,
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
riskAssigned: boolean;
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [dutyRequired, setDutyRequired] = useState(true);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [serial, setSerial] = useState("");
const [attachment, setAttachment] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const duty = clearance.secondDuty ?? null;
if (duty?.skipped) {
return (
<StepStatus done pendingLabel="" doneLabel="No additional duty or tax applies." />
);
}
if (duty?.advised) {
return (
<Stack gap="sm">
<Text size="sm">
Additional duty advised:{" "}
<strong>
{duty.amount?.toLocaleString()} {duty.currency}
</strong>
{duty.declarationSerial ? ` · ${duty.declarationSerial}` : ""}
</Text>
{duty.noticeFile ? (
<PhasedUploadedFileRow
label="Additional Duty / Tax Notice"
file={duty.noticeFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{duty.slipFile ? (
<PhasedUploadedFileRow
label="Customer payment slip"
file={duty.slipFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
<StepStatus
done={duty.paid}
pendingLabel="Waiting for the customer to pay and attach the slip in the portal."
doneLabel="Additional duty paid — slip received."
/>
</Stack>
);
}
if (!canAct || !bookingId) {
return (
<StepStatus
done={false}
pendingLabel="GL Ethiopia decides whether additional duty/tax applies."
doneLabel=""
/>
);
}
return (
<Stack gap="md">
{!riskAssigned ? (
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
Usually decided after the customs risk is assigned.
</Alert>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="md">
<Switch
label="Additional duty/tax applies"
description="Turn off if no further duty or tax is due after arrival."
checked={dutyRequired}
onChange={(e) => setDutyRequired(e.currentTarget.checked)}
/>
{dutyRequired ? (
<>
<Group grow align="flex-start">
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
size="sm"
thousandSeparator=","
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
/>
</Group>
<TextInput
label="Declaration / payment code"
placeholder="Customs payment reference"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
<PhasedFileDropzone
label="Duty notice attachment"
description="Any file type — shown to the customer in the portal."
accept="*/*"
value={attachment}
onChange={setAttachment}
onPreview={onViewFile}
/>
</>
) : null}
</Stack>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={dutyRequired && (amount === "" || Number(amount) <= 0 || !attachment)}
fullWidth
onClick={async () => {
setLoading(true);
try {
await contractsService.adviseSecondDuty(bookingId, {
dutyRequired,
amount: dutyRequired ? Number(amount) : undefined,
currency,
declarationSerial: serial || undefined,
attachment: dutyRequired ? attachment : null,
});
toast.success(
dutyRequired ? "Additional duty advised to customer" : "No additional duty recorded",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
{dutyRequired ? "Send to customer" : "No additional duty"}
</Button>
</Stack>
);
}
/** GL ET uploads the import release document (auto-completes IMPORT_RELEASE_GRANTED). */
function ImportReleaseStep({
bookingId,
clearance,
canAct,
workflowFiles = [],
onChanged,
onViewFile,
onDownloadFile,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
onChanged?: () => void;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const releaseFile = findWorkflowFile(workflowFiles, "import_release");
return (
<Stack gap="sm">
{releaseFile ? (
<PhasedUploadedFileRow
label="Import Release"
file={releaseFile}
onView={onViewFile}
onDownload={onDownloadFile}
compact
/>
) : null}
{clearance.importReleaseGranted ? (
<StepStatus done pendingLabel="" doneLabel="Import release granted." />
) : canAct && bookingId ? (
<>
<PhasedFileDropzone
label="Import release document"
description="Any file type."
accept="*/*"
value={file}
onChange={setFile}
replaceMode={Boolean(releaseFile)}
onPreview={onViewFile}
/>
<Button
color="edr-green"
loading={loading}
disabled={!file}
leftSection={<Upload size={16} />}
onClick={async () => {
if (!file) return;
setLoading(true);
try {
await contractsService.uploadGlDocuments(bookingId, {
import_release: file,
});
setFile(null);
toast.success("Import release uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{releaseFile ? "Replace import release" : "Upload import release"}
</Button>
</>
) : (
<StepStatus
done={false}
pendingLabel="Waiting for GL Ethiopia to upload the import release document."
doneLabel=""
/>
)}
</Stack>
);
}
export function StepStatus({
done,
pendingLabel,

View File

@@ -224,6 +224,8 @@ export const URL_CONSTANTS = {
`/contracts/bookings/${bookingId}/final-invoice`,
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
`/contracts/bookings/${bookingId}/final-invoice/confirm`,
BOOKING_SECOND_DUTY: (bookingId: string) =>
`/contracts/bookings/${bookingId}/second-duty`,
BOOKING_INCIDENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/incidents`,
},

View File

@@ -362,9 +362,14 @@ export const bookingsService = {
return unwrap(response.data) as BookingDetail;
},
uploadDeliveryOrder: async (id: string, file: File): Promise<BookingDetail> => {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});

View File

@@ -283,9 +283,11 @@ export const contractsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
@@ -407,6 +409,30 @@ export const contractsService = {
return unwrap(response.data) as Freight.ClearanceFinalInvoiceSummary;
},
/** GL ET advises (or skips) the post-arrival additional duty/tax round (import). */
adviseSecondDuty: async (
bookingId: string,
payload: {
dutyRequired: boolean;
amount?: number;
currency?: string;
declarationSerial?: string;
attachment?: File | null;
},
): Promise<{ advised: boolean; skipped: boolean }> => {
const form = new FormData();
form.append("dutyRequired", String(payload.dutyRequired));
if (payload.amount != null) form.append("amount", String(payload.amount));
if (payload.currency) form.append("currency", payload.currency);
if (payload.declarationSerial)
form.append("declarationSerial", payload.declarationSerial);
if (payload.attachment) form.append("attachment", payload.attachment);
const response = await client.post(C.BOOKING_SECOND_DUTY(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as { advised: boolean; skipped: boolean };
},
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(