diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index e108d1b1f..31c7efc52 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -1,3 +1,4 @@ +import type { Freight } from "@edr/types"; import { Badge, Button, Group, Tooltip } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; @@ -88,7 +89,13 @@ export function ProfileChips({ }) { if (!profiles.length) { return ( - + No profiles ); @@ -118,7 +125,13 @@ export function ProfileChips({ ))} {extra > 0 ? ( - + +{extra} ) : null} @@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record = { CANCELLED: "red", }; -export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) { +export function BookingStatusBadge({ + status, +}: { + status: CustomerBookingStatus; +}) { return ( = { refunded: "grape", }; -export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) { +export function PaymentStatusBadge({ + status, +}: { + status: CustomerPaymentStatus; +}) { return ( = { + DRAFT: "gray", + ISSUED: "cyan", + PENDING: "yellow", + PARTIALLY_PAID: "orange", + PAID: "edr-green", + OVERDUE: "red", + CANCELLED: "gray", + REFUNDED: "grape", + EXPIRED: "red", +}; + +export function InvoiceStatusBadge({ + status, +}: { + status: Freight.InvoiceStatus; +}) { + return ( + + {humanize(status)} + + ); +} + /** * Inline approval action buttons for a profile row. * Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate @@ -225,8 +278,7 @@ export function ProfileApprovalActions({ api.customers.setProfileStatus.mutationOptions(), ); - const act = (next: ProfileStatus) => - mutate({ profileId, status: next }); + const act = (next: ProfileStatus) => mutate({ profileId, status: next }); if (status === "pending") { return ( diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 61b75767a..620b5ec35 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -2,6 +2,7 @@ export { BookingStatusBadge, CompanyStatusBadge, CompanyTypeBadge, + InvoiceStatusBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx index 4ca24476c..c00777ef7 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx +++ b/apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx @@ -1,24 +1,3 @@ -import { useCallback, useState } from "react"; -import { FileViewerModal, type ViewableFile } from "@edr/ui-common"; - -/** - * Drives a single shared {@link FileViewerModal} for a page. Call `view(file)` - * from any file row to open the document inline (pdf / image / video / office / - * text); render `viewer` once near the page root. - * - * const { view, viewer } = useFileViewer(); - * - * {viewer} - */ -export function useFileViewer() { - const [file, setFile] = useState(null); - - const view = useCallback((f: ViewableFile) => setFile(f), []); - const close = useCallback(() => setFile(null), []); - - const viewer = ( - - ); - - return { view, close, viewer }; -} +// Re-export of the shared hook, now living in @edr/ui-common. Kept so existing +// `@/hooks/useFileViewer` imports keep working. +export { useFileViewer } from "@edr/ui-common"; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 5689186cd..f94f97c71 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Anchor, Box, Button, Card, @@ -21,6 +22,8 @@ import { IdCard, LayoutGrid, Package, + Paperclip, + Receipt, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -30,6 +33,7 @@ import { BookingStatusBadge, CompanyStatusBadge, CompanyTypeBadge, + InvoiceStatusBadge, PaymentStatusBadge, ProfileApprovalActions, ProfileChips, @@ -50,7 +54,8 @@ import type { CustomerDocument, CustomerPayment, } from "@/types/customer"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; +import type { Invoice } from "@/types/invoice"; +import { DataTable, usePagination, type ColumnDef } from "@edr/ui-common"; function InfoField({ label, value }: { label: string; value?: string | null }) { return ( @@ -104,9 +109,34 @@ export default function CustomerDetailPage() { }), ); + const { pagination: invoicePagination, setPagination: setInvoicePagination } = + usePagination({ + pageSize: 10, + }); + const invoiceFilter = useMemo( + () => ({ + companyId: id ?? "", + page: invoicePagination.pageIndex + 1, + pageSize: invoicePagination.pageSize, + }), + [id, invoicePagination.pageIndex, invoicePagination.pageSize], + ); + const invoicesQuery = useQuery( + api.invoices.list.queryOptions({ + input: { filter: invoiceFilter }, + enabled: Boolean(id), + }), + ); + const bookings = bookingsQuery.data ?? []; const documents = documentsQuery.data ?? []; const payments = paymentsQuery.data ?? []; + const invoices = invoicesQuery.data?.items ?? []; + const invoiceTotal = invoicesQuery.data?.total ?? 0; + const invoicePageCount = Math.max( + 1, + Math.ceil(invoiceTotal / invoicePagination.pageSize), + ); const totalPaid = useMemo( () => @@ -358,6 +388,59 @@ export default function CustomerDetailPage() { [], ); + const invoiceColumns: ColumnDef[] = useMemo( + () => [ + { + id: "invoiceNumber", + header: "Invoice", + cell: ({ row }) => ( + + {row.original.invoiceNumber} + + ), + }, + { + id: "source", + header: "Source", + cell: ({ row }) => ( + + {humanize(row.original.source)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "dueAt", + header: "Due", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.dueAt)} + + ), + }, + ], + [], + ); + + const licenseProfiles = (company?.companyProfiles ?? []).filter( + (p) => p.licenseFiles && p.licenseFiles.length > 0, + ); + if (isLoading) { return (
@@ -392,8 +475,9 @@ export default function CustomerDetailPage() { ]} backTo="/dashboard/customers" title={company.name} - subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : "" - }`} + subtitle={`TIN ${company.tin}${ + company.country ? ` · ${company.country}` : "" + }`} meta={ @@ -416,6 +500,9 @@ export default function CustomerDetailPage() { }> Payments + }> + Invoices + {/* OVERVIEW */} @@ -528,9 +615,9 @@ export default function CustomerDetailPage() { error={ bookingsQuery.isError ? { - message: "Failed to load bookings.", - onRetry: () => void bookingsQuery.refetch(), - } + message: "Failed to load bookings.", + onRetry: () => void bookingsQuery.refetch(), + } : undefined } /> @@ -539,23 +626,57 @@ export default function CustomerDetailPage() { {/* DOCUMENTS */} - - void documentsQuery.refetch(), - } - : undefined - } - /> - + + + void documentsQuery.refetch(), + } + : undefined + } + /> + + + {licenseProfiles.length > 0 && ( + + + + Business licenses + + + {licenseProfiles.map((p) => ( + + + {humanize(p.type)} · {p.reference} + + {(p.licenseFiles ?? []).map((f) => ( + + + + {f.name} + + + ))} + + ))} + + + + )} + {/* PAYMENTS */} @@ -570,14 +691,50 @@ export default function CustomerDetailPage() { error={ paymentsQuery.isError ? { - message: "Failed to load payments.", - onRetry: () => void paymentsQuery.refetch(), - } + message: "Failed to load payments.", + onRetry: () => void paymentsQuery.refetch(), + } : undefined } /> + + {/* INVOICES */} + + + + navigate(`/dashboard/invoices/${row.id}`)} + emptyMessage="No invoices for this customer." + containerClassName="border-0 shadow-none bg-transparent" + error={ + invoicesQuery.isError + ? { + message: "Failed to load invoices.", + onRetry: () => void invoicesQuery.refetch(), + } + : undefined + } + pagination={{ + pageIndex: invoicePagination.pageIndex, + pageSize: invoicePagination.pageSize, + pageCount: invoicePageCount, + totalCount: invoiceTotal, + }} + tableOptions={{ + state: { pagination: invoicePagination }, + onPaginationChange: setInvoicePagination, + manualPagination: true, + pageCount: invoicePageCount, + }} + /> + + + ); diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 638ab98cd..a67f1732f 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -32,6 +32,14 @@ export type ProfileType = /** Mirrors backend `ProfileStatus`. */ export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted"; +/** A business-license document uploaded for a company profile. */ +export interface LicenseFile { + name: string; + url: string; + size: number; + mimeType?: string; +} + /** A single role a company is registered for, with its reference code. */ export interface CompanyProfile { id: string; @@ -39,7 +47,10 @@ export interface CompanyProfile { type: ProfileType; reference: string; status: ProfileStatus; + /** @deprecated Superseded by licenseFiles (file model). */ businessLicense?: string | null; + /** Business-license documents uploaded for this profile. */ + licenseFiles?: LicenseFile[]; attributes?: Record | null; createdAt: string; updatedAt: string;