mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
631 lines
19 KiB
TypeScript
631 lines
19 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
FileButton,
|
|
Group,
|
|
Loader,
|
|
NumberInput,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
CheckCircle2,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
Lock,
|
|
Receipt,
|
|
Send,
|
|
Upload,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
import type { Freight } from "@edr/types";
|
|
import { isViewable } from "@edr/ui-common";
|
|
|
|
import { bookingsService } from "@/services/bookings.service";
|
|
import {
|
|
downloadBookingFile,
|
|
fetchViewableFile,
|
|
} from "@/services/files.service";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { extractErrorMessage } from "@/utils/errorExtractor";
|
|
|
|
const CURRENCIES = ["ETB", "USD"];
|
|
|
|
const STATUS_META: Record<
|
|
Freight.ClearanceChargeStatus,
|
|
{ label: string; color: string }
|
|
> = {
|
|
DOC_UPLOADED: { label: "Awaiting billing", color: "yellow" },
|
|
BILLED: { label: "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. */
|
|
roleMode: "ET" | "DJ";
|
|
onViewFile: (file: { name: string; url: string }) => void;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
roleMode,
|
|
onViewFile,
|
|
}: ClearanceChargesTabProps) {
|
|
const qc = useQueryClient();
|
|
// Bumped after each create so the form remounts empty for the next charge.
|
|
const [miscCreated, setMiscCreated] = useState(0);
|
|
const { data: charges, isLoading } = useQuery({
|
|
queryKey: ["clearance-charges", bookingId],
|
|
queryFn: () => bookingsService.getClearanceCharges(bookingId),
|
|
});
|
|
|
|
const refresh = (next: Freight.ClearanceCharge[]) =>
|
|
qc.setQueryData(["clearance-charges", bookingId], next);
|
|
const onError = (e: unknown) =>
|
|
toast.error(extractErrorMessage(e, "Could not update the charge"));
|
|
|
|
const uploadPort = useMutation({
|
|
mutationFn: (file: File) =>
|
|
bookingsService.uploadPortChargeDocument(bookingId, file),
|
|
onSuccess: (next) => {
|
|
toast.success("Port-charges document uploaded");
|
|
refresh(next);
|
|
},
|
|
onError,
|
|
});
|
|
const bill = useMutation({
|
|
// Body must be exactly the DTO — the API rejects unknown keys like chargeId.
|
|
mutationFn: ({ chargeId, ...payload }: BillInput & { chargeId: string }) =>
|
|
bookingsService.billClearanceCharge(bookingId, chargeId, payload),
|
|
onSuccess: (next) => {
|
|
toast.success("Charge amount saved");
|
|
refresh(next);
|
|
},
|
|
onError,
|
|
});
|
|
const send = useMutation({
|
|
mutationFn: (chargeId: string) =>
|
|
bookingsService.sendClearanceCharge(bookingId, chargeId),
|
|
onSuccess: (next) => {
|
|
toast.success("Sent to the customer for approval");
|
|
refresh(next);
|
|
},
|
|
onError,
|
|
});
|
|
const createMisc = useMutation({
|
|
mutationFn: ({ file, ...payload }: BillInput & { file: File }) =>
|
|
bookingsService.createMiscellaneousCharge(bookingId, file, payload),
|
|
onSuccess: (next) => {
|
|
toast.success("Miscellaneous charge created");
|
|
// Remount the form so the next charge starts from an empty one.
|
|
setMiscCreated((n) => n + 1);
|
|
refresh(next);
|
|
},
|
|
onError,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Group justify="center" py="xl" gap={10}>
|
|
<Loader size="sm" color="edr-green" />
|
|
<Text c="dimmed">Loading charges…</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const port = (charges ?? []).find((c) => c.type === "PORT_CHARGES") ?? null;
|
|
const miscCharges = (charges ?? []).filter(
|
|
(c) => c.type === "MISCELLANEOUS",
|
|
);
|
|
const busy =
|
|
uploadPort.isPending || bill.isPending || send.isPending || createMisc.isPending;
|
|
|
|
const totals = new Map<string, number>();
|
|
for (const c of charges ?? []) {
|
|
if (c.amount != null && c.currency)
|
|
totals.set(c.currency, (totals.get(c.currency) ?? 0) + c.amount);
|
|
}
|
|
|
|
return (
|
|
<Stack gap="md" maw={860}>
|
|
<ChargeCard
|
|
title="Port charges"
|
|
charge={port}
|
|
roleMode={roleMode}
|
|
busy={busy}
|
|
emptyHint={
|
|
roleMode === "DJ"
|
|
? "Upload the port-charges document to start this charge."
|
|
: "Waiting for GL Djibouti to upload the port-charges document."
|
|
}
|
|
onViewFile={onViewFile}
|
|
onBill={(input) => port && bill.mutate({ chargeId: port.id, ...input })}
|
|
onSend={() => port && send.mutate(port.id)}
|
|
djUpload={
|
|
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
|
<FileButton
|
|
onChange={(f) => f && uploadPort.mutate(f)}
|
|
accept="application/pdf,image/*"
|
|
disabled={busy}
|
|
>
|
|
{(props) => (
|
|
<Button
|
|
{...props}
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Upload size={14} />}
|
|
loading={uploadPort.isPending}
|
|
>
|
|
{port ? "Replace document" : "Upload document"}
|
|
</Button>
|
|
)}
|
|
</FileButton>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
{/* Any number of miscellaneous charges, in any order relative to the
|
|
port charge — each is billed and paid on its own. */}
|
|
{miscCharges.map((c, i) => (
|
|
<ChargeCard
|
|
key={c.id}
|
|
title={
|
|
miscCharges.length > 1
|
|
? `Miscellaneous charge ${i + 1}`
|
|
: "Miscellaneous charge"
|
|
}
|
|
charge={c}
|
|
roleMode={roleMode}
|
|
busy={busy}
|
|
emptyHint=""
|
|
onViewFile={onViewFile}
|
|
onBill={(input) => bill.mutate({ chargeId: c.id, ...input })}
|
|
onSend={() => send.mutate(c.id)}
|
|
/>
|
|
))}
|
|
|
|
{roleMode === "ET" && (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Text fz="14px" fw={700} c="edr-text" mb={4}>
|
|
{miscCharges.length > 0
|
|
? "Add another miscellaneous charge"
|
|
: "Add a miscellaneous charge"}
|
|
</Text>
|
|
<Text fz="12px" c="dimmed" mb="sm">
|
|
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, input) => createMisc.mutate({ file, ...input })}
|
|
/>
|
|
</Paper>
|
|
)}
|
|
|
|
{totals.size > 0 && (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Group justify="space-between">
|
|
<Text fz="13px" fw={700} c="edr-text">
|
|
Total billed
|
|
</Text>
|
|
<Group gap="md">
|
|
{[...totals.entries()].map(([currency, amount]) => (
|
|
<Text key={currency} fz="14px" fw={800} c="edr-text">
|
|
{amount.toLocaleString(undefined, {
|
|
minimumFractionDigits: 2,
|
|
})}{" "}
|
|
{currency}
|
|
</Text>
|
|
))}
|
|
</Group>
|
|
</Group>
|
|
</Paper>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function ChargeCard({
|
|
title,
|
|
charge,
|
|
roleMode,
|
|
busy,
|
|
emptyHint,
|
|
onViewFile,
|
|
onBill,
|
|
onSend,
|
|
djUpload,
|
|
etCreate,
|
|
}: {
|
|
title: string;
|
|
charge: Freight.ClearanceCharge | null;
|
|
roleMode: "ET" | "DJ";
|
|
busy: boolean;
|
|
emptyHint: string;
|
|
onViewFile: (file: { name: string; url: string }) => void;
|
|
onBill: (input: BillInput) => void;
|
|
onSend: () => void;
|
|
djUpload?: React.ReactNode;
|
|
etCreate?: React.ReactNode;
|
|
}) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [amount, setAmount] = useState<number | string>(charge?.amount ?? "");
|
|
const [currency, setCurrency] = useState<string>(charge?.currency ?? "ETB");
|
|
const [description, setDescription] = useState(charge?.description ?? "");
|
|
|
|
const status = charge?.status ?? null;
|
|
const meta = status ? STATUS_META[status] : null;
|
|
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 (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
|
<Group gap={10} wrap="nowrap">
|
|
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
|
|
<Box>
|
|
<Text fz="14px" fw={700} c="edr-text">
|
|
{title}
|
|
</Text>
|
|
{charge?.uploadedAt && (
|
|
<Text fz="11.5px" c="dimmed">
|
|
Document uploaded
|
|
{charge.uploadedByName ? ` by ${charge.uploadedByName}` : ""} ·{" "}
|
|
{formatDateTime(charge.uploadedAt)}
|
|
</Text>
|
|
)}
|
|
{charge?.billedAt && (
|
|
<Text fz="11.5px" c="dimmed">
|
|
Billed{charge.billedByName ? ` by ${charge.billedByName}` : ""} ·{" "}
|
|
{formatDateTime(charge.billedAt)}
|
|
</Text>
|
|
)}
|
|
{charge?.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)}
|
|
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
<Group gap={8} wrap="nowrap">
|
|
{charge?.amount != null && charge.currency && (
|
|
<Text fz="14px" fw={800} c="edr-text">
|
|
{charge.amount.toLocaleString(undefined, {
|
|
minimumFractionDigits: 2,
|
|
})}{" "}
|
|
{charge.currency}
|
|
</Text>
|
|
)}
|
|
{meta && (
|
|
<Badge variant="light" color={meta.color} radius="sm">
|
|
{meta.label}
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
|
|
{charge?.file && (
|
|
<Group gap={8} mt="sm" wrap="nowrap">
|
|
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
|
|
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0 }}>
|
|
{charge.file.name}
|
|
</Text>
|
|
{isViewable({ name: charge.file.name, url: "" }) && (
|
|
<Tooltip label="View">
|
|
<Box
|
|
component="button"
|
|
type="button"
|
|
onClick={() =>
|
|
void fetchViewableFile(
|
|
charge.file!.id,
|
|
charge.file!.name,
|
|
).then(onViewFile)
|
|
}
|
|
c="edr-green"
|
|
style={{
|
|
display: "flex",
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Eye size={15} />
|
|
</Box>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip label="Download">
|
|
<Box
|
|
component="button"
|
|
type="button"
|
|
onClick={() =>
|
|
void downloadBookingFile(charge.file!.id, charge.file!.name)
|
|
}
|
|
c="edr-green"
|
|
style={{
|
|
display: "flex",
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Download size={15} />
|
|
</Box>
|
|
</Tooltip>
|
|
</Group>
|
|
)}
|
|
|
|
{charge?.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}
|
|
</Text>
|
|
)}
|
|
{djUpload && <Box mt="sm">{djUpload}</Box>}
|
|
{etCreate && <Box mt="sm">{etCreate}</Box>}
|
|
|
|
{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"
|
|
radius="md"
|
|
min={0.01}
|
|
decimalScale={2}
|
|
value={amount}
|
|
onChange={setAmount}
|
|
w={160}
|
|
/>
|
|
<Select
|
|
label="Currency"
|
|
size="xs"
|
|
radius="md"
|
|
data={CURRENCIES}
|
|
value={currency}
|
|
onChange={(v) => v && setCurrency(v)}
|
|
w={100}
|
|
/>
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
radius="md"
|
|
disabled={
|
|
busy ||
|
|
!(Number(amount) > 0) ||
|
|
(needsDescription && !description.trim())
|
|
}
|
|
onClick={() => {
|
|
onBill({
|
|
amount: Number(amount),
|
|
currency,
|
|
description: description.trim(),
|
|
});
|
|
setEditing(false);
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
{editing && (
|
|
<Button
|
|
size="compact-sm"
|
|
variant="subtle"
|
|
color="gray"
|
|
radius="md"
|
|
disabled={busy}
|
|
onClick={() => setEditing(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
)}
|
|
|
|
{roleMode === "ET" && charge && !showBillForm && !locked && (
|
|
<Group mt="sm" gap={8} justify="flex-end">
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="gray"
|
|
radius="md"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setAmount(charge.amount ?? "");
|
|
setCurrency(charge.currency ?? "ETB");
|
|
setDescription(charge.description ?? "");
|
|
setEditing(true);
|
|
}}
|
|
>
|
|
{charge.status === "SENT" ? "Revise" : "Edit"}
|
|
</Button>
|
|
{(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"
|
|
radius="md"
|
|
leftSection={<Send size={14} />}
|
|
disabled={busy}
|
|
onClick={onSend}
|
|
>
|
|
{charge.status === "REJECTED"
|
|
? "Send again for approval"
|
|
: "Send to customer for approval"}
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</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" && (
|
|
<Group mt="sm" gap={6} justify="flex-end">
|
|
<CheckCircle2 size={14} color="var(--mantine-color-edr-green-6)" />
|
|
<Text fz="12px" c="edr-green.8" fw={600}>
|
|
Settled
|
|
</Text>
|
|
</Group>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function MiscCreateForm({
|
|
busy,
|
|
onCreate,
|
|
}: {
|
|
busy: boolean;
|
|
onCreate: (file: File, 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
|
|
{...props}
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Upload size={14} />}
|
|
>
|
|
{file ? file.name : "Choose document"}
|
|
</Button>
|
|
)}
|
|
</FileButton>
|
|
<NumberInput
|
|
label="Amount"
|
|
size="xs"
|
|
radius="md"
|
|
min={0.01}
|
|
decimalScale={2}
|
|
value={amount}
|
|
onChange={setAmount}
|
|
w={160}
|
|
/>
|
|
<Select
|
|
label="Currency"
|
|
size="xs"
|
|
radius="md"
|
|
data={CURRENCIES}
|
|
value={currency}
|
|
onChange={(v) => v && setCurrency(v)}
|
|
w={100}
|
|
/>
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
radius="md"
|
|
disabled={busy || !file || !(Number(amount) > 0) || !description.trim()}
|
|
loading={busy}
|
|
onClick={() =>
|
|
file &&
|
|
onCreate(file, {
|
|
amount: Number(amount),
|
|
currency,
|
|
description: description.trim(),
|
|
})
|
|
}
|
|
>
|
|
Create charge
|
|
</Button>
|
|
</Group>
|
|
);
|
|
}
|