feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices

This commit is contained in:
Marshal
2026-08-20 05:24:57 +00:00
parent 4adb53b486
commit c138da6137
14 changed files with 1281 additions and 53 deletions

View File

@@ -0,0 +1,525 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Loader,
NumberInput,
Paper,
Select,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
CheckCircle2,
Download,
Eye,
FileText,
Receipt,
Send,
Upload,
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { bookingsService } from "@/services/bookings.service";
import {
downloadBookingFile,
fetchViewableFile,
} from "@/services/files.service";
import { formatDateTime } from "@/lib/format";
import { extractErrorMessage } from "@/utils/errorExtractor";
const CURRENCIES = ["ETB", "USD"];
const STATUS_META: Record<
Freight.ClearanceChargeStatus,
{ label: string; color: string }
> = {
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
BILLED: { label: "Ready to send", color: "blue" },
SENT: { label: "Sent — unpaid", color: "orange" },
PAID: { label: "Paid", color: "edr-green" },
};
export interface ClearanceChargesTabProps {
bookingId: string;
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
roleMode: "ET" | "DJ";
onViewFile: (file: { name: string; url: string }) => void;
}
/**
* Post-finalization charges billed to the customer, two levels: port charges
* (document from GL Djibouti, billed by GL Ethiopia) then miscellaneous
* (created whole by GL Ethiopia once the port charge is paid). Each level
* issues its own payable invoice — ETB settles through the portal gateway
* (CBE), other currencies through Finance's manual settlement.
*/
export function ClearanceChargesTab({
bookingId,
roleMode,
onViewFile,
}: ClearanceChargesTabProps) {
const qc = useQueryClient();
const { data: charges, isLoading } = useQuery({
queryKey: ["clearance-charges", bookingId],
queryFn: () => bookingsService.getClearanceCharges(bookingId),
});
const refresh = (next: Freight.ClearanceCharge[]) =>
qc.setQueryData(["clearance-charges", bookingId], next);
const onError = (e: unknown) =>
toast.error(extractErrorMessage(e, "Could not update the charge"));
const uploadPort = useMutation({
mutationFn: (file: File) =>
bookingsService.uploadPortChargeDocument(bookingId, file),
onSuccess: (next) => {
toast.success("Port-charges document uploaded");
refresh(next);
},
onError,
});
const bill = useMutation({
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
onSuccess: (next) => {
toast.success("Charge amount saved");
refresh(next);
},
onError,
});
const send = useMutation({
mutationFn: (chargeId: string) =>
bookingsService.sendClearanceCharge(bookingId, chargeId),
onSuccess: (next) => {
toast.success("Invoice sent to the customer");
refresh(next);
},
onError,
});
const createMisc = useMutation({
mutationFn: (p: { file: File; amount: number; currency: string }) =>
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
onSuccess: (next) => {
toast.success("Miscellaneous charge created");
refresh(next);
},
onError,
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading charges</Text>
</Group>
);
}
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
const misc = (charges ?? []).find((c) => c.type === "MISCELLANEOUS") ?? null;
const busy =
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
const totals = new Map<string, number>();
for (const c of charges ?? []) {
if (c.amount != null && c.currency)
totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount);
}
return (
<Stack gap="md" maw={860}>
<ChargeCard
title="1 · Port charges"
charge={port}
roleMode={roleMode}
busy={busy}
emptyHint={
roleMode === "DJ"
? "Upload the port-charges document to start this charge."
: "Waiting for GL Djibouti to upload the port-charges document."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
port && bill.mutate({ chargeId: port.id, amount, currency })
}
onSend={() => port && send.mutate(port.id)}
djUpload={
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
<FileButton
onChange={(f) => f && uploadPort.mutate(f)}
accept="application/pdf,image/*"
disabled={busy}
>
{(props) => (
<Button
{...props}
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
loading={uploadPort.isPending}
>
{port ? "Replace document" : "Upload document"}
</Button>
)}
</FileButton>
) : null
}
/>
<ChargeCard
title="2 · Miscellaneous charges"
charge={misc}
roleMode={roleMode}
busy={busy}
emptyHint={
port?.status !== "PAID"
? "Unlocks once the port charge is paid."
: roleMode === "ET"
? "Create the miscellaneous charge with its document, amount and currency."
: "GL Ethiopia creates this charge once the port charge is paid."
}
onViewFile={onViewFile}
onBill={(amount, currency) =>
misc && bill.mutate({ chargeId: misc.id, amount, currency })
}
onSend={() => misc && send.mutate(misc.id)}
etCreate={
roleMode === "ET" && !misc && port?.status === "PAID" ? (
<MiscCreateForm
busy={createMisc.isPending}
onCreate={(file, amount, currency) =>
createMisc.mutate({ file, amount, currency })
}
/>
) : null
}
/>
{totals.size > 0 && (
<Paper withBorder radius="md" p="md">
<Group justify="space-between">
<Text fz="13px" fw={700} c="edr-text">
Total billed
</Text>
<Group gap="md">
{[...totals.entries()].map(([currency, amount]) => (
<Text key={currency} fz="14px" fw={800} c="edr-text">
{amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{currency}
</Text>
))}
</Group>
</Group>
</Paper>
)}
</Stack>
);
}
function ChargeCard({
title,
charge,
roleMode,
busy,
emptyHint,
onViewFile,
onBill,
onSend,
djUpload,
etCreate,
}: {
title: string;
charge: Freight.ClearanceCharge | null;
roleMode: "ET" | "DJ";
busy: boolean;
emptyHint: string;
onViewFile: (file: { name: string; url: string }) => void;
onBill: (amount: number, currency: string) => void;
onSend: () => void;
djUpload?: React.ReactNode;
etCreate?: React.ReactNode;
}) {
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
const status = charge?.status ?? null;
const meta = status ? STATUS_META[status] : null;
// ET enters/revises the amount while the charge is unpaid.
const showBillForm =
roleMode === "ET" &&
charge != null &&
(charge.status === "DOC_UPLOADED" || editing);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap={10} wrap="nowrap">
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
<Box>
<Text fz="14px" fw={700} c="edr-text">
{title}
</Text>
{charge?.uploadedAt && (
<Text fz="11.5px" c="dimmed">
Document uploaded
{charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "}
{formatDateTime(charge.uploadedAt)}
</Text>
)}
{charge?.billedAt && (
<Text fz="11.5px" c="dimmed">
Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "}
{formatDateTime(charge.billedAt)}
</Text>
)}
{charge?.paidAt && (
<Text fz="11.5px" c="edr-green.8" fw={600}>
Paid · {formatDateTime(charge.paidAt)}
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
</Text>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
{charge?.amount != null && charge.currency && (
<Text fz="14px" fw={800} c="edr-text">
{charge.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}{" "}
{charge.currency}
</Text>
)}
{meta && (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
)}
</Group>
</Group>
{charge?.file && (
<Group gap={8} mt="sm" wrap="nowrap">
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
{charge.file.name}
</Text>
{isViewable({ name: charge.file.name, url: "" }) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
void fetchViewableFile(
charge.file!.id,
charge.file!.name,
).then(onViewFile)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="button"
type="button"
onClick={() =>
void downloadBookingFile(charge.file!.id, charge.file!.name)
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Download size={15} />
</Box>
</Tooltip>
</Group>
)}
{!charge && (
<Text fz="12.5px" c="dimmed" mt="xs">
{emptyHint}
</Text>
)}
{djUpload && <Box mt="sm">{djUpload}</Box>}
{etCreate && <Box mt="sm">{etCreate}</Box>}
{showBillForm && (
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
<NumberInput
label="Amount"
size="xs"
radius="md"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
w={160}
/>
<Select
label="Currency"
size="xs"
radius="md"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
<Button
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !(Number(amount) > 0)}
onClick={() => {
onBill(Number(amount), currency);
setEditing(false);
}}
>
Save amount
</Button>
{editing && (
<Button
size="compact-sm"
variant="subtle"
color="gray"
radius="md"
disabled={busy}
onClick={() => setEditing(false)}
>
Cancel
</Button>
)}
</Group>
)}
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
<Group mt="sm" gap={8} justify="flex-end">
<Button
size="compact-sm"
variant="light"
color="gray"
radius="md"
disabled={busy}
onClick={() => {
setAmount(charge.amount ?? "");
setCurrency(charge.currency ?? "ETB");
setEditing(true);
}}
>
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
</Button>
{charge.status === "BILLED" && (
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<Send size={14} />}
disabled={busy}
onClick={onSend}
>
Send invoice to customer
</Button>
</Tooltip>
)}
{charge.status === "SENT" && charge.invoiceNumber && (
<Badge variant="light" color="orange" radius="sm">
Invoice {charge.invoiceNumber}
</Badge>
)}
</Group>
)}
{charge?.status === "PAID" && (
<Group mt="sm" gap={6} justify="flex-end">
<CheckCircle2 size={14} color="var(--mantine-color-edr-green-6)" />
<Text fz="12px" c="edr-green.8" fw={600}>
Settled
</Text>
</Group>
)}
</Paper>
);
}
function MiscCreateForm({
busy,
onCreate,
}: {
busy: boolean;
onCreate: (file: File, amount: number, currency: string) => void;
}) {
const [file, setFile] = useState<File | null>(null);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
return (
<Group gap={8} align="flex-end" wrap="wrap">
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
{(props) => (
<Button
{...props}
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose document"}
</Button>
)}
</FileButton>
<NumberInput
label="Amount"
size="xs"
radius="md"
min={0.01}
decimalScale={2}
value={amount}
onChange={setAmount}
w={160}
/>
<Select
label="Currency"
size="xs"
radius="md"
data={CURRENCIES}
value={currency}
onChange={(v) => v && setCurrency(v)}
w={100}
/>
<Button
size="compact-sm"
color="edr-green"
radius="md"
disabled={busy || !file || !(Number(amount) > 0)}
loading={busy}
onClick={() => file && onCreate(file, Number(amount), currency)}
>
Create charge
</Button>
</Group>
);
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, Share2, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, Receipt, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
@@ -9,6 +9,7 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
@@ -68,6 +69,12 @@ export function ClearanceOpsTabs({
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
// Post-finalization customer billing. This layout is only rendered on the ET
// clearance pages — the DJ page (GlClearanceDetailPage) mounts its own tab.
const showCharges =
Boolean(bookingId) &&
Boolean(onViewFile) &&
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
// Risk assignment + incident reporting hit bookings:operations endpoints.
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
@@ -100,6 +107,11 @@ export function ClearanceOpsTabs({
Document exchange
</Tabs.Tab>
) : null}
{showCharges ? (
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
Customer charges
</Tabs.Tab>
) : null}
{showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -131,6 +143,16 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showCharges ? (
<Tabs.Panel value="charges">
<ClearanceChargesTab
bookingId={bookingId!}
roleMode="ET"
onViewFile={onViewFile!}
/>
</Tabs.Panel>
) : null}
{showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -20,6 +20,7 @@ import {
AlertTriangle,
ClipboardList,
FileText,
Receipt,
Share2,
Upload,
} from "lucide-react";
@@ -36,6 +37,7 @@ import { BookingCompanyCard } from "@/components/bookings/detail/BookingCompanyC
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -247,6 +249,11 @@ export default function GlClearanceDetailPage() {
<Tabs.Tab value="exchange" leftSection={<Share2 size={14} />}>
Document exchange
</Tabs.Tab>
{data.kind === "booking" ? (
<Tabs.Tab value="charges" leftSection={<Receipt size={14} />}>
Customer charges
</Tabs.Tab>
) : null}
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -369,6 +376,12 @@ export default function GlClearanceDetailPage() {
<GlExchangePanel entityId={id!} />
</Tabs.Panel>
{data.kind === "booking" ? (
<Tabs.Panel value="charges">
<ClearanceChargesTab bookingId={id!} roleMode="DJ" onViewFile={view} />
</Tabs.Panel>
) : null}
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

View File

@@ -432,6 +432,69 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Djibouti uploads (or replaces, until billed) the port-charges document. */
uploadPortChargeDocument: async (
id: string,
file: File,
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
const response = await client.post(
`/bookings/${id}/clearance/charges/port-document`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia sets or revises a charge's amount + currency. */
billClearanceCharge: async (
id: string,
chargeId: string,
payload: { amount: number; currency: string },
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.patch(
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
payload,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia issues the charge's payable invoice to the customer. */
sendClearanceCharge: async (
id: string,
chargeId: string,
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.post(
`/bookings/${id}/clearance/charges/${chargeId}/send`,
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
createMiscellaneousCharge: async (
id: string,
file: File,
payload: { amount: number; currency: string },
): Promise<Freight.ClearanceCharge[]> => {
const form = new FormData();
form.append("file", file);
form.append("amount", String(payload.amount));
form.append("currency", payload.currency);
const response = await client.post(
`/bookings/${id}/clearance/charges/miscellaneous`,
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {