mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
feat(bookings): add Additional Payments tab, add-charge modal, portal pay
Backoffice: new tab on the booking detail page (?tab=additional-charges, already linked from the row menu) with an Add Charge modal — Save draft or Send to customer. Portal: charges panel on the booking detail page, Pay now per charge via the existing OTP/CBE-bill payment flow. Also fixes: GET /bookings/:id/additional-charges was returning DRAFT charges to the customer (hidden client-side only) — now filtered server-side per caller.
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
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 {
|
||||
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 { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const CURRENCIES = ["ETB", "USD"];
|
||||
|
||||
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;
|
||||
}) => 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>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<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>
|
||||
</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;
|
||||
}) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [amount, setAmount] = useState<number | string>("");
|
||||
const [currency, setCurrency] = useState("ETB");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
const valid = reason.trim().length > 0 && Number(amount) > 0;
|
||||
|
||||
const reset = () => {
|
||||
setReason("");
|
||||
setAmount("");
|
||||
setCurrency("ETB");
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
const submit = (action: "draft" | "send") => {
|
||||
if (!valid) return;
|
||||
onSubmit({ reason: reason.trim(), amount: Number(amount), currency, action, file });
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user