Merge pull request #1500 from Tria-plc/freight_feature/usermanagement

This commit is contained in:
marshal
2026-09-05 08:39:00 +03:00
28 changed files with 1965 additions and 242 deletions

View File

@@ -68,6 +68,7 @@ import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerforman
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
import TrainCrewPage from "./pages/train-crew/TrainCrewPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
@@ -87,6 +88,7 @@ import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import ScheduleCrewPage from "./pages/trainScheduling/ScheduleCrewPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
@@ -900,6 +902,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<ScheduleCrewPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
@@ -1017,6 +1027,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="train-crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainCrew.view}>
<TrainCrewPage />
</RequirePermission>
}
/>
<Route
path="fuel-purchases"
element={

View File

@@ -29,6 +29,13 @@ const rulesRouteMeta = RULE_ENGINE_RESOURCES.filter((r) => r.category === "rules
);
const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
{
prefix: "/dashboard/train-crew",
meta: {
title: "Train Crew",
subtitle: "Roster of on-board personnel assignable to a train",
},
},
{
prefix: "/dashboard/overview",
meta: {

View File

@@ -523,6 +523,18 @@ export const buildSidebarSections = (
},
],
},
{
title: "Rolling stock",
mutedTitle: true,
items: [
{
label: "Train Crew",
href: "/dashboard/train-crew",
icon: <Users />,
permission: FREIGHT_PERMS.trainCrew.view,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,

View File

@@ -194,6 +194,13 @@ export const QUERY_KEYS = {
byId: (id: string) => ["vehicles", "detail", id] as const,
},
TRAIN_CREW: {
ROOT: ["train-crew"] as const,
list: (filter?: Record<string, unknown>) =>
["train-crew", "list", filter ?? {}] as const,
byId: (id: string) => ["train-crew", "detail", id] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) =>

View File

@@ -870,4 +870,9 @@ export const URL_CONSTANTS = {
BASE: "/drivers",
BY_ID: (id: string) => `/drivers/${id}`,
},
TRAIN_CREW: {
BASE: "/train-crew",
BY_ID: (id: string) => `/train-crew/${id}`,
},
};

View File

@@ -266,6 +266,12 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:drivers:update",
delete: "edr_freight_app:drivers:delete",
},
trainCrew: {
view: "edr_freight_app:train_crew:view",
create: "edr_freight_app:train_crew:create",
update: "edr_freight_app:train_crew:update",
delete: "edr_freight_app:train_crew:delete",
},
tracking: {
view: "edr_freight_app:tracking:view",
manage: "edr_freight_app:tracking:manage",

View File

@@ -0,0 +1,459 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
Select,
Stack,
Switch,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Pencil, Plus, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
// Generic list footer — shared by the fleet and train-scheduling lists despite
// the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import {
TRAIN_CREW_NATIONALITY_OPTIONS,
TRAIN_CREW_ROLE_OPTIONS,
TRAIN_CREW_STATUS_OPTIONS,
trainCrewNationalityLabel,
trainCrewRoleLabel,
trainCrewService,
trainCrewStatusLabel,
type SaveTrainCrewMemberPayload,
type TrainCrewMember,
type TrainCrewNationality,
type TrainCrewRole,
type TrainCrewStatus,
} from "@/services/trainCrew.service";
const DEFAULT_PAGE_SIZE = 10;
const ALL = "__all__";
/** Mantine colour per status, so the roster reads at a glance. */
const STATUS_COLOR: Record<TrainCrewStatus, string> = {
ACTIVE: "green",
INACTIVE: "gray",
SUSPENDED: "red",
ON_LEAVE: "yellow",
};
type FormState = {
firstName: string;
lastName: string;
role: TrainCrewRole | "";
nationality: TrainCrewNationality | "";
status: TrainCrewStatus;
isActive: boolean;
};
const EMPTY_FORM: FormState = {
firstName: "",
lastName: "",
role: "",
nationality: "",
status: "ACTIVE",
isActive: true,
};
export default function TrainCrewPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canCreate = hasPermission(user, FREIGHT_PERMS.trainCrew.create);
const canUpdate = hasPermission(user, FREIGHT_PERMS.trainCrew.update);
const canDelete = hasPermission(user, FREIGHT_PERMS.trainCrew.delete);
// The footer owns page size as well as page, so both live here. `pageIndex`
// is 0-based to match the footer's PaginationState; the API is 1-based.
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE,
});
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>(ALL);
const [nationalityFilter, setNationalityFilter] = useState<string>(ALL);
const [statusFilter, setStatusFilter] = useState<string>(ALL);
const [modalOpen, setModalOpen] = useState(false);
/** Row being edited; null means the modal is in create mode. */
const [editing, setEditing] = useState<TrainCrewMember | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState<TrainCrewMember | null>(null);
// Filtering and paging are server-side, so the active filters are part of the
// query key — changing one refetches rather than slicing a stale page.
const filters = useMemo(
() => ({
page: pagination.pageIndex + 1,
limit: pagination.pageSize,
...(search.trim() ? { search: search.trim() } : {}),
...(roleFilter !== ALL ? { role: roleFilter as TrainCrewRole } : {}),
...(nationalityFilter !== ALL
? { nationality: nationalityFilter as TrainCrewNationality }
: {}),
...(statusFilter !== ALL ? { status: statusFilter as TrainCrewStatus } : {}),
}),
[pagination, search, roleFilter, nationalityFilter, statusFilter],
);
const { data, isLoading } = useQuery({
queryKey: QUERY_KEYS.TRAIN_CREW.list(filters),
queryFn: async () => {
const res = await trainCrewService.getAll(filters);
return res.data;
},
});
const members = data?.data ?? [];
const total = data?.total ?? 0;
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_CREW.ROOT });
const describeError = (error: unknown, fallback: string): string => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
return typeof message === "string" ? message : fallback;
};
const saveMutation = useMutation({
mutationFn: async (values: FormState) => {
const payload: Partial<SaveTrainCrewMemberPayload> = {
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
role: values.role as TrainCrewRole,
nationality: values.nationality as TrainCrewNationality,
status: values.status,
isActive: values.isActive,
};
return editing
? trainCrewService.update(editing.id, payload)
: trainCrewService.create(payload);
},
onSuccess: () => {
toast({ title: editing ? "Crew member updated" : "Crew member added" });
closeModal();
invalidate();
},
onError: (error: unknown) => {
toast({
title: editing ? "Could not update crew member" : "Could not add crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => trainCrewService.delete(id),
onSuccess: () => {
toast({ title: "Crew member removed" });
setDeleteTarget(null);
invalidate();
},
onError: (error: unknown) => {
toast({
title: "Could not remove crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const openCreate = () => {
setEditing(null);
setForm(EMPTY_FORM);
setModalOpen(true);
};
const openEdit = (member: TrainCrewMember) => {
setEditing(member);
setForm({
firstName: member.firstName,
lastName: member.lastName,
role: member.role,
nationality: member.nationality,
status: member.status,
isActive: member.isActive,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
};
/** Every column is NOT NULL server-side, so all four must be filled. */
const formValid =
form.firstName.trim().length > 0 &&
form.lastName.trim().length > 0 &&
form.role !== "" &&
form.nationality !== "";
// Filters narrow the result set, so a page beyond the new last page would
// render empty — reset to the first page whenever one changes.
const resetToFirstPage = () =>
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
const onFilterChange = (setter: (value: string) => void) => (value: string | null) => {
setter(value ?? ALL);
resetToFirstPage();
};
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Train Crew" }, { label: "Crew Members" }]} />
<Group justify="space-between" mb="lg">
<div>
<Title order={1}>Train Crew</Title>
<Text size="sm" c="dimmed">
Roster of on-board personnel assignable to a train
</Text>
</div>
{canCreate ? (
<Button leftSection={<Plus size={16} />} onClick={openCreate} color="edr-green">
Add Crew Member
</Button>
) : null}
</Group>
<Card withBorder>
<Group p="md" gap="sm" align="flex-end" wrap="wrap">
<TextInput
label="Search"
placeholder="Search by name…"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetToFirstPage();
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
label="Role"
data={[{ label: "All roles", value: ALL }, ...TRAIN_CREW_ROLE_OPTIONS]}
value={roleFilter}
onChange={onFilterChange(setRoleFilter)}
style={{ minWidth: 180 }}
/>
<Select
label="Nationality"
data={[
{ label: "All nationalities", value: ALL },
...TRAIN_CREW_NATIONALITY_OPTIONS,
]}
value={nationalityFilter}
onChange={onFilterChange(setNationalityFilter)}
style={{ minWidth: 170 }}
/>
<Select
label="Status"
data={[{ label: "All statuses", value: ALL }, ...TRAIN_CREW_STATUS_OPTIONS]}
value={statusFilter}
onChange={onFilterChange(setStatusFilter)}
style={{ minWidth: 160 }}
/>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>First Name</Table.Th>
<Table.Th>Last Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Nationality</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : members.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No crew members found.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{members.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>{member.firstName}</Table.Td>
<Table.Td>{member.lastName}</Table.Td>
<Table.Td>{trainCrewRoleLabel(member.role)}</Table.Td>
<Table.Td>{trainCrewNationalityLabel(member.nationality)}</Table.Td>
<Table.Td>
<Badge size="sm" color={STATUS_COLOR[member.status] ?? "gray"}>
{trainCrewStatusLabel(member.status)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" color={member.isActive ? "green" : "gray"} variant="light">
{member.isActive ? "Yes" : "No"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
{canUpdate ? (
<Button
size="xs"
variant="light"
leftSection={<Pencil size={14} />}
onClick={() => openEdit(member)}
>
Edit
</Button>
) : null}
{canDelete ? (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setDeleteTarget(member)}
>
Delete
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
itemLabel="crew members"
pagination={pagination}
pageCount={Math.ceil(total / pagination.pageSize)}
totalCount={total}
onPaginationChange={setPagination}
/>
</Card>
<Modal
opened={modalOpen}
onClose={closeModal}
title={editing ? "Edit Crew Member" : "Add Crew Member"}
size="lg"
>
<Stack gap="md">
<TextInput
label="First Name"
placeholder="First name"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.currentTarget.value })}
required
/>
<TextInput
label="Last Name"
placeholder="Last name"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.currentTarget.value })}
required
/>
<Select
label="Role"
placeholder="Select role"
data={TRAIN_CREW_ROLE_OPTIONS}
value={form.role || null}
onChange={(val) => setForm({ ...form, role: (val as TrainCrewRole) ?? "" })}
required
/>
<Select
label="Nationality"
placeholder="Select nationality"
data={TRAIN_CREW_NATIONALITY_OPTIONS}
value={form.nationality || null}
onChange={(val) =>
setForm({ ...form, nationality: (val as TrainCrewNationality) ?? "" })
}
required
/>
<Select
label="Status"
data={TRAIN_CREW_STATUS_OPTIONS}
value={form.status}
onChange={(val) =>
setForm({ ...form, status: (val as TrainCrewStatus) ?? "ACTIVE" })
}
required
/>
<Switch
label="Active"
checked={form.isActive}
onChange={(e) => setForm({ ...form, isActive: e.currentTarget.checked })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={closeModal}>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate(form)}
loading={saveMutation.isPending}
disabled={!formValid}
>
{editing ? "Save Changes" : "Add Crew Member"}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title="Remove Crew Member"
size="md"
>
<Stack gap="md">
<Text size="sm">
Remove {deleteTarget?.firstName} {deleteTarget?.lastName} from the train crew
roster?
</Text>
<Group justify="flex-end">
<Button variant="light" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,23 @@
import { useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
/**
* Train crew assignment for one schedule.
*
* Intentionally blank: the assignment rules — crew counts per role, the driver
* pairing cases, and which corridor segment each driver covers — are still to
* be specified, so only the route and header exist so far.
*/
export default function ScheduleCrewPage() {
const { scheduleId = "" } = useParams();
return (
<PageContainer>
<PageHeader
title="Assign Train Crew"
backTo={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
/>
</PageContainer>
);
}

View File

@@ -37,6 +37,7 @@ import {
Send,
Table2,
Train,
Users,
Weight,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -372,12 +373,26 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "actions",
size: 32,
size: 210,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const schedule = row.original;
return (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Button
variant="light"
color="indigo"
size="xs"
radius="md"
leftSection={<Users size={14} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`,
)
}
>
Assign Train Crew
</Button>
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
@@ -639,6 +654,9 @@ export default function TrainScheduleV2ListPage() {
onTrack={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
onAssignCrew={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`)
}
/>
))}
</SimpleGrid>
@@ -1053,10 +1071,12 @@ function ScheduleCard({
schedule,
onOpen,
onTrack,
onAssignCrew,
}: {
schedule: TrainScheduleListItem;
onOpen: () => void;
onTrack: () => void;
onAssignCrew: () => void;
}) {
const { day, time } = splitDate(schedule.scheduleDate);
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -1153,6 +1173,19 @@ function ScheduleCard({
Track
</Button>
) : null}
<Button
variant="light"
color="indigo"
size="sm"
radius="md"
leftSection={<Users size={15} />}
onClick={(e) => {
e.stopPropagation();
onAssignCrew();
}}
>
Assign Train Crew
</Button>
</Group>
</Stack>
</Card>

View File

@@ -29,9 +29,14 @@ import {
ArrowUp,
ChartColumn,
ChevronRight,
CircleCheck,
List,
MapPin,
PauseCircle,
Search,
TrainFront,
Wrench,
type LucideIcon,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -48,6 +53,17 @@ import {
} from "./wagonPerformance";
import { downloadSheet, downloadSheets } from "./exportSection";
import { SectionExportButton } from "./SectionExportButton";
import {
CardHeader,
ColumnChart,
LegendKey,
LegendRow,
SplitBar,
StackedBar,
formatCount,
toneVar,
} from "./chartKit";
import "./wagonPerformance.css";
const WINDOWS = [
{ value: "30", label: "30d" },
@@ -89,25 +105,78 @@ const IDLE_BUCKETS: Array<{
{ label: "46 d +", min: 46, max: Infinity, tone: "red" },
];
/**
* One headline figure.
*
* The tone rail down the left edge is the tile's status channel — it repeats
* what the value's colour already says, so severity survives for a reader who
* cannot separate the hues. `meter` is an optional share of the fleet, drawn
* on a track one step lighter than its own fill so the whole bar reads.
*/
const StatTile = ({
label,
value,
hint,
color,
tone = "gray",
icon: Icon,
meter,
}: {
label: string;
value: React.ReactNode;
hint: string;
color?: string;
tone?: string;
icon?: LucideIcon;
meter?: number;
}) => (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="26px" fw={700} lh={1.1} mt={8} c={color}>
<Card
withBorder
radius="lg"
padding="md"
pl="lg"
className="wp-stat-tile"
style={{ position: "relative", overflow: "hidden" }}
>
<Box
style={{
position: "absolute",
insetBlock: 0,
left: 0,
width: 3,
background: toneVar(tone),
}}
/>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} lh={1.3}>
{label}
</Text>
{Icon ? <Icon size={15} strokeWidth={2} color={toneVar(tone)} /> : null}
</Group>
<Text size="30px" fw={700} lh={1.05} mt={10} c={color}>
{value}
</Text>
<Text size="xs" c="dimmed" mt={6} lh={1.35}>
{meter == null ? null : (
<Box
mt={12}
h={4}
style={{
borderRadius: 999,
background: toneVar(tone, 1),
overflow: "hidden",
}}
>
<Box
h="100%"
w={`${Math.min(100, Math.max(0, meter))}%`}
style={{ borderRadius: 999, background: toneVar(tone) }}
/>
</Box>
)}
<Text size="xs" c="dimmed" mt={meter == null ? 8 : 8} lh={1.35}>
{hint}
</Text>
</Card>
@@ -140,7 +209,13 @@ const SortHeader = ({
</UnstyledButton>
);
/** A short ranked list — the "best / worst" boards. */
/**
* A short ranked list — the "best / worst" boards.
*
* Each row carries a hairline bar scaled against the board's own leader, so
* the shape of the ranking (a runaway top wagon, or a flat field) is visible
* without reading every figure. Rows are buttons: they open the wagon.
*/
const Leaderboard = ({
title,
subtitle,
@@ -151,69 +226,114 @@ const Leaderboard = ({
title: string;
subtitle: string;
accent: string;
rows: Array<{ id: string; number: string; note: string; value: string }>;
rows: Array<{
id: string;
number: string;
note: string;
value: string;
weight?: number;
}>;
onOpen: (id: string) => void;
}) => (
<Card withBorder radius="md" padding={0}>
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap={8} wrap="nowrap">
<Box
w={7}
h={7}
style={{
borderRadius: 2,
background: `var(--mantine-color-${accent}-6)`,
}}
/>
}) => {
const peak = Math.max(1, ...rows.map((r) => r.weight ?? 0));
return (
<Card withBorder radius="lg" padding={0} style={{ overflow: "hidden" }}>
<Box style={{ height: 3, background: toneVar(accent) }} />
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Text fw={600} size="sm">
{title}
</Text>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={9}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} c="dimmed" w={14}>
{i + 1}
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={10}
className="wp-rank-row"
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
{/* Medallion: the top three carry the board's own tone. */}
<Center
w={20}
h={20}
style={{
flexShrink: 0,
borderRadius: 6,
background:
i < 3
? toneVar(accent, 0)
: "var(--mantine-color-edr-slate-soft-0)",
}}
>
<Text
size="10px"
fw={700}
c={i < 3 ? `${accent}.8` : "dimmed"}
lh={1}
>
{i + 1}
</Text>
</Center>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text
size="sm"
fw={700}
style={{
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
}}
>
{r.value}
</Text>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text size="sm" fw={700} style={{ whiteSpace: "nowrap" }}>
{r.value}
</Text>
</Group>
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
{r.weight == null ? null : (
<Box
mt={8}
ml={30}
h={3}
style={{
borderRadius: 999,
background: "var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(2, (r.weight / peak) * 100)}%`}
style={{ borderRadius: 999, background: toneVar(accent) }}
/>
</Box>
)}
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
};
/**
* Wagon performance — the executive report on how the wagon fleet is earning
@@ -355,6 +475,9 @@ const WagonPerformancePage = () => {
.sort((a, b) => b.wagons - a.wagons);
}, [wagons]);
/** Busiest yard — the scale every yard's share bar is drawn against. */
const yardPeak = Math.max(1, ...byYard.map((y) => y.wagons));
/** Which classes of stock earn, and which sit. */
const byType = useMemo(() => {
const rows = new Map<
@@ -425,6 +548,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${w.loadsInWindow ?? 0} loads`,
weight: w.loadsInWindow ?? 0,
})),
stranded: withIdle
.sort((a, b) => b.idle - a.idle)
@@ -434,6 +558,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${idle} days`,
weight: idle,
})),
idle: [...wagons]
.filter((w) => (w.movesInWindow ?? 0) === 0)
@@ -721,12 +846,17 @@ const WagonPerformancePage = () => {
<SimpleGrid cols={{ base: 1, sm: 2, lg: 5 }} spacing="md">
<StatTile
label="Fleet size"
value={kpis.total}
value={formatCount(kpis.total)}
hint="Wagons on the register"
tone="edr-slate"
icon={TrainFront}
/>
<StatTile
label="In service"
value={kpis.inService}
value={formatCount(kpis.inService)}
tone="edr-green"
icon={CircleCheck}
meter={kpis.total > 0 ? (kpis.inService / kpis.total) * 100 : 0}
hint={
kpis.total > 0
? `${Math.round((kpis.inService / kpis.total) * 100)}% of the fleet`
@@ -735,20 +865,28 @@ const WagonPerformancePage = () => {
/>
<StatTile
label={`Idle over ${IDLE_THRESHOLD_DAYS}d`}
value={kpis.stranded}
value={formatCount(kpis.stranded)}
hint="No movement in the current yard"
color={kpis.stranded > 0 ? "red" : undefined}
tone={kpis.stranded > 0 ? "red" : "edr-slate"}
icon={PauseCircle}
meter={kpis.total > 0 ? (kpis.stranded / kpis.total) * 100 : 0}
/>
<StatTile
label="Off roster"
value={kpis.offRoster}
value={formatCount(kpis.offRoster)}
hint="Maintenance, detained or withdrawn"
color={kpis.offRoster > 0 ? "yellow.8" : undefined}
tone={kpis.offRoster > 0 ? "yellow" : "edr-slate"}
icon={Wrench}
meter={kpis.total > 0 ? (kpis.offRoster / kpis.total) * 100 : 0}
/>
<StatTile
label="Loads · moves"
value={kpis.loads}
hint={`${kpis.moves} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
value={formatCount(kpis.loads)}
tone="edr-blue"
icon={ChartColumn}
hint={`${formatCount(kpis.moves)} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
/>
</SimpleGrid>
@@ -776,136 +914,88 @@ const WagonPerformancePage = () => {
<Stack gap="lg">
{/* ── Status mix + idle distribution ───────────── */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Status mix</Text>
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{kpis.total} wagons on the register
</Text>
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Status mix"
subtitle={`${kpis.total} wagons on the register`}
action={
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
}
/>
{statusMix.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
<Text size="sm" c="dimmed" py="xl" ta="center">
No wagons registered.
</Text>
) : (
<>
<Progress.Root size="lg" radius="xl" mt="md" mb="md">
<Box mt="lg" mb="lg">
<StackedBar
height={14}
unit="wagons"
segments={statusMix.map((s) => ({
key: s.status,
label: s.label,
value: s.count,
pct: s.pct,
tone: s.color,
}))}
/>
</Box>
<Stack gap={11}>
{statusMix.map((s) => (
<Progress.Section
<LegendRow
key={s.status}
value={s.pct}
color={s.color}
tone={s.color}
label={s.label}
value={s.count}
pct={s.pct}
/>
))}
</Progress.Root>
<Stack gap={9}>
{statusMix.map((s) => (
<Group
key={s.status}
justify="space-between"
gap="sm"
>
<Group gap={9} wrap="nowrap">
<Box
w={9}
h={9}
style={{
borderRadius: 3,
background: `var(--mantine-color-${s.color}-6)`,
}}
/>
<Text size="sm">{s.label}</Text>
</Group>
<Group gap="sm">
<Text size="sm" fw={700}>
{s.count}
</Text>
<Text size="xs" c="dimmed" w={34} ta="right">
{s.pct}%
</Text>
</Group>
</Group>
))}
</Stack>
</>
)}
</Card>
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Idle-day distribution</Text>
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Wagons by days without movement in their current yard
</Text>
<Group
align="flex-end"
gap="md"
h={170}
mt="lg"
wrap="nowrap"
>
{idleDistribution.map((b) => (
<Stack
key={b.label}
gap={6}
align="center"
justify="flex-end"
h="100%"
style={{ flex: 1 }}
>
<Text
size="xs"
fw={700}
c={
b.tone === "red"
? "red"
: b.tone === "yellow"
? "yellow.8"
: undefined
}
>
{b.count}
</Text>
<Box
w="100%"
h={`${Math.max(3, b.pct)}%`}
style={{
background: `var(--mantine-color-${b.tone}-6)`,
borderRadius: "5px 5px 0 0",
minHeight: 3,
}}
/>
<Text
size="xs"
c="dimmed"
style={{ whiteSpace: "nowrap" }}
>
{b.label}
</Text>
</Stack>
))}
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Idle-day distribution"
subtitle="Wagons by days without movement in their current yard"
action={
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
}
/>
{/* The bar colours are a severity scale, not identity, so
the key names the bands rather than each bucket. */}
<Group gap="lg" mt="sm">
<LegendKey tone="edr-green" label="Healthy" />
<LegendKey tone="yellow" label="Watch" />
<LegendKey tone="red" label="Stranded" />
</Group>
<ColumnChart data={idleDistribution} />
<Text
size="xs"
c="dimmed"
mt="md"
mt="lg"
pt="sm"
style={{
borderTop:
"1px solid var(--mantine-color-edr-divider-0)",
}}
>
<strong>{kpis.stranded}</strong> wagons have sat over{" "}
{IDLE_THRESHOLD_DAYS} days
<Text
span
fw={700}
c={kpis.stranded > 0 ? "red" : undefined}
>
{kpis.stranded}
</Text>{" "}
wagons have sat over {IDLE_THRESHOLD_DAYS} days
{kpis.total > 0
? `${Math.round((kpis.stranded / kpis.total) * 100)}% of the fleet locked up`
: ""}
@@ -947,29 +1037,30 @@ const WagonPerformancePage = () => {
</SimpleGrid>
{/* ── By yard ──────────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Box p="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>By yard</Text>
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Where the fleet is parked and how long it stays
</Text>
<Card withBorder radius="lg" padding={0}>
<Box p="lg" pb="md">
<CardHeader
title="By yard"
subtitle="Where the fleet is parked and how long it stays"
action={
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
}
/>
</Box>
{byYard.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
No wagons to group.
</Text>
) : (
<Table.ScrollContainer minWidth={640}>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Yard</Table.Th>
<Table.Th w={200}>Share of fleet</Table.Th>
<Table.Th w={110} ta="right">
Wagons
</Table.Th>
@@ -985,12 +1076,60 @@ const WagonPerformancePage = () => {
{byYard.map((y) => (
<Table.Tr key={y.label}>
<Table.Td>
<Text size="sm" fw={600}>
{y.label}
</Text>
<Group gap={8} wrap="nowrap">
<MapPin
size={13}
color="var(--mantine-color-edr-muted-0)"
style={{ flexShrink: 0 }}
/>
<Text size="sm" fw={600}>
{y.label}
</Text>
</Group>
</Table.Td>
<Table.Td>
{/* Bar is scaled against the busiest yard, so
the biggest one always fills the track. */}
<Tooltip
withArrow
label={`${y.wagons} wagons · ${
kpis.total > 0
? Math.round(
(y.wagons / kpis.total) * 100,
)
: 0
}% of the fleet`}
>
<Box
h={6}
style={{
borderRadius: 999,
background:
"var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(
2,
(y.wagons / yardPeak) * 100,
)}%`}
style={{
borderRadius: 999,
background: toneVar("edr-blue"),
}}
/>
</Box>
</Tooltip>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
<Text
size="sm"
fw={600}
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{y.wagons}
</Text>
</Table.Td>
@@ -1029,8 +1168,14 @@ const WagonPerformancePage = () => {
</Card>
{/* ── By wagon type ────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Card withBorder radius="lg" padding={0}>
<Group
p="lg"
pb="md"
justify="space-between"
wrap="wrap"
gap="sm"
>
<div>
<Text fw={600}>By wagon type</Text>
<Text size="xs" c="dimmed" mt={4}>
@@ -1038,33 +1183,9 @@ const WagonPerformancePage = () => {
stuck
</Text>
</div>
<Group gap="md">
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-edr-green-6)",
}}
/>
<Text size="xs" c="dimmed">
Loaded
</Text>
</Group>
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-teal-4)",
}}
/>
<Text size="xs" c="dimmed">
Empty
</Text>
</Group>
<Group gap="lg">
<LegendKey tone="edr-green" label="Loaded" />
<LegendKey tone="teal.3" label="Empty" />
<SectionExportButton
label="by-type"
onExport={exportByType}
@@ -1122,21 +1243,23 @@ const WagonPerformancePage = () => {
</Table.Td>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Progress.Root
size="sm"
radius="xl"
style={{ flex: 1 }}
<Box style={{ flex: 1 }}>
<SplitBar
primaryPct={t.loadedPct}
secondaryPct={t.emptyPct}
primaryLabel="Loaded"
secondaryLabel="Empty"
/>
</Box>
<Text
size="xs"
fw={600}
w={34}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
<Progress.Section
value={t.loadedPct}
color="edr-green"
/>
<Progress.Section
value={t.emptyPct}
color="teal.4"
/>
</Progress.Root>
<Text size="xs" fw={600} w={34} ta="right">
{t.loadedPct}%
</Text>
</Group>
@@ -1491,7 +1614,15 @@ const WagonPerformancePage = () => {
}
w={72}
/>
<Text size="sm" fw={600} w={38} ta="right">
<Text
size="sm"
fw={600}
w={38}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{share}%
</Text>
</Group>

View File

@@ -0,0 +1,380 @@
/**
* Presentation primitives for the wagon performance report.
*
* Pure display — every one of these takes numbers that are already derived
* and draws them. Kept apart from the page so the report's markup stays about
* what is being said, not about how a bar is rounded.
*
* House rules these encode (so charts across the report agree):
* · columns cap at 28px and never fill their slot — the leftover is air;
* · a data-end is rounded 4px, the baseline end stays square;
* · touching fills are separated by a 2px gap in the surface colour, never
* by a border — ink that is not data;
* · text wears text tokens; the colour lives on the mark beside it.
*/
import type { ReactNode } from "react";
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import "./wagonPerformance.css";
/**
* Thousands-separated count. Fleet figures run into four digits, and `1284`
* is read as a code rather than a quantity.
*/
export const formatCount = (n: number): string => n.toLocaleString("en-US");
/** One-step-off-surface hairline, for gridlines and baselines. */
export const GRID_LINE = "var(--mantine-color-edr-divider-0)";
/** Resolve a Mantine colour name (`edr-green`, `red.6`) to a CSS variable. */
export const toneVar = (tone: string, fallbackShade = 6): string => {
const [name, shade] = tone.split(".");
return `var(--mantine-color-${name}-${shade ?? fallbackShade})`;
};
/* ────────────────────────────────────────────────────────────────────────── */
export interface SparkBarDatum {
label: string;
count: number;
/** Bar height as a share of the tallest bar, 0100. */
pct: number;
tone: string;
}
/**
* Column chart for a bucketed distribution.
*
* Bars sit on a real baseline with three recessive gridlines behind them, so
* a reader can judge a middle bar against a neighbour instead of guessing.
* Only the tallest column keeps a permanent value label; the rest carry theirs
* in the hover tooltip, because a number over every column stops being read.
*/
export const ColumnChart = ({
data,
height = 190,
unit = "wagons",
}: {
data: SparkBarDatum[];
height?: number;
unit?: string;
}) => {
const peak = Math.max(...data.map((d) => d.count), 0);
return (
<Box mt="lg">
<Box style={{ position: "relative", height, marginTop: 18 }}>
{/* Gridlines at the peak and two even steps below it, behind the
bars. The peak line doubles as the chart's top edge, so the
tallest column reads as touching it rather than floating. */}
{[0, 1, 2].map((i) => (
<Box
key={i}
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(i * 100) / 3}%`,
borderTop: `1px solid ${GRID_LINE}`,
pointerEvents: "none",
}}
/>
))}
<Group
align="flex-end"
gap="xs"
h="100%"
wrap="nowrap"
className="wp-col-chart"
style={{ position: "relative" }}
>
{data.map((d) => {
const isPeak = d.count === peak && peak > 0;
return (
<Tooltip
key={d.label}
withArrow
label={`${d.label} · ${d.count} ${unit}`}
>
<Stack
gap={0}
align="center"
justify="flex-end"
h="100%"
className="wp-col-slot"
style={{ flex: 1, cursor: "default" }}
>
{/* The label is absolutely positioned above its bar so it
never eats the bar's own height — otherwise the tallest
column can never reach the peak gridline. */}
<Box
w="100%"
maw={28}
h={`${Math.max(2, d.pct)}%`}
className="wp-col-bar"
style={{
position: "relative",
background: toneVar(d.tone),
borderRadius: "4px 4px 0 0",
minHeight: 2,
transition: "opacity 120ms ease",
}}
>
{isPeak ? (
<Text
size="xs"
fw={700}
lh={1}
ta="center"
style={{
position: "absolute",
left: "50%",
bottom: "100%",
transform: "translateX(-50%)",
marginBottom: 5,
}}
>
{d.count}
</Text>
) : null}
</Box>
</Stack>
</Tooltip>
);
})}
</Group>
</Box>
{/* Baseline: one weight heavier than the gridlines, so zero reads. */}
<Box
style={{ borderTop: `1px solid var(--mantine-color-edr-border-0)` }}
/>
<Group gap="xs" wrap="nowrap" mt={8}>
{data.map((d) => (
<Text
key={d.label}
size="xs"
c="dimmed"
ta="center"
style={{ flex: 1, whiteSpace: "nowrap" }}
>
{d.label}
</Text>
))}
</Group>
</Box>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
export interface StackSegment {
key: string;
label: string;
value: number;
/** Segment width as a share of the whole, 0100. */
pct: number;
tone: string;
}
/**
* A single stacked proportion bar.
*
* Segments are separated by a 2px gap in the surface colour rather than a
* stroke, so neighbouring shades stay distinct without extra ink. Every
* segment is hoverable; none is labelled inline, since interior segments have
* no free end to label without clipping.
*/
export const StackedBar = ({
segments,
height = 12,
unit = "",
}: {
segments: StackSegment[];
height?: number;
unit?: string;
}) => (
<Group gap={2} wrap="nowrap" style={{ width: "100%" }}>
{segments
.filter((s) => s.value > 0)
.map((s, i, shown) => (
<Tooltip
key={s.key}
withArrow
label={`${s.label} · ${s.value}${unit ? ` ${unit}` : ""} (${s.pct}%)`}
>
<Box
h={height}
style={{
// Flex-grow by share, but never vanish: a 1-wagon status still
// needs a visible sliver to be hoverable.
flex: `${Math.max(s.pct, 0.5)} 1 0`,
minWidth: 3,
background: toneVar(s.tone),
borderRadius:
shown.length === 1
? 999
: i === 0
? "999px 2px 2px 999px"
: i === shown.length - 1
? "2px 999px 999px 2px"
: 2,
cursor: "default",
}}
/>
</Tooltip>
))}
</Group>
);
/* ────────────────────────────────────────────────────────────────────────── */
/**
* Two-tone split bar for a loaded / empty style mix, sized inside a table row.
* The unfilled remainder is a lighter step of the same ramp, so the whole
* track carries state rather than only the filled part.
*/
export const SplitBar = ({
primaryPct,
secondaryPct,
primaryTone = "edr-green",
secondaryTone = "teal.3",
primaryLabel,
secondaryLabel,
}: {
primaryPct: number;
secondaryPct: number;
primaryTone?: string;
secondaryTone?: string;
primaryLabel: string;
secondaryLabel: string;
}) => {
const both = primaryPct > 0 && secondaryPct > 0;
// A zero-value side is dropped entirely rather than shown as a sliver —
// a 1px nub of the wrong colour on a 100% bar reads as bad data.
return (
<Group gap={both ? 2 : 0} wrap="nowrap" style={{ width: "100%" }}>
{primaryPct > 0 ? (
<Tooltip withArrow label={`${primaryLabel} · ${primaryPct}%`}>
<Box
h={8}
style={{
flex: `${primaryPct} 1 0`,
minWidth: 3,
background: toneVar(primaryTone),
borderRadius: both ? "999px 2px 2px 999px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{secondaryPct > 0 ? (
<Tooltip withArrow label={`${secondaryLabel} · ${secondaryPct}%`}>
<Box
h={8}
style={{
flex: `${secondaryPct} 1 0`,
minWidth: 3,
background: toneVar(secondaryTone),
borderRadius: both ? "2px 999px 999px 2px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{/* Nothing moved at all — an empty track, so the row still has a shape. */}
{primaryPct === 0 && secondaryPct === 0 ? (
<Box
h={8}
style={{
flex: 1,
background: "var(--mantine-color-edr-divider-0)",
borderRadius: 999,
}}
/>
) : null}
</Group>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
/** Legend swatch + label + value, the identity channel beside every chart. */
export const LegendRow = ({
tone,
label,
value,
pct,
}: {
tone: string;
label: string;
value: ReactNode;
pct?: number;
}) => (
<Group justify="space-between" gap="sm" wrap="nowrap">
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={8}
h={8}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="sm" truncate>
{label}
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
{value}
</Text>
{pct == null ? null : (
<Text
size="xs"
c="dimmed"
w={34}
ta="right"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{pct}%
</Text>
)}
</Group>
</Group>
);
/** Small square colour key used in a card header's inline legend. */
export const LegendKey = ({ tone, label }: { tone: string; label: string }) => (
<Group gap={6} wrap="nowrap">
<Box
w={10}
h={10}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
);
/** A card's title block: name, one line of context, and its own actions. */
export const CardHeader = ({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) => (
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<div style={{ minWidth: 0 }}>
<Text fw={600}>{title}</Text>
{subtitle ? (
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
) : null}
</div>
{action}
</Group>
);

View File

@@ -0,0 +1,46 @@
/* ============================================================
Wagon performance report — hover affordances.
Only the states Mantine props cannot express live here. Everything
structural stays in the components; this file is purely "what changes
under the pointer".
============================================================ */
/* Leaderboard rows are buttons that open a wagon — they need to say so. */
.wp-rank-row {
border-radius: 8px;
transition:
background-color 120ms ease,
transform 120ms ease;
}
.wp-rank-row:hover {
background: var(--mantine-color-edr-slate-soft-0);
}
.wp-rank-row:active {
transform: scale(0.995);
}
.wp-rank-row:focus-visible {
outline: 2px solid var(--mantine-color-edr-green-5);
outline-offset: -2px;
}
/* Cards lift very slightly on hover — enough to read as a surface, not
enough to make a still page feel restless. */
.wp-stat-tile {
transition:
box-shadow 140ms ease,
border-color 140ms ease;
}
.wp-stat-tile:hover {
border-color: var(--mantine-color-edr-border-0);
box-shadow: 0 4px 14px rgba(16, 24, 40, 0.07);
}
/* Bars dim their neighbours on hover so the hovered one reads as selected. */
.wp-col-chart:hover .wp-col-bar {
opacity: 0.45;
}
.wp-col-chart .wp-col-bar:hover,
.wp-col-chart:hover .wp-col-slot:hover .wp-col-bar {
opacity: 1;
}

View File

@@ -0,0 +1,114 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
export type TrainCrewRole =
| 'TRAIN_DRIVER'
| 'FEDERAL_POLICE'
| 'TECHNICIAN'
| 'REEFER_TECHNICIAN'
| 'HAZMAT_ESCORT'
| 'LASHING_INSPECTOR'
| 'LIVESTOCK_HANDLER';
export type TrainCrewNationality = 'ETHIOPIAN' | 'DJIBOUTIAN';
export type TrainCrewStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'ON_LEAVE';
export interface TrainCrewMember {
id: string;
firstName: string;
lastName: string;
role: TrainCrewRole;
nationality: TrainCrewNationality;
status: TrainCrewStatus;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface TrainCrewListFilters {
search?: string;
role?: TrainCrewRole;
nationality?: TrainCrewNationality;
status?: TrainCrewStatus;
isActive?: boolean;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
/** Paginated envelope returned by GET /train-crew. */
export interface TrainCrewListResponse {
data: TrainCrewMember[];
total: number;
page: number;
limit: number;
}
export type SaveTrainCrewMemberPayload = Omit<
TrainCrewMember,
'id' | 'createdAt' | 'updatedAt'
>;
export const trainCrewService = {
getAll: (filters: TrainCrewListFilters = {}) => {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.role) params.set('role', filters.role);
if (filters.nationality) params.set('nationality', filters.nationality);
if (filters.status) params.set('status', filters.status);
if (filters.isActive !== undefined) {
params.set('isActive', String(filters.isActive));
}
if (filters.page) params.set('page', String(filters.page));
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.sortBy) params.set('sortBy', filters.sortBy);
if (filters.sortOrder) params.set('sortOrder', filters.sortOrder);
const qs = params.toString();
return apiClient.get<TrainCrewListResponse>(
`${URL_CONSTANTS.TRAIN_CREW.BASE}${qs ? `?${qs}` : ''}`,
);
},
getById: (id: string) =>
apiClient.get<TrainCrewMember>(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
create: (data: Partial<SaveTrainCrewMemberPayload>) =>
apiClient.post(URL_CONSTANTS.TRAIN_CREW.BASE, data),
update: (id: string, data: Partial<SaveTrainCrewMemberPayload>) =>
apiClient.patch(URL_CONSTANTS.TRAIN_CREW.BY_ID(id), data),
delete: (id: string) => apiClient.delete(URL_CONSTANTS.TRAIN_CREW.BY_ID(id)),
};
export const TRAIN_CREW_ROLE_OPTIONS: Array<{ label: string; value: TrainCrewRole }> = [
{ label: 'Train Driver', value: 'TRAIN_DRIVER' },
{ label: 'Federal Police', value: 'FEDERAL_POLICE' },
{ label: 'Technician', value: 'TECHNICIAN' },
{ label: 'Reefer Technician', value: 'REEFER_TECHNICIAN' },
{ label: 'HAZMAT Escort', value: 'HAZMAT_ESCORT' },
{ label: 'Lashing Inspector', value: 'LASHING_INSPECTOR' },
{ label: 'Livestock Handler', value: 'LIVESTOCK_HANDLER' },
];
export const TRAIN_CREW_NATIONALITY_OPTIONS: Array<{
label: string;
value: TrainCrewNationality;
}> = [
{ label: 'Ethiopian', value: 'ETHIOPIAN' },
{ label: 'Djiboutian', value: 'DJIBOUTIAN' },
];
export const TRAIN_CREW_STATUS_OPTIONS: Array<{ label: string; value: TrainCrewStatus }> = [
{ label: 'Active', value: 'ACTIVE' },
{ label: 'Inactive', value: 'INACTIVE' },
{ label: 'Suspended', value: 'SUSPENDED' },
{ label: 'On leave', value: 'ON_LEAVE' },
];
export const trainCrewRoleLabel = (role: TrainCrewRole): string =>
TRAIN_CREW_ROLE_OPTIONS.find((o) => o.value === role)?.label ?? role;
export const trainCrewNationalityLabel = (n: TrainCrewNationality): string =>
TRAIN_CREW_NATIONALITY_OPTIONS.find((o) => o.value === n)?.label ?? n;
export const trainCrewStatusLabel = (s: TrainCrewStatus): string =>
TRAIN_CREW_STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s;