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 = { 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 ( Loading additional charges… ); } const rows = charges ?? []; const busy = send.isPending || cancel.isPending; return ( Additional charges {rows.length === 0 && ( No additional charges raised on this booking yet. )} {rows.map((charge) => ( send.mutate(charge.id)} onCancel={() => cancel.mutate(charge.id)} /> ))} setModalOpen(false)} busy={create.isPending} onSubmit={(p) => create.mutate(p)} /> ); } 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 ( {charge.reason} Raised{charge.createdByName ? ` by ${charge.createdByName}` : ""} ·{" "} {formatDateTime(charge.createdAt)} {charge.sentAt && ( Sent{charge.sentByName ? ` by ${charge.sentByName}` : ""} ·{" "} {formatDateTime(charge.sentAt)} {charge.paymentReference ? ` · ref ${charge.paymentReference}` : ""} )} {charge.paidAt && ( Paid · {formatDateTime(charge.paidAt)} {charge.invoiceNumber ? ` (invoice ${charge.invoiceNumber})` : ""} )} {charge.cancelledAt && ( Cancelled · {formatDateTime(charge.cancelledAt)} {charge.cancelReason ? ` — ${charge.cancelReason}` : ""} )} {charge.dueAt && charge.status !== "PAID" && charge.status !== "CANCELLED" && ( Due {formatDate(charge.dueAt)} )} {charge.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} {charge.currency} {meta.label} {charge.convertedAmount != null && ( ≈ {charge.convertedAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}{" "} {charge.convertedCurrency} )} {charge.file && ( {charge.file.name} {isViewable({ name: charge.file.name, url: "" }) && ( void fetchViewableFile(charge.file!.id, charge.file!.name).then(onViewFile) } c="edr-green" style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} > )} void downloadBookingFile(charge.file!.id, charge.file!.name)} c="edr-green" style={{ display: "flex", background: "transparent", border: "none", cursor: "pointer" }} > )} {(charge.status === "DRAFT" || charge.status === "SENT") && ( {charge.status === "DRAFT" && ( )} )} ); } 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(""); const [currency, setCurrency] = useState("ETB"); const [file, setFile] = useState(null); const [dueDate, setDueDate] = useState(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 ( { onClose(); reset(); }} title="Add additional charge" radius="md" centered >