mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
approve-delivery exit-gate fix + Import Loading Confirmation frontend panel — done this session, not yet committed
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -17,10 +18,13 @@ import {
|
||||
ArrowRight,
|
||||
Banknote,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
BookingStatusBadge,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
PaymentStatusBadge,
|
||||
ProfileApprovalActions,
|
||||
ProfileChips,
|
||||
@@ -50,7 +55,13 @@ import type {
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
} from "@/types/customer";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
useFileViewer,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
@@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: company, isLoading } = useQuery(
|
||||
api.customers.getById.queryOptions({
|
||||
@@ -104,9 +116,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(
|
||||
() =>
|
||||
@@ -285,20 +322,37 @@ export default function CustomerDetailPage() {
|
||||
header: "",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={fileViewUrl(row.original.id, true)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download"
|
||||
data-stop-row-click
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="View"
|
||||
data-stop-row-click
|
||||
onClick={() =>
|
||||
view({
|
||||
name: row.original.name,
|
||||
url: fileViewUrl(row.original.id),
|
||||
mimeType: row.original.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={fileViewUrl(row.original.id, true)}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Download"
|
||||
data-stop-row-click
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[view],
|
||||
);
|
||||
|
||||
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
||||
@@ -358,6 +412,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 +499,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 +524,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 +639,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 +650,63 @@ 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
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* PAYMENTS */}
|
||||
@@ -570,15 +721,53 @@ 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>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
InvoiceStatusBadge,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
|
||||
function openPdfBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const opened = window.open(url, "_blank");
|
||||
if (!opened) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-muted"
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: "0.04em" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" c="edr-text">
|
||||
{value && value.trim() ? value : "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: invoice, isLoading } = useQuery(
|
||||
api.invoices.getById.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
|
||||
const downloadDocument = async () => {
|
||||
if (!id) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { data } = await invoicesService.downloadDocument(id);
|
||||
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center mih="60vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<Container size="sm" py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Text fw={700}>Invoice not found</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
onClick={() => navigate("/dashboard/invoices")}
|
||||
>
|
||||
Back to invoices
|
||||
</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Invoices", href: "/dashboard/invoices" },
|
||||
{ label: invoice.invoiceNumber },
|
||||
]}
|
||||
backTo="/dashboard/invoices"
|
||||
title={invoice.invoiceNumber}
|
||||
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
|
||||
meta={<InvoiceStatusBadge status={invoice.status} />}
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Download invoice"
|
||||
loading={downloading}
|
||||
onClick={() => void downloadDocument()}
|
||||
>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack gap="lg">
|
||||
<Card>
|
||||
<Stack gap="lg">
|
||||
<Text fw={600} c="edr-text">
|
||||
Summary
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
|
||||
<InfoField label="Billed to" value={invoice.company?.name} />
|
||||
<InfoField
|
||||
label="Profile"
|
||||
value={invoice.companyProfile?.reference}
|
||||
/>
|
||||
<InfoField label="Type" value={humanize(invoice.type)} />
|
||||
<InfoField label="Currency" value={invoice.currency} />
|
||||
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
|
||||
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
|
||||
<InfoField
|
||||
label="Total"
|
||||
value={formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
/>
|
||||
<InfoField
|
||||
label="Balance"
|
||||
value={formatMoney(invoice.balanceAmount, invoice.currency)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
Line items
|
||||
</Text>
|
||||
<Table striped withRowBorders={false} verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Charge type</Table.Th>
|
||||
<Table.Th ta="right">Quantity</Table.Th>
|
||||
<Table.Th ta="right">Unit rate</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(invoice.lines ?? []).map((line) => (
|
||||
<Table.Tr key={line.id}>
|
||||
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{humanize(line.chargeType)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.unitRate, line.currency)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{formatMoney(line.amount, line.currency)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{(invoice.lines ?? []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No line items.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap="xl"
|
||||
pt="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Subtotal
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.subtotalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Tax
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.taxAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" c="edr-muted">
|
||||
Paid
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{formatMoney(invoice.paidAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2} align="flex-end">
|
||||
<Text size="xs" fw={700}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{formatMoney(invoice.totalAmount, invoice.currency)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RefreshCw, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
InvoiceStatusBadge,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.invoices.list.queryOptions({ input: { filter } }),
|
||||
);
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "invoiceNumber",
|
||||
header: "Invoice",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.invoiceNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "billedTo",
|
||||
header: "Billed to",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text">
|
||||
{row.original.company?.name ?? "—"}
|
||||
</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: "balance",
|
||||
header: "Balance",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatMoney(row.original.balanceAmount, 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>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
|
||||
action={
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
aria-label="Refresh"
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search by invoice number…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(
|
||||
v === "all" ? "" : (v as Freight.InvoiceStatus),
|
||||
);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending", value: "PENDING" },
|
||||
{ label: "Paid", value: "PAID" },
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={920}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: "No invoices yet."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,14 @@ export interface FormFieldDef {
|
||||
* showWhen and not match hideWhen.
|
||||
*/
|
||||
showWhen?: { field: string; equals: string[] };
|
||||
/**
|
||||
* Select options computed from other fields' current values. When set, the
|
||||
* form resolves the option list at render time from the live form state
|
||||
* instead of the static `options` list. Used for the rate unit selector,
|
||||
* whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly
|
||||
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
|
||||
*/
|
||||
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export interface RuleEngineOrderConfig {
|
||||
@@ -116,12 +124,54 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
];
|
||||
|
||||
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
|
||||
(v) => ({
|
||||
label: v.replace(/_/g, " "),
|
||||
value: v,
|
||||
}),
|
||||
);
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
* Valid weighting units for a rate shape — mirrors the API's
|
||||
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
|
||||
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||
*/
|
||||
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||
if (appliesTo === "OTHER") {
|
||||
switch (trigger) {
|
||||
case "OVERWEIGHT":
|
||||
return ["PER_TON"];
|
||||
case "REEFER":
|
||||
case "HAZARDOUS":
|
||||
case "DEMURRAGE":
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
case "CONSOLIDATION":
|
||||
case "SHIPPING_LINE":
|
||||
case "PIL_EXTRA_FEE":
|
||||
return ["PER_CONTAINER", "FLAT"];
|
||||
default:
|
||||
return ["FLAT", "PER_TON", "PER_CONTAINER"];
|
||||
}
|
||||
}
|
||||
switch (appliesTo) {
|
||||
case "CONTAINER":
|
||||
return ["PER_CONTAINER", "PER_WAGON"];
|
||||
case "BULK":
|
||||
return ["PER_TON", "PER_WAGON"];
|
||||
case "INTERCITY":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
|
||||
case "FIRST_MILE":
|
||||
case "LAST_MILE":
|
||||
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
|
||||
default:
|
||||
return ["FLAT"];
|
||||
}
|
||||
};
|
||||
|
||||
const rateUnitOptions = (values: Record<string, unknown>) => {
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
return allowedRateUnits(appliesTo, trigger).map(unitOption);
|
||||
};
|
||||
|
||||
const CURRENCIES = [
|
||||
{ label: "USD", value: "USD" },
|
||||
@@ -324,8 +374,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
},
|
||||
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
|
||||
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
{
|
||||
@@ -343,8 +391,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: TRADE_DIRECTIONS,
|
||||
},
|
||||
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -407,7 +453,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
{
|
||||
@@ -457,9 +502,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
// is always per excess ton, so the unit field is hidden for it — the API
|
||||
// forces PER_TON regardless.
|
||||
{
|
||||
name: "rateUnit",
|
||||
label: "Rate unit",
|
||||
type: "select",
|
||||
required: true,
|
||||
optionsFromValues: rateUnitOptions,
|
||||
description: "Weighting basis — options depend on what the rate applies to.",
|
||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
@@ -242,6 +243,25 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
||||
Gov
|
||||
</Badge>
|
||||
) : null}
|
||||
{b.consolidationPartnerRef ? (
|
||||
<Tooltip
|
||||
label={`Consolidated — shares one wagon with ${b.consolidationPartnerRef}`}
|
||||
withArrow
|
||||
multiline
|
||||
maw={260}
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<ArrowLeftRight size={10} />}
|
||||
style={{ textTransform: "none" }}
|
||||
>
|
||||
shared wagon · {b.consolidationPartnerRef}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -10,7 +10,10 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
|
||||
// Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save.
|
||||
const [form, setForm] = useState<
|
||||
Partial<Record<keyof TrainSchedulingGlobalRules, number | string>>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -65,7 +68,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Sum of all wagon lengths must not exceed this"
|
||||
value={form.maxTrainLengthMeters ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -75,7 +78,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Total container and bulk cargo weight must not exceed this"
|
||||
value={form.maxTrainWeightTons ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -84,7 +87,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
label="Max wagons per train"
|
||||
value={form.maxWagonsPerTrain ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -96,7 +99,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftContainerWeightTons: Number(value),
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
min={0.001}
|
||||
@@ -109,7 +112,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
max20ftPairWeightDiffTons: Number(value),
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
min={0}
|
||||
@@ -129,7 +132,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="The single booking day opens this many days before departure"
|
||||
value={form.importWindowLeadDays ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: Number(value) }))
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
@@ -139,7 +142,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||
value={form.exportBookingLeadHours ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: Number(value) }))
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -149,7 +152,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
||||
value={form.windowOpenHour ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowOpenHour: Number(value) }))
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
}
|
||||
min={0}
|
||||
max={23}
|
||||
@@ -159,7 +162,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
label="Window duration (hours)"
|
||||
value={form.windowDurationHours ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowDurationHours: Number(value) }))
|
||||
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||
}
|
||||
min={0.25}
|
||||
max={12}
|
||||
@@ -171,7 +174,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Max staff time to accept booking documents after the window closes"
|
||||
value={form.docReviewMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, docReviewMinutes: Number(value) }))
|
||||
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
@@ -181,7 +184,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Time a selected customer has to pay before the slot expires"
|
||||
value={form.paymentWindowMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: Number(value) }))
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
@@ -191,7 +194,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||
value={form.reopenDelayMinutes ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: Number(value) }))
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
|
||||
Reference in New Issue
Block a user