mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 03:03:39 +00:00
Currency dropdowns/pickers (AdditionalPaymentsTab, ClearanceChargesTab, PhasedClearanceActionPanel, AdviseDutyCard, ContractRequestsPage, ruleEngine/resources, WarehouseRulesPage, VehicleDetailPage, FeePreviewModal) offer DJF alongside ETB/USD; GlCreateBookingForm's currency selector gets allowDjf next to allowUsd. Narrow 'ETB'|'USD' type unions widened to include 'DJF' across the warehouse billingCurrency plumbing (useWarehouses, warehouse.service, api.ts) and the customer/invoice types. Ad-hoc money() formatters (BookingTrucksPanel, AccrualDashboard, ImportTrucksPage, EmptyReturnRequestsPage) and formatMoney call sites that hardcoded 2 decimals (wagon-cancellation cards, BookingRequestDetailPage, WagonCancellationsPage, PaymentsPage, WarehouseInvoicesPage) now use currencyDecimals() from @edr/ui-common so DJF renders with 0 decimals instead of forced cents. The 3 duplicate overview formatCurrency/ formatAmount helpers (typed 'ETB'|'USD') widen to accept any currency. Two correctness fixes: OverviewRecentBookingsTable's currency==='USD' ? 'USD' : 'ETB' was mislabeling every non-USD currency as ETB; and WarehouseInvoicesPage's gateway-method default now routes any non-ETB currency (not just USD) to WAAFI, so DJF invoices get a working default instead of TELEBIRR (ETB-only). Claude-Session: https://claude.ai/code/session_01CZy77vCWhka3pnmVF9NDkL
435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
FileButton,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
NumberInput,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { DateInput } from "@mantine/dates";
|
|
import {
|
|
Ban,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
Plus,
|
|
Receipt,
|
|
Send,
|
|
Upload,
|
|
} from "lucide-react";
|
|
import toast from "react-hot-toast";
|
|
import type { Freight } from "@edr/types";
|
|
import { isViewable } from "@edr/ui-common";
|
|
|
|
import { bookingsService } from "@/services/bookings.service";
|
|
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
|
|
import { formatDate, formatDateTime } from "@/lib/format";
|
|
import { extractErrorMessage } from "@/utils/errorExtractor";
|
|
|
|
const CURRENCIES = ["ETB", "USD", "DJF"];
|
|
|
|
const STATUS_META: Record<Freight.AdditionalChargeStatus, { label: string; color: string }> = {
|
|
DRAFT: { label: "Draft", color: "gray" },
|
|
SENT: { label: "Sent — unpaid", color: "orange" },
|
|
PAID: { label: "Paid", color: "edr-green" },
|
|
CANCELLED: { label: "Cancelled", color: "red" },
|
|
};
|
|
|
|
export interface AdditionalPaymentsTabProps {
|
|
bookingId: string;
|
|
onViewFile: (file: { name: string; url: string }) => void;
|
|
}
|
|
|
|
/**
|
|
* Ad-hoc extra charges finance raises against a booking — any number, free-text
|
|
* reason. Draft until sent; sending issues the payable invoice and notifies the
|
|
* customer (in-app + SMS + email). Settles the same way every invoice does.
|
|
*/
|
|
export function AdditionalPaymentsTab({ bookingId, onViewFile }: AdditionalPaymentsTabProps) {
|
|
const qc = useQueryClient();
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
const { data: charges, isLoading } = useQuery({
|
|
queryKey: ["additional-charges", bookingId],
|
|
queryFn: () => bookingsService.getAdditionalCharges(bookingId),
|
|
});
|
|
|
|
const refresh = (next: Freight.AdditionalCharge[]) =>
|
|
qc.setQueryData(["additional-charges", bookingId], next);
|
|
const onError = (e: unknown) =>
|
|
toast.error(extractErrorMessage(e, "Could not update the charge"));
|
|
|
|
const create = useMutation({
|
|
mutationFn: (p: {
|
|
reason: string;
|
|
amount: number;
|
|
currency: string;
|
|
action: "draft" | "send";
|
|
file?: File | null;
|
|
dueDate?: string | null;
|
|
}) => bookingsService.createAdditionalCharge(bookingId, p),
|
|
onSuccess: (next, p) => {
|
|
toast.success(p.action === "send" ? "Charge sent to the customer" : "Draft saved");
|
|
refresh(next);
|
|
setModalOpen(false);
|
|
},
|
|
onError,
|
|
});
|
|
const send = useMutation({
|
|
mutationFn: (chargeId: string) => bookingsService.sendAdditionalCharge(bookingId, chargeId),
|
|
onSuccess: (next) => {
|
|
toast.success("Charge sent to the customer");
|
|
refresh(next);
|
|
},
|
|
onError,
|
|
});
|
|
const cancel = useMutation({
|
|
mutationFn: (chargeId: string) => bookingsService.cancelAdditionalCharge(bookingId, chargeId),
|
|
onSuccess: (next) => {
|
|
toast.success("Charge cancelled");
|
|
refresh(next);
|
|
},
|
|
onError,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Group justify="center" py="xl" gap={10}>
|
|
<Loader size="sm" color="edr-green" />
|
|
<Text c="dimmed">Loading additional charges…</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
const rows = charges ?? [];
|
|
const busy = send.isPending || cancel.isPending;
|
|
|
|
return (
|
|
<Stack gap="md" maw={860}>
|
|
<Group justify="space-between">
|
|
<Text fz="13px" fw={700} c="edr-text">
|
|
Additional charges
|
|
</Text>
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Plus size={14} />}
|
|
onClick={() => setModalOpen(true)}
|
|
>
|
|
Add charge
|
|
</Button>
|
|
</Group>
|
|
|
|
{rows.length === 0 && (
|
|
<Text fz="12.5px" c="dimmed">
|
|
No additional charges raised on this booking yet.
|
|
</Text>
|
|
)}
|
|
|
|
{rows.map((charge) => (
|
|
<ChargeCard
|
|
key={charge.id}
|
|
charge={charge}
|
|
busy={busy}
|
|
onViewFile={onViewFile}
|
|
onSend={() => send.mutate(charge.id)}
|
|
onCancel={() => cancel.mutate(charge.id)}
|
|
/>
|
|
))}
|
|
|
|
<AddChargeModal
|
|
opened={modalOpen}
|
|
onClose={() => setModalOpen(false)}
|
|
busy={create.isPending}
|
|
onSubmit={(p) => create.mutate(p)}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function ChargeCard({
|
|
charge,
|
|
busy,
|
|
onViewFile,
|
|
onSend,
|
|
onCancel,
|
|
}: {
|
|
charge: Freight.AdditionalCharge;
|
|
busy: boolean;
|
|
onViewFile: (file: { name: string; url: string }) => void;
|
|
onSend: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const meta = STATUS_META[charge.status];
|
|
|
|
return (
|
|
<Paper withBorder radius="md" p="md">
|
|
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
|
<Group gap={10} wrap="nowrap" align="flex-start">
|
|
<Receipt size={18} color="var(--mantine-color-edr-green-6)" />
|
|
<Box>
|
|
<Text fz="14px" fw={700} c="edr-text">
|
|
{charge.reason}
|
|
</Text>
|
|
<Text fz="11.5px" c="dimmed">
|
|
Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "}
|
|
{formatDateTime(charge.createdAt)}
|
|
</Text>
|
|
{charge.sentAt && (
|
|
<Text fz="11.5px" c="dimmed">
|
|
Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "}
|
|
{formatDateTime(charge.sentAt)}
|
|
{charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""}
|
|
</Text>
|
|
)}
|
|
{charge.paidAt && (
|
|
<Text fz="11.5px" c="edr-green.8" fw={600}>
|
|
Paid · {formatDateTime(charge.paidAt)}
|
|
{charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""}
|
|
</Text>
|
|
)}
|
|
{charge.cancelledAt && (
|
|
<Text fz="11.5px" c="red.7">
|
|
Cancelled · {formatDateTime(charge.cancelledAt)}
|
|
{charge.cancelReason ? ` — ${charge.cancelReason}` : ""}
|
|
</Text>
|
|
)}
|
|
{charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && (
|
|
<Text fz="11.5px" c="dimmed">
|
|
Due {formatDate(charge.dueAt)}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Group>
|
|
<Group gap={8} wrap="nowrap" align="flex-end" style={{ flexDirection: "column" }}>
|
|
<Group gap={8} wrap="nowrap">
|
|
<Text fz="14px" fw={800} c="edr-text">
|
|
{charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
|
{charge.currency}
|
|
</Text>
|
|
<Badge variant="light" color={meta.color} radius="sm">
|
|
{meta.label}
|
|
</Badge>
|
|
</Group>
|
|
{charge.convertedAmount != null && (
|
|
<Text fz="11.5px" c="dimmed">
|
|
≈ {charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "}
|
|
{charge.convertedCurrency}
|
|
</Text>
|
|
)}
|
|
</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.status === "DRAFT" || charge.status === "SENT") && (
|
|
<Group mt="sm" gap={8} justify="flex-end">
|
|
<Button
|
|
size="compact-sm"
|
|
variant="light"
|
|
color="red"
|
|
radius="md"
|
|
leftSection={<Ban size={14} />}
|
|
disabled={busy}
|
|
onClick={onCancel}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{charge.status === "DRAFT" && (
|
|
<Button
|
|
size="compact-sm"
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Send size={14} />}
|
|
disabled={busy}
|
|
onClick={onSend}
|
|
>
|
|
Send to customer
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function AddChargeModal({
|
|
opened,
|
|
onClose,
|
|
busy,
|
|
onSubmit,
|
|
}: {
|
|
opened: boolean;
|
|
onClose: () => void;
|
|
busy: boolean;
|
|
onSubmit: (p: {
|
|
reason: string;
|
|
amount: number;
|
|
currency: string;
|
|
action: "draft" | "send";
|
|
file?: File | null;
|
|
dueDate?: string | null;
|
|
}) => void;
|
|
}) {
|
|
const [reason, setReason] = useState("");
|
|
const [amount, setAmount] = useState<number | string>("");
|
|
const [currency, setCurrency] = useState("ETB");
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [dueDate, setDueDate] = useState<Date | null>(null);
|
|
|
|
const valid = reason.trim().length > 0 && Number(amount) > 0;
|
|
|
|
const reset = () => {
|
|
setReason("");
|
|
setAmount("");
|
|
setCurrency("ETB");
|
|
setFile(null);
|
|
setDueDate(null);
|
|
};
|
|
|
|
const submit = (action: "draft" | "send") => {
|
|
if (!valid) return;
|
|
onSubmit({
|
|
reason: reason.trim(),
|
|
amount: Number(amount),
|
|
currency,
|
|
action,
|
|
file,
|
|
// Local calendar date, not a UTC-shifted ISO timestamp — toISOString() can
|
|
// roll the date back a day for evening local time in a positive-offset zone.
|
|
dueDate: dueDate
|
|
? `${dueDate.getFullYear()}-${String(dueDate.getMonth() + 1).padStart(2, "0")}-${String(dueDate.getDate()).padStart(2, "0")}`
|
|
: null,
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={() => {
|
|
onClose();
|
|
reset();
|
|
}}
|
|
title="Add additional charge"
|
|
radius="md"
|
|
centered
|
|
>
|
|
<Stack gap="sm">
|
|
<Textarea
|
|
label="Reason for charge"
|
|
placeholder="e.g. Re-weighing fee at Mojo dry port"
|
|
autosize
|
|
minRows={2}
|
|
value={reason}
|
|
onChange={(e) => setReason(e.currentTarget.value)}
|
|
/>
|
|
<Group gap={8} align="flex-end">
|
|
<NumberInput
|
|
label="Amount"
|
|
min={0.01}
|
|
decimalScale={2}
|
|
value={amount}
|
|
onChange={setAmount}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<Select
|
|
label="Currency"
|
|
data={CURRENCIES}
|
|
value={currency}
|
|
onChange={(v) => v && setCurrency(v)}
|
|
w={100}
|
|
/>
|
|
</Group>
|
|
<DateInput
|
|
label="Due date"
|
|
placeholder="Defaults to 14 days after sending"
|
|
value={dueDate}
|
|
onChange={(v) => setDueDate(v ? new Date(v) : null)}
|
|
minDate={new Date()}
|
|
clearable
|
|
/>
|
|
<FileButton onChange={setFile} accept="application/pdf,image/*">
|
|
{(props) => (
|
|
<Button
|
|
{...props}
|
|
variant="light"
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Upload size={14} />}
|
|
>
|
|
{file ? file.name : "Attach a document (optional)"}
|
|
</Button>
|
|
)}
|
|
</FileButton>
|
|
|
|
<Group justify="flex-end" mt="sm" gap={8}>
|
|
<Button
|
|
variant="light"
|
|
color="gray"
|
|
radius="md"
|
|
disabled={busy || !valid}
|
|
loading={busy}
|
|
onClick={() => submit("draft")}
|
|
>
|
|
Save draft
|
|
</Button>
|
|
<Button
|
|
color="edr-green"
|
|
radius="md"
|
|
leftSection={<Send size={14} />}
|
|
disabled={busy || !valid}
|
|
loading={busy}
|
|
onClick={() => submit("send")}
|
|
>
|
|
Send to customer
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|