mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
fix issue
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 maker–checker 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 maker–checker
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user