Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx

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>
);
}