mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user