mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: add invoice to customer detail on backoffice
This commit is contained in:
@@ -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 (
|
||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||
<Badge
|
||||
color="gray"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
style={badgeStyle}
|
||||
>
|
||||
No profiles
|
||||
</Badge>
|
||||
);
|
||||
@@ -118,7 +125,13 @@ export function ProfileChips({
|
||||
</Tooltip>
|
||||
))}
|
||||
{extra > 0 ? (
|
||||
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
|
||||
<Badge
|
||||
color="gray"
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
style={badgeStyle}
|
||||
>
|
||||
+{extra}
|
||||
</Badge>
|
||||
) : null}
|
||||
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||
CANCELLED: "red",
|
||||
};
|
||||
|
||||
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
|
||||
export function BookingStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: CustomerBookingStatus;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
|
||||
@@ -194,7 +211,11 @@ const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
|
||||
refunded: "grape",
|
||||
};
|
||||
|
||||
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
|
||||
export function PaymentStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: CustomerPaymentStatus;
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
|
||||
@@ -210,6 +231,38 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }
|
||||
);
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
|
||||
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 (
|
||||
<Badge
|
||||
color={INVOICE_STATUS_COLOR[status] ?? "gray"}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
fw={600}
|
||||
style={badgeStyle}
|
||||
>
|
||||
{humanize(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
BookingStatusBadge,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
|
||||
@@ -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();
|
||||
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
|
||||
* {viewer}
|
||||
*/
|
||||
export function useFileViewer() {
|
||||
const [file, setFile] = useState<ViewableFile | null>(null);
|
||||
|
||||
const view = useCallback((f: ViewableFile) => setFile(f), []);
|
||||
const close = useCallback(() => setFile(null), []);
|
||||
|
||||
const viewer = (
|
||||
<FileViewerModal open={file !== null} file={file} onClose={close} />
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
@@ -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<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoiceNumber",
|
||||
header: "Invoice",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.invoiceNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: "Source",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(row.original.source)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
header: "Amount",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "dueAt",
|
||||
header: "Due",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.dueAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih="60vh">
|
||||
@@ -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={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
@@ -416,6 +500,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
||||
Payments
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
{/* 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 */}
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<TableCard minWidth={760}>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
status={tableStatus(documentsQuery)}
|
||||
emptyMessage="No documents uploaded."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCard>
|
||||
<Stack gap="lg">
|
||||
<TableCard minWidth={760}>
|
||||
<DataTable
|
||||
columns={documentColumns}
|
||||
data={documents}
|
||||
status={tableStatus(documentsQuery)}
|
||||
emptyMessage="No documents uploaded."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCard>
|
||||
|
||||
{licenseProfiles.length > 0 && (
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
Business licenses
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{humanize(p.type)} · {p.reference}
|
||||
</Text>
|
||||
{(p.licenseFiles ?? []).map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Anchor
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* 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
|
||||
}
|
||||
/>
|
||||
</TableCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* INVOICES */}
|
||||
<Tabs.Panel value="invoices" pt="lg">
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={860}>
|
||||
<DataTable
|
||||
columns={invoiceColumns}
|
||||
data={invoices}
|
||||
status={tableStatus(invoicesQuery)}
|
||||
onRowClick={(row) => 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,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user