mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
chore: group invoices, payments and usd payments
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user