mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
@@ -19,9 +21,11 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Lock,
|
||||
Receipt,
|
||||
Send,
|
||||
Upload,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -42,11 +46,19 @@ const STATUS_META: Record<
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
|
||||
BILLED: { label: "Ready to send", color: "blue" },
|
||||
SENT: { label: "Sent — unpaid", color: "orange" },
|
||||
BILLED: { label: "Draft — not sent", color: "blue" },
|
||||
SENT: { label: "Awaiting customer approval", color: "orange" },
|
||||
REJECTED: { label: "Rejected by customer", color: "red" },
|
||||
ACCEPTED: { label: "Accepted — invoice unpaid", color: "teal" },
|
||||
PAID: { label: "Paid", color: "edr-green" },
|
||||
};
|
||||
|
||||
/** Once the customer accepts, the invoice exists and GL can no longer edit. */
|
||||
const isLocked = (s: Freight.ClearanceChargeStatus) =>
|
||||
s === "ACCEPTED" || s === "PAID";
|
||||
|
||||
type BillInput = { amount: number; currency: string; description: string };
|
||||
|
||||
export interface ClearanceChargesTabProps {
|
||||
bookingId: string;
|
||||
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
|
||||
@@ -55,11 +67,12 @@ export interface ClearanceChargesTabProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Post-finalization charges billed to the customer: port charges (document
|
||||
* from GL Djibouti, priced by GL Ethiopia) and any number of miscellaneous
|
||||
* charges. GL prices + describes a charge and sends it; the customer accepts
|
||||
* (invoice issued, charge locked) or rejects with a note (GL revises and
|
||||
* re-sends). ETB settles through the portal gateway (CBE), other currencies
|
||||
* through Finance's manual settlement.
|
||||
*/
|
||||
export function ClearanceChargesTab({
|
||||
bookingId,
|
||||
@@ -89,7 +102,7 @@ export function ClearanceChargesTab({
|
||||
onError,
|
||||
});
|
||||
const bill = useMutation({
|
||||
mutationFn: (p: { chargeId: string; amount: number; currency: string }) =>
|
||||
mutationFn: (p: BillInput & { chargeId: string }) =>
|
||||
bookingsService.billClearanceCharge(bookingId, p.chargeId, p),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Charge amount saved");
|
||||
@@ -101,13 +114,13 @@ export function ClearanceChargesTab({
|
||||
mutationFn: (chargeId: string) =>
|
||||
bookingsService.sendClearanceCharge(bookingId, chargeId),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Invoice sent to the customer");
|
||||
toast.success("Sent to the customer for approval");
|
||||
refresh(next);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const createMisc = useMutation({
|
||||
mutationFn: (p: { file: File; amount: number; currency: string }) =>
|
||||
mutationFn: (p: BillInput & { file: File }) =>
|
||||
bookingsService.createMiscellaneousCharge(bookingId, p.file, p),
|
||||
onSuccess: (next) => {
|
||||
toast.success("Miscellaneous charge created");
|
||||
@@ -153,9 +166,7 @@ export function ClearanceChargesTab({
|
||||
: "Waiting for GL Djibouti to upload the port-charges document."
|
||||
}
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
port && bill.mutate({ chargeId: port.id, amount, currency })
|
||||
}
|
||||
onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
|
||||
onSend={() => port && send.mutate(port.id)}
|
||||
djUpload={
|
||||
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
||||
@@ -196,9 +207,7 @@ export function ClearanceChargesTab({
|
||||
busy={busy}
|
||||
emptyHint=""
|
||||
onViewFile={onViewFile}
|
||||
onBill={(amount, currency) =>
|
||||
bill.mutate({ chargeId: c.id, amount, currency })
|
||||
}
|
||||
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
|
||||
onSend={() => send.mutate(c.id)}
|
||||
/>
|
||||
))}
|
||||
@@ -211,15 +220,13 @@ export function ClearanceChargesTab({
|
||||
: "Add a miscellaneous charge"}
|
||||
</Text>
|
||||
<Text fz="12px" c="dimmed" mb="sm">
|
||||
Upload the supporting document and set the amount. You can raise as
|
||||
many as the shipment needs, before or after the port charge.
|
||||
Upload the supporting document, set the amount and say what it is
|
||||
for. The customer sees it once you send it for approval.
|
||||
</Text>
|
||||
<MiscCreateForm
|
||||
key={miscCreated}
|
||||
busy={createMisc.isPending}
|
||||
onCreate={(file, amount, currency) =>
|
||||
createMisc.mutate({ file, amount, currency })
|
||||
}
|
||||
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
@@ -265,7 +272,7 @@ function ChargeCard({
|
||||
busy: boolean;
|
||||
emptyHint: string;
|
||||
onViewFile: (file: { name: string; url: string }) => void;
|
||||
onBill: (amount: number, currency: string) => void;
|
||||
onBill: (input: BillInput) => void;
|
||||
onSend: () => void;
|
||||
djUpload?: React.ReactNode;
|
||||
etCreate?: React.ReactNode;
|
||||
@@ -273,13 +280,17 @@ function ChargeCard({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
|
||||
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
|
||||
const [description, setDescription] = useState(charge?.description ?? "");
|
||||
|
||||
const status = charge?.status ?? null;
|
||||
const meta = status ? STATUS_META[status] : null;
|
||||
// ET enters/revises the amount while the charge is unpaid.
|
||||
const locked = status != null && isLocked(status);
|
||||
const needsDescription = charge?.type === "MISCELLANEOUS";
|
||||
// ET enters/revises the price until the customer accepts it.
|
||||
const showBillForm =
|
||||
roleMode === "ET" &&
|
||||
charge != null &&
|
||||
!locked &&
|
||||
(charge.status === "DOC_UPLOADED" || editing);
|
||||
|
||||
return (
|
||||
@@ -304,6 +315,12 @@ function ChargeCard({
|
||||
{formatDateTime(charge.billedAt)}
|
||||
</Text>
|
||||
)}
|
||||
{charge?.status === "ACCEPTED" && charge.customerDecidedAt && (
|
||||
<Text fz="11.5px" c="teal.8" fw={600}>
|
||||
Accepted by the customer · {formatDateTime(charge.customerDecidedAt)}
|
||||
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
||||
</Text>
|
||||
)}
|
||||
{charge?.paidAt && (
|
||||
<Text fz="11.5px" c="edr-green.8" fw={600}>
|
||||
Paid · {formatDateTime(charge.paidAt)}
|
||||
@@ -379,6 +396,32 @@ function ChargeCard({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{charge?.description && !showBillForm && (
|
||||
<Text fz="12.5px" c="edr-text" mt="xs">
|
||||
{charge.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{charge?.status === "REJECTED" && charge.customerNote && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
p="xs"
|
||||
mt="sm"
|
||||
icon={<XCircle size={16} />}
|
||||
title="Rejected by the customer"
|
||||
>
|
||||
<Text fz="12.5px">{charge.customerNote}</Text>
|
||||
{charge.customerDecidedAt && (
|
||||
<Text fz="11px" c="dimmed" mt={4}>
|
||||
{formatDateTime(charge.customerDecidedAt)} — fix the price or
|
||||
description and send it again.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!charge && (
|
||||
<Text fz="12.5px" c="dimmed" mt="xs">
|
||||
{emptyHint}
|
||||
@@ -389,6 +432,15 @@ function ChargeCard({
|
||||
|
||||
{showBillForm && (
|
||||
<Group mt="sm" gap={8} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={needsDescription ? "What is this charge for?" : "Description (optional)"}
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
maxLength={1000}
|
||||
w={320}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
size="xs"
|
||||
@@ -412,13 +464,21 @@ function ChargeCard({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={busy || !(Number(amount) > 0)}
|
||||
disabled={
|
||||
busy ||
|
||||
!(Number(amount) > 0) ||
|
||||
(needsDescription && !description.trim())
|
||||
}
|
||||
onClick={() => {
|
||||
onBill(Number(amount), currency);
|
||||
onBill({
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
description: description.trim(),
|
||||
});
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save amount
|
||||
Save
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
@@ -435,7 +495,7 @@ function ChargeCard({
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{roleMode === "ET" && charge && !showBillForm && charge.status !== "PAID" && (
|
||||
{roleMode === "ET" && charge && !showBillForm && !locked && (
|
||||
<Group mt="sm" gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -446,13 +506,14 @@ function ChargeCard({
|
||||
onClick={() => {
|
||||
setAmount(charge.amount ?? "");
|
||||
setCurrency(charge.currency ?? "ETB");
|
||||
setDescription(charge.description ?? "");
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{charge.status === "SENT" ? "Revise (cancels invoice)" : "Edit amount"}
|
||||
{charge.status === "SENT" ? "Revise" : "Edit"}
|
||||
</Button>
|
||||
{charge.status === "BILLED" && (
|
||||
<Tooltip label="ETB is payable online via CBE; other currencies go to Finance's manual settlement.">
|
||||
{(charge.status === "BILLED" || charge.status === "REJECTED") && (
|
||||
<Tooltip label="The customer accepts or rejects the price in the portal; the invoice is issued when they accept.">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
@@ -461,15 +522,20 @@ function ChargeCard({
|
||||
disabled={busy}
|
||||
onClick={onSend}
|
||||
>
|
||||
Send invoice to customer
|
||||
{charge.status === "REJECTED"
|
||||
? "Send again for approval"
|
||||
: "Send to customer for approval"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{charge.status === "SENT" && charge.invoiceNumber && (
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
Invoice {charge.invoiceNumber}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{charge?.status === "ACCEPTED" && (
|
||||
<Group mt="sm" gap={6} justify="flex-end">
|
||||
<Lock size={14} color="var(--mantine-color-teal-7)" />
|
||||
<Text fz="12px" c="teal.8" fw={600}>
|
||||
Locked — invoice {charge.invoiceNumber ?? ""} awaiting payment
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{charge?.status === "PAID" && (
|
||||
@@ -489,14 +555,25 @@ function MiscCreateForm({
|
||||
onCreate,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCreate: (file: File, amount: number, currency: string) => void;
|
||||
onCreate: (file: File, input: BillInput) => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
return (
|
||||
<Group gap={8} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label="What is this charge for?"
|
||||
placeholder="e.g. Container cleaning and weighbridge fee"
|
||||
size="xs"
|
||||
radius="md"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
maxLength={1000}
|
||||
w={320}
|
||||
/>
|
||||
<FileButton onChange={setFile} accept="application/pdf,image/*" disabled={busy}>
|
||||
{(props) => (
|
||||
<Button
|
||||
@@ -534,9 +611,16 @@ function MiscCreateForm({
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={busy || !file || !(Number(amount) > 0)}
|
||||
disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
|
||||
loading={busy}
|
||||
onClick={() => file && onCreate(file, Number(amount), currency)}
|
||||
onClick={() =>
|
||||
file &&
|
||||
onCreate(file, {
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
description: description.trim(),
|
||||
})
|
||||
}
|
||||
>
|
||||
Create charge
|
||||
</Button>
|
||||
|
||||
@@ -199,7 +199,13 @@ export function RequestServiceTypeCard({
|
||||
if (lastMile)
|
||||
chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse });
|
||||
if (customs)
|
||||
chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck });
|
||||
chips.push({
|
||||
label: st.includesEthiopianCustomsOnly
|
||||
? "Ethiopian customs clearance (GL)"
|
||||
: "Customs clearance (GL)",
|
||||
color: "grape",
|
||||
icon: FileCheck,
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, Lock, MapPin } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
|
||||
|
||||
/**
|
||||
* Schedule yards tab: where THIS departure plans to board each consist wagon,
|
||||
* side by side with where the wagon physically stands (the train builder's
|
||||
* truth). Booking capacity per origin reads the plan; dispatch refuses to
|
||||
* leave until plan and physical yards agree. Edits are queued locally and
|
||||
* saved in one PATCH.
|
||||
*/
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scheduleId: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
const { toast } = useToast();
|
||||
const query = useQuery(
|
||||
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
|
||||
);
|
||||
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
|
||||
const data = query.data;
|
||||
|
||||
/** wagonId → yardId queued but not yet saved. */
|
||||
const [pending, setPending] = useState<Record<string, string>>({});
|
||||
const [bulkType, setBulkType] = useState<string | null>(null);
|
||||
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
||||
const [bulkTo, setBulkTo] = useState<string | null>(null);
|
||||
const [bulkCount, setBulkCount] = useState<number | string>(1);
|
||||
|
||||
const editable = Boolean(canEdit && data?.editable);
|
||||
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
|
||||
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
|
||||
const yardLabel = (id: string | null) =>
|
||||
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
|
||||
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
|
||||
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
|
||||
id ??
|
||||
"—";
|
||||
|
||||
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
|
||||
|
||||
const perStop = useMemo(
|
||||
() =>
|
||||
(data?.stops ?? []).map((s) => ({
|
||||
...s,
|
||||
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
|
||||
.length,
|
||||
})),
|
||||
[data, pending],
|
||||
);
|
||||
const typeOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [data]);
|
||||
|
||||
const pendingCount = Object.keys(pending).length;
|
||||
|
||||
const queueBulk = () => {
|
||||
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
||||
const n = Number(bulkCount) || 0;
|
||||
const picked = data.wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
!w.locked &&
|
||||
effectiveYard(w) === bulkFrom &&
|
||||
(!bulkType || w.wagonType.id === bulkType),
|
||||
)
|
||||
.slice(0, n);
|
||||
if (!picked.length) {
|
||||
toast({ title: "No free wagons match", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setPending((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const w of picked) {
|
||||
if (w.plannedYardId === bulkTo) delete next[w.id];
|
||||
else next[w.id] = bulkTo;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!pendingCount) return;
|
||||
try {
|
||||
const result = await save.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })),
|
||||
},
|
||||
});
|
||||
setPending({});
|
||||
toast({
|
||||
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
||||
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
||||
variant: result.warnings.length ? "destructive" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update the schedule's wagon yards"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (query.isLoading) return <Loader size="sm" />;
|
||||
if (query.isError || !data) {
|
||||
return (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
||||
{parseError(
|
||||
query.error,
|
||||
"This schedule has no wagon yard plan (not created from a built train).",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
|
||||
<b>Planned</b> = where this departure boards the wagon (what customers can book per
|
||||
origin). <b>Physical</b> = where the wagon stands now (train builder). Dispatch is blocked
|
||||
until every wagon stands at its planned yard.
|
||||
{data.misaligned > 0 ? (
|
||||
<Text component="span" c="orange" fw={600}>
|
||||
{" "}
|
||||
{data.misaligned} wagon(s) currently misaligned.
|
||||
</Text>
|
||||
) : null}
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
|
||||
{perStop.map((s) => (
|
||||
<Paper key={s.yardId} withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fw={600} size="sm">
|
||||
{s.label}
|
||||
</Text>
|
||||
{!s.pickup ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
destination
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color="edr-green" variant="filled">
|
||||
Planned {s.planned}
|
||||
</Badge>
|
||||
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
|
||||
Physical {s.physical}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{editable ? (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group align="end" gap="sm" wrap="wrap">
|
||||
<NumberInput
|
||||
label="Move"
|
||||
min={1}
|
||||
max={data.wagons.length}
|
||||
value={bulkCount}
|
||||
onChange={setBulkCount}
|
||||
w={90}
|
||||
/>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={typeOptions}
|
||||
value={bulkType}
|
||||
onChange={setBulkType}
|
||||
w={140}
|
||||
/>
|
||||
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
|
||||
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={queueBulk}
|
||||
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
|
||||
>
|
||||
Queue
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>#</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Physical yard</Table.Th>
|
||||
<Table.Th>Planned yard (this schedule)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.wagons.map((w) => {
|
||||
const planned = effectiveYard(w);
|
||||
const changed = w.id in pending;
|
||||
return (
|
||||
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
||||
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{w.wagonType.code}</Table.Td>
|
||||
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
|
||||
<Table.Td>
|
||||
{editable && !w.locked ? (
|
||||
<Select
|
||||
size="xs"
|
||||
data={yardOptions}
|
||||
value={planned}
|
||||
onChange={(v) =>
|
||||
setPending((prev) => {
|
||||
const next = { ...prev };
|
||||
if (!v || v === w.plannedYardId) delete next[w.id];
|
||||
else next[w.id] = v;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
w={180}
|
||||
/>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
<Text size="sm">{yardLabel(planned)}</Text>
|
||||
{w.locked ? (
|
||||
<Tooltip label={w.lockReason ?? "Locked"}>
|
||||
<Lock size={14} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{planned === w.physicalYardId ? (
|
||||
<Badge color="teal" variant="light" size="sm">
|
||||
Aligned
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
Needs move
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{editable ? (
|
||||
<Group justify="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
{pendingCount} pending change(s)
|
||||
</Text>
|
||||
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
loading={save.isPending}
|
||||
disabled={!pendingCount}
|
||||
>
|
||||
Save plan
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
import {
|
||||
DEFAULT_CONFIGURATION_SLUG,
|
||||
DEFAULT_RULES_SLUG,
|
||||
ROUTE_SCOPED_TRIGGERS,
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
getRuleEngineResource,
|
||||
@@ -120,9 +121,7 @@ const yardOptionsForLegEnd = (
|
||||
// direction + route, so their yard dropdowns narrow exactly like base
|
||||
// freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
|
||||
@@ -221,6 +221,10 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
{
|
||||
label: "Ethiopian customs clearance service fee (Ethiopian-side-only services)",
|
||||
value: "ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
},
|
||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||
];
|
||||
|
||||
@@ -279,8 +283,16 @@ const SHIPPING_LINE_CARGO_KINDS = [
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
|
||||
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
|
||||
export const ROUTE_SCOPED_TRIGGERS = [
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
"WITH_RETURN",
|
||||
"FUEL",
|
||||
];
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fee and
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fees and
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
@@ -289,19 +301,19 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")));
|
||||
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, then names
|
||||
* the container type or bulk commodity the fee covers.
|
||||
*/
|
||||
const isCargoKindTrigger = (values: Record<string, unknown>) =>
|
||||
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
|
||||
["CUSTOMS_CLEARANCE", "ETHIOPIAN_CUSTOMS_CLEARANCE", "CANCELLATION"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
);
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
@@ -345,6 +357,7 @@ const unitsForShape = (
|
||||
// Wagon cancellation fee — scales with the cancelled wagons only.
|
||||
return ["PER_WAGON"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
case "ETHIOPIAN_CUSTOMS_CLEARANCE":
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
? ["PER_TON", "PER_WAGON"]
|
||||
@@ -876,6 +889,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
|
||||
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
|
||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||
{
|
||||
name: "includesEthiopianCustomsOnly",
|
||||
label: "Ethiopian customs only",
|
||||
type: "boolean",
|
||||
description:
|
||||
"EDR clears customs on the Ethiopian side only. Same clearance flow; contracts and bookings price off the Ethiopian customs clearance rate instead of the standard one.",
|
||||
showIf: (v) => v.includesCustoms === true,
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
@@ -1081,6 +1102,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "ethiopian-customs",
|
||||
label: "Ethiopian customs",
|
||||
filters: { trigger: "ETHIOPIAN_CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
@@ -1235,6 +1261,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
[
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
"CANCELLATION",
|
||||
"WITH_RETURN",
|
||||
"LASHING",
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Train,
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -59,6 +60,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
@@ -1287,6 +1289,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
|
||||
Leg board
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="wagon-yards" leftSection={<Warehouse size={16} />}>
|
||||
Schedule yards
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -1382,6 +1387,15 @@ export default function TrainScheduleV2DetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="wagon-yards">
|
||||
{scheduleId ? (
|
||||
<ScheduleWagonYardPanel
|
||||
scheduleId={scheduleId}
|
||||
canEdit={hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update)}
|
||||
/>
|
||||
) : null}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history">
|
||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -232,6 +232,9 @@ import {
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type ScheduleConsist,
|
||||
type ScheduleWagonYards,
|
||||
type UpdateScheduleWagonYardsPayload,
|
||||
type UpdateScheduleWagonYardsResult,
|
||||
type ScheduleHistoryEntry,
|
||||
type TrainComposition,
|
||||
type UpdateTrainDetailsPayload,
|
||||
@@ -416,6 +419,30 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
scheduleWagonYards: endpoint<{ scheduleId: string }, ScheduleWagonYards>(
|
||||
"train-scheduling",
|
||||
"schedule-wagon-yards",
|
||||
({ scheduleId }) =>
|
||||
trainBuilderService.scheduleWagonYards(scheduleId).then((r) => r.data),
|
||||
({ scheduleId }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"wagon-yards",
|
||||
scheduleId,
|
||||
],
|
||||
),
|
||||
|
||||
updateScheduleWagonYards: endpoint<
|
||||
{ scheduleId: string; payload: UpdateScheduleWagonYardsPayload },
|
||||
UpdateScheduleWagonYardsResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"update-schedule-wagon-yards",
|
||||
({ scheduleId, payload }) =>
|
||||
trainBuilderService.updateScheduleWagonYards(scheduleId, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
adjustConsist: endpoint<
|
||||
{ scheduleId: string; payload: AdjustConsistPayload },
|
||||
AdjustConsistResult
|
||||
|
||||
@@ -466,11 +466,11 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia sets or revises a charge's amount + currency. */
|
||||
/** GL Ethiopia sets or revises a charge's amount, currency and description. */
|
||||
billClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
payload: { amount: number; currency: string },
|
||||
payload: { amount: number; currency: string; description?: string },
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const response = await client.patch(
|
||||
`/bookings/${id}/clearance/charges/${chargeId}/bill`,
|
||||
@@ -479,7 +479,7 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia issues the charge's payable invoice to the customer. */
|
||||
/** GL Ethiopia sends the priced charge to the customer for approval. */
|
||||
sendClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
@@ -490,16 +490,17 @@ export const bookingsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
|
||||
/** GL Ethiopia creates the miscellaneous charge (document + amount + currency). */
|
||||
/** GL Ethiopia creates a miscellaneous charge (document + amount + currency + description). */
|
||||
createMiscellaneousCharge: async (
|
||||
id: string,
|
||||
file: File,
|
||||
payload: { amount: number; currency: string },
|
||||
payload: { amount: number; currency: string; description: string },
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("amount", String(payload.amount));
|
||||
form.append("currency", payload.currency);
|
||||
form.append("description", payload.description);
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/clearance/charges/miscellaneous`,
|
||||
form,
|
||||
|
||||
@@ -300,6 +300,45 @@ export interface ScheduleHistoryEntry {
|
||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
|
||||
|
||||
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
|
||||
export interface ScheduleWagonYardRow {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
sequenceNumber: number | null;
|
||||
wagonType: { id: string; code: string; name: string };
|
||||
physicalYardId: string | null;
|
||||
physicalYardLabel: string | null;
|
||||
plannedYardId: string | null;
|
||||
plannedYardLabel: string | null;
|
||||
aligned: boolean;
|
||||
locked: boolean;
|
||||
lockReason: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleWagonYardStop {
|
||||
yardId: string;
|
||||
label: string;
|
||||
/** Origin or intermediate stop — wagons can board here. The destination cannot. */
|
||||
pickup: boolean;
|
||||
planned: number;
|
||||
physical: number;
|
||||
}
|
||||
|
||||
export interface ScheduleWagonYards {
|
||||
scheduleId: string;
|
||||
train: { id: string; code: string };
|
||||
editable: boolean;
|
||||
stops: ScheduleWagonYardStop[];
|
||||
wagons: ScheduleWagonYardRow[];
|
||||
misaligned: number;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleWagonYardsPayload {
|
||||
moves: Array<{ wagonId: string; yardId: string }>;
|
||||
}
|
||||
|
||||
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
|
||||
|
||||
export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
@@ -350,6 +389,15 @@ export const trainBuilderService = {
|
||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||
payload,
|
||||
),
|
||||
/** Schedule-only wagon yard plan (where THIS departure boards each wagon). */
|
||||
scheduleWagonYards: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleWagonYards>(`/train-scheduling/schedules/${scheduleId}/wagon-yards`),
|
||||
/** Re-plan boarding yards for this schedule; physical wagon yards untouched. */
|
||||
updateScheduleWagonYards: (scheduleId: string, payload: UpdateScheduleWagonYardsPayload) =>
|
||||
apiClient.patch<UpdateScheduleWagonYardsResult>(
|
||||
`/train-scheduling/schedules/${scheduleId}/wagon-yards`,
|
||||
payload,
|
||||
),
|
||||
/** Unified wagon/booking change history for the schedule's History tab. */
|
||||
scheduleHistory: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleHistoryEntry[]>(
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { memo } from "react";
|
||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||
import { Stepper } from "./Stepper";
|
||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||
import { payWindowState } from "@/pages/bookings/payments/payment-drain";
|
||||
import { PayButton } from "@/pages/bookings/payments/PayButton";
|
||||
import { useMyPayables } from "@/pages/bookings/payments/useMyPayables";
|
||||
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -28,21 +28,9 @@ export const BookingRow = memo(function BookingRow({
|
||||
const Icon = cfg.icon;
|
||||
const AIcon = cfg.action.icon;
|
||||
const ap = ACTION_PROPS[cfg.action.kind];
|
||||
// Payable bookings get an inline "Pay now" that opens the payment modal
|
||||
// instead of navigating to the detail page. A general contract is payable as
|
||||
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
|
||||
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
|
||||
const payableStatus =
|
||||
booking.bookingType === "GENERAL_CONTRACT"
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
// A fully-closed pay window (deadline + drain both elapsed) has nothing to pay
|
||||
// against, so the row falls back to its normal action instead of an empty slot.
|
||||
// The drain itself still routes here — PayNowButton renders the wait notice.
|
||||
const canPay =
|
||||
booking.status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed";
|
||||
// Anything outstanding (freight, clearance charge, duty slip, cancellation
|
||||
// fee) → "Pay" jumps to the booking's Payments tab. One shared query.
|
||||
const payable = useMyPayables().get(booking.id);
|
||||
// Clearance/operation steps + changes-requested resubmit can be done in place
|
||||
// via a modal on the row.
|
||||
const hasInlineAction = bookingHasInlineAction(booking);
|
||||
@@ -113,8 +101,8 @@ export const BookingRow = memo(function BookingRow({
|
||||
{cfg.badgeLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
{canPay ? (
|
||||
<PayNowButton booking={booking} size="sm" />
|
||||
{payable ? (
|
||||
<PayButton bookingId={booking.id} summary={payable} size="sm" />
|
||||
) : canSign ? (
|
||||
<ContractSignButton booking={booking} size="sm" />
|
||||
) : canApproveDelivery ? (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
@@ -15,19 +15,20 @@ import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
FileBadge,
|
||||
MessageSquareWarning,
|
||||
Receipt,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import {
|
||||
bookingsService,
|
||||
} from "@/services/bookings.service";
|
||||
import { downloadStoredFile } from "@/services/files.service";
|
||||
import { ClearancePhaseStepper } from "../contracts/ClearancePhaseStepper";
|
||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||
@@ -36,29 +37,6 @@ import { GREEN, INK } from "../contracts/contract-ui";
|
||||
|
||||
const BORDER = "#E6ECF2";
|
||||
|
||||
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
|
||||
const INVOICE_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft",
|
||||
ISSUED: "Issued",
|
||||
PENDING: "Due",
|
||||
PAYMENT_PROCESSING: "Payment processing",
|
||||
PARTIALLY_PAID: "Partially paid",
|
||||
PAID: "Paid",
|
||||
OVERDUE: "Overdue",
|
||||
CANCELLED: "Cancelled",
|
||||
REFUNDED: "Refunded",
|
||||
EXPIRED: "Expired",
|
||||
};
|
||||
|
||||
function invoiceStatusLabel(status: string): string {
|
||||
return (
|
||||
INVOICE_STATUS_LABELS[status] ??
|
||||
status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,13 +59,9 @@ export function BookingClearanceWorkflowBanner({
|
||||
|
||||
if (!isPhased || !clearance) return null;
|
||||
|
||||
const dutyPaid = clearance.milestones?.some(
|
||||
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
||||
);
|
||||
const dutyPending =
|
||||
clearance.dutyRequired &&
|
||||
clearance.dutyAdvice &&
|
||||
!dutyPaid;
|
||||
// Duty / tax, additional duty and the final invoice are paid from the
|
||||
// booking's Payments tab (CustomsPaymentsCard); this banner keeps the
|
||||
// progress, the draft declaration and the documents.
|
||||
// A change request clears the draft while it's open — show the "waiting on
|
||||
// GL" state instead of the review panel until GL sends a corrected draft.
|
||||
const draftDeclarationChangeRequestPending = Boolean(
|
||||
@@ -131,14 +105,6 @@ export function BookingClearanceWorkflowBanner({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dutyPending && clearance.dutyAdvice ? (
|
||||
<DutyAdvicePanel
|
||||
dutyAdvice={clearance.dutyAdvice}
|
||||
bookingId={booking.id}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clearance.riskLevel ? (
|
||||
<Group gap={10} align="center">
|
||||
<Text fw={700} fz={14} c={INK}>
|
||||
@@ -160,24 +126,6 @@ export function BookingClearanceWorkflowBanner({
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{clearance.secondDuty?.advised ? (
|
||||
<SecondDutyDueCard
|
||||
duty={clearance.secondDuty}
|
||||
bookingId={booking.id}
|
||||
onView={(f) => view(f)}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clearance.finalInvoice ? (
|
||||
<FinalInvoiceDueCard
|
||||
invoice={clearance.finalInvoice}
|
||||
bookingId={booking.id}
|
||||
onView={(f) => view(f)}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{clearance.operationReady ? (
|
||||
<Alert color="green" variant="light">
|
||||
Clearance is complete. You may proceed to request your operation date.
|
||||
@@ -197,76 +145,6 @@ export function BookingClearanceWorkflowBanner({
|
||||
);
|
||||
}
|
||||
|
||||
function DutyAdvicePanel({
|
||||
dutyAdvice,
|
||||
bookingId,
|
||||
onChanged,
|
||||
}: {
|
||||
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
|
||||
bookingId: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const noticeFile = dutyAdvice.noticeFile;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||
<Stack gap="sm">
|
||||
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
||||
<Text size="sm">
|
||||
Amount due:{" "}
|
||||
<strong>
|
||||
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
||||
</strong>
|
||||
{dutyAdvice.declarationSerial
|
||||
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
||||
: null}
|
||||
</Text>
|
||||
{noticeFile ? (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
|
||||
size="sm"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Download size={14} />
|
||||
Download duty notice ({noticeFile.name})
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : null}
|
||||
<Text size="sm" c="dimmed">
|
||||
Pay the amount above, then upload your payment slip so clearance can continue.
|
||||
</Text>
|
||||
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
|
||||
<Button
|
||||
color="orange"
|
||||
loading={loading}
|
||||
disabled={!file}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
|
||||
toast.success("Payment slip uploaded");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Submit payment slip
|
||||
</Button>
|
||||
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia sent a draft customs declaration — an estimated price + files
|
||||
* the customer must accept before the real declaration is filed, or send back
|
||||
@@ -446,328 +324,8 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
|
||||
* invoice document; the customer pays offline and attaches the payment slip
|
||||
* here, then GL confirms and the badge flips to PAID.
|
||||
*/
|
||||
function FinalInvoiceDueCard({
|
||||
invoice,
|
||||
bookingId,
|
||||
onView,
|
||||
onChanged,
|
||||
}: {
|
||||
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
|
||||
bookingId: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const paid = invoice.status === "PAID";
|
||||
// GL Djibouti raises it as a draft: nothing is payable until the customer
|
||||
// reviews the attached invoice and approves it.
|
||||
const approved = Boolean(invoice.approvedAt);
|
||||
|
||||
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
|
||||
? "Final invoice paid"
|
||||
: approved
|
||||
? "Final invoice due"
|
||||
: "Final invoice — your approval needed"}{" "}
|
||||
— {invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{approved
|
||||
? invoiceStatusLabel(invoice.status)
|
||||
: "Awaiting your approval"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
{invoice.description ? (
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{invoice.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||
{approved
|
||||
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
|
||||
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" miw={260}>
|
||||
{invoice.invoiceFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.invoiceFile!.name,
|
||||
url: invoice.invoiceFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{invoice.slipFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.slipFile!.name,
|
||||
url: invoice.slipFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View payment slip
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && !approved ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={approving}
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={async () => {
|
||||
setApproving(true);
|
||||
try {
|
||||
await contractsService.approveFinalInvoice(bookingId);
|
||||
toast.success("Invoice approved — you can now pay");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Approval failed");
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Approve invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && approved ? (
|
||||
<>
|
||||
<FileInput
|
||||
placeholder={
|
||||
invoice.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.uploadFinalInvoiceSlip(bookingId, slip);
|
||||
setSlip(null);
|
||||
toast.success("Payment slip attached");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Upload failed",
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</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.ClearanceView["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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,9 +252,9 @@ export function BookingPaymentPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
<SectionCard id="freight-payment" p={22}>
|
||||
<Group justify="space-between" align="center">
|
||||
<CardTitle>Payment</CardTitle>
|
||||
<CardTitle>Freight payment</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Box, Button, Group, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, CreditCard, Receipt, X } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { formatAmount } from "../utils";
|
||||
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const LABEL: Record<Freight.ClearanceChargeType, string> = {
|
||||
PORT_CHARGES: "Port charges",
|
||||
MISCELLANEOUS: "Miscellaneous charge",
|
||||
};
|
||||
|
||||
const STATUS: Record<
|
||||
Freight.ClearanceChargeStatus,
|
||||
{ label: string; bg: string; fg: string }
|
||||
> = {
|
||||
DOC_UPLOADED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
|
||||
BILLED: { label: "DRAFT", bg: "#EEF2F6", fg: "#64748B" },
|
||||
SENT: { label: "NEEDS YOUR APPROVAL", bg: "#FEF3E2", fg: "#B45309" },
|
||||
REJECTED: { label: "REJECTED", bg: "#FEE2E2", fg: "#B91C1C" },
|
||||
ACCEPTED: { label: "ACCEPTED — UNPAID", bg: "#E0F2FE", fg: "#0369A1" },
|
||||
PAID: { label: "PAID", bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||
};
|
||||
|
||||
const money = (c: Freight.ClearanceCharge) =>
|
||||
`${formatAmount(c.amount)} ${c.currency ?? ""}`;
|
||||
|
||||
/**
|
||||
* Clearance charges Global Logistics proposed for this shipment. The customer
|
||||
* accepts a price (its invoice is then issued and payable here) or rejects it
|
||||
* with a note so GL can revise. Renders nothing until GL sends a charge.
|
||||
*/
|
||||
export function ClearanceChargesSection({ bookingId }: { bookingId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const key = ["booking-clearance-charges", bookingId];
|
||||
const { data: charges = [] } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => bookingsService.getClearanceCharges(bookingId),
|
||||
});
|
||||
const [rejecting, setRejecting] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [payCharge, setPayCharge] = useState<Freight.ClearanceCharge | null>(null);
|
||||
const pay = useInvoicePayment();
|
||||
|
||||
const onError = (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : "Could not update the charge");
|
||||
const accept = useMutation({
|
||||
mutationFn: (chargeId: string) =>
|
||||
bookingsService.acceptClearanceCharge(bookingId, chargeId),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(key, next);
|
||||
toast.success("Accepted — your invoice is ready to pay");
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const reject = useMutation({
|
||||
mutationFn: (p: { chargeId: string; note: string }) =>
|
||||
bookingsService.rejectClearanceCharge(bookingId, p.chargeId, p.note),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(key, next);
|
||||
setRejecting(null);
|
||||
setNote("");
|
||||
toast.success("Sent back to Global Logistics");
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const busy = accept.isPending || reject.isPending;
|
||||
|
||||
if (charges.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionCard id="clearance-charges">
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Clearance charges</CardTitle>
|
||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
||||
{charges.length} {charges.length === 1 ? "charge" : "charges"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap={12}>
|
||||
{charges.map((c) => {
|
||||
const st = STATUS[c.status];
|
||||
return (
|
||||
<Box
|
||||
key={c.id}
|
||||
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fz="13.5px" fw={700} c="#10202F">
|
||||
{LABEL[c.type]}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
padding: "3px 9px",
|
||||
borderRadius: 999,
|
||||
background: st.bg,
|
||||
color: st.fg,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{st.label}
|
||||
</Box>
|
||||
</Group>
|
||||
{c.description && (
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={4}>
|
||||
{c.description}
|
||||
</Text>
|
||||
)}
|
||||
{c.invoiceNumber && c.invoiceId && (
|
||||
<Text fz="12px" c="#9AA8B5" mt={4}>
|
||||
Invoice{" "}
|
||||
<Link to={`/billing/${c.invoiceId}`} style={{ color: "#2E5B96" }}>
|
||||
{c.invoiceNumber}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text fz="14px" fw={800} c="#10202F" style={{ whiteSpace: "nowrap" }}>
|
||||
{money(c)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{c.status === "REJECTED" && c.customerNote && (
|
||||
<Alert color="red" variant="light" radius="md" p="xs" mt="sm">
|
||||
<Text fz="12.5px">
|
||||
You rejected this price: “{c.customerNote}”. Global Logistics
|
||||
will revise it and send it again.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{c.status === "SENT" &&
|
||||
(rejecting === c.id ? (
|
||||
<Stack gap={6} mt="sm">
|
||||
<Textarea
|
||||
label="Why are you rejecting this charge?"
|
||||
placeholder="Tell Global Logistics what is wrong with the price…"
|
||||
minRows={2}
|
||||
autosize
|
||||
maxLength={1000}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
/>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setRejecting(null);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
size="xs"
|
||||
loading={reject.isPending}
|
||||
disabled={!note.trim()}
|
||||
onClick={() => reject.mutate({ chargeId: c.id, note: note.trim() })}
|
||||
>
|
||||
Submit rejection
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap="xs" justify="flex-end" mt="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius={10}
|
||||
leftSection={<X size={14} />}
|
||||
disabled={busy}
|
||||
onClick={() => setRejecting(c.id)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius={10}
|
||||
leftSection={<Check size={14} />}
|
||||
loading={accept.isPending}
|
||||
disabled={busy}
|
||||
onClick={() => accept.mutate(c.id)}
|
||||
>
|
||||
Accept price
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{c.status === "ACCEPTED" && c.invoiceId && (
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={() => setPayCharge(c)}
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{c.status === "PAID" && (
|
||||
<Group gap={6} justify="flex-end" mt="sm">
|
||||
<Receipt size={14} color="#0A6F4D" />
|
||||
<Text fz="12px" c="#0A6F4D" fw={600}>
|
||||
Paid{c.paidAt ? ` · ${new Date(c.paidAt).toLocaleString()}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payCharge !== null}
|
||||
onClose={() => {
|
||||
if (!pay.processing) {
|
||||
setPayCharge(null);
|
||||
pay.reset();
|
||||
}
|
||||
}}
|
||||
amountLabel={payCharge ? money(payCharge) : undefined}
|
||||
currency={payCharge?.currency}
|
||||
onConfirm={(method, payerAccount) =>
|
||||
payCharge?.invoiceId && pay.pay(payCharge.invoiceId, method, payerAccount)
|
||||
}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
bill={pay.bill}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
Eye,
|
||||
FileBadge,
|
||||
Receipt,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useState,
|
||||
} from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
useFileViewer,
|
||||
} from "@/hooks/useFileViewer";
|
||||
import {
|
||||
GREEN,
|
||||
INK,
|
||||
} from "@/pages/contracts/contract-ui";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { downloadStoredFile } from "@/services/files.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
// Customer-facing labels for an invoice status (Freight.InvoiceStatus).
|
||||
const INVOICE_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft",
|
||||
ISSUED: "Issued",
|
||||
PENDING: "Due",
|
||||
PAYMENT_PROCESSING: "Payment processing",
|
||||
PARTIALLY_PAID: "Partially paid",
|
||||
PAID: "Paid",
|
||||
OVERDUE: "Overdue",
|
||||
CANCELLED: "Cancelled",
|
||||
REFUNDED: "Refunded",
|
||||
EXPIRED: "Expired",
|
||||
};
|
||||
|
||||
function invoiceStatusLabel(status: string): string {
|
||||
return (
|
||||
INVOICE_STATUS_LABELS[status] ??
|
||||
status
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (m) => m.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customs payments on a phased (customs) booking — duty / tax, the post-arrival
|
||||
* additional duty and GL Djibouti's final invoice. All are paid by bank
|
||||
* transfer; the customer attaches the slip here and Global Logistics confirms.
|
||||
* Renders nothing until customs has advised something.
|
||||
*/
|
||||
export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking }) {
|
||||
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { data: clearance, refetch } = useQuery({
|
||||
queryKey: ["booking-clearance", booking.id],
|
||||
queryFn: () => bookingsService.getClearance(booking.id),
|
||||
enabled: isPhased,
|
||||
});
|
||||
if (!isPhased || !clearance) return null;
|
||||
|
||||
const dutyPaid = Boolean(
|
||||
clearance.milestones?.some(
|
||||
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
||||
),
|
||||
);
|
||||
const showDuty = Boolean(clearance.dutyRequired && clearance.dutyAdvice);
|
||||
const showSecond = Boolean(
|
||||
clearance.secondDuty?.advised || clearance.secondDuty?.paid,
|
||||
);
|
||||
const showFinal = Boolean(clearance.finalInvoice);
|
||||
if (!showDuty && !showSecond && !showFinal) return null;
|
||||
|
||||
const onChanged = () => void refetch();
|
||||
|
||||
return (
|
||||
<SectionCard id="customs-payments">
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Customs payments</CardTitle>
|
||||
<Text fz="12px" c="#9AA8B5">
|
||||
Bank transfer · upload the slip here
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="md">
|
||||
{showDuty && clearance.dutyAdvice && (
|
||||
dutyPaid ? (
|
||||
<PaidRow
|
||||
label="Customs duty & tax"
|
||||
amount={clearance.dutyAdvice.amount}
|
||||
currency={clearance.dutyAdvice.currency}
|
||||
/>
|
||||
) : (
|
||||
<DutyAdvicePanel
|
||||
dutyAdvice={clearance.dutyAdvice}
|
||||
bookingId={booking.id}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{showSecond && clearance.secondDuty && (
|
||||
<SecondDutyDueCard
|
||||
duty={clearance.secondDuty}
|
||||
bookingId={booking.id}
|
||||
onView={view}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
{showFinal && clearance.finalInvoice && (
|
||||
<FinalInvoiceDueCard
|
||||
invoice={clearance.finalInvoice}
|
||||
bookingId={booking.id}
|
||||
onView={view}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A settled customs payment — slip uploaded, nothing left to do. */
|
||||
function PaidRow({
|
||||
label,
|
||||
amount,
|
||||
currency,
|
||||
}: {
|
||||
label: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
border: "1px solid #CDEBDD",
|
||||
background: "#F6FBF8",
|
||||
borderRadius: 12,
|
||||
padding: "12px 14px",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<CheckCircle2 size={16} color={GREEN} />
|
||||
<Text fz="13.5px" fw={700} c={INK}>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
Slip uploaded
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz="14px" fw={800} c={INK} style={{ whiteSpace: "nowrap" }}>
|
||||
{amount.toLocaleString()} {currency}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function DutyAdvicePanel({
|
||||
dutyAdvice,
|
||||
bookingId,
|
||||
onChanged,
|
||||
}: {
|
||||
dutyAdvice: NonNullable<Freight.ClearanceView["dutyAdvice"]>;
|
||||
bookingId: string;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const noticeFile = dutyAdvice.noticeFile;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md" bg="#FFFBF0">
|
||||
<Stack gap="sm">
|
||||
<GroupLabel icon={Receipt} text="Duty / tax payment" />
|
||||
<Text size="sm">
|
||||
Amount due:{" "}
|
||||
<strong>
|
||||
{dutyAdvice.amount.toLocaleString()} {dutyAdvice.currency}
|
||||
</strong>
|
||||
{dutyAdvice.declarationSerial
|
||||
? ` · Payment code: ${dutyAdvice.declarationSerial}`
|
||||
: null}
|
||||
</Text>
|
||||
{noticeFile ? (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => void downloadStoredFile(noticeFile.id, noticeFile.name)}
|
||||
size="sm"
|
||||
>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Download size={14} />
|
||||
Download duty notice ({noticeFile.name})
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : null}
|
||||
<Text size="sm" c="dimmed">
|
||||
Pay the amount above, then upload your payment slip so clearance can continue.
|
||||
</Text>
|
||||
<FileInput label="Payment slip" value={file} onChange={setFile} size="sm" />
|
||||
<Button
|
||||
color="orange"
|
||||
loading={loading}
|
||||
disabled={!file}
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await bookingsService.uploadBookingClearanceDutySlip(bookingId, file);
|
||||
toast.success("Payment slip uploaded");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Submit payment slip
|
||||
</Button>
|
||||
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Icon size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
{text}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
|
||||
* invoice document; the customer pays offline and attaches the payment slip
|
||||
* here, then GL confirms and the badge flips to PAID.
|
||||
*/
|
||||
function FinalInvoiceDueCard({
|
||||
invoice,
|
||||
bookingId,
|
||||
onView,
|
||||
onChanged,
|
||||
}: {
|
||||
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
|
||||
bookingId: string;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const paid = invoice.status === "PAID";
|
||||
// GL Djibouti raises it as a draft: nothing is payable until the customer
|
||||
// reviews the attached invoice and approves it.
|
||||
const approved = Boolean(invoice.approvedAt);
|
||||
|
||||
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
|
||||
? "Final invoice paid"
|
||||
: approved
|
||||
? "Final invoice due"
|
||||
: "Final invoice — your approval needed"}{" "}
|
||||
— {invoice.invoiceNumber}
|
||||
</Text>
|
||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
||||
{approved
|
||||
? invoiceStatusLabel(invoice.status)
|
||||
: "Awaiting your approval"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
||||
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
||||
</Text>
|
||||
{invoice.description ? (
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{invoice.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{!paid ? (
|
||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
||||
{approved
|
||||
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
|
||||
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" miw={260}>
|
||||
{invoice.invoiceFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.invoiceFile!.name,
|
||||
url: invoice.invoiceFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{invoice.slipFile ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
onView({
|
||||
name: invoice.slipFile!.name,
|
||||
url: invoice.slipFile!.url,
|
||||
})
|
||||
}
|
||||
>
|
||||
View payment slip
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && !approved ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={approving}
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={async () => {
|
||||
setApproving(true);
|
||||
try {
|
||||
await contractsService.approveFinalInvoice(bookingId);
|
||||
toast.success("Invoice approved — you can now pay");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Approval failed");
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Approve invoice
|
||||
</Button>
|
||||
) : null}
|
||||
{!paid && approved ? (
|
||||
<>
|
||||
<FileInput
|
||||
placeholder={
|
||||
invoice.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.uploadFinalInvoiceSlip(bookingId, slip);
|
||||
setSlip(null);
|
||||
toast.success("Payment slip attached");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Upload failed",
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.ClearanceView["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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { Box, Button, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
CreditCard,
|
||||
FileCheck2,
|
||||
Landmark,
|
||||
Scale,
|
||||
Upload,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import {
|
||||
isReviewAction,
|
||||
useBookingPayables,
|
||||
type PayableAction,
|
||||
type PayableItem,
|
||||
} from "@/pages/bookings/payments/useBookingPayables";
|
||||
import type { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
import { formatAmount } from "../utils";
|
||||
import { BookingPaymentPanel } from "./BookingPaymentPanel";
|
||||
import { ClearanceChargesSection } from "./ClearanceChargesSection";
|
||||
import { CustomsPaymentsCard } from "./CustomsPaymentsCard";
|
||||
import { BodyGrid, CardTitle, SectionCard } from "./layout";
|
||||
import { WagonCancellationCard } from "./WagonCancellationCard";
|
||||
|
||||
const ACTION_META: Record<
|
||||
PayableAction,
|
||||
{ verb: string; icon: LucideIcon; color: string }
|
||||
> = {
|
||||
PAY: { verb: "Pay now", icon: CreditCard, color: "#0A6F4D" },
|
||||
BANK_TRANSFER: { verb: "Bank transfer", icon: Landmark, color: "#B07D14" },
|
||||
UPLOAD_SLIP: { verb: "Upload slip", icon: Upload, color: "#B07D14" },
|
||||
APPROVE: { verb: "Approve", icon: FileCheck2, color: "#2E5B96" },
|
||||
DECIDE: { verb: "Accept or reject", icon: Scale, color: "#2E5B96" },
|
||||
};
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${formatAmount(amount)} ${currency}`.trim();
|
||||
|
||||
const scrollTo = (id: string) =>
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
/**
|
||||
* The booking's Payments tab — every amount the customer owes or must decide
|
||||
* on, in one place: freight (with its pay window), clearance charges to
|
||||
* accept and pay, customs duty / final invoice slips, wagon-cancellation fees.
|
||||
* The summary strip at the top lists what is outstanding and jumps to the card
|
||||
* that settles it.
|
||||
*/
|
||||
export function PaymentsTab({
|
||||
booking,
|
||||
pay,
|
||||
showCountdown,
|
||||
onBookingUpdated,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
pay: ReturnType<typeof useBookingPayment>;
|
||||
showCountdown: boolean;
|
||||
onBookingUpdated?: () => void;
|
||||
}) {
|
||||
const { items, dueTotals, loading } = useBookingPayables(booking);
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<PaymentsSummary items={items} dueTotals={dueTotals} loading={loading} />
|
||||
<BodyGrid
|
||||
left={
|
||||
<>
|
||||
<ClearanceChargesSection bookingId={booking.id} />
|
||||
<CustomsPaymentsCard booking={booking} />
|
||||
<WagonCancellationCard
|
||||
booking={booking}
|
||||
onBookingUpdated={onBookingUpdated}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
right={
|
||||
<BookingPaymentPanel
|
||||
booking={booking}
|
||||
pricing={booking.pricingBreakdown}
|
||||
onPay={pay.open}
|
||||
paying={pay.processing}
|
||||
showCountdown={showCountdown}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentsSummary({
|
||||
items,
|
||||
dueTotals,
|
||||
loading,
|
||||
}: {
|
||||
items: PayableItem[];
|
||||
dueTotals: Array<{ currency: string; amount: number }>;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const reviews = items.filter((i) => isReviewAction(i.action)).length;
|
||||
const settled = !loading && items.length === 0;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
p={22}
|
||||
style={{
|
||||
background: settled ? "#F6FBF8" : "#FFFDF7",
|
||||
borderColor: settled ? "#CDEBDD" : "#F3E2B8",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||
<Box style={{ minWidth: 220 }}>
|
||||
<CardTitle>{settled ? "All settled" : "Amount due"}</CardTitle>
|
||||
{loading ? (
|
||||
<Text fz="14px" c="#9AA8B5" mt={8}>
|
||||
Checking your payments…
|
||||
</Text>
|
||||
) : settled ? (
|
||||
<Group gap={8} mt={8} wrap="nowrap">
|
||||
<CheckCircle2 size={20} color="#0A6F4D" />
|
||||
<Text fz="18px" fw={800} c="#10202F">
|
||||
Nothing to pay right now
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
<Group gap={18} mt={6} align="baseline">
|
||||
{dueTotals.length > 0 ? (
|
||||
dueTotals.map((t) => (
|
||||
<Text key={t.currency} fz="28px" fw={800} c="#10202F" lh={1.1}>
|
||||
{money(t.amount, t.currency)}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text fz="20px" fw={800} c="#10202F">
|
||||
Your review is needed
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={6}>
|
||||
{items.length} {items.length === 1 ? "item needs" : "items need"} your
|
||||
attention
|
||||
{reviews > 0 ? ` · ${reviews} awaiting your review` : ""}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!settled && !loading && (
|
||||
<Stack gap={6} style={{ flex: 1, minWidth: 280, maxWidth: 480 }}>
|
||||
{items.map((it) => {
|
||||
const m = ACTION_META[it.action];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={`${it.anchor}-${it.id}`}
|
||||
onClick={() => scrollTo(it.anchor)}
|
||||
style={{
|
||||
border: "1px solid #EEF2F6",
|
||||
borderRadius: 10,
|
||||
padding: "8px 12px",
|
||||
background: "white",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap={10}>
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Icon size={14} color={m.color} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="13px" fw={700} c="#10202F" truncate>
|
||||
{it.label}
|
||||
</Text>
|
||||
{it.detail && (
|
||||
<Text fz="11.5px" c="#9AA8B5" truncate>
|
||||
{it.detail}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text
|
||||
fz="13px"
|
||||
fw={800}
|
||||
c="#10202F"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{money(it.amount, it.currency)}
|
||||
</Text>
|
||||
<Text
|
||||
fz="11.5px"
|
||||
fw={700}
|
||||
c={m.color}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{m.verb}
|
||||
</Text>
|
||||
<ArrowRight size={13} color="#9AA8B5" />
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Group>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "amount due" strip on the Overview tab — the only payment surface
|
||||
* left there. Renders nothing when the booking has nothing outstanding.
|
||||
*/
|
||||
export function PaymentsDueStrip({
|
||||
booking,
|
||||
onOpen,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { items, dueTotals } = useBookingPayables(booking);
|
||||
if (items.length === 0) return null;
|
||||
const labels = [...new Set(items.map((i) => i.label))].join(", ");
|
||||
return (
|
||||
<SectionCard
|
||||
p="md"
|
||||
style={{ background: "#FFFDF7", borderColor: "#F3E2B8" }}
|
||||
>
|
||||
<Group justify="space-between" wrap="wrap" gap="md">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FEF3E2",
|
||||
color: "#B45309",
|
||||
}}
|
||||
>
|
||||
<CreditCard size={18} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz="14px" fw={800} c="#10202F">
|
||||
{dueTotals.length > 0
|
||||
? `${dueTotals.map((t) => money(t.amount, t.currency)).join(" + ")} due`
|
||||
: "A payment needs your review"}
|
||||
</Text>
|
||||
<Text fz="12.5px" c="#6B7C8E" truncate>
|
||||
{items.length} {items.length === 1 ? "item" : "items"}: {labels}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius={10}
|
||||
rightSection={<ArrowRight size={15} />}
|
||||
onClick={onOpen}
|
||||
>
|
||||
Go to payments
|
||||
</Button>
|
||||
</Group>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ export function WagonCancellationCard({
|
||||
if (!canRequest && !ownRows.length) return null;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<SectionCard id="wagon-cancellation">
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<CardTitle>Wagon Cancellation</CardTitle>
|
||||
{/* {canRequest && !openRow && !creditRow && (
|
||||
|
||||
@@ -34,8 +34,8 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { payWindowState } from "./payments/payment-drain";
|
||||
import { PayButton } from "./payments/PayButton";
|
||||
import { useMyPayables } from "./payments/useMyPayables";
|
||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||
import {
|
||||
@@ -181,11 +181,14 @@ const STAT_CARDS: Array<{
|
||||
function PrimaryAction({
|
||||
booking,
|
||||
credit,
|
||||
payable,
|
||||
onNavigate,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
|
||||
credit?: WagonCancellation;
|
||||
/** Outstanding payments on this booking (from `my-payables`), if any. */
|
||||
payable?: Freight.BookingPayableSummary;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
@@ -199,9 +202,6 @@ function PrimaryAction({
|
||||
/>
|
||||
);
|
||||
}
|
||||
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
||||
// one-time booking only after it's SELECTED_FOR_BATCH.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
if (status === "DRAFT") {
|
||||
return (
|
||||
<Button
|
||||
@@ -220,24 +220,16 @@ function PrimaryAction({
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// Anything outstanding (freight, clearance charge, duty slip, cancellation
|
||||
// fee) → "Pay" jumps to the booking's Payments tab.
|
||||
if (payable) {
|
||||
return <PayButton bookingId={id} summary={payable} />;
|
||||
}
|
||||
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
|
||||
// modal (update & resubmit, upload clearance docs, schedule & proceed).
|
||||
if (bookingHasInlineAction(booking)) {
|
||||
return <BookingActionButton booking={booking} size="xs" />;
|
||||
}
|
||||
const payableStatus = isGeneralContract
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
// A fully-closed pay window (deadline + drain both elapsed) falls through to
|
||||
// the default action. The drain itself still routes here — PayNowButton
|
||||
// renders the "payment processing" wait notice instead of a pay action.
|
||||
if (
|
||||
status === payableStatus &&
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
payWindowState(booking).phase !== "closed"
|
||||
) {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
// Contract ready for the customer's signature → full-page contract viewer.
|
||||
if (bookingIsSignable(booking)) {
|
||||
return <ContractSignButton booking={booking} size="xs" />;
|
||||
@@ -460,6 +452,8 @@ export default function BookingsListPage() {
|
||||
input: { pageSize: 100 },
|
||||
}),
|
||||
);
|
||||
// Outstanding payments per booking → row "Pay" button (one shared query).
|
||||
const payables = useMyPayables();
|
||||
const creditByBooking = useMemo(() => {
|
||||
const m = new Map<string, WagonCancellation>();
|
||||
for (const r of myCancellations?.items ?? []) {
|
||||
@@ -702,6 +696,7 @@ export default function BookingsListPage() {
|
||||
<PrimaryAction
|
||||
booking={booking}
|
||||
credit={creditByBooking.get(booking.id)}
|
||||
payable={payables.get(booking.id)}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
|
||||
@@ -39,7 +39,7 @@ interface BookingActionButtonProps {
|
||||
* when the booking has no customer-actionable clearance/operation step;
|
||||
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
|
||||
*
|
||||
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
|
||||
* Drop it into a list row exactly like {@link PayButton} — it stops click
|
||||
* propagation so it never triggers the row's navigation handler.
|
||||
*/
|
||||
export function BookingActionButton({
|
||||
|
||||
@@ -28,7 +28,7 @@ interface ContractSignButtonProps {
|
||||
* that navigates to the full-page contract viewer ({@link BookingContractPage})
|
||||
* where the signature flow lives.
|
||||
*
|
||||
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
|
||||
* Drop it into a list row exactly like {@link PayButton} — it stops click
|
||||
* propagation so it never triggers the row's navigation handler.
|
||||
*/
|
||||
export function ContractSignButton({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatAmount } from "../BookingDetailPage/utils";
|
||||
|
||||
/** The booking detail page opened on its Payments tab. */
|
||||
export const paymentsTabPath = (bookingId: string) =>
|
||||
`/bookings/${bookingId}?tab=payments`;
|
||||
|
||||
/**
|
||||
* "Pay" on a list / home row. Every payable item (freight, clearance charges,
|
||||
* customs duty, cancellation fees) lives on the booking's Payments tab, so the
|
||||
* row only needs to get the customer there — no per-row payment modal.
|
||||
*/
|
||||
export function PayButton({
|
||||
bookingId,
|
||||
summary,
|
||||
size = "xs",
|
||||
fullWidth,
|
||||
}: {
|
||||
bookingId: string;
|
||||
summary?: Freight.BookingPayableSummary;
|
||||
size?: ButtonProps["size"];
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const single = summary?.totals.length === 1 ? summary.totals[0] : null;
|
||||
// Only items awaiting the customer's review (a proposed price, a draft
|
||||
// final invoice): nothing to pay yet, but still theirs to act on.
|
||||
const reviewOnly = summary != null && summary.totals.length === 0;
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={(e) => {
|
||||
// Don't let a surrounding row-click handler fire.
|
||||
e.stopPropagation();
|
||||
navigate(paymentsTabPath(bookingId));
|
||||
}}
|
||||
>
|
||||
{reviewOnly
|
||||
? "Review payment"
|
||||
: single
|
||||
? `Pay ${formatAmount(single.amount)} ${single.currency}`
|
||||
: "Pay"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Badge, Button, type ButtonProps } from "@mantine/core";
|
||||
import { CreditCard, Landmark } from "lucide-react";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||
import { priceTotal } from "../BookingDetailPage/utils";
|
||||
import { isUsdOfflineBooking } from "./offline-payment";
|
||||
import { payWindowState } from "./payment-drain";
|
||||
import { PaymentProcessingNotice } from "./PaymentProcessingNotice";
|
||||
import { useBookingPayment } from "./useBookingPayment";
|
||||
|
||||
interface PayNowButtonProps {
|
||||
booking: Freight.IBooking;
|
||||
label?: string;
|
||||
size?: ButtonProps["size"];
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained "Pay now" action: shows the payment-method modal in place
|
||||
* instead of navigating to the booking detail page. Drop it into list rows,
|
||||
* cards, or anywhere a payable booking surfaces.
|
||||
*/
|
||||
export function PayNowButton({
|
||||
booking,
|
||||
label = "Pay now",
|
||||
size = "xs",
|
||||
fullWidth,
|
||||
}: PayNowButtonProps) {
|
||||
const pay = useBookingPayment(booking.id);
|
||||
const pricing = booking.pricingBreakdown;
|
||||
const payWindow = payWindowState(booking);
|
||||
|
||||
// Pay deadline passed but in-flight payments are still settling: show the
|
||||
// drain countdown instead of any pay action, so nobody pays a second time.
|
||||
// Checked before the USD branch — a bank transfer is just as double-payable.
|
||||
if (payWindow.phase === "draining" && payWindow.drainEndsAt) {
|
||||
return (
|
||||
<PaymentProcessingNotice
|
||||
drainEndsAt={payWindow.drainEndsAt}
|
||||
variant="inline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Window fully over (drain included) — nothing to pay against anymore.
|
||||
if (payWindow.phase === "closed") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// USD is paid by bank transfer and confirmed by Finance — no online payment.
|
||||
if (isUsdOfflineBooking(booking)) {
|
||||
return (
|
||||
<Badge
|
||||
size={size === "xs" ? "md" : "lg"}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<Landmark size={12} />}
|
||||
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
|
||||
>
|
||||
Pay by bank transfer
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalSafeWrapper>
|
||||
<Button
|
||||
size={size}
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
color="edr-green"
|
||||
fullWidth={fullWidth}
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={(e) => {
|
||||
// Don't let a surrounding row-click handler fire.
|
||||
e.stopPropagation();
|
||||
pay.open();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={pay.modalOpen}
|
||||
onClose={pay.close}
|
||||
amountLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
currency={pricing?.currency ?? booking.paymentCurrency}
|
||||
processing={pay.processing}
|
||||
error={pay.error}
|
||||
otp={pay.otp}
|
||||
bill={pay.bill}
|
||||
onConfirm={pay.confirm}
|
||||
/>
|
||||
</ModalSafeWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
|
||||
import { isUsdOfflineBooking } from "./offline-payment";
|
||||
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "./useBookingPayment";
|
||||
|
||||
export type PayableAction =
|
||||
| "PAY"
|
||||
| "BANK_TRANSFER"
|
||||
| "UPLOAD_SLIP"
|
||||
| "APPROVE"
|
||||
| "DECIDE";
|
||||
|
||||
export interface PayableItem {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string | null;
|
||||
amount: number;
|
||||
currency: string;
|
||||
/** What the customer must do with it. */
|
||||
action: PayableAction;
|
||||
/** DOM id of the Payments-tab card that handles it. */
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
/** Card ids on the Payments tab — the summary strip scrolls to these. */
|
||||
export const PAYABLE_ANCHORS = {
|
||||
freight: "freight-payment",
|
||||
charges: "clearance-charges",
|
||||
customs: "customs-payments",
|
||||
wagons: "wagon-cancellation",
|
||||
} as const;
|
||||
|
||||
/** Booking statuses at which the freight invoice is actually due (mirrors the API). */
|
||||
const FREIGHT_PAYABLE_STATUSES = new Set([
|
||||
"FULLY_EXECUTED",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"AWAITING_PAYMENT",
|
||||
]);
|
||||
|
||||
const CHARGE_LABEL: Record<Freight.ClearanceChargeType, string> = {
|
||||
PORT_CHARGES: "Port charges",
|
||||
MISCELLANEOUS: "Miscellaneous charge",
|
||||
};
|
||||
|
||||
/** Items the customer still has to review before anything is payable. */
|
||||
export const isReviewAction = (a: PayableAction) =>
|
||||
a === "DECIDE" || a === "APPROVE";
|
||||
|
||||
/**
|
||||
* Everything the customer still owes or must decide on for one booking,
|
||||
* assembled from the same queries the Payments-tab cards use (shared keys, so
|
||||
* no extra requests): freight + wagon-fee + final invoices, clearance charges,
|
||||
* customs duty advices. Mirrors the server's `my-payables` rule set.
|
||||
*/
|
||||
export function useBookingPayables(booking: Freight.IBooking) {
|
||||
const isPhased = Boolean(booking.customsClearingEnabled && booking.contractId);
|
||||
|
||||
const invoicesQ = useQuery({
|
||||
queryKey: ["booking-invoices", booking.id],
|
||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||
});
|
||||
const chargesQ = useQuery({
|
||||
queryKey: ["booking-clearance-charges", booking.id],
|
||||
queryFn: () => bookingsService.getClearanceCharges(booking.id),
|
||||
});
|
||||
const clearanceQ = useQuery({
|
||||
queryKey: ["booking-clearance", booking.id],
|
||||
queryFn: () => bookingsService.getClearance(booking.id),
|
||||
enabled: isPhased,
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
const out: PayableItem[] = [];
|
||||
const offline = isUsdOfflineBooking(booking);
|
||||
|
||||
for (const inv of invoicesQ.data ?? []) {
|
||||
const balance = Number(inv.balanceAmount ?? 0);
|
||||
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) {
|
||||
// Raised as DRAFT; issuing IS the customer's approval, then slip-paid.
|
||||
if (inv.status === Freight.InvoiceStatus.Draft) {
|
||||
out.push({
|
||||
id: inv.id,
|
||||
label: "Final invoice",
|
||||
detail: `${inv.invoiceNumber} · approve to proceed`,
|
||||
amount: Number(inv.totalAmount),
|
||||
currency: inv.currency,
|
||||
action: "APPROVE",
|
||||
anchor: PAYABLE_ANCHORS.customs,
|
||||
});
|
||||
} else if (isPayable(inv.status) && balance > 0) {
|
||||
out.push({
|
||||
id: inv.id,
|
||||
label: "Final invoice",
|
||||
detail: inv.invoiceNumber,
|
||||
amount: balance,
|
||||
currency: inv.currency,
|
||||
action: "UPLOAD_SLIP",
|
||||
anchor: PAYABLE_ANCHORS.customs,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isPayable(inv.status) || balance <= 0) continue;
|
||||
if (inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE) {
|
||||
out.push({
|
||||
id: inv.id,
|
||||
label: "Wagon cancellation fee",
|
||||
detail: inv.invoiceNumber,
|
||||
amount: balance,
|
||||
currency: inv.currency,
|
||||
action: "PAY",
|
||||
anchor: PAYABLE_ANCHORS.wagons,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
booking.paymentStatus !== "PAID" &&
|
||||
FREIGHT_PAYABLE_STATUSES.has(booking.status as string)
|
||||
) {
|
||||
out.push({
|
||||
id: inv.id,
|
||||
label: "Freight",
|
||||
detail: inv.invoiceNumber,
|
||||
amount: balance,
|
||||
currency: inv.currency,
|
||||
action: offline ? "BANK_TRANSFER" : "PAY",
|
||||
anchor: PAYABLE_ANCHORS.freight,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of chargesQ.data ?? []) {
|
||||
if (c.status !== "SENT" && c.status !== "ACCEPTED") continue;
|
||||
out.push({
|
||||
id: c.id,
|
||||
label: CHARGE_LABEL[c.type],
|
||||
detail: c.status === "SENT" ? c.description : c.invoiceNumber,
|
||||
amount: c.amount ?? 0,
|
||||
currency: c.currency ?? "",
|
||||
action: c.status === "SENT" ? "DECIDE" : "PAY",
|
||||
anchor: PAYABLE_ANCHORS.charges,
|
||||
});
|
||||
}
|
||||
|
||||
const cl = clearanceQ.data;
|
||||
if (cl) {
|
||||
const dutyPaid = cl.milestones?.some(
|
||||
(m) => m.milestoneCode === "DUTY_TAX_PAID" && m.status === "COMPLETED",
|
||||
);
|
||||
if (cl.dutyRequired && cl.dutyAdvice && !dutyPaid) {
|
||||
out.push({
|
||||
id: "duty",
|
||||
label: "Customs duty & tax",
|
||||
detail: cl.dutyAdvice.declarationSerial
|
||||
? `Payment code ${cl.dutyAdvice.declarationSerial}`
|
||||
: null,
|
||||
amount: cl.dutyAdvice.amount,
|
||||
currency: cl.dutyAdvice.currency,
|
||||
action: "UPLOAD_SLIP",
|
||||
anchor: PAYABLE_ANCHORS.customs,
|
||||
});
|
||||
}
|
||||
if (cl.secondDuty?.advised && !cl.secondDuty.paid) {
|
||||
out.push({
|
||||
id: "second-duty",
|
||||
label: "Additional duty & tax",
|
||||
detail: cl.secondDuty.declarationSerial
|
||||
? `Payment code ${cl.secondDuty.declarationSerial}`
|
||||
: null,
|
||||
amount: cl.secondDuty.amount ?? 0,
|
||||
currency: cl.secondDuty.currency ?? "",
|
||||
action: "UPLOAD_SLIP",
|
||||
anchor: PAYABLE_ANCHORS.customs,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [booking, invoicesQ.data, chargesQ.data, clearanceQ.data]);
|
||||
|
||||
// Payable now, per currency. Items still under review are not "due" yet.
|
||||
const dueTotals = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
if (isReviewAction(it.action) || !it.currency) continue;
|
||||
m.set(it.currency, (m.get(it.currency) ?? 0) + it.amount);
|
||||
}
|
||||
return [...m.entries()].map(([currency, amount]) => ({ currency, amount }));
|
||||
}, [items]);
|
||||
|
||||
return {
|
||||
items,
|
||||
dueTotals,
|
||||
reviewCount: items.filter((i) => isReviewAction(i.action)).length,
|
||||
loading:
|
||||
invoicesQ.isPending ||
|
||||
chargesQ.isPending ||
|
||||
(isPhased && clearanceQ.isPending),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
export const MY_PAYABLES_KEY = ["my-payables"] as const;
|
||||
|
||||
/**
|
||||
* Outstanding payments for every booking of the signed-in company, keyed by
|
||||
* booking id. One request shared by every row on the home page and the
|
||||
* bookings list (react-query dedupes by key), so rows can show "Pay" without
|
||||
* each resolving their own invoices.
|
||||
*/
|
||||
export function useMyPayables(): Map<string, Freight.BookingPayableSummary> {
|
||||
const { data } = useQuery({
|
||||
queryKey: MY_PAYABLES_KEY,
|
||||
queryFn: bookingsService.getMyPayables,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
return useMemo(
|
||||
() => new Map((data ?? []).map((p) => [p.bookingId, p] as const)),
|
||||
[data],
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,9 @@ function serviceFeatures(s: ServiceItem) {
|
||||
{
|
||||
key: "customs",
|
||||
icon: ShieldCheck,
|
||||
label: "Customs clearance",
|
||||
label: s.includesEthiopianCustomsOnly
|
||||
? "Ethiopian customs clearance"
|
||||
: "Customs clearance",
|
||||
on: s.includesCustoms,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -533,6 +533,40 @@ export const bookingsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Outstanding payments per booking — drives the "Pay" badge on list/home rows. */
|
||||
getMyPayables: async (): Promise<Freight.BookingPayableSummary[]> => {
|
||||
const { data } = await client.get(`/api/bookings/my-payables`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
// ── Clearance charges (port + miscellaneous) the customer approves, then pays ──
|
||||
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/clearance/charges`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
acceptClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/charges/${chargeId}/accept`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
rejectClearanceCharge: async (
|
||||
id: string,
|
||||
chargeId: string,
|
||||
note: string,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/charges/${chargeId}/reject`,
|
||||
{ note },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
acceptDraftDeclaration: async (id: string): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/draft-declaration/accept`,
|
||||
|
||||
Reference in New Issue
Block a user