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>(

View File

@@ -133,6 +133,8 @@ export const URL_CONSTANTS = {
`/api/contracts/bookings/${bookingId}/duty-slip`,
BOOKING_FINAL_INVOICE_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
BOOKING_SECOND_DUTY_SLIP: (bookingId: string) =>
`/api/contracts/bookings/${bookingId}/second-duty-slip`,
CAPACITY: (id: string) => `/api/contracts/${id}/capacity`,
BOOKING_REQUESTS: (id: string) => `/api/contracts/${id}/booking-requests`,
BOOKING_REQUEST_CANCEL: (reqId: string) =>

View File

@@ -584,6 +584,42 @@ export default function ContractDetailPage() {
<ContractClearanceWorkflowBanner contract={contract} />
) : null}
{clearanceView?.riskLevel ? (
<Paper
withBorder
radius="lg"
p="md"
style={{ borderColor: BORDER, background: "#FBFDFC" }}
>
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
Customs risk level
</Text>
<Badge
color={CUSTOMS_RISK_COLOR[clearanceView.riskLevel] ?? "gray"}
variant="filled"
radius="sm"
>
{clearanceView.riskLevel}
</Badge>
{clearanceView.riskAssignedAt ? (
<Text fz={12} c="dimmed">
assigned {new Date(clearanceView.riskAssignedAt).toLocaleString()}
</Text>
) : null}
</Group>
</Paper>
) : null}
{clearanceView?.secondDuty?.advised && clearanceView?.linkedBookingId ? (
<SecondDutyDueCard
duty={clearanceView.secondDuty}
bookingId={clearanceView.linkedBookingId}
onView={view}
onChanged={() => void refetchClearance()}
/>
) : null}
{clearanceView?.finalInvoice && clearanceView?.linkedBookingId ? (
<FinalInvoiceDueCard
invoice={clearanceView.finalInvoice}
@@ -1605,3 +1641,145 @@ function FinalInvoiceDueCard({
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ContractClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}

View File

@@ -333,4 +333,19 @@ export const contractsService = {
);
return data.data ?? data;
},
/** Customer attaches the slip for the post-arrival additional duty round (import). */
uploadSecondDutySlip: async (
bookingId: string,
file: File,
): Promise<{ milestoneCompleted: boolean }> => {
const form = new FormData();
form.append("file", file);
const { data } = await client.post(
C.BOOKING_SECOND_DUTY_SLIP(bookingId),
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data ?? data;
},
};