fix issue

This commit is contained in:
Marshal
2026-08-20 18:10:29 +00:00
183 changed files with 14241 additions and 1265 deletions

View File

@@ -13,6 +13,7 @@ import {
Milestone,
MoreHorizontal,
Package,
Receipt,
RefreshCw,
Ship,
Truck,
@@ -64,6 +65,7 @@ import {
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
import { AdditionalPaymentsTab } from "@/components/bookings/AdditionalPaymentsTab";
import { getStatusMeta } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { formatDateTime, formatMoney } from "@/lib/format";
@@ -74,7 +76,10 @@ import {
useBookingMutations,
} from "@/hooks/bookings/useBookings";
import { useScrollToHash } from "@/hooks/useScrollToHash";
import { useFileViewer } from "@/hooks/useFileViewer";
import { bookingsService } from "@/services/bookings.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -82,6 +87,12 @@ export default function BookingRequestDetailPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Deep-link from a warehouse fee invoice → this booking's warehouse section.
useScrollToHash();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const canSeeAdditionalCharges = hasFreightPermission(
user,
FREIGHT_PERMS.additionalCharges.view,
);
// Consolidated pair: `?booking=<partnerId>` swaps the WHOLE page over to the
// other half of the shared wagon. Everything below — KPIs, stepper, the
@@ -206,7 +217,9 @@ export default function BookingRequestDetailPage() {
? "documents"
: requestedTab === "trucks"
? "trucks"
: "overview";
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -509,6 +522,14 @@ export default function BookingRequestDetailPage() {
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
{canSeeAdditionalCharges && (
<Tabs.Tab
value="additional-charges"
leftSection={<Receipt size={16} />}
>
Additional payments
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="overview">
@@ -528,6 +549,11 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
{canSeeAdditionalCharges && (
<Tabs.Panel value="additional-charges">
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />
</Tabs.Panel>
)}
</Tabs>
</Grid.Col>
@@ -562,6 +588,7 @@ export default function BookingRequestDetailPage() {
</Grid.Col>
</Grid>
</Stack>
{viewer}
</PageContainer>
);
}

View File

@@ -27,8 +27,9 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, humanize } from "@/lib/format";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -170,7 +171,7 @@ export default function BookingRequestsPage() {
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
{
key: "route", label: "Route", type: "route", options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
toParams: routeParams("originYardId", "destinationYardId"),
},
{
key: "created", label: "Created", type: "date", secondary: true,
@@ -547,7 +548,9 @@ export default function BookingRequestsPage() {
controls={controls}
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests"
/>
>
<ExportButton datasetKey="bookings" params={controls.params} />
</FilterBar>
</Box>
{showEmpty ? (

View File

@@ -52,7 +52,8 @@ import {
DataTableFooter,
type ColumnDef,
} from "@edr/ui-common";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
@@ -183,7 +184,7 @@ export default function ContractRequestsPage() {
label: "Route",
type: "route",
options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
toParams: routeParams("originYardId", "destinationYardId"),
},
],
[filterOptions, yardOptions, serviceTypeOptions],
@@ -468,7 +469,9 @@ export default function ContractRequestsPage() {
searchPlaceholder="Search reference or customer…"
sortOptions={SORT_OPTIONS}
viewId="contract-requests"
/>
>
<ExportButton datasetKey="contracts" params={controls.params} />
</FilterBar>
</Box>
{showEmpty ? (

View File

@@ -38,6 +38,7 @@ import type { Company, CompanyStatus } 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 { ExportButton } from "@/components/export/ExportButton";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
@@ -318,6 +319,7 @@ export default function CustomersPage() {
{ label: "Active", value: "active" },
]}
/>
<ExportButton datasetKey="customers" params={controls.params} />
</FilterBar>
</Box>

View File

@@ -24,14 +24,21 @@ import { useOverview } from "@/hooks/useOverview";
import type { OverviewRange } from "@/types/overview";
import "@/components/overview/summary/overview-summary.css";
const RANGE_LABEL: Record<OverviewRange, string> = { "7d": "7d", "30d": "30d", "90d": "90d" };
const RANGE_LABEL: Record<OverviewRange, string> = {
"7d": "7d",
"30d": "30d",
"90d": "90d",
};
/** Which composition each role sees below the hero. */
const LAYOUTS: Record<OverviewLayoutKey, (props: RoleOverviewProps) => ReactElement> = {
const LAYOUTS: Record<
OverviewLayoutKey,
(props: RoleOverviewProps) => ReactElement
> = {
executive: ExecutiveOverview,
operations: OperationsOverview,
operation: OperationsOverview,
occ: OccOverview,
marketing: MarketingOverview,
marketer: MarketingOverview,
finance: FinanceOverview,
clearance: ClearanceOverview,
};
@@ -51,15 +58,17 @@ 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, isLoading, isError, error, refetch, isFetching } =
useOverview(range);
// Hero, range control and headline KPIs are role-neutral; everything below
// them is chosen by role key.
const layoutKey = resolveOverviewLayout(user);
const RoleLayout = LAYOUTS[layoutKey];
const RoleLayout = layoutKey ? LAYOUTS[layoutKey] : null;
const accessDenied =
(error as { response?: { status?: number } } | null)?.response?.status === 403;
(error as { response?: { status?: number } } | null)?.response?.status ===
403;
const handleRefresh = () => {
void refetch();
@@ -79,7 +88,9 @@ const OverviewPage = () => {
label={OVERVIEW_LAYOUT_LABEL[layoutKey]}
/>
{data ? (
<div style={{ marginTop: -52, paddingInline: 20, position: "relative" }}>
<div
style={{ marginTop: -52, paddingInline: 20, position: "relative" }}
>
<OverviewHeroKpis
kpis={data.kpis}
current={data.current}
@@ -100,7 +111,12 @@ const OverviewPage = () => {
>
<Stack gap="sm" align="flex-start">
<span>Check your connection and try again.</span>
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
<Button
size="xs"
variant="light"
color="red"
onClick={() => void refetch()}
>
Retry
</Button>
</Stack>
@@ -117,7 +133,7 @@ const OverviewPage = () => {
<Stack mt="lg">
<OverviewSkeleton />
</Stack>
) : data ? (
) : data && RoleLayout ? (
<RoleLayout data={data} range={range} />
) : null}
</PageContainer>

View File

@@ -42,6 +42,7 @@ import {
} from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter } from "@edr/ui-common";
import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters";
import { ExportButton } from "@/components/export/ExportButton";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
@@ -621,6 +622,9 @@ const FleetResourcePage = () => {
]}
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
/>
{config.exportKey ? (
<ExportButton datasetKey={config.exportKey} params={controls.params} />
) : null}
</FilterBar>
</Box>

View File

@@ -119,6 +119,11 @@ export interface FleetResourceConfig {
supportsSearch: boolean;
/** Server-side list filters (e.g. wagon status / readiness). */
listFilters?: FleetListFilterDef[];
/**
* Export dataset key for this resource. Omitted where no dataset exists yet,
* in which case the page renders no export button.
*/
exportKey?: string;
columns: FleetResourceColumn[];
formFields: FleetFormFieldDef[];
emptyValues: Record<string, unknown>;
@@ -182,6 +187,7 @@ const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter(
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
exportKey: "locomotives",
label: "Locomotives",
subtitle: "Manage locomotive master data used by train scheduling and fleet operations",
basePath: "/dashboard/locomotives",
@@ -249,6 +255,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
},
{
slug: "trains",
exportKey: "trains",
label: "Trains",
subtitle: "Manage train master data independently from train scheduling",
basePath: "/dashboard/trains",
@@ -292,6 +299,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
},
{
slug: "wagons",
exportKey: "wagons",
label: "Wagons",
subtitle: "Manage wagon master data. Operational scheduling uses train schedules separately",
basePath: "/dashboard/wagons",

View File

@@ -1,6 +1,7 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Badge,
Box,
Card,
Group,
@@ -11,34 +12,55 @@ import {
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Banknote,
CircleDollarSign,
Landmark,
RefreshCw,
Search,
X,
} from "lucide-react";
import { Banknote, CircleDollarSign, Landmark, 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 { InvoiceStatusBadge, formatDate, formatMoney, humanize } from "@/components/customers";
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 { DataTable, DataTableFooter, usePagination, type ColumnDef } from "@edr/ui-common";
/**
* Which record raised the invoice, not just which subsystem. The source label
* stays (it says how the charge arose); under it sits the reference a human
* actually recognises — booking, GRN, or the shipping line billed. Falls back
* to the bare label when the server resolved nothing.
*/
function InvoiceSourceCell({ invoice }: { invoice: Invoice }) {
const ref = invoice.sourceRef;
const detail = ref?.bookingReference ?? ref?.shippingLineName ?? null;
return (
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" c="edr-text" lh={1.2}>
{humanize(invoice.source)}
</Text>
{ref?.tradeDirection ? (
<Badge size="xs" variant="light" color="gray">
{ref.tradeDirection}
</Badge>
) : null}
</Group>
{detail ? (
<Text size="xs" c="dimmed" ff="monospace" lh={1.2} truncate>
{detail}
</Text>
) : null}
{/* GRN only when it adds something the booking reference doesn't. */}
{ref?.grnNumber ? (
<Text size="xs" c="dimmed" lh={1.2} truncate>
{ref.grnNumber}
</Text>
) : null}
</Stack>
);
}
/** Invoices tab body of `FinanceHubPage` — page chrome lives in the parent. */
export default function InvoicesPanel() {
@@ -46,9 +68,7 @@ export default function InvoicesPanel() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>("");
const filter = useMemo(
() => ({
@@ -71,10 +91,7 @@ export default function InvoicesPanel() {
// Shipping-line credit invoices carry makerchecker actions (mark paid /
// cancel). One batched lookup fetches the visible rows' pending requests.
const creditInvoiceIds = useMemo(
() =>
rows
.filter((inv) => inv.source === "shipping_line_credit")
.map((inv) => inv.id),
() => rows.filter((inv) => inv.source === "shipping_line_credit").map((inv) => inv.id),
[rows],
);
const { data: pendingActions } = useQuery(
@@ -119,20 +136,15 @@ export default function InvoicesPanel() {
header: "Billed to",
cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
{row.original.company?.name ?? row.original.shippingLineCompany?.name ?? "—"}
</Text>
),
},
{
id: "source",
header: "Source",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
),
size: 220,
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
},
{
id: "status",
@@ -171,7 +183,6 @@ export default function InvoicesPanel() {
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const inv = row.original;
// Only shipping-line credit invoices have manual makerchecker
@@ -223,99 +234,96 @@ 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 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" },
]}
/>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</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}
/>
<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" },
]}
/>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</Box>
</Box>
</Stack>
<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>
</Stack>
);

View File

@@ -25,6 +25,7 @@ import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip } from "@/components/page";
import { ExportButton } from "@/components/export/ExportButton";
import { formatDate, formatMoney } from "@/lib/format";
import { api } from "@/services/api";
import type { PaymentMethod, PaymentRow } from "@/services/payments.service";
@@ -295,6 +296,7 @@ export default function PaymentsPanel() {
}
style={{ flex: 1, minWidth: "200px" }}
/>
<ExportButton datasetKey="payments" params={filter} size="sm" />
<Select
placeholder="All methods"
clearable

View File

@@ -1,17 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { api } from "@/services/api";
/**
* `/dashboard/reports` has no page of its own — it forwards to the first
* report the caller has access to (catalog order = registration order,
* already permission-filtered server-side), or home if they have none.
*/
export default function ReportsIndexRedirect() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}

View File

@@ -0,0 +1,79 @@
import { SimpleGrid, Stack, Title } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import { ReportSection } from "@/components/reports/ReportSection";
import { api } from "@/services/api";
/**
* The dashboards the reporting specs ask for, assembled from reports that
* already exist rather than a second aggregation API: each tile is a
* `ReportSection` opened on its chart, and each one permission-gates itself by
* rendering nothing when the caller's catalog lacks that report.
*/
const REVENUE_TILES = [
"revenue-by-period",
"revenue-by-category",
"revenue-by-route",
"revenue-top-customers",
];
const OPERATIONS_TILES = [
"cargo-volume-performance",
"teu-performance",
"trainset-performance",
"turnaround-cycle",
];
export default function ReportsLandingPage() {
const { data: catalog, isLoading } = useQuery(api.reports.catalog.queryOptions());
if (isLoading) return null;
const visible = (keys: string[]) =>
keys.filter((key) => catalog?.some((r) => r.key === key));
const revenue = visible(REVENUE_TILES);
const operations = visible(OPERATIONS_TILES);
// No dashboard reports for this user — fall back to the old behaviour and
// send them to the first report they can actually open.
if (!revenue.length && !operations.length) {
const first = catalog?.[0];
return <Navigate to={first ? `/dashboard/reports/${first.key}` : "/dashboard"} replace />;
}
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Reports dashboard"
subtitle="Billed rail revenue and operational performance at a glance. Pick any report in the sidebar for the full table, filters and export."
/>
{revenue.length > 0 && (
<Stack gap="sm">
<Title order={3}>Revenue</Title>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{revenue.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
</Stack>
)}
{operations.length > 0 && (
<Stack gap="sm">
<Title order={3}>Operations</Title>
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
{operations.map((key) => (
<ReportSection key={key} reportKey={key} defaultView="chart" />
))}
</SimpleGrid>
</Stack>
)}
</Stack>
</PageContainer>
);
}

View File

@@ -313,7 +313,9 @@ const RuleEngineResourcePage = () => {
(f) =>
f.name === "originYardId" ||
f.name === "fromYardId" ||
f.name === "toYardId",
f.name === "toYardId" ||
// Operational targets pick a station by yard code.
f.name === "dimensionKey",
),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
@@ -475,6 +477,20 @@ const RuleEngineResourcePage = () => {
.map(({ label, value }) => ({ label, value })),
};
}
// An operational target's key is a category, a container class, or a
// station's YARD CODE — never a yard id, because the reports match it
// against what their classification CASE emits.
if (field.name === "dimensionKey") {
const staticOptions = field.optionsFromValues;
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
String(values.dimension ?? "") === "station"
? (yardOptions ?? []).map(({ label, code }) => ({ label, value: code }))
: (staticOptions?.(values) ?? []),
};
}
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {

View File

@@ -142,6 +142,37 @@ const TRADE_DIRECTIONS = [
{ label: "Both", value: "BOTH" },
];
/**
* The cargo categories and container classes an operational target may be
* keyed on.
*
* Mirrors CARGO_CATEGORIES / CONTAINER_CLASSES in the API's
* `modules/reports/operations-classification.ts`, which is the source of truth:
* a report matches a target by this exact key, so a value here that the API
* does not emit is a plan the report will never find. The API spec
* `operations-classification.spec.ts` guards the API side of the pair.
*/
export const OPERATIONS_CARGO_CATEGORIES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
{ label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" },
{ label: "Export container", value: "CONTAINER_EXPORT" },
{ label: "Empty container", value: "EMPTY_CONTAINER" },
{ label: "Fertilizer", value: "FERTILIZER" },
{ label: "RoRo", value: "RORO" },
{ label: "Break bulk", value: "BREAK_BULK" },
{ label: "Sand", value: "SAND" },
{ label: "Bulk", value: "BULK" },
{ label: "Other imports", value: "OTHER_IMPORT" },
{ label: "Other export cargo", value: "OTHER_EXPORT" },
];
export const OPERATIONS_CONTAINER_CLASSES = [
{ label: "Multimodal container import", value: "CONTAINER_IMPORT_MULTIMODAL" },
{ label: "Unimodal container import", value: "CONTAINER_IMPORT_UNIMODAL" },
{ label: "Full export container", value: "CONTAINER_EXPORT" },
{ label: "Empty container return", value: "EMPTY_CONTAINER_RETURN" },
];
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
const YARD_COUNTRIES = [
{ label: "Ethiopia", value: "Ethiopia" },
@@ -478,6 +509,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
optional: true,
placeholder: "Select parent cargo type (optional)",
},
{
name: "fullTrainsetWagons",
label: "Wagons in a full trainset",
type: "number",
optional: true,
description:
"What the Trainset Performance report divides loaded wagons by — 37 for vehicles, 22 for sand. Leave blank to use the default in Operating standards.",
},
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
{
@@ -639,6 +678,108 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
],
},
{
slug: "operations-targets",
label: "Operational Targets",
category: "configuration",
subtitle:
"Planned TEU, trainsets and tonnage per period — the Plan column in the operations reports",
searchPlaceholder: "Search by category, station or note...",
supportsSearch: true,
cardTitleKey: "appliesToLabel",
cardSubtitleKey: "periodStart",
columns: [
// The *Label columns are readable twins the API sends alongside the stored
// codes (see OperationsTargetsService.toRow) — the codes themselves are
// enums the reports join on and stay out of the grid.
{ id: "periodStart", header: "Period start", accessorKey: "periodStart", format: "date" },
{ id: "periodLabel", header: "Period", accessorKey: "periodLabel" },
{ id: "metricLabel", header: "Metric", accessorKey: "metricLabel" },
{ id: "dimensionLabel", header: "Plan by", accessorKey: "dimensionLabel" },
{ id: "appliesToLabel", header: "Applies to", accessorKey: "appliesToLabel" },
{ id: "cargoCategoryLabel", header: "Cargo category", accessorKey: "cargoCategoryLabel" },
{ id: "plannedValue", header: "Plan", accessorKey: "plannedValue", format: "number" },
],
formFields: [
{
name: "metric",
label: "Metric",
type: "select",
required: true,
options: [
{ label: "TEU", value: "TEU" },
{ label: "Trainsets", value: "TRAINSET" },
{ label: "Volume (tons)", value: "VOLUME_TONS" },
],
},
{
name: "periodType",
label: "Period",
type: "select",
required: true,
options: [
{ label: "Weekly", value: "week" },
{ label: "Monthly", value: "month" },
{ label: "Quarterly", value: "quarter" },
{ label: "Yearly", value: "year" },
],
},
{
name: "periodStart",
label: "Period start",
type: "date",
required: true,
description: "Any date inside the period — snapped to its start on save.",
},
{
name: "dimension",
label: "Plan by",
type: "select",
required: true,
options: [
{ label: "Cargo category", value: "cargo_category" },
{ label: "Station", value: "station" },
{ label: "Container class", value: "container_class" },
],
},
{
name: "dimensionKey",
label: "Applies to",
type: "select",
required: true,
placeholder: "Select",
// The valid keys depend on the chosen dimension, and must match what the
// reports emit exactly — a mismatch here is a target the report never
// finds. Station options are the live yard codes, injected by
// RuleEngineResourcePage.
optionsFromValues: (values) => {
const dimension = String(values.dimension ?? "");
if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES;
if (dimension === "station") return [];
return OPERATIONS_CARGO_CATEGORIES;
},
},
{
name: "cargoCategory",
label: "Cargo category",
type: "select",
required: true,
// A station's plan is per station AND per cargo type — the OCC report
// plans Nagad-Mojo container and Nagad-Mojo fertilizer separately. The
// other two dimensions already carry the category in the key above.
showWhen: { field: "dimension", equals: ["station"] },
options: OPERATIONS_CARGO_CATEGORIES,
},
{
name: "plannedValue",
label: "Planned value",
type: "number",
required: true,
description: "TEU, trainsets or tonnes — whichever the metric above is.",
},
{ name: "note", label: "Note", type: "text", optional: true },
],
},
{
slug: "yard-distances",
label: "Yard Distances",
@@ -652,6 +793,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" },
{ id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" },
{ id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" },
{ id: "standardHours", header: "Standard (hrs)", accessorKey: "standardHours", format: "number" },
],
formFields: [
// Options injected at render from useYardOptions (RuleEngineResourcePage).
@@ -665,6 +807,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
description:
"Symmetric — one entry covers both directions. Route segments between these yards use this value.",
},
{
name: "standardHours",
label: "Standard running time (hrs)",
type: "number",
optional: true,
description:
"What the Train Delays report judges this leg against — 21h Negad to GMP, 20h to Adama, 20.5h to Modjo, 22h to Sebeta. Leave blank to use the default in Operating standards.",
},
],
},
{

View File

@@ -0,0 +1,282 @@
import { useState } from "react";
import { Save } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
useOperationsStandardsQuery,
useUpdateOperationsStandards,
} from "@/hooks/useOperationsStandards";
import type { OperationsStandards } from "@/services/operationsStandards.service";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useAuth } from "@/auth/useAuth";
type Field = {
name: keyof Omit<OperationsStandards, "id" | "updatedAt">;
label: string;
hint: string;
unit: string;
integer?: boolean;
};
type Section = { title: string; description: string; fields: Field[] };
/**
* Grouped the way the reporting spec reads, so an operator changing "the
* Djibouti standard" finds it next to the Ethiopian one rather than hunting a
* flat list of fifteen numbers.
*/
const SECTIONS: Section[] = [
{
title: "Station staying time",
description:
"How long a train may stand at a station before the stop needs a reason. Used by Station Staying Time.",
fields: [
{
name: "stationStandardHoursEthiopia",
label: "Ethiopian stations",
hint: "Standard stop on the Ethiopian side",
unit: "hrs",
},
{
name: "stationStandardHoursDjibouti",
label: "Djibouti stations",
hint: "Standard stop on the Djibouti side",
unit: "hrs",
},
],
},
{
title: "Turnaround cycle",
description:
"The full out-and-back a train is expected to complete in. Used by Turnaround Cycle.",
fields: [
{
name: "cycleStandardHoursContainer",
label: "Container",
hint: "10 + 21 + 13 + 21",
unit: "hrs",
},
{
name: "cycleStandardHoursBulkDmp",
label: "Bulk via DMP",
hint: "13 + 21 + 33 + 21",
unit: "hrs",
},
{
name: "cycleStandardHoursBulkNagad",
label: "Bulk via Negad",
hint: "13 + 21 + 41 + 21",
unit: "hrs",
},
{
name: "cycleStandardHoursBulkBcc",
label: "Bulk via BCC",
hint: "13 + 21 + 41 + 21",
unit: "hrs",
},
],
},
{
title: "Delay",
description:
"Used by Train Delays when a yard pair has no standard of its own. Per-corridor times live on Yard Distances.",
fields: [
{
name: "defaultLegStandardHours",
label: "Default leg standard",
hint: "Negad to GMP is 21 hours",
unit: "hrs",
},
{
name: "delayToleranceMinutes",
label: "Tolerance",
hint: "Grace before a leg counts as delayed",
unit: "min",
integer: true,
},
],
},
{
title: "Charged volume",
description:
"The standard weight capacity cargo is charged on, as opposed to what was weighed. Used by Charged and Actual Volumes.",
fields: [
{
name: "chargedTonsFull20ft",
label: "Laden 20ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsFull40ft",
label: "Laden 40ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsEmpty20ft",
label: "Empty 20ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsEmpty40ft",
label: "Empty 40ft container",
hint: "Per container",
unit: "t",
},
{
name: "chargedTonsPerWagonGeneral",
label: "Wagon of steel, fertilizer, rice, sugar",
hint: "Per wagon",
unit: "t",
},
{
name: "chargedTonsPerWagonPerishable",
label: "Wagon of vegetables, milk, meat, livestock",
hint: "Per wagon",
unit: "t",
},
],
},
{
title: "Trainset",
description:
"Used by Trainset Performance when a cargo type has no wagon count of its own — set those on Cargo Types.",
fields: [
{
name: "defaultFullTrainsetWagons",
label: "Wagons in a full trainset",
hint: "37 for vehicles and 22 for sand are set per cargo type",
unit: "wagons",
integer: true,
},
],
},
];
const ALL_FIELDS = SECTIONS.flatMap((s) => s.fields);
/**
* The operating standards the operations reports measure against.
*
* A single settings row rather than constants in the code, because the business
* treats these as tunable — the corridor standard is explicitly described as
* flexible. Every value here changes what a report calls on-time, encouraging,
* or on plan, so the page shows what each one drives.
*/
export default function OperationsStandardsPage() {
const { user } = useAuth();
const { data, isLoading } = useOperationsStandardsQuery();
const update = useUpdateOperationsStandards();
const [draft, setDraft] = useState<Record<string, string>>({});
const canEdit =
hasPermission(user, FREIGHT_PERMS.settings.operationsStandards.manage) ||
hasPermission(user, FREIGHT_PERMS.admin);
const valueOf = (field: Field): string =>
draft[field.name] ?? (data ? String(data[field.name] ?? "") : "");
const invalid = (field: Field): boolean => {
const raw = draft[field.name];
if (raw === undefined) return false;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return true;
return field.integer ? !Number.isInteger(parsed) : false;
};
const anyInvalid = ALL_FIELDS.some(invalid);
const dirty = Object.keys(draft).length > 0;
const handleSave = async () => {
if (anyInvalid || !dirty) return;
const patch = Object.fromEntries(
Object.entries(draft).map(([key, value]) => [key, Number(value)]),
);
await update.mutateAsync(patch);
setDraft({});
};
return (
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Operating standards</h1>
<p className="text-sm text-muted-foreground max-w-3xl">
The figures every operations report measures actual performance
against. Changing one changes what the reports call on time, over
standard, or on plan it does not change any charge a customer
pays.
</p>
</div>
<Button
onClick={handleSave}
disabled={!canEdit || !dirty || anyInvalid || update.isPending}
>
<Save className="h-4 w-4 mr-2" />
{update.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
{SECTIONS.map((section) => (
<Card key={section.title}>
<CardHeader>
<CardTitle>{section.title}</CardTitle>
<CardDescription>{section.description}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{section.fields.map((field) => (
<div key={field.name} className="space-y-1">
<label
className="text-sm font-medium"
htmlFor={`standard-${field.name}`}
>
{field.label}
</label>
<div className="flex items-center gap-2">
<Input
id={`standard-${field.name}`}
type="number"
step={field.integer ? 1 : 0.01}
min={field.integer ? 1 : 0.01}
value={valueOf(field)}
disabled={isLoading || !canEdit}
aria-invalid={invalid(field)}
onChange={(e) =>
setDraft((d) => ({ ...d, [field.name]: e.target.value }))
}
/>
<span className="text-sm text-muted-foreground w-16">
{field.unit}
</span>
</div>
<p className="text-xs text-muted-foreground">
{invalid(field)
? field.integer
? "Must be a whole number above zero"
: "Must be above zero"
: field.hint}
</p>
</div>
))}
</CardContent>
</Card>
))}
{!canEdit && (
<p className="text-sm text-muted-foreground">
You can view these standards but not change them.
</p>
)}
</div>
);
}

View File

@@ -5,11 +5,13 @@ import {
Box,
Button,
Card,
Center,
Checkbox,
Divider,
Group,
Menu,
Modal,
SegmentedControl,
Select,
SimpleGrid,
Stack,
@@ -19,7 +21,6 @@ import {
ThemeIcon,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios";
import {
ArrowRight,
@@ -27,27 +28,33 @@ import {
CalendarClock,
Clock,
Eye,
LayoutGrid,
MoreHorizontal,
Navigation,
Pencil,
Play,
Send,
Table2,
Train,
Weight,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import {
FilterBar,
routeParams,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
type SortOption,
} from "@/components/filters";
import { useFleetViewMode, type FleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
directionColor,
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
@@ -55,10 +62,8 @@ import CreateScheduleWindowFields, {
} from "@/components/trainScheduling/CreateScheduleWindowFields";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
import {
RouteCorridor,
StatusPill,
} from "@/components/trainScheduling/scheduleVisuals";
import { ExportButton } from "@/components/export/ExportButton";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
@@ -67,12 +72,34 @@ import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
TrainScheduleStatus,
} from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { DataTable, DataTableFooter } from "@edr/ui-common";
const SCHEDULE_STATUS_OPTIONS = [
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
];
/** Server sort fields (TRAIN_SCHEDULE_SORT_FIELDS) in the shared "field:DIR" form. */
const SORT_OPTIONS: SortOption[] = [
{ value: "createdAt:DESC", label: "Newest created" },
{ value: "createdAt:ASC", label: "Oldest created" },
{ value: "scheduledDepartureDate:DESC", label: "Departure ↓" },
{ value: "scheduledDepartureDate:ASC", label: "Departure ↑" },
{ value: "reference:ASC", label: "Reference ↑" },
{ value: "reference:DESC", label: "Reference ↓" },
];
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
const nowLocalDateTime = () => {
@@ -114,34 +141,16 @@ export default function TrainScheduleV2ListPage() {
const canCreate = canCreateSchedule(user);
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState<"ALL" | TrainScheduleStatus>("ALL");
const [freightFilter, setFreightFilter] = useState<"ALL" | FreightType>("ALL");
// Origin/destination hold yard IDs ("ALL" = no filter); the server matches
// the schedule's origin_station_id / destination_station_id exactly.
const [originFilter, setOriginFilter] = useState("ALL");
const [destinationFilter, setDestinationFilter] = useState("ALL");
// Default: newest-created first, matching the API's default order. Values
// are the server sort fields (see TRAIN_SCHEDULE_SORT_FIELDS).
const [sortBy, setSortBy] = useState<
"createdAt" | "scheduledDepartureDate" | "reference"
>("createdAt");
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null);
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
// Actual departure — defaults to now when the dialog opens; past is fine.
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] =
useState<TrainScheduleListItem | null>(null);
const [cancelTarget, setCancelTarget] = useState<TrainScheduleListItem | null>(null);
const [editDateSchedule, setEditDateSchedule] = useState<TrainScheduleListItem | null>(null);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [trainId, setTrainId] = useState("");
@@ -155,51 +164,59 @@ export default function TrainScheduleV2ListPage() {
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
// Recomputed each time the create modal opens so a long-lived tab can't keep
// offering a stale "now" as the earliest selectable departure.
const minScheduleDate = useMemo(
() => (createOpen ? nowLocalDateTime() : ""),
[createOpen],
const minScheduleDate = useMemo(() => (createOpen ? nowLocalDateTime() : ""), [createOpen]);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const resetPage = useCallback(() => {
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
);
}, [setPagination]);
// One Route pill covering both ends. It is the paired `route` type — which
// no longer forces both sides to be filled — so filtering by origin alone,
// by destination alone, or by several yards per side all still work, and the
// two ends read as the one thing an operator is actually picking.
const scheduleFilterDefs: FilterDef[] = useMemo(
() => [
{
key: "status",
label: "Status",
type: "enum",
multiple: false,
options: SCHEDULE_STATUS_OPTIONS,
},
{
key: "freightType",
label: "Freight",
type: "enum",
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
{
key: "route",
label: "Route",
type: "route",
options: yardOptions,
toParams: routeParams("originStationId", "destinationStationId"),
},
],
[yardOptions],
);
// Search resets the page only once the debounced value settles — resetting
// per keystroke would refetch page 1 mid-typing.
useEffect(() => {
resetPage();
}, [debouncedSearch, resetPage]);
const controls = useFilters(scheduleFilterDefs, {
defaultSort: "createdAt:DESC",
pageSize: 10,
});
// Fully server-driven list: pagination, search, filters, and sort all travel
// as query params; the response envelope carries the page + totals.
const filters = useMemo<TrainScheduleListFilters>(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(freightFilter !== "ALL" ? { freightType: freightFilter } : {}),
...(originFilter !== "ALL" ? { originStationId: originFilter } : {}),
...(destinationFilter !== "ALL"
? { destinationStationId: destinationFilter }
: {}),
sortBy,
sortOrder: sortDir === "asc" ? "ASC" : "DESC",
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedSearch,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
],
);
const filters = controls.params as unknown as TrainScheduleListFilters;
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({
@@ -211,14 +228,7 @@ export default function TrainScheduleV2ListPage() {
staleTime: 30_000,
}),
);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
);
const routesQuery = useQuery(api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }));
const trainsQuery = useQuery(
api.trainScheduling.availableTrains.queryOptions({
input: { routeId },
@@ -235,9 +245,7 @@ export default function TrainScheduleV2ListPage() {
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const dispatchSchedule = useMutation(
api.trainScheduling.dispatchSchedule.mutationOptions(),
);
const dispatchSchedule = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
@@ -252,9 +260,7 @@ export default function TrainScheduleV2ListPage() {
const trainYardHint = useMemo(() => {
if (!selectedRoute) return "Select a route first";
const originLabel =
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "the route origin yard";
return `All schedulable built trains are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
}, [selectedRoute]);
@@ -266,7 +272,6 @@ export default function TrainScheduleV2ListPage() {
// current page, and the meta envelope carries the totals.
const schedules = schedulesQuery.data?.items ?? [];
const totalSchedules = schedulesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, schedulesQuery.data?.meta.totalPages ?? 1);
// Status/weight tiles count the visible page only — board-wide numbers would
// need a dedicated summary endpoint now that the list is server-paginated.
@@ -286,92 +291,62 @@ export default function TrainScheduleV2ListPage() {
return base;
}, [schedules]);
// Corridor filter options: every yard from the shared reference list, sent
// to the server as origin/destination station IDs.
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "reference",
header: "Ref",
// Train, reference and status share one identity column — three
// stacked lines cost the width of the widest, not three columns.
id: "train",
header: "Train",
size: 170,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
{row.original.reference ?? "—"}
</Text>
),
cell: ({ row }) => <TrainIdentityCell schedule={row.original} />,
},
{
id: "date",
header: "Departure",
size: 110,
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-edr-green-0)",
color: "var(--mantine-color-edr-green-7)",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
);
},
},
{
id: "route",
header: "Route",
size: 280,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Stack gap={6}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(row.original.direction)}
>
<Badge size="xs" variant="light" color={directionColor(row.original.direction)}>
{row.original.direction}
</Badge>
) : null}
<ShippingLineBadge schedule={row.original} />
</Group>
<Box maw={220}>
<Box maw={260}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
orientation="vertical"
/>
</Box>
</Stack>
@@ -383,59 +358,6 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "train",
header: "Train",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
// Schedules created from the Train Builder show the direction-matched
// run number first (falling back to the train code); legacy rows fall
// back to their locomotive set.
if (row.original.train) {
const subtitle = [row.original.trainNumber ? row.original.train.code : null,
row.original.train.trainName]
.filter(Boolean)
.join(" · ");
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Stack gap={0}>
<Text size="sm" fw={600} ff="monospace" lh={1.2}>
{row.original.trainNumber ?? row.original.train.code}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2}>
{subtitle}
</Text>
) : null}
</Stack>
</Group>
);
}
const locos =
row.original.locomotives && row.original.locomotives.length > 0
? row.original.locomotives
: row.original.locomotive
? [row.original.locomotive]
: [];
if (!locos.length) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{locos[0].code}
{locos.length > 1 ? ` +${locos.length - 1}` : ""}
</Text>
</Group>
);
},
},
{
id: "metrics",
header: "Load",
@@ -448,15 +370,9 @@ export default function TrainScheduleV2ListPage() {
</Group>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
id: "actions",
size:32,
size: 32,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const schedule = row.original;
@@ -481,9 +397,7 @@ export default function TrainScheduleV2ListPage() {
<Menu.Item
leftSection={<Navigation size={15} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
>
Track
@@ -560,10 +474,7 @@ export default function TrainScheduleV2ListPage() {
toast({ title: "Booking window settings are still loading", variant: "destructive" });
return;
}
const built = buildWindowRulePayload(
windowForm,
selectedRoute?.direction === "EXPORT",
);
const built = buildWindowRulePayload(windowForm, selectedRoute?.direction === "EXPORT");
if ("error" in built) {
toast({ title: built.error, variant: "destructive" });
return;
@@ -631,114 +542,42 @@ export default function TrainScheduleV2ListPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
<FilterBar
defs={scheduleFilterDefs}
controls={controls}
searchPlaceholder="Search schedules…"
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<>
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => {
if (!v) return;
setStatusFilter(v as "ALL" | TrainScheduleStatus);
resetPage();
}}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={freightFilter}
onChange={(v) => {
if (!v) return;
setFreightFilter(v as "ALL" | FreightType);
resetPage();
}}
data={[
{ value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Origin"
searchable
value={originFilter}
onChange={(v) => {
setOriginFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All origins" },
...yardOptions,
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Destination"
searchable
value={destinationFilter}
onChange={(v) => {
setDestinationFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All destinations" },
...yardOptions,
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={`${sortBy}:${sortDir}`}
onChange={(v) => {
if (!v) return;
const [by, dir] = v.split(":") as [
typeof sortBy,
typeof sortDir,
];
setSortBy(by);
setSortDir(dir);
resetPage();
}}
data={[
{ value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduledDepartureDate:desc", label: "Departure ↓" },
{ value: "scheduledDepartureDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" },
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</>
}
/>
sortOptions={SORT_OPTIONS}
viewId="train-schedules"
>
<Group gap="sm" wrap="nowrap">
<SegmentedControl
size="xs"
radius="lg"
value={viewMode}
onChange={(v) => setViewMode(v as FleetViewMode)}
data={[
{
value: "table",
label: (
<Center>
<Table2 size={14} />
</Center>
),
},
{
value: "cards",
label: (
<Center>
<LayoutGrid size={14} />
</Center>
),
},
]}
aria-label="View mode"
/>
<ExportButton datasetKey="train-schedules" params={controls.params} size="sm" />
</Group>
</FilterBar>
</Box>
{viewMode === "table" ? (
@@ -763,18 +602,7 @@ export default function TrainScheduleV2ListPage() {
: undefined
}
emptyMessage="No train schedules found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: totalSchedules,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
{...controls.tableProps(totalSchedules)}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
@@ -797,25 +625,18 @@ export default function TrainScheduleV2ListPage() {
key={schedule.id}
schedule={schedule}
onOpen={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
onTrack={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`,
)
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
/>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={totalSchedules}
{...toRuleEngineFooterProps(controls, totalSchedules)}
itemLabel="schedules"
onPaginationChange={setPagination}
/>
</Stack>
)}
@@ -957,13 +778,12 @@ export default function TrainScheduleV2ListPage() {
{dispatchTarget?.trainNumber ?? dispatchTarget?.reference ?? "This train"}
</Text>{" "}
departs {dispatchTarget?.origin ?? "its origin"} for{" "}
{dispatchTarget?.destination ?? "its destination"} and its booking
window closes. This cannot be undone.
{dispatchTarget?.destination ?? "its destination"} and its booking window closes. This
cannot be undone.
</Text>
<Text size="xs" c="dimmed">
Open the schedule detail first if you want to check for unassigned
wagons or cargo not yet marked loaded those warnings are shown
there, not here.
Open the schedule detail first if you want to check for unassigned wagons or cargo not
yet marked loaded those warnings are shown there, not here.
</Text>
<DateTimePicker
label="Actual departure"
@@ -988,9 +808,7 @@ export default function TrainScheduleV2ListPage() {
try {
await dispatchSchedule.mutateAsync({
id: dispatchTarget.id,
payload: dispatchAt
? { actualDepartureAt: dispatchAt.toISOString() }
: {},
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
});
toast({ title: "Train dispatched" });
setDispatchTarget(null);
@@ -1024,14 +842,13 @@ export default function TrainScheduleV2ListPage() {
<Text span fw={600} c="dark">
{cancelTarget?.trainNumber ?? cancelTarget?.reference ?? "This train"}
</Text>{" "}
will be cancelled and removed from the active schedule board. This
cannot be undone.
will be cancelled and removed from the active schedule board. This cannot be undone.
</Text>
{cancelTarget?.bookingsCount ? (
<Text size="sm" c="red.7" fw={500}>
{cancelTarget.bookingsCount} booking
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will
need to be moved to another schedule.
{cancelTarget.bookingsCount === 1 ? "" : "s"} on this train will need to be moved to
another schedule.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
@@ -1083,6 +900,56 @@ const SHIPPING_LINE_ROW_STYLE = {
backgroundColor: "var(--mantine-color-edr-green-0)",
} as const;
/**
* The row's identity: which train is running, under what reference, in what
* state. Stacked into one column so the three read as a unit and cost one
* column's width between them.
*/
function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
// Schedules created from the Train Builder show the direction-matched run
// number first (falling back to the train code); legacy rows fall back to
// their locomotive set.
const locos =
schedule.locomotives && schedule.locomotives.length > 0
? schedule.locomotives
: schedule.locomotive
? [schedule.locomotive]
: [];
let title = "—";
let subtitle = "";
if (schedule.train) {
title = schedule.trainNumber ?? schedule.train.code;
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
.filter(Boolean)
.join(" · ");
} else if (locos.length) {
title = `${locos[0].code}${locos.length > 1 ? ` +${locos.length - 1}` : ""}`;
}
return (
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Train size={14} color="var(--mantine-color-gray-5)" style={{ flexShrink: 0 }} />
<Text size="sm" fw={600} ff="monospace" lh={1.2} truncate>
{title}
</Text>
</Group>
{subtitle ? (
<Text size="xs" c="dimmed" lh={1.2} truncate>
{subtitle}
</Text>
) : null}
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} ff="monospace" c="edr-green.8" lh={1.2} truncate>
{schedule.reference ?? "—"}
</Text>
<StatusPill status={schedule.status} />
</Group>
</Stack>
);
}
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
if (!schedule.shippingLineCompanyId) return null;
return (
@@ -1110,12 +977,8 @@ function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
value={`${used}/${total}`}
label={schedule.wagonCount === 0 ? "wgn planned" : "wgn used"}
/>
{reserved > used ? (
<MetricChip value={reserved} label="reserved" subtle />
) : null}
{remaining != null ? (
<MetricChip value={remaining} label="bookable" subtle />
) : null}
{reserved > used ? <MetricChip value={reserved} label="reserved" subtle /> : null}
{remaining != null ? <MetricChip value={remaining} label="bookable" subtle /> : null}
</>
);
}
@@ -1136,9 +999,7 @@ function MetricChip({
style={{
padding: "2px 8px",
borderRadius: 8,
background: subtle
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-edr-green-0)",
background: subtle ? "var(--mantine-color-gray-1)" : "var(--mantine-color-edr-green-0)",
border: `1px solid ${
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-edr-green-1)"
}`,
@@ -1215,11 +1076,7 @@ function ScheduleCard({
<Group gap={6} wrap="nowrap">
<FreightTypeBadge freightType={schedule.freightType} />
{schedule.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(schedule.direction)}
>
<Badge size="xs" variant="light" color={directionColor(schedule.direction)}>
{schedule.direction}
</Badge>
) : null}

View File

@@ -1,21 +1,23 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Badge, Card, Center, Divider, Group, Loader, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { Badge, Card, Center, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, ThemeIcon } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import {
ClipboardCheck,
ClipboardList,
ShieldCheck,
PackageCheck,
PackageOpen,
PackagePlus,
PackageSearch,
CircleCheck,
Send,
Train,
Truck,
Warehouse as WarehouseIcon,
Boxes,
Layers,
} from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import { getDateRangePresets } from '@/components/common/dateRangePresets';
import {
AccrualDashboard,
CycleTimeCard,
@@ -25,7 +27,7 @@ import {
WarehouseOpsKpiStrip,
ZoneOccupancyHeatmap,
} from '@/components/warehouses';
import { useWarehouseDashboard } from '@/hooks/useWarehouses';
import { useWarehouseDashboard, useWarehouses } from '@/hooks/useWarehouses';
import type { WarehouseDashboard } from '@/types/warehouse';
function SectionTitle({ children }: { children: React.ReactNode }) {
@@ -51,21 +53,37 @@ const GREEN = '#084b21';
const METRICS: Metric[] = [
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'received', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
{ key: 'inspected', label: 'Inspected', icon: <ShieldCheck size={22} />, to: '/dashboard/warehouse-inventory', theme: ORANGE },
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: ORANGE },
{ key: 'emptyContainers', label: 'Empty Containers', icon: <PackageOpen size={22} />, to: '/dashboard/containers', theme: ORANGE },
{ key: 'importTrains', label: 'Import Trains', icon: <Train size={22} />, to: '/dashboard/import-warehouse', theme: GREEN },
{ key: 'exportTrains', label: 'Export Trains', icon: <Train size={22} />, to: '/dashboard/export-warehouse', theme: ORANGE },
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: GREEN },
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: ORANGE },
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: GREEN },
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: ORANGE },
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
];
export default function WarehouseDashboardPage() {
const navigate = useNavigate();
const { data, isError, isLoading } = useWarehouseDashboard();
// Both null → the API defaults `received` to "today", matching the page's original behaviour.
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
const [warehouseId, setWarehouseId] = useState<string | null>(null);
const [dateFrom, dateTo] = dateRange;
const hasCustomRange = Boolean(dateFrom || dateTo);
const warehousesQuery = useWarehouses();
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const { data, isError, isLoading } = useWarehouseDashboard({
dateFrom: dateFrom ?? undefined,
dateTo: dateTo ?? undefined,
warehouseId: warehouseId ?? undefined,
});
return (
<PageContainer>
@@ -73,27 +91,58 @@ export default function WarehouseDashboardPage() {
title="Warehouse Dashboard"
subtitle="Live overview of warehouse capacity and inventory lifecycle."
action={
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
<Group gap="sm" wrap="wrap" justify="flex-end">
<Select
placeholder="All warehouses"
clearable
searchable
data={warehouseOptions}
value={warehouseId}
onChange={setWarehouseId}
w={220}
/>
<DatePickerInput
type="range"
placeholder="Received: today"
value={dateRange}
onChange={setDateRange}
presets={getDateRangePresets()}
clearable
w={230}
/>
<Badge
color="edr-green"
variant="light"
size="lg"
leftSection={
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: 'var(--mantine-color-edr-green-6)',
}}
/>
}
>
Live · updates every 60s
</Badge>
</Group>
}
/>
{(warehouseId || hasCustomRange) && (
<Text size="xs" c="dimmed" mt={-8}>
Scoped to{' '}
{warehouseId ? warehouseOptions.find((o) => o.value === warehouseId)?.label ?? 'selected warehouse' : 'all warehouses'}
{hasCustomRange
? ` · Received counts ${dateFrom ?? '…'} to ${dateTo ?? '…'}`
: ' · Received counts: today'}
. Status-backlog and fleet counters are always current regardless of the date range.
</Text>
)}
{isLoading ? (
<Center py="xl">
<Loader />
@@ -124,7 +173,7 @@ export default function WarehouseDashboardPage() {
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<Text size="xs" c="edr-muted" tt="uppercase" fw={700} style={{ letterSpacing: 0.4 }}>
{metric.label}
{metric.key === 'received' && hasCustomRange ? 'Received' : metric.label}
</Text>
<Text fw={800} fz={32} mt={8} c="edr-text" lh={1.1}>
{data ? data[metric.key] : 0}