chore: group invoices, payments and usd payments

This commit is contained in:
Nathnael
2026-08-13 11:22:45 +00:00
parent 4ae63d4e5c
commit 86d69a9577
7 changed files with 244 additions and 170 deletions

View File

@@ -37,15 +37,13 @@ import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPag
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
import InvoicesPage from "./pages/invoices/InvoicesPage";
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
import MyProfilePage from "./pages/dashboard/MyProfilePage";
import OverviewPage from "./pages/dashboard/OverviewPage";
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
import ReportPage from "./pages/reports/ReportPage";
import AuditLogsPage from "./pages/AuditLogsPage";
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
import PaymentsPage from "./pages/payments/PaymentsPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import { RequirePermission } from "./components/auth/RequirePermission";
import { FREIGHT_PERMS } from "./lib/permissions";
@@ -266,13 +264,11 @@ const App = () => {
</RequirePermission>
}
/>
{/* Payments used to be its own page; it's now the "payments" tab on
the merged Invoices hub. Old bookmarks/links still land there. */}
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.payments.view}>
<PaymentsPage />
</RequirePermission>
}
element={<Navigate to="/dashboard/invoices?tab=payments" replace />}
/>
<Route
path="support"
@@ -298,21 +294,27 @@ const App = () => {
</RequirePermission>
}
/>
{/* Merged Invoices / Payments / USD Payments hub — tabs switch via
?tab=invoices|payments|usd-payments (default invoices). Access is
OR'd across both keys so a user with just one still gets in; each
tab hides itself if the user lacks the permission it used to be
routed on. */}
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoicesPage />
<RequirePermission
permission={[
FREIGHT_PERMS.invoices.view,
FREIGHT_PERMS.payments.view,
]}
>
<FinanceHubPage />
</RequirePermission>
}
/>
<Route
path="usd-payments"
element={
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<UsdPaymentsPage />
</RequirePermission>
}
element={<Navigate to="/dashboard/invoices?tab=usd-payments" replace />}
/>
<Route
path="invoices/:id"

View File

@@ -44,10 +44,12 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
},
},
{
prefix: "/dashboard/payments",
// Invoices, Payments, and USD Payments are tabs on one page now
// (FinanceHubPage); the header title itself is set per-tab there.
prefix: "/dashboard/invoices",
meta: {
title: "Payments",
subtitle: "View booking payment transactions",
title: "Invoices",
subtitle: "Invoices, payments, and USD bank transfers",
},
},
{

View File

@@ -9,7 +9,6 @@ import {
FileText,
Hammer,
History,
Landmark,
LayoutDashboard,
LayoutGrid,
MapPin,
@@ -97,22 +96,13 @@ export const buildSidebarSections = (
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.payments.view,
},
{
label: "Invoices",
// Invoices, Payments, and USD Payments live on one page as tabs
// (FinanceHubPage) — single nav entry, OR'd across both keys so
// either permission alone still gets a user in.
label: "Transactions",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "USD Payments",
href: "/dashboard/usd-payments",
icon: <Landmark />,
permission: FREIGHT_PERMS.invoices.view,
permission: [FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.payments.view],
},
{
label: "Support",

View File

@@ -0,0 +1,100 @@
import { Tabs } from "@mantine/core";
import { Landmark, Receipt, Wallet } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import InvoicesPanel from "./InvoicesPage";
import UsdPaymentsPanel from "./UsdPaymentsPage";
import PaymentsPanel from "../payments/PaymentsPage";
/**
* Invoices, Payments, and USD Payments used to be three separate routes/pages
* with near-identical chrome. They're merged here as URL-linkable tabs
* (`?tab=`) on one page — each tab keeps the permission it was individually
* gated on before, and just doesn't render if the user lacks it.
*/
const TABS = [
{
key: "invoices",
label: "Invoices",
icon: Receipt,
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"Every invoice issued across bookings, warehouse fees and clearance charges.",
Panel: InvoicesPanel,
},
{
key: "payments",
label: "Payments",
icon: Wallet,
permission: FREIGHT_PERMS.payments.view,
subtitle: "View and reconcile booking payment transactions.",
Panel: PaymentsPanel,
},
{
key: "usd-payments",
label: "USD Payments",
icon: Landmark,
// Same gate as Invoices, not a dedicated key — mirrors the old route.
permission: FREIGHT_PERMS.invoices.view,
subtitle:
"USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes.",
Panel: UsdPaymentsPanel,
},
] as const;
type TabKey = (typeof TABS)[number]["key"];
export default function FinanceHubPage() {
const { user } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const visibleTabs = TABS.filter((tab) => hasPermission(user, tab.permission));
const requested = searchParams.get("tab");
const active: TabKey =
visibleTabs.find((tab) => tab.key === requested)?.key ??
visibleTabs[0]?.key ??
"invoices";
const activeTab = visibleTabs.find((tab) => tab.key === active);
const handleChange = (value: string | null) => {
if (!value) return;
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set("tab", value);
return next;
},
{ replace: true },
);
};
return (
<PageContainer>
<PageHeader title={activeTab?.label ?? "Invoices"} subtitle={activeTab?.subtitle} />
<Tabs value={active} onChange={handleChange} keepMounted={false}>
<Tabs.List>
{visibleTabs.map((tab) => (
<Tabs.Tab
key={tab.key}
value={tab.key}
leftSection={<tab.icon size={16} />}
>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
{visibleTabs.map((tab) => (
<Tabs.Panel key={tab.key} value={tab.key} pt="lg">
<tab.Panel />
</Tabs.Panel>
))}
</Tabs>
</PageContainer>
);
}

View File

@@ -21,7 +21,6 @@ import {
formatMoney,
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
@@ -31,7 +30,8 @@ import {
type ColumnDef,
} from "@edr/ui-common";
export default function InvoicesPage() {
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
@@ -127,112 +127,103 @@ export default function InvoicesPage() {
);
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: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
<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: "Payment processing", value: "PAYMENT_PROCESSING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<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 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>
</Box>
</Stack>
</Card>
);
}

View File

@@ -25,7 +25,6 @@ import {
humanize,
} from "@/components/customers";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { PageContainer, PageHeader } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
@@ -95,7 +94,8 @@ function windowClosed(row: OfflineUsdInvoice): boolean {
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
}
export default function UsdPaymentsPage() {
/** USD Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function UsdPaymentsPanel() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
@@ -262,24 +262,7 @@ export default function UsdPaymentsPage() {
);
return (
<PageContainer>
<PageHeader
title="USD Payments"
subtitle="USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes."
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%">
@@ -324,6 +307,16 @@ export default function UsdPaymentsPage() {
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
@@ -421,6 +414,6 @@ export default function UsdPaymentsPage() {
</Stack>
)}
</Modal>
</PageContainer>
</>
);
}

View File

@@ -25,7 +25,7 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { KpiStrip } from "@/components/page";
import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
import {
@@ -101,7 +101,8 @@ function formatDate(iso: string | null): string {
const tableHeader =
"text-xs font-semibold uppercase tracking-wide text-muted-foreground";
export default function PaymentsPage() {
/** Payments tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function PaymentsPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusTab, setStatusTab] = useState<StatusTabKey>("all");
@@ -205,12 +206,7 @@ export default function PaymentsPage() {
];
return (
<PageContainer>
<PageHeader
title="Payments"
subtitle="View and reconcile booking payment transactions."
/>
<Stack gap="lg">
<KpiStrip
loading={summaryLoading}
items={[
@@ -360,6 +356,6 @@ export default function PaymentsPage() {
</Box>
</Stack>
</Card>
</PageContainer>
</Stack>
);
}