Merge remote-tracking branch 'origin/dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-06 21:57:27 +03:00
149 changed files with 10387 additions and 566 deletions

View File

@@ -206,6 +206,10 @@ export const LEGACY_APPROVAL_ROLES = [
const RATE_APPLIES_TO = [
{ label: "Bulk (base freight)", value: "BULK" },
{ label: "Container (base freight)", value: "CONTAINER" },
{
label: "Empty container (base freight, import)",
value: "EMPTY_CONTAINER",
},
{ label: "Intercity (base freight)", value: "INTERCITY" },
{ label: "First mile", value: "FIRST_MILE" },
{ label: "Last mile", value: "LAST_MILE" },
@@ -290,7 +294,9 @@ const SHIPPING_LINE_CARGO_KINDS = [
/** True when the rate being edited is base rail freight, which is priced per leg. */
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
["BULK", "CONTAINER", "EMPTY_CONTAINER", "INTERCITY"].includes(
String(values.appliesTo ?? ""),
);
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
export const ROUTE_SCOPED_TRIGGERS = [
@@ -388,6 +394,9 @@ const unitsForShape = (
switch (appliesTo) {
case "CONTAINER":
return ["PER_CONTAINER", "PER_WAGON"];
case "EMPTY_CONTAINER":
// No cargo to weigh — only the box and the wagon it rides on.
return ["PER_CONTAINER", "PER_WAGON"];
case "BULK":
return ["PER_TON", "PER_WAGON"];
case "INTERCITY":
@@ -1144,6 +1153,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
{
key: "empty-container",
label: "Empty container",
filters: { appliesTo: "EMPTY_CONTAINER", isShippingLineRate: "false" },
},
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
{
key: "trucking",
@@ -1301,8 +1315,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: (v: Record<string, unknown>) =>
String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN"
// Empty freight and the empty-return surcharge are both import-only.
String(v.appliesTo ?? "") === "EMPTY_CONTAINER" ||
(String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN")
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "FUEL"
@@ -1310,7 +1326,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
!isShippingLineRate(v) &&
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(["BULK", "CONTAINER", "EMPTY_CONTAINER"].includes(
String(v.appliesTo ?? ""),
) ||
(String(v.appliesTo ?? "") === "OTHER" &&
[
"CUSTOMS_CLEARANCE",
@@ -1513,6 +1531,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
},
// Empty freight has no cargo to narrow by, so the box size IS the scope —
// required here, unlike the laden catch-all above. The API rejects an
// unscoped empty rate for the same reason.
{
name: "containerTypeId",
label: "Container type",
type: "select",
required: true,
placeholder: "Which container type this rate covers",
description: "20ft and 40ft price differently — one rate per size per lane.",
showIf: (v) =>
!isShippingLineRate(v) && v.appliesTo === "EMPTY_CONTAINER",
},
// Container type for a shipping-line base-freight rate. Required here,
// unlike the customer form's optional catch-all: a line negotiates a
// price per box size, so an unscoped line rate has no meaning.

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,612 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Loader,
Select,
Stack,
Stepper,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle2,
Plus,
ShieldCheck,
Train,
Trash2,
Users,
Wrench,
} from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
trainCrewService,
trainCrewRoleLabel,
type TrainCrewMember,
type TrainCrewRole,
} from "@/services/trainCrew.service";
import {
DUTY_ROLE_OPTIONS,
trainCrewAssignmentService,
type CorridorYard,
type CrewDutyRole,
} from "@/services/trainCrewAssignment.service";
/**
* One driver row being built. The leg (two yards) and the duty role are
* properties of THIS run, not of the person.
*/
interface DriverRow {
key: string;
crewMemberId: string | null;
fromYardId: string | null;
toYardId: string | null;
dutyRole: CrewDutyRole | null;
}
/**
* A new row pre-filled with the schedule's own endpoints — the common case is
* one driver over the whole route, and staff narrow it from there.
*/
const newDriverRow = (yards: CorridorYard[]): DriverRow => ({
key: `driver-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
crewMemberId: null,
fromYardId: yards[0]?.id ?? null,
toYardId: yards[yards.length - 1]?.id ?? null,
dutyRole: null,
});
/**
* Assign a train crew to one schedule — ITLMS Rolling Stock §1.1 and §1.2.
*
* Crew sizes are free-form: operations add as many drivers, police, technicians
* or specialists as a given run needs, rather than filling the fixed pairing
* cases of §2. What is still enforced is what makes a run coherent — every
* driver carries a leg and duty role, one Primary per leg, Djibouti drivers
* confined to Dire Dawa and eastward (§1.1), and the specialized crew the cargo
* actually demands (§1.2).
*
* A partial crew always saves: §1.2 puts the hard gate at departure, so this
* page and the dispatch guard call the same server-side validator.
*/
export default function ScheduleCrewPage() {
const { scheduleId = "" } = useParams();
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trainCrew.assign);
const [step, setStep] = useState(0);
const [drivers, setDrivers] = useState<DriverRow[]>([]);
/** Support and specialist picks, keyed by role. */
const [supportIds, setSupportIds] = useState<Record<string, Array<string | null>>>({});
const { data: crew, isLoading } = useQuery({
queryKey: ["schedule-crew", scheduleId],
queryFn: async () => (await trainCrewAssignmentService.get(scheduleId)).data,
enabled: Boolean(scheduleId),
});
const { data: roster = [] } = useQuery({
queryKey: ["train-crew", "roster-all"],
queryFn: async () => {
const res = await trainCrewService.getAll({ limit: 200, status: "ACTIVE" });
return res.data.data;
},
});
// Seed from what is already saved, so reopening resumes rather than restarts.
useEffect(() => {
if (!crew) return;
const driverRows: DriverRow[] = [];
const support: Record<string, Array<string | null>> = {};
for (const a of crew.assignments) {
if (a.role === "TRAIN_DRIVER") {
driverRows.push({
key: a.id,
crewMemberId: a.crewMemberId,
fromYardId: a.fromYardId ?? null,
toYardId: a.toYardId ?? null,
dutyRole: a.dutyRole ?? null,
});
} else {
support[a.role] = [...(support[a.role] ?? []), a.crewMemberId];
}
}
setDrivers(driverRows);
setSupportIds(support);
}, [crew]);
const corridorYards = crew?.corridorYards ?? [];
const yardOptions = useMemo(
() => corridorYards.map((y) => ({ value: y.id, label: y.label })),
[corridorYards],
);
/**
* §1.1 — a leg is open to a Djibouti driver only when both ends sit at or
* beyond Dire Dawa. Position along the corridor answers this without naming
* station pairs, so a handover anywhere east of Dire Dawa works.
*/
const legOpenToDjibouti = (leg: {
fromYardId: string | null;
toYardId: string | null;
}) => {
const boundary = corridorYards.find((y) => /dire dawa/i.test(y.label));
const from = corridorYards.find((y) => y.id === leg.fromYardId);
const to = corridorYards.find((y) => y.id === leg.toYardId);
// An unknown boundary or half-built leg is not a breach — the server-side
// validator reports the incomplete leg on its own.
if (!boundary || !from || !to) return true;
return Math.min(from.displayOrder, to.displayOrder) >= boundary.displayOrder;
};
const byRole = useMemo(() => {
const map = new Map<TrainCrewRole, TrainCrewMember[]>();
for (const m of roster) {
map.set(m.role, [...(map.get(m.role) ?? []), m]);
}
return map;
}, [roster]);
/** Everyone already picked — nobody may hold two seats on one run. */
const takenIds = useMemo(() => {
const ids = [
...drivers.map((d) => d.crewMemberId),
...Object.values(supportIds).flat(),
].filter(Boolean) as string[];
return new Set(ids);
}, [drivers, supportIds]);
const memberOptions = (
role: TrainCrewRole,
currentValue: string | null,
leg?: { fromYardId: string | null; toYardId: string | null },
) =>
(byRole.get(role) ?? [])
.filter((m) => {
// §1.1 territorial boundary: a Djibouti driver never appears on a leg
// they may not work. Enforced by making the invalid choice unavailable
// rather than by rejecting it afterwards.
if (leg && m.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(leg)) {
return false;
}
return m.id === currentValue || !takenIds.has(m.id);
})
.map((m) => ({
value: m.id,
label: `${m.firstName} ${m.lastName} · ${m.nationality === "ETHIOPIAN" ? "ET" : "DJ"}`,
}));
const setDriver = (key: string, patch: Partial<DriverRow>) =>
setDrivers((prev) =>
prev.map((row) => {
if (row.key !== key) return row;
const next = { ...row, ...patch };
// Moving the leg can invalidate the person already chosen — clear
// rather than silently persist a territorial breach.
const legMoved = patch.fromYardId !== undefined || patch.toYardId !== undefined;
if (legMoved && next.crewMemberId) {
const member = roster.find((m) => m.id === next.crewMemberId);
if (member?.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(next)) {
next.crewMemberId = null;
}
}
return next;
}),
);
const setSupportCount = (role: TrainCrewRole, count: number) =>
setSupportIds((prev) => ({
...prev,
[role]: Array.from({ length: count }, (_, i) => prev[role]?.[i] ?? null),
}));
const buildPayload = () => {
const assignments: Array<{
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole;
fromYardId?: string;
toYardId?: string;
}> = [];
for (const row of drivers) {
if (row.crewMemberId) {
assignments.push({
crewMemberId: row.crewMemberId,
role: "TRAIN_DRIVER",
...(row.dutyRole ? { dutyRole: row.dutyRole } : {}),
...(row.fromYardId ? { fromYardId: row.fromYardId } : {}),
...(row.toYardId ? { toYardId: row.toYardId } : {}),
});
}
}
for (const [role, ids] of Object.entries(supportIds)) {
for (const id of ids) {
if (id) assignments.push({ crewMemberId: id, role: role as TrainCrewRole });
}
}
return { assignments };
};
const saveMutation = useMutation({
mutationFn: () => trainCrewAssignmentService.save(scheduleId, buildPayload()),
onSuccess: (res) => {
const validation = res.data;
toast({
title: validation.complete
? "Crew saved — composition complete"
: "Crew saved (still incomplete)",
description: validation.complete
? undefined
: "The train cannot be dispatched until every rule passes.",
});
qc.invalidateQueries({ queryKey: ["schedule-crew", scheduleId] });
},
onError: (error: unknown) => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
toast({
title: "Could not save crew",
description: Array.isArray(message)
? message.join(", ")
: typeof message === "string"
? message
: "The request failed. Please try again.",
variant: "destructive",
});
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
const demand = crew?.demand;
const specialized = crew?.requirements.specialized ?? [];
const technicianRule = crew?.requirements.technician;
const validation = crew?.validation;
return (
<PageContainer>
<PageHeader
title="Assign Train Crew"
subtitle="Add as many drivers and crew as this run needs"
backTo={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
meta={
validation ? (
<Badge
variant="light"
color={validation.complete ? "green" : "orange"}
leftSection={
validation.complete ? <CheckCircle2 size={12} /> : <AlertTriangle size={12} />
}
>
{validation.complete ? "Ready to dispatch" : "Incomplete"}
</Badge>
) : null
}
action={
canAssign ? (
<Button
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
color="edr-green"
>
Save Crew
</Button>
) : null
}
/>
<Stepper active={step} onStepClick={setStep} mt="md" size="sm">
<Stepper.Step label="Drivers" description="Any number">
<Stack gap="md" mt="lg">
<Text size="sm" c="dimmed">
Add a row per driver and set the leg they work any two yards on this
schedule's route, so a handover at Feto or Meiso is as easy as one at
Dire Dawa. Djibouti drivers are offered only on legs from Dire Dawa
eastward.
</Text>
{drivers.length === 0 ? (
<Alert color="gray">No drivers added yet.</Alert>
) : (
drivers.map((row, index) => (
<Card key={row.key} withBorder padding="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
<Train size={14} />
</ThemeIcon>
<Text fw={600} size="sm">
Driver {index + 1}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove driver"
onClick={() =>
setDrivers((prev) => prev.filter((d) => d.key !== row.key))
}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Group grow align="flex-start" wrap="wrap">
<Select
label="From yard"
placeholder="Start of this leg"
searchable
data={yardOptions}
value={row.fromYardId}
onChange={(val) => setDriver(row.key, { fromYardId: val })}
/>
<Select
label="To yard"
placeholder="End of this leg"
searchable
data={yardOptions}
value={row.toYardId}
onChange={(val) => setDriver(row.key, { toYardId: val })}
/>
<Select
label="Duty role"
placeholder="Select a duty role"
data={DUTY_ROLE_OPTIONS}
value={row.dutyRole}
onChange={(val) =>
setDriver(row.key, { dutyRole: (val as CrewDutyRole) ?? null })
}
/>
<Select
label="Driver"
placeholder="Select a driver"
searchable
clearable
data={memberOptions("TRAIN_DRIVER", row.crewMemberId, row)}
value={row.crewMemberId}
onChange={(val) => setDriver(row.key, { crewMemberId: val })}
/>
</Group>
</Card>
))
)}
<Button
variant="light"
leftSection={<Plus size={16} />}
onClick={() => setDrivers((prev) => [...prev, newDriverRow(corridorYards)])}
>
Add Driver
</Button>
</Stack>
</Stepper.Step>
<Stepper.Step label="Support crew" description="Police, technical, cargo">
<Stack gap="lg" mt="lg">
<SupportSection
role="FEDERAL_POLICE"
icon={<ShieldCheck size={16} />}
color="blue"
title="Security detail"
hint="Add as many federal police as this run needs"
values={supportIds.FEDERAL_POLICE ?? []}
onCount={(n) => setSupportCount("FEDERAL_POLICE", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
FEDERAL_POLICE: (prev.FEDERAL_POLICE ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions("FEDERAL_POLICE", value)}
/>
<SupportSection
role="TECHNICIAN"
icon={<Wrench size={16} />}
color="orange"
title="Technical maintenance crew"
hint={technicianRule?.reason ?? "Optional technical maintenance crew"}
alert={
demand?.hasBadOrderWagon
? "A defective wagon is attached, so at least one technician is mandatory."
: undefined
}
values={supportIds.TECHNICIAN ?? []}
onCount={(n) => setSupportCount("TECHNICIAN", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
TECHNICIAN: (prev.TECHNICIAN ?? []).map((v, idx) => (idx === i ? val : v)),
}))
}
options={(value) => memberOptions("TECHNICIAN", value)}
/>
{specialized.length ? (
specialized.map((rule) => (
<SupportSection
key={rule.role}
role={rule.role}
icon={<Users size={16} />}
color="grape"
title={trainCrewRoleLabel(rule.role)}
hint={rule.reason}
values={supportIds[rule.role] ?? []}
onCount={(n) => setSupportCount(rule.role, n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
[rule.role]: (prev[rule.role] ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions(rule.role, value)}
/>
))
) : (
<Alert color="gray">
No specialized cargo detected on this train no reefer, HAZMAT, break-bulk
or livestock crew is required.
</Alert>
)}
</Stack>
</Stepper.Step>
<Stepper.Completed>
<Stack gap="md" mt="lg">
<Card withBorder padding="lg">
<Text fw={600} mb="sm">
Composition checklist
</Text>
{validation?.complete ? (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="green" variant="light">
<CheckCircle2 size={14} />
</ThemeIcon>
<Text size="sm">Every rule passes this train may be dispatched.</Text>
</Group>
) : (
<Stack gap="xs">
{validation?.issues.map((issue) => (
<Group key={`${issue.code}-${issue.message}`} gap="xs" wrap="nowrap">
<ThemeIcon size={22} radius="xl" color="orange" variant="light">
<AlertTriangle size={14} />
</ThemeIcon>
<Text size="sm">{issue.message}</Text>
</Group>
))}
</Stack>
)}
</Card>
{validation?.runType ? (
<Text size="sm" c="dimmed">
Derived run type:{" "}
<Text span fw={600}>
{validation.runType === "LONG_RUN" ? "Long run" : "Short run"}
</Text>
</Text>
) : null}
</Stack>
</Stepper.Completed>
</Stepper>
<Group justify="space-between" mt="xl">
<Button variant="light" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>
Back
</Button>
<Button variant="light" disabled={step > 1} onClick={() => setStep((s) => s + 1)}>
Next
</Button>
</Group>
</PageContainer>
);
}
/** A crew block: add/remove rows freely, each naming one person. */
function SupportSection({
role,
icon,
color,
title,
hint,
alert,
values,
onCount,
onPick,
options,
}: {
role: TrainCrewRole;
icon: React.ReactNode;
color: string;
title: string;
hint: string;
alert?: string;
values: Array<string | null>;
onCount: (count: number) => void;
onPick: (index: number, value: string | null) => void;
options: (currentValue: string | null) => Array<{ value: string; label: string }>;
}) {
return (
<Card withBorder padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size={32} radius="md" variant="light" color={color}>
{icon}
</ThemeIcon>
<div>
<Text fw={600}>{title}</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</div>
</Group>
{alert ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} mb="md">
{alert}
</Alert>
) : null}
<Stack gap="sm">
{values.map((value, index) => (
<Group key={index} align="flex-end" wrap="nowrap">
<Select
label={`${trainCrewRoleLabel(role)} ${index + 1}`}
placeholder="Select a crew member"
searchable
clearable
data={options(value)}
value={value}
onChange={(val) => onPick(index, val)}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove"
onClick={() => {
const next = values.filter((_, i) => i !== index);
onCount(next.length);
next.forEach((v, i) => onPick(i, v));
}}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => onCount(values.length + 1)}
style={{ alignSelf: "flex-start" }}
>
Add {trainCrewRoleLabel(role)}
</Button>
</Stack>
</Card>
);
}

View File

@@ -42,6 +42,7 @@ import {
Ruler,
Send,
Train,
Unlock,
Weight,
Workflow as WorkflowIcon,
Warehouse,
@@ -77,6 +78,7 @@ import { StationWorkControls } from "@/components/trainScheduling/StationWorkCon
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
@@ -135,6 +137,8 @@ export default function TrainScheduleV2DetailPage() {
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
// Reopen a train whose booking shut only because of its close offset.
const [closeOffsetOpen, setCloseOffsetOpen] = useState(false);
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -1325,6 +1329,19 @@ export default function TrainScheduleV2DetailPage() {
}
action={
<Group gap="sm" wrap="nowrap">
{/* Booking shut only by the close offset — the one closed state
staff can undo here, so it gets a visible button. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="compact-sm"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reduce close offset
</Button>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
@@ -1441,6 +1458,15 @@ export default function TrainScheduleV2DetailPage() {
Window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
Reschedule train
@@ -1820,6 +1846,13 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={scheduleId ?? null}
opened={closeOffsetOpen}
onClose={() => setCloseOffsetOpen(false)}
onSaved={() => void detailQuery.refetch()}
/>
<LoadEmptyContainersModal
opened={loadEmptiesOpen}
onClose={() => setLoadEmptiesOpen(false)}

View File

@@ -37,6 +37,8 @@ import {
Send,
Table2,
Train,
Unlock,
Users,
Weight,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -57,6 +59,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
type WindowFormState,
@@ -144,6 +147,9 @@ export default function TrainScheduleV2ListPage() {
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
// "Shorten close offset": reopens a train whose booking shut only because
// of its close offset (the API flags exactly those rows).
const [closeOffsetId, setCloseOffsetId] = useState<string | null>(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
@@ -372,12 +378,40 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "actions",
size: 32,
size: 330,
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()}>
{/* Booking shut only by the close offset: a visible button, since
this is the one closed state staff can fix from the board. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="xs"
radius="md"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reduce offset
</Button>
) : null}
<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">
@@ -419,6 +453,15 @@ export default function TrainScheduleV2ListPage() {
Booking window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{/* Start the run. Same transition as the detail page's
Dispatch button — that page also shows unassigned-wagon
and not-loaded warnings, so it stays the fuller surface. */}
@@ -639,6 +682,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>
@@ -787,6 +833,13 @@ export default function TrainScheduleV2ListPage() {
onSaved={() => void schedulesQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={closeOffsetId}
opened={closeOffsetId != null}
onClose={() => setCloseOffsetId(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
<EditScheduleDateModal
scheduleId={editDateSchedule?.id ?? null}
currentDate={editDateSchedule?.scheduleDate ?? null}
@@ -1053,10 +1106,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 +1208,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;
}