mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
@@ -1,6 +1,3 @@
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { getPositionKeys } from "@/lib/permissions";
|
||||
|
||||
/** One overview composition. Every backoffice user lands on exactly one of these. */
|
||||
export type OverviewLayoutKey =
|
||||
| "executive"
|
||||
@@ -20,80 +17,35 @@ export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Position/role key → layout, in match priority order: a user holding several
|
||||
* of these keys gets the first match, so the specific operational view wins
|
||||
* over the broad executive one. Roles are matched alongside positions because
|
||||
* the IAM payload models the GL desks as positions (`ethiopian_gl`) on some
|
||||
* accounts and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
|
||||
*
|
||||
* The `edr_freight_app/…` keys are the org's real position keys (root desks and
|
||||
* their sub-positions) as configured under Unit → Departments. They are typed
|
||||
* by hand in the Add/Edit Department form, so a new sub-position appears here
|
||||
* only once someone adds it — unmapped keys fall through to `executive`.
|
||||
* Priority order: a caller who holds more than one of the six
|
||||
* `edr_freight_app:overview:<key>:view` permissions gets the FIRST match
|
||||
* here — the specific operational view wins over the broad executive one.
|
||||
* Mirrors `OVERVIEW_LAYOUT_KEYS` in the API's freight-permissions.registry.ts
|
||||
* bit for bit; keep the two in sync if this ever changes.
|
||||
*/
|
||||
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
|
||||
// ── Clearance & logistics: both GL desks, root and sub-positions ──────────
|
||||
["ethiopian_gl", "clearance"],
|
||||
["edr_freight_app/gl_003", "clearance"], // Ethiopian GL Chief
|
||||
["edr_freight_app/off_001", "clearance"], // Ethiopian GL Director
|
||||
["edr_freight_app/off_0056", "clearance"], // Ethiopian GL Officer
|
||||
["djibouti_gl", "clearance"],
|
||||
["edr_freight_app/dj_gl_001", "clearance"], // Djibouti GL Director
|
||||
["edr_freight_app/dj_gl_002", "clearance"], // Djibouti GL Chief
|
||||
["edr_freight_app/dj_gl_003", "clearance"], // Djibouti GL Officer
|
||||
["edr_gl_ethiopia", "clearance"], // legacy role form
|
||||
["edr_gl_djibouti", "clearance"], // legacy role form
|
||||
|
||||
// ── Control centre ───────────────────────────────────────────────────────
|
||||
["edr_freight_app/occ_001", "occ"], // OCC
|
||||
["edr_freight_app/occ_005", "occ"], // OCC Director
|
||||
["edr_line_staff", "occ"], // legacy role form
|
||||
|
||||
// ── Operations: operations desk, track & machinery, rolling stock ─────────
|
||||
["edr_freight_app/opn", "operation"], // Operation
|
||||
["edr_freight_app/opcf", "operation"], // Operation Chief
|
||||
["edr_freight_app/opdr", "operation"], // Operation Director
|
||||
["edr_freight_app/opco", "operation"], // Operation Officer
|
||||
["edr_freight_app/opp_005", "operation"], // Operation Dispatcher
|
||||
["edr_freight_app/opp_0067", "operation"], // Gelan Operation Director
|
||||
["edr_freight_app/track_001", "operation"], // Track And Machinery
|
||||
["edr_freight_app/ttk_001", "operation"], // Track Director
|
||||
["edr_freight_app/tto_001", "operation"], // Track Operator
|
||||
["edr_freight_app/rool_001", "operation"], // Rolling Stock
|
||||
["edr_freight_app/rl_003", "operation"], // Rolling Stock Director
|
||||
["edr_freight_app/rl_009", "operation"], // Rolling Stock Team Lead
|
||||
["edr_freight_app/rl_0090", "operation"], // Rolling Stock Dispatcher
|
||||
["operation", "operation"],
|
||||
["operations_chief", "operation"],
|
||||
["dispatcher", "operation"],
|
||||
["truck_machinery_chief", "operation"],
|
||||
["edr_operations_officer", "operation"], // legacy role form
|
||||
|
||||
// ── Marketing ────────────────────────────────────────────────────────────
|
||||
["edr_freight_app/edr_test_org_0022", "marketer"], // Commercial Marketing
|
||||
["edr_freight_app/edr_test_org_00567", "marketer"], // Marketing Director
|
||||
["edr_freight_app/edr_test_org_0054", "marketer"], // Marketing Chief
|
||||
["edr_freight_app/edr_test_org_0013", "marketer"], // Marketing Officer
|
||||
["marketer", "marketer"],
|
||||
["edr_marketing", "marketer"], // legacy role form
|
||||
|
||||
// ── Finance ──────────────────────────────────────────────────────────────
|
||||
["edr_freight_app/finance", "finance"],
|
||||
["edr_finance", "finance"], // legacy role form
|
||||
|
||||
// ── Executive: org-wide desks with no operational queue of their own ──────
|
||||
["ceo", "executive"],
|
||||
["director", "executive"],
|
||||
["chief", "executive"],
|
||||
["edr_ceo", "executive"], // legacy role form
|
||||
["edr_director", "executive"], // legacy role form
|
||||
["edr_org_manager", "executive"], // legacy role form
|
||||
const LAYOUT_PRIORITY: OverviewLayoutKey[] = [
|
||||
"clearance",
|
||||
"occ",
|
||||
"operation",
|
||||
"marketer",
|
||||
"finance",
|
||||
"executive",
|
||||
];
|
||||
|
||||
/** Unmapped keys (superadmin, IAM admins, Safety, new positions) keep the executive layout. */
|
||||
/**
|
||||
* Which layout to render, given the keys `GET /overview/layouts` said the
|
||||
* caller may see — the endpoint already filtered those by permission, so
|
||||
* this only breaks the tie when a caller holds more than one. Same shape as
|
||||
* the Reports page trusting `GET /reports`'s catalog rather than re-deriving
|
||||
* access from permission keys client-side.
|
||||
*
|
||||
* Empty/unmapped falls back to the executive layout — same default the old
|
||||
* role/position-key table used for superadmin, IAM admins, and any position
|
||||
* that hasn't been granted one of these permissions yet.
|
||||
*/
|
||||
export function resolveOverviewLayout(
|
||||
user: AuthUser | null | undefined,
|
||||
allowed: OverviewLayoutKey[] | undefined,
|
||||
): OverviewLayoutKey {
|
||||
const held = new Set(getPositionKeys(user));
|
||||
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
|
||||
const held = new Set(allowed ?? []);
|
||||
return LAYOUT_PRIORITY.find((key) => held.has(key)) ?? "executive";
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ export const QUERY_KEYS = {
|
||||
|
||||
OVERVIEW: {
|
||||
ROOT: ["overview"] as const,
|
||||
layouts: () => ["overview", "layouts"] as const,
|
||||
dashboard: (range?: string) =>
|
||||
["overview", "dashboard", range ?? "30d"] as const,
|
||||
bookingsTab: (range?: string) =>
|
||||
|
||||
@@ -184,6 +184,7 @@ export const URL_CONSTANTS = {
|
||||
|
||||
OVERVIEW: {
|
||||
BASE: "/overview",
|
||||
LAYOUTS: "/overview/layouts",
|
||||
BOOKINGS: "/overview/bookings",
|
||||
CONTRACTS: "/overview/contracts",
|
||||
BILLING: "/overview/billing",
|
||||
|
||||
@@ -11,6 +11,15 @@ export function useOverview(range: OverviewRange = "30d") {
|
||||
});
|
||||
}
|
||||
|
||||
/** Layouts the caller may render — server-filtered by permission, same shape as useReports' catalog. */
|
||||
export function useOverviewLayouts() {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.layouts(),
|
||||
queryFn: () => overviewService.getLayouts(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.bookingsTab(range),
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
ShieldOff,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
@@ -31,49 +30,22 @@ import {
|
||||
ManualRegistrationBadge,
|
||||
ProfileChips,
|
||||
formatDate,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyStatus } from "@/types/customer";
|
||||
import type { Company, CompanyListFilter } from "@/types/customer";
|
||||
import { isOnboardingDraft } from "@/types/customer";
|
||||
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||||
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
|
||||
import {
|
||||
FilterBar,
|
||||
dateRangeParams,
|
||||
isoToLocalDateStr,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
} from "@/components/filters";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
|
||||
/**
|
||||
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
|
||||
* review, so it excludes drafts — a company row exists from the onboarding
|
||||
* wizard's first click and would otherwise pad the review queue. Those drafts
|
||||
* get their own view instead of disappearing, so staff can still chase them.
|
||||
*/
|
||||
type CustomerView =
|
||||
| "all"
|
||||
| "pending"
|
||||
| "pendingChanges"
|
||||
| "onboarding"
|
||||
| "active";
|
||||
|
||||
/**
|
||||
* "Pending changes" is deliberately not folded into "Pending approval". A
|
||||
* customer who edits their profile after being approved stays `status = active`,
|
||||
* so the pending filter can never match them — their resubmission would only
|
||||
* ever be visible by opening their detail page. This view is that queue.
|
||||
*/
|
||||
const VIEW_FILTERS: Record<
|
||||
CustomerView,
|
||||
{
|
||||
status?: CompanyStatus;
|
||||
onboardingCompleted?: boolean;
|
||||
hasPendingChangeRequest?: boolean;
|
||||
}
|
||||
> = {
|
||||
all: {},
|
||||
pending: { status: "pending", onboardingCompleted: true },
|
||||
pendingChanges: { hasPendingChangeRequest: true },
|
||||
onboarding: { onboardingCompleted: false },
|
||||
active: { status: "active" },
|
||||
};
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
// Queue ordering: awaiting first approval → pending profile changes → the
|
||||
// rest, newest first within each group. The default, so whatever marketing
|
||||
@@ -85,29 +57,110 @@ const SORT_OPTIONS = [
|
||||
{ value: "name:DESC", label: "Name (Z–A)" },
|
||||
] as const;
|
||||
|
||||
/** No filter pills — search/sort/page are the only real filter dimensions;
|
||||
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
|
||||
const NO_FILTER_DEFS: FilterDef[] = [];
|
||||
/**
|
||||
* Every state a customer can be in, as one single-select list.
|
||||
*
|
||||
* Three of these are not `companies.status` values at all, which is why each
|
||||
* option maps its own params:
|
||||
* - **Pending approval** is submitted-and-awaiting-review, so it excludes
|
||||
* drafts — a company row exists from the onboarding wizard's first click and
|
||||
* would otherwise pad the review queue.
|
||||
* - **Onboarding** is that draft: still in the portal wizard, never submitted.
|
||||
* - **Pending changes** is an already-approved (`active`) customer who edited
|
||||
* their profile. `status` can never match them, so without this option their
|
||||
* resubmission is only visible by opening their detail page.
|
||||
*/
|
||||
const STATUS_OPTIONS: {
|
||||
value: string;
|
||||
label: string;
|
||||
params: Record<string, string>;
|
||||
}[] = [
|
||||
{ value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } },
|
||||
{ value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } },
|
||||
{ value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } },
|
||||
{ value: "active", label: "Active", params: { status: "active" } },
|
||||
{ value: "suspended", label: "Suspended", params: { status: "suspended" } },
|
||||
{ value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter pills. The review queues that used to sit beside them as segmented
|
||||
* tabs are folded into the Status pill above — three of the five were never a
|
||||
* plain `status` value, so as a separate tab strip they could contradict the
|
||||
* status filter next to them. One list, mutually exclusive, no contradiction.
|
||||
*/
|
||||
const CUSTOMER_FILTER_DEFS: FilterDef[] = [
|
||||
{
|
||||
key: "status",
|
||||
label: "Status",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })),
|
||||
toParams: (v) =>
|
||||
STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {},
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
label: "Type",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: (
|
||||
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
label: "Sector",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [
|
||||
{ value: "commercial", label: "Commercial" },
|
||||
{ value: "government", label: "Government" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "nationality",
|
||||
label: "Nationality",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [
|
||||
{ value: "ethiopian", label: "Ethiopian" },
|
||||
{ value: "foreign", label: "Foreign" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
label: "Registered",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||
},
|
||||
];
|
||||
|
||||
export default function CustomersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
|
||||
const controls = useFilters(CUSTOMER_FILTER_DEFS, {
|
||||
defaultSort: "review:DESC",
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const filter = useMemo(() => {
|
||||
const [sortBy, sortOrder] = controls.sort.split(":") as [
|
||||
"review" | "name" | "createdAt" | "updatedAt",
|
||||
"ASC" | "DESC",
|
||||
];
|
||||
return {
|
||||
page: controls.page,
|
||||
pageSize: controls.pageSize,
|
||||
search: String(controls.params.search ?? ""),
|
||||
sortBy,
|
||||
sortOrder,
|
||||
...VIEW_FILTERS[view],
|
||||
};
|
||||
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
|
||||
// `controls.params` is the whole query: page/pageSize/search, the split
|
||||
// sortBy/sortOrder, and every pill's mapped params.
|
||||
const filter = controls.params as unknown as CompanyListFilter;
|
||||
|
||||
/**
|
||||
* The export's `daterange` filters are coerced from calendar days while the
|
||||
* list takes ISO instants — hand the dialog the local day each bound falls on
|
||||
* so the file covers the same range the screen shows.
|
||||
*/
|
||||
const exportParams = useMemo(() => {
|
||||
const out: Record<string, unknown> = { ...controls.params };
|
||||
for (const key of ["createdFrom", "createdTo"]) {
|
||||
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
|
||||
}
|
||||
return out;
|
||||
}, [controls.params]);
|
||||
|
||||
const { data: stats } = useQuery(
|
||||
api.customers.stats.queryOptions({ input: {} }),
|
||||
@@ -293,33 +346,13 @@ export default function CustomersPage() {
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<FilterBar
|
||||
defs={NO_FILTER_DEFS}
|
||||
defs={CUSTOMER_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
viewId="customers"
|
||||
>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
// `view` lives outside useFilters (it's a tab, not a
|
||||
// filter pill), so switching it needs its own page reset —
|
||||
// the same "stranded on page 5" hazard useFilters guards
|
||||
// against for its own filters.
|
||||
setView(v as CustomerView);
|
||||
controls.setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Pending changes", value: "pendingChanges" },
|
||||
{ label: "Onboarding", value: "onboarding" },
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
<ExportButton datasetKey="customers" params={controls.params} />
|
||||
<ExportButton datasetKey="customers" params={exportParams} />
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
@@ -331,8 +364,8 @@ export default function CustomersPage() {
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
||||
emptyMessage={
|
||||
controls.searchText
|
||||
? "No companies match your search."
|
||||
controls.activeCount > 0
|
||||
? "No companies match these filters."
|
||||
: "No companies yet."
|
||||
}
|
||||
error={
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
|
||||
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
|
||||
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useOverview } from "@/hooks/useOverview";
|
||||
import { useOverview, useOverviewLayouts } from "@/hooks/useOverview";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
import "@/components/overview/summary/overview-summary.css";
|
||||
|
||||
@@ -57,13 +56,14 @@ function OverviewSkeleton() {
|
||||
const OverviewPage = () => {
|
||||
const [range, setRange] = useState<OverviewRange>("30d");
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const { data, isLoading, isError, error, refetch, isFetching } =
|
||||
useOverview(range);
|
||||
const { data: layouts, isLoading: layoutsLoading } = useOverviewLayouts();
|
||||
|
||||
// Hero, range control and headline KPIs are role-neutral; everything below
|
||||
// them is chosen by role key.
|
||||
const layoutKey = resolveOverviewLayout(user);
|
||||
// them is chosen by which overview:<key>:view permissions the caller holds
|
||||
// (GET /overview/layouts already filtered these server-side).
|
||||
const layoutKey = resolveOverviewLayout(layouts?.map((l) => l.key));
|
||||
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
|
||||
|
||||
const accessDenied =
|
||||
@@ -129,7 +129,7 @@ const OverviewPage = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading && !data ? (
|
||||
{(isLoading || layoutsLoading) && !data ? (
|
||||
<Stack mt="lg">
|
||||
<OverviewSkeleton />
|
||||
</Stack>
|
||||
|
||||
@@ -224,6 +224,21 @@ const FleetResourcePage = () => {
|
||||
secondary: true,
|
||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||
};
|
||||
// Wagons-only: "last maintenance" is a derived value (latest status-log
|
||||
// flip to MAINTENANCE), not a column other fleet resources have.
|
||||
const dateDefs: FilterDef[] =
|
||||
slug === "wagons"
|
||||
? [
|
||||
dateDef,
|
||||
{
|
||||
key: "lastMaintenance",
|
||||
label: "Last maintenance",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
toParams: dateRangeParams("maintenanceFrom", "maintenanceTo"),
|
||||
},
|
||||
]
|
||||
: [dateDef];
|
||||
if (config?.listFilters?.length) {
|
||||
return [
|
||||
...config.listFilters.map((filter): FilterDef => ({
|
||||
@@ -235,13 +250,13 @@ const FleetResourcePage = () => {
|
||||
? (dynamicOptions[filter.dynamicOptions] ?? [])
|
||||
: (filter.options ?? []),
|
||||
})),
|
||||
dateDef,
|
||||
...dateDefs,
|
||||
];
|
||||
}
|
||||
const fallback = FALLBACK_STATUS_OPTIONS[slug];
|
||||
return fallback
|
||||
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
|
||||
: [dateDef];
|
||||
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, ...dateDefs]
|
||||
: dateDefs;
|
||||
}, [config, dynamicOptions, slug]);
|
||||
|
||||
const controls = useFilters(filterDefs, { pageSize: 10 });
|
||||
|
||||
@@ -1,29 +1,127 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { Freight } from "@edr/types";
|
||||
import { ActionIcon, Badge, Box, Card, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Banknote, CircleDollarSign, Landmark, RefreshCw, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Banknote, CircleDollarSign, Landmark, RefreshCw } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers";
|
||||
import {
|
||||
FilterBar,
|
||||
dateRangeParams,
|
||||
isoToLocalDateStr,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
} from "@/components/filters";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Invoice, InvoiceListFilter } from "@/types/invoice";
|
||||
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
/** Mirrors `EimsInvoiceStatus` in the API — Finance's "what still needs filing" cut. */
|
||||
const EIMS_STATUS_OPTIONS = [
|
||||
"NOT_SUBMITTED",
|
||||
"SUBMITTING",
|
||||
"REGISTERED",
|
||||
"FAILED",
|
||||
"UNKNOWN",
|
||||
"CANCELLED",
|
||||
].map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
/**
|
||||
* Every dimension the list narrows by. Keys are the URL keys; `toParams` maps
|
||||
* them onto the API's `FilterInvoiceDto`. Secondary defs sit behind "More
|
||||
* filters" until they hold a value, then pin themselves as a pill.
|
||||
*/
|
||||
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
|
||||
{
|
||||
key: "currency",
|
||||
label: "Currency",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [
|
||||
{ value: "ETB", label: "ETB" },
|
||||
{ value: "USD", label: "USD" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// One pill for the two settlement cuts Finance actually chases. Both are
|
||||
// computed from the balance and due date rather than read off `status` —
|
||||
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
|
||||
key: "settlement",
|
||||
label: "Settlement",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [
|
||||
{ value: "outstanding", label: "Outstanding" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
],
|
||||
toParams: (v) =>
|
||||
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
|
||||
},
|
||||
{
|
||||
key: "issued",
|
||||
label: "Issued",
|
||||
type: "date",
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("issuedFrom", "issuedTo"),
|
||||
},
|
||||
{
|
||||
key: "due",
|
||||
label: "Due",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("dueFrom", "dueTo"),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["between", "is"],
|
||||
// Amounts are compared in each invoice's OWN currency — pair this with the
|
||||
// currency pill when the mix matters.
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? { minAmount: v.v[0], maxAmount: v.v[1] }
|
||||
: { minAmount: v.v[0], maxAmount: v.v[0] },
|
||||
},
|
||||
{
|
||||
key: "eimsStatuses",
|
||||
label: "EIMS",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: EIMS_STATUS_OPTIONS,
|
||||
},
|
||||
];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "issuedAt:DESC", label: "Newest issued" },
|
||||
{ value: "issuedAt:ASC", label: "Oldest issued" },
|
||||
{ value: "dueAt:ASC", label: "Due soonest" },
|
||||
{ value: "totalAmount:DESC", label: "Largest amount" },
|
||||
{ value: "balanceAmount:DESC", label: "Largest balance" },
|
||||
{ value: "invoiceNumber:ASC", label: "Invoice no. (A–Z)" },
|
||||
];
|
||||
|
||||
/** Date params the export's `daterange` coercion expects as calendar days. */
|
||||
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
|
||||
|
||||
/**
|
||||
* Which record raised the invoice, not just which subsystem. The source label
|
||||
@@ -65,20 +163,12 @@ function InvoiceSourceCell({ invoice }: { invoice: Invoice }) {
|
||||
/** 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("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>("");
|
||||
const controls = useFilters(INVOICE_FILTER_DEFS, {
|
||||
defaultSort: "issuedAt:DESC",
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
);
|
||||
const filter = controls.params as unknown as InvoiceListFilter;
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||
api.invoices.list.queryOptions({ input: { filter } }),
|
||||
@@ -86,7 +176,6 @@ export default function InvoicesPanel() {
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
// Shipping-line credit invoices carry maker–checker actions (mark paid /
|
||||
// cancel). One batched lookup fetches the visible rows' pending requests.
|
||||
@@ -106,14 +195,28 @@ export default function InvoicesPanel() {
|
||||
);
|
||||
|
||||
// Summary card: total collected (paidAmount) across every invoice matching
|
||||
// the current search/status filters, not just the visible page.
|
||||
// the current filters, not just the visible page. Same params minus
|
||||
// pagination, so the card can never total a different set than the table.
|
||||
const summaryFilter = useMemo(() => {
|
||||
const { page: _page, pageSize: _pageSize, ...rest } = filter;
|
||||
return rest;
|
||||
}, [filter]);
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery(
|
||||
api.invoices.collectedSummary.queryOptions({
|
||||
input: {
|
||||
filter: { search: debouncedQuery, status: statusFilter || undefined },
|
||||
},
|
||||
}),
|
||||
api.invoices.collectedSummary.queryOptions({ input: { filter: summaryFilter } }),
|
||||
);
|
||||
|
||||
/**
|
||||
* The export's `daterange` filters are coerced from calendar days, while the
|
||||
* list takes ISO instants — hand the dialog the local day each bound falls
|
||||
* on so an exported file covers the same range the screen shows.
|
||||
*/
|
||||
const exportParams = useMemo(() => {
|
||||
const out: Record<string, unknown> = { ...controls.params };
|
||||
for (const key of EXPORT_DAY_KEYS) {
|
||||
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
|
||||
}
|
||||
return out;
|
||||
}, [controls.params]);
|
||||
const { data: exchangeSettings } = useExchangeSettingsQuery();
|
||||
const etbCollected = summary?.ETB ?? 0;
|
||||
const usdCollected = summary?.USD ?? 0;
|
||||
@@ -236,45 +339,14 @@ export default function InvoicesPanel() {
|
||||
<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 invoice, customer, booking ref, GRN or shipping line…"
|
||||
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"
|
||||
/>
|
||||
<ExportButton datasetKey="invoices" params={filter} size="sm" />
|
||||
<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" },
|
||||
]}
|
||||
/>
|
||||
<FilterBar
|
||||
defs={INVOICE_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search invoice, customer, booking ref, GRN or shipping line…"
|
||||
sortOptions={SORT_OPTIONS}
|
||||
viewId="invoices"
|
||||
>
|
||||
<ExportButton datasetKey="invoices" params={exportParams} size="sm" />
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
@@ -285,7 +357,7 @@ export default function InvoicesPanel() {
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
@@ -296,7 +368,9 @@ export default function InvoicesPanel() {
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
||||
emptyMessage={
|
||||
debouncedQuery ? "No invoices match your search." : "No invoices yet."
|
||||
controls.activeCount > 0
|
||||
? "No invoices match these filters."
|
||||
: "No invoices yet."
|
||||
}
|
||||
error={
|
||||
isError
|
||||
@@ -306,18 +380,7 @@ export default function InvoicesPanel() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { OverviewLayoutKey } from "@/components/overview/role-dashboards.config";
|
||||
import type {
|
||||
IOverviewBillingTab,
|
||||
IOverviewBookingsTab,
|
||||
@@ -16,7 +17,19 @@ import type {
|
||||
|
||||
const O = URL_CONSTANTS.OVERVIEW;
|
||||
|
||||
/** Mirrors the API's OverviewLayoutDto — one entry per GET /overview/layouts item. */
|
||||
export interface IOverviewLayoutOption {
|
||||
key: OverviewLayoutKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const overviewService = {
|
||||
/** Layouts the caller has permission to render, in server priority order. */
|
||||
getLayouts: async (): Promise<IOverviewLayoutOption[]> => {
|
||||
const response = await client.get<IOverviewLayoutOption[]>(O.LAYOUTS);
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getDashboard: async (range?: OverviewRange): Promise<IOverviewDashboard> => {
|
||||
const response = await client.get<IOverviewDashboard>(O.BASE, {
|
||||
params: range ? { range } : undefined,
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface WagonListFilters {
|
||||
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
/** Last-maintenance day range (YYYY-MM-DD), both ends inclusive — matches
|
||||
* the latest status-log flip to MAINTENANCE, not a stored column. */
|
||||
maintenanceFrom?: string;
|
||||
maintenanceTo?: string;
|
||||
/** Only read by `getPaged`. */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -67,6 +71,8 @@ const wagonListQuery = (filters: WagonListFilters): string => {
|
||||
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
|
||||
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
|
||||
if (filters.createdTo) params.set('createdTo', filters.createdTo);
|
||||
if (filters.maintenanceFrom) params.set('maintenanceFrom', filters.maintenanceFrom);
|
||||
if (filters.maintenanceTo) params.set('maintenanceTo', filters.maintenanceTo);
|
||||
if (filters.page) params.set('page', String(filters.page));
|
||||
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
|
||||
const qs = params.toString();
|
||||
|
||||
@@ -313,6 +313,10 @@ export interface CompanyListFilter {
|
||||
type?: CompanyType;
|
||||
kind?: CompanyKind;
|
||||
status?: CompanyStatus;
|
||||
nationality?: CompanyNationality;
|
||||
/** ISO instants — inclusive bounds on the registration date. */
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
|
||||
onboardingCompleted?: boolean;
|
||||
/**
|
||||
|
||||
@@ -24,15 +24,39 @@ export interface Invoice extends Freight.IInvoice {
|
||||
sourceRef?: InvoiceSourceRef | null;
|
||||
}
|
||||
|
||||
/** Query parameters for the invoice list. */
|
||||
/**
|
||||
* Query parameters for the invoice list. Every key maps 1:1 onto
|
||||
* `FilterInvoiceDto` on the API — the list endpoint runs with
|
||||
* `forbidNonWhitelisted`, so a param that isn't declared there is a 400, not a
|
||||
* silently ignored extra.
|
||||
*/
|
||||
export interface InvoiceListFilter {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
companyId?: string;
|
||||
/** Single status — kept for the worklists that pin one. */
|
||||
status?: Freight.InvoiceStatus;
|
||||
/** CSV multi-select status, as the filter bar sends it. */
|
||||
statuses?: string;
|
||||
/** CSV of `Freight.InvoiceSource` values. */
|
||||
sources?: string;
|
||||
/** CSV of EIMS filing states. */
|
||||
eimsStatuses?: string;
|
||||
search?: string;
|
||||
/** Manual-payments worklist only. */
|
||||
currency?: "USD" | "ETB";
|
||||
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
|
||||
issuedFrom?: string;
|
||||
issuedTo?: string;
|
||||
dueFrom?: string;
|
||||
dueTo?: string;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
/** Outstanding balance only. */
|
||||
hasBalance?: boolean;
|
||||
/** Outstanding AND past due — computed, not read off `status`. */
|
||||
overdue?: boolean;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
/** Standard paginated list envelope (matches the customers/bookings service shape). */
|
||||
|
||||
Reference in New Issue
Block a user