diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts index 8b526fb3d..39daf7b57 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts @@ -31,9 +31,6 @@ export interface WarehouseHandoverPdfContext { const escapePdfText = (value: string) => value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); -const money = (amount: unknown, currency = 'USD') => - `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; - const fmtDate = (value: unknown) => { if (!value) return '-'; const date = new Date(value as string | Date); @@ -96,12 +93,6 @@ const textOp = ( color = '0 0 0', ) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; -const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [ - lineOp(60, 242, 535, 242), - textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN), - buildCircularSeal(452, 155, label), -]; - const buildWarehouseOfficerSealBand = () => [ lineOp(60, 218, 535, 218), textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN), @@ -146,57 +137,6 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { return new Blob([pdf], { type: 'application/pdf' }); } -export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') { - const paid = kind === 'RECEIPT' || invoice.status === 'PAID'; - const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`; - const bookingReference = firstText(invoice.bookingReference); - const customerName = firstText(invoice.customerName); - const inventoryReference = firstText(invoice.inventoryReference); - const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription); - const clearanceStatus = firstText( - invoice.clearanceStatus, - paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT', - ); - const lines: PdfLine[] = [ - { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, - { text: title, size: 23, bold: true, yGap: 28, align: 'center' }, - { text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' }, - { text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' }, - { text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' }, - { text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' }, - { text: `Clearance: ${clearanceStatus}`, align: 'center' }, - { text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' }, - { text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' }, - ...(invoice.items ?? []).flatMap((item) => [ - { text: item.description, bold: true, align: 'center' as const }, - { - text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`, - yGap: 13, - align: 'center' as const, - }, - ]), - { text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' }, - { text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' }, - { text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' }, - { text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' }, - { text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' }, - { text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' }, - ]; - const authorizationOps = [ - ...buildAuthorizationBand('PAID'), - textOp('Prepared by EDR warehouse finance', 72, 196, 10), - textOp('Finance officer name / signature / date:', 72, 164, 10), - lineOp(245, 162, 360, 162, '0 0 0'), - ]; - const invoiceOps = [ - lineOp(60, 242, 535, 242), - textOp('Prepared by EDR warehouse finance', 72, 196, 10), - textOp('Finance officer name / signature / date:', 72, 164, 10), - lineOp(245, 162, 360, 162, '0 0 0'), - ]; - return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps); -} - const firstText = (...values: Array) => { for (const value of values) { if (value !== null && value !== undefined && String(value).trim()) return String(value); diff --git a/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts b/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts new file mode 100644 index 000000000..f381f49d5 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useScrollToHash.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +/** + * Scroll to the element whose `id` matches the URL hash. Retries for a short + * window so it still lands on sections that mount after an async fetch (there is + * no router-level hash handling). Deep-link targets give a card an `id`. + */ +export function useScrollToHash(): void { + const { hash } = useLocation(); + + useEffect(() => { + if (!hash) return; + const id = decodeURIComponent(hash.slice(1)); + let tries = 0; + let timer: ReturnType; + + const tick = () => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + if (tries++ < 20) timer = setTimeout(tick, 100); + }; + + timer = setTimeout(tick, 100); + return () => clearTimeout(timer); + }, [hash]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 75badc5b3..7a13888fe 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -50,6 +50,7 @@ import { useBookingDetail, useBookingMutations, } from "@/hooks/bookings/useBookings"; +import { useScrollToHash } from "@/hooks/useScrollToHash"; import toast from "react-hot-toast"; // Signature / generated-contract files are surfaced on the contract page, not @@ -65,6 +66,8 @@ export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + // Deep-link from a warehouse fee invoice → this booking's warehouse section. + useScrollToHash(); const { data: booking, isLoading, @@ -281,10 +284,12 @@ export default function BookingRequestDetailPage() { - + + + = { @@ -155,6 +156,7 @@ export default function WarehouseInvoicesPage() { function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { const { toast } = useToast(); + const navigate = useNavigate(); const { data: inv, isLoading } = useQuery( api.warehouses.invoice.queryOptions({ input: { id: id ?? '' }, @@ -172,13 +174,33 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE'); - openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceDocument(invoice.id); + openPdfBlob(data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => { - const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT'); - openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); + const pdfWindow = window.open('', '_blank'); + try { + const { data } = await warehouseService.downloadInvoiceReceipt(invoice.id); + openPdfBlob(data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`, pdfWindow); + } catch (error) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Download failed', + description: extractErrorMessage(error), + }); + } }; const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => { @@ -366,6 +388,20 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => )} + {inv.bookingId && ( + + )} + )} - )} + {hasReceipt && ( + + )} + {payable && ( + + )} + {payMutation.isError && ( diff --git a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx index 1e19b1c9a..3503fbc71 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx @@ -22,6 +22,7 @@ const STATUS_STYLE: Record< [Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" }, [Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" }, [Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" }, + [Freight.InvoiceStatus.Expired]: { label: "Expired", bg: "#FBEAE7", fg: "#C0392B" }, }; export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 065ee8385..7c2c2631c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -24,19 +24,22 @@ import { ConsolidationPairedNotice, ConsolidationWaitingBanner, } from "./components/Notices"; +import { BookingPaymentPanel } from "./components/BookingPaymentPanel"; import { HeaderButton, PageHeader } from "./components/PageHeader"; -import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard"; import { PaymentMethodModal } from "./components/PaymentMethodModal"; -import { PaymentCard } from "./components/pricing"; import { ScheduleCard } from "./components/ScheduleCard"; +import { WarehousePaymentsSection } from "./components/WarehousePaymentsSection"; import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard"; import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard"; import { StatusHero } from "./components/StatusHero"; import { SupportCard } from "./components/SupportCard"; import { fmtDate, isNegative, priceTotal } from "./utils"; +import { useScrollToHash } from "@/hooks/useScrollToHash"; export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const navigate = useNavigate(); + // Deep-link support: e.g. /bookings/:id#warehouse-payments from an invoice. + useScrollToHash(); const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); const { view, viewer } = useFileViewer(); @@ -164,6 +167,8 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) + + {booking.files && booking.files.length > 0 && ( @@ -215,14 +220,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) } right={ <> - {showCountdown && ( - setPayModalOpen(true)} - paying={payMutation.isPending} - /> - )} - + setPayModalOpen(true)} + paying={payMutation.isPending} + showCountdown={showCountdown} + /> ; + +// ── Pay-window countdown ───────────────────────────────────────────────────── + +interface Remaining { + days: number; + hours: number; + minutes: number; + seconds: number; + expired: boolean; +} + +function getRemaining(deadlineMs: number): Remaining { + const diff = deadlineMs - Date.now(); + if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; + const total = Math.floor(diff / 1000); + return { + days: Math.floor(total / 86400), + hours: Math.floor((total % 86400) / 3600), + minutes: Math.floor((total % 3600) / 60), + seconds: total % 60, + expired: false, + }; +} + +function Segment({ value, label }: { value: number; label: string }) { + return ( + + + {String(value).padStart(2, "0")} + + + {label} + + + ); +} + +function Countdown({ + deadline, + onPay, + paying, +}: { + deadline: string; + onPay?: () => void; + paying?: boolean; +}) { + const deadlineMs = new Date(deadline).getTime(); + const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); + + useEffect(() => { + setRemaining(getRemaining(deadlineMs)); + const interval = setInterval(() => { + const next = getRemaining(deadlineMs); + setRemaining(next); + if (next.expired) clearInterval(interval); + }, 1000); + return () => clearInterval(interval); + }, [deadlineMs]); + + if (remaining.expired) { + return ( + + The payment window has closed. Move this booking to another schedule or + contact support. + + ); + } + + return ( + <> + + + + + + + + Deadline:{" "} + {new Date(deadline).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + + {onPay && ( + + )} + + ); +} + +// ── Merged payment panel ───────────────────────────────────────────────────── + +/** + * One card covering the whole payment story for a booking: the live pay-window + * countdown (when open), the price breakdown, and the invoice(s) — each with a + * link to its detail page and a download. Replaces the separate deadline + + * breakdown cards. + */ +export function BookingPaymentPanel({ + booking, + pricing, + onPay, + paying, + showCountdown, +}: { + booking: Freight.IBooking; + pricing: Pricing; + onPay?: () => void; + paying?: boolean; + showCountdown?: boolean; +}) { + const navigate = useNavigate(); + const paid = booking.paymentStatus === "PAID"; + const isAdjusted = + booking.adjustedTotalAmount !== null && + booking.adjustedTotalAmount !== undefined; + const currency = pricing?.currency ?? booking.paymentCurrency; + const total = isAdjusted + ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` + : priceTotal(pricing); + const items = priceLineItems(pricing); + + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-invoices", booking.id], + queryFn: () => invoicesService.listForSource("booking", booking.id), + }); + // The invoice worth a prominent "Download" — the first issued one, else any. + const primary = + invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0]; + const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false; + + const downloadInvoice = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadDocument(inv.id), + `invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Invoice PDF isn't ready yet. Contact EDR if this persists."); + } + }; + + const downloadReceipt = async (inv: PortalInvoice) => { + try { + saveBlob( + await invoicesService.downloadReceipt(inv.id), + `receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Payment + + {paid ? : showCountdown ? : null} + {paid + ? "Paid" + : showCountdown + ? "Pay window open" + : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} + + + + {showCountdown && booking.paymentDeadline && ( + + + + + )} + + + + {total} + + {isAdjusted && ( + + Adjusted by EDR + + )} + {isAdjusted && booking.adjustmentReason && ( + + {booking.adjustmentReason} + + )} + {paid && ( + + Paid · {fmtDate(booking.updatedAt)} + + )} + + + {items.length > 0 && ( + <> + + + {items.map((it) => ( + + + {it.label} + + + {it.value} + + + ))} + + + + {isAdjusted ? "Adjusted total" : "Total"} + + + {total} + + + + )} + + {invoices.length > 0 && ( + <> + + + Invoices + + {invoices.length} + + + + {invoices.map((inv) => ( + + + navigate(`/billing/${inv.id}`)} + > + {inv.invoiceNumber} + + + {titleCase(inv.type)} + + + + + downloadInvoice(inv)} + > + + + + + ))} + + + )} + + {primary && ( + + )} + {primary && primaryPaid && ( + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx deleted file mode 100644 index 54f900ab5..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentDeadlineCard.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { Box, Button, Group, Stack, Text } from "@mantine/core"; -import { CreditCard, Timer } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { CardTitle, SectionCard } from "./layout"; - -interface Remaining { - days: number; - hours: number; - minutes: number; - seconds: number; - expired: boolean; -} - -function getRemaining(deadlineMs: number): Remaining { - const diff = deadlineMs - Date.now(); - if (diff <= 0) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; - } - const totalSeconds = Math.floor(diff / 1000); - return { - days: Math.floor(totalSeconds / 86400), - hours: Math.floor((totalSeconds % 86400) / 3600), - minutes: Math.floor((totalSeconds % 3600) / 60), - seconds: totalSeconds % 60, - expired: false, - }; -} - -function Segment({ value, label }: { value: number; label: string }) { - return ( - - - {String(value).padStart(2, "0")} - - - {label} - - - ); -} - -export function PaymentDeadlineCard({ - paymentDeadline, - onPay, - paying, -}: { - /** ISO timestamp marking the end of the pay window. */ - paymentDeadline: string; - onPay?: () => void; - paying?: boolean; -}) { - const deadlineMs = new Date(paymentDeadline).getTime(); - const [remaining, setRemaining] = useState(() => getRemaining(deadlineMs)); - - useEffect(() => { - setRemaining(getRemaining(deadlineMs)); - const interval = setInterval(() => { - const next = getRemaining(deadlineMs); - setRemaining(next); - if (next.expired) clearInterval(interval); - }, 1000); - return () => clearInterval(interval); - }, [deadlineMs]); - - const accentBg = remaining.expired ? "#FBEAE7" : "#FEF6E6"; - const accentFg = remaining.expired ? "#C0392B" : "#B07D14"; - - return ( - - - Payment deadline - - - {remaining.expired ? "Expired" : "Pay window open"} - - - - {remaining.expired ? ( - - The payment window has closed. Move this booking to another schedule or - contact support. - - ) : ( - <> - - - - - - - - Complete payment before the window closes to secure your slot. - - {onPay && ( - - )} - - )} - - - - Deadline:{" "} - {new Date(paymentDeadline).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - })} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx new file mode 100644 index 000000000..146e31c1e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -0,0 +1,156 @@ +import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Download, Receipt } from "lucide-react"; +import toast from "react-hot-toast"; + +import { + warehouseInvoicesService, + type PortalWarehouseInvoice, +} from "@/services/warehouse-invoices.service"; +import { saveBlob } from "@/utils/download"; + +import { CardTitle, SectionCard } from "./layout"; + +const money = (amount: number | string | null | undefined, currency: string) => + `${Number(amount ?? 0).toLocaleString()} ${currency}`; + +const STATUS_STYLE: Record = { + DRAFT: { bg: "#EEF2F6", fg: "#64748B" }, + ISSUED: { bg: "#FEF3E2", fg: "#B45309" }, + PARTIALLY_PAID: { bg: "#FEF9E7", fg: "#A16207" }, + PAID: { bg: "#E6F7EF", fg: "#0A6F4D" }, + CANCELLED: { bg: "#EEF2F6", fg: "#64748B" }, +}; + +function StatusPill({ status }: { status: string }) { + const s = STATUS_STYLE[status] ?? { bg: "#EEF2F6", fg: "#64748B" }; + return ( + + {status.replace(/_/g, " ")} + + ); +} + +/** + * Warehouse fee invoices linked to this booking — display + PDF download only. + * Paying them online is tracked separately (in-system demurrage/storage + * payment). Renders nothing when the booking has no warehouse fees. Carries + * `id="warehouse-payments"` so the invoice detail page can deep-link here. + */ +export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + const { data: invoices = [] } = useQuery({ + queryKey: ["booking-warehouse-invoices", bookingId], + queryFn: () => warehouseInvoicesService.listForBooking(bookingId), + }); + + if (invoices.length === 0) return null; + + const download = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadDocument(inv.id), + `warehouse-invoice-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Warehouse invoice PDF isn't ready yet."); + } + }; + + const downloadReceipt = async (inv: PortalWarehouseInvoice) => { + try { + saveBlob( + await warehouseInvoicesService.downloadReceipt(inv.id), + `warehouse-receipt-${inv.invoiceNumber}.pdf`, + ); + } catch { + toast.error("Receipt isn't available yet."); + } + }; + + return ( + + + Warehouse payments + + {invoices.length} {invoices.length === 1 ? "invoice" : "invoices"} + + + + {invoices.map((inv) => { + const detail = [ + inv.invoiceType?.replace(/_/g, " "), + inv.cargoDescription ?? + inv.containerNumber ?? + inv.inventoryReference ?? + undefined, + ] + .filter(Boolean) + .join(" · "); + return ( + + + + + {inv.invoiceNumber} + + + + {detail && ( + + {detail} + + )} + + Total {money(inv.totalAmount, inv.currency)} · Balance{" "} + {money(inv.balanceAmount, inv.currency)} + + + + download(inv)} + > + + + {Number(inv.paidAmount) > 0 && ( + downloadReceipt(inv)} + > + + + )} + + + ); + })} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx index 93d843065..8c4ab9bcb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/pricing.tsx @@ -1,9 +1,7 @@ import { Box, Group, Stack, Text } from "@mantine/core"; -import { CheckCircle2, Clock } from "lucide-react"; +import { Clock } from "lucide-react"; -import type { Freight } from "@edr/types"; - -import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils"; +import { priceLineItems, priceTotal, type Pricing } from "../utils"; import { CardTitle, SectionCard } from "./layout"; function LineItems({ pricing }: { pricing: Pricing }) { @@ -107,113 +105,6 @@ export function EstimateCard({ ); } -export function PaymentCard({ - booking, - pricing, -}: { - booking: Freight.IBooking; - pricing: Pricing; -}) { - const paid = booking.paymentStatus === "PAID"; - // Customer sees the grand total plus the price breakdown that makes it up. - // A staff adjustment, when present, overrides the computed total and is - // flagged with an "Adjusted by EDR" badge. - const isAdjusted = - booking.adjustedTotalAmount !== null && - booking.adjustedTotalAmount !== undefined; - const currency = pricing?.currency ?? booking.paymentCurrency; - const total = isAdjusted - ? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}` - : priceTotal(pricing); - const hasItems = priceLineItems(pricing).length > 0; - - return ( - - - Payment - - {paid && } - {paid - ? "Paid" - : (booking.paymentStatus?.replace(/_/g, " ") ?? "Pending")} - - - - - {total} - - {isAdjusted && ( - - Adjusted by EDR - - )} - {isAdjusted && booking.adjustmentReason && ( - - {booking.adjustmentReason} - - )} - {paid && ( - - Paid · {fmtDate(booking.updatedAt)} - - )} - - {hasItems && ( - <> - - - - - {isAdjusted ? "Adjusted total" : "Total"} - - - {total} - - - - )} - {/* */} - - ); -} +// The booking payment card (countdown + breakdown + invoices + download) now +// lives in ./BookingPaymentPanel. EstimateCard above stays for the draft and +// changes-requested views, which only show an estimate. diff --git a/apps/edr-freight-web/portal/src/services/invoices.service.ts b/apps/edr-freight-web/portal/src/services/invoices.service.ts index 99b1d0dee..2ee18ec4e 100644 --- a/apps/edr-freight-web/portal/src/services/invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/invoices.service.ts @@ -29,12 +29,39 @@ export const invoicesService = { return data.data ?? data; }, + /** The customer's invoices for one source record (e.g. a booking). */ + listForSource: async ( + source: string, + sourceId: string, + ): Promise => { + const { data } = await client.get(B.MY_INVOICES, { + params: { source, sourceId }, + }); + return data.data ?? data; + }, + /** One of the customer's invoices, with its line items. */ get: async (id: string): Promise => { const { data } = await client.get(B.MY_INVOICE_BY_ID(id)); return data.data ?? data; }, + /** The sealed invoice PDF for one of the customer's invoices. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_DOCUMENT(id), { + responseType: "blob", + }); + return data; + }, + + /** The sealed payment-receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(B.MY_INVOICE_RECEIPT(id), { + responseType: "blob", + }); + return data; + }, + /** Initiate gateway payment for an open invoice; returns the client action. */ pay: async ( id: string, diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts new file mode 100644 index 000000000..f1fa4e841 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -0,0 +1,54 @@ +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const W = URL_CONSTANTS.WAREHOUSE_INVOICES; + +/** + * A warehouse fee invoice as the freight API projects it for the customer + * (the historical `WarehouseFeeInvoice` view shape — a subset is used here). + */ +export interface PortalWarehouseInvoice { + id: string; + invoiceNumber: string; + invoiceType: string; + status: string; + currency: string; + totalAmount: number | string; + paidAmount: number | string; + balanceAmount: number | string; + issuedAt?: string | null; + dueDate?: string | null; + paidAt?: string | null; + bookingId?: string | null; + inventoryId?: string | null; + bookingReference?: string | null; + inventoryReference?: string | null; + cargoDescription?: string | null; + containerNumber?: string | null; +} + +export const warehouseInvoicesService = { + /** Warehouse fee invoices linked to a booking (via its inventory items). */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(W.FOR_BOOKING(bookingId)); + return data.data ?? data; + }, + + /** A single warehouse fee invoice (carries `bookingId` for source linking). */ + get: async (id: string): Promise => { + const { data } = await client.get(W.BY_ID(id)); + return data.data ?? data; + }, + + /** The sealed warehouse fee invoice PDF. */ + downloadDocument: async (id: string): Promise => { + const { data } = await client.get(W.DOCUMENT(id), { responseType: "blob" }); + return data; + }, + + /** The sealed warehouse fee payment receipt PDF (available once paid). */ + downloadReceipt: async (id: string): Promise => { + const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); + return data; + }, +}; diff --git a/apps/edr-freight-web/portal/src/utils/download.ts b/apps/edr-freight-web/portal/src/utils/download.ts new file mode 100644 index 000000000..594513566 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/download.ts @@ -0,0 +1,11 @@ +/** Trigger a browser download of a Blob under `filename`. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +}