mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(WIP): filtering, exporting and more reports
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Freight Backoffice</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -85,6 +85,7 @@ import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2De
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
||||
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
|
||||
import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard";
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
@@ -1167,6 +1168,16 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/> */}
|
||||
<Route
|
||||
path="configuration/operations-standards"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.settings.operationsStandards.view}
|
||||
>
|
||||
<OperationsStandardsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route
|
||||
path="configuration/cargo-types/:id"
|
||||
|
||||
@@ -555,6 +555,11 @@ export const buildSidebarSections = (
|
||||
href: "/dashboard/configuration/exchange-rate",
|
||||
permission: FREIGHT_PERMS.settings.exchangeRate.view,
|
||||
},
|
||||
{
|
||||
label: "Operating standards",
|
||||
href: "/dashboard/configuration/operations-standards",
|
||||
permission: FREIGHT_PERMS.settings.operationsStandards.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,6 +57,10 @@ export const URL_CONSTANTS = {
|
||||
BASE: "/exchange-settings",
|
||||
},
|
||||
|
||||
OPERATIONS_STANDARDS: {
|
||||
BASE: "/operations-standards",
|
||||
},
|
||||
|
||||
AUDIT_LOGS: {
|
||||
BASE: "/audit",
|
||||
},
|
||||
@@ -542,6 +546,7 @@ export const URL_CONSTANTS = {
|
||||
YARD_BY_ID: (id: string) => `/yards/${id}`,
|
||||
|
||||
YARD_DISTANCES: "/yard-distances",
|
||||
OPERATIONS_TARGETS: "/operations-targets",
|
||||
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
|
||||
|
||||
SHIPPING_LINES: "/shipping-lines",
|
||||
|
||||
@@ -221,6 +221,8 @@ export interface YardOption {
|
||||
label: string;
|
||||
value: string;
|
||||
country: string;
|
||||
/** The yard's business code — what config keyed on a station stores. */
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,6 +245,7 @@ export const useYardOptions = (enabled = true) =>
|
||||
label: label && code ? `${label} (${code})` : label || code || String(row.id),
|
||||
value: String(row.id),
|
||||
country: String(row.country ?? ""),
|
||||
code,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
operationsStandardsService,
|
||||
type OperationsStandardsPatch,
|
||||
} from "@/services/operationsStandards.service";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const QUERY_KEY = ["operationsStandards"];
|
||||
|
||||
export const useOperationsStandardsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => operationsStandardsService.get(),
|
||||
});
|
||||
|
||||
export const useUpdateOperationsStandards = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (patch: OperationsStandardsPatch) =>
|
||||
operationsStandardsService.update(patch),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success(
|
||||
t("operationsStandards.updated", "Operating standards updated"),
|
||||
);
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
@@ -372,6 +372,12 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:exchange_rate:view",
|
||||
manage: "edr_freight_app:settings:exchange_rate:manage",
|
||||
},
|
||||
// Standard station stay, cycle and leg times, and the charged-tonnage
|
||||
// factors the operations reports measure actual performance against.
|
||||
operationsStandards: {
|
||||
view: "edr_freight_app:settings:operations_standards:view",
|
||||
manage: "edr_freight_app:settings:operations_standards:manage",
|
||||
},
|
||||
contractTemplates: {
|
||||
view: "edr_freight_app:settings:contract_templates:view",
|
||||
manage: "edr_freight_app:settings:contract_templates:manage",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SimpleGrid, Stack } from "@mantine/core";
|
||||
import { SimpleGrid, Stack, Title } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
@@ -7,28 +7,39 @@ import { ReportSection } from "@/components/reports/ReportSection";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
/**
|
||||
* The revenue dashboard the reporting spec asks for, assembled from reports
|
||||
* that already exist rather than a second aggregation API: each tile is a
|
||||
* 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 TILES = [
|
||||
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 = TILES.filter((key) => catalog?.some((r) => r.key === key));
|
||||
const visible = (keys: string[]) =>
|
||||
keys.filter((key) => catalog?.some((r) => r.key === key));
|
||||
|
||||
// No revenue reports for this user — fall back to the old behaviour and send
|
||||
// them to the first report they can actually open.
|
||||
if (!visible.length) {
|
||||
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 />;
|
||||
}
|
||||
@@ -37,14 +48,31 @@ export default function ReportsLandingPage() {
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Revenue dashboard"
|
||||
subtitle="Billed rail revenue by period, category, corridor and customer. Pick any report in the sidebar for the full table, filters and export."
|
||||
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."
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="lg">
|
||||
{visible.map((key) => (
|
||||
<ReportSection key={key} reportKey={key} defaultView="chart" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -309,7 +309,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 } =
|
||||
@@ -471,6 +473,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 {
|
||||
|
||||
@@ -140,6 +140,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" },
|
||||
@@ -463,6 +494,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" },
|
||||
{
|
||||
@@ -624,6 +663,88 @@ 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: "dimensionKey",
|
||||
cardSubtitleKey: "periodStart",
|
||||
columns: [
|
||||
{ id: "periodStart", header: "Period start", accessorKey: "periodStart", format: "date" },
|
||||
{ id: "periodType", header: "Period", accessorKey: "periodType" },
|
||||
{ id: "metric", header: "Metric", accessorKey: "metric" },
|
||||
{ id: "dimension", header: "Dimension", accessorKey: "dimension" },
|
||||
{ id: "dimensionKey", header: "Applies to", accessorKey: "dimensionKey" },
|
||||
{ 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 — it is snapped to the start of the week, month, quarter or year on save.",
|
||||
},
|
||||
{
|
||||
name: "dimension",
|
||||
label: "Applies to",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ label: "Cargo category", value: "cargo_category" },
|
||||
{ label: "Station", value: "station" },
|
||||
{ label: "Container class", value: "container_class" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "dimensionKey",
|
||||
label: "Category / station code",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Select what the target applies to",
|
||||
// The valid keys depend on the chosen dimension, and must match what the
|
||||
// reports emit exactly — a typo here is a target the report never finds.
|
||||
optionsFromValues: (values) => {
|
||||
const dimension = String(values.dimension ?? "");
|
||||
if (dimension === "container_class") return OPERATIONS_CONTAINER_CLASSES;
|
||||
if (dimension === "station") return [];
|
||||
return OPERATIONS_CARGO_CATEGORIES;
|
||||
},
|
||||
description:
|
||||
"Station targets are keyed on the yard code (KALITY, MOJO, NAGAD…) — type it exactly as it appears on Yards.",
|
||||
},
|
||||
{ name: "plannedValue", label: "Planned value", type: "number", required: true },
|
||||
{ name: "note", label: "Note", type: "text", optional: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "yard-distances",
|
||||
label: "Yard Distances",
|
||||
@@ -637,6 +758,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).
|
||||
@@ -650,6 +772,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
const BASE = URL_CONSTANTS.OPERATIONS_STANDARDS.BASE;
|
||||
|
||||
/**
|
||||
* The railway's operating standards — the numbers the operations reports
|
||||
* measure actual performance against. One row, edited here.
|
||||
*/
|
||||
export interface OperationsStandards {
|
||||
id: string;
|
||||
stationStandardHoursEthiopia: number;
|
||||
stationStandardHoursDjibouti: number;
|
||||
cycleStandardHoursContainer: number;
|
||||
cycleStandardHoursBulkDmp: number;
|
||||
cycleStandardHoursBulkNagad: number;
|
||||
cycleStandardHoursBulkBcc: number;
|
||||
defaultLegStandardHours: number;
|
||||
delayToleranceMinutes: number;
|
||||
chargedTonsFull20ft: number;
|
||||
chargedTonsFull40ft: number;
|
||||
chargedTonsEmpty20ft: number;
|
||||
chargedTonsEmpty40ft: number;
|
||||
chargedTonsPerWagonGeneral: number;
|
||||
chargedTonsPerWagonPerishable: number;
|
||||
defaultFullTrainsetWagons: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export type OperationsStandardsPatch = Partial<
|
||||
Omit<OperationsStandards, "id" | "updatedAt">
|
||||
>;
|
||||
|
||||
export const operationsStandardsService = {
|
||||
get: async (): Promise<OperationsStandards> => {
|
||||
const response = await client.get<ApiResponse<OperationsStandards>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
update: async (
|
||||
patch: OperationsStandardsPatch,
|
||||
): Promise<OperationsStandards> => {
|
||||
const response = await client.patch<ApiResponse<OperationsStandards>>(
|
||||
BASE,
|
||||
patch,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -96,6 +96,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
||||
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
|
||||
"yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES,
|
||||
"operations-targets": URL_CONSTANTS.RULE_ENGINE.OPERATIONS_TARGETS,
|
||||
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
|
||||
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
|
||||
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
|
||||
|
||||
@@ -86,7 +86,7 @@ export const freightMantineTheme = createTheme({
|
||||
black: "#10202F",
|
||||
|
||||
fontFamily:
|
||||
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
'"Space Grotesk", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
|
||||
defaultRadius: "md",
|
||||
|
||||
@@ -123,7 +123,7 @@ export const freightMantineTheme = createTheme({
|
||||
},
|
||||
|
||||
headings: {
|
||||
fontFamily: '"Inter", var(--mantine-font-family)',
|
||||
fontFamily: '"Space Grotesk", var(--mantine-font-family)',
|
||||
fontWeight: "700",
|
||||
sizes: {
|
||||
h1: { fontSize: "36px", lineHeight: "1.1", fontWeight: "800" },
|
||||
|
||||
@@ -11,7 +11,8 @@ export type RuleEngineResourceSlug =
|
||||
| "shipping-lines"
|
||||
| "rates"
|
||||
| "approval-rules"
|
||||
| "transit-agents";
|
||||
| "transit-agents"
|
||||
| "operations-targets";
|
||||
|
||||
/**
|
||||
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are
|
||||
|
||||
Reference in New Issue
Block a user