Merge freight/develop into Warehouses

This commit is contained in:
Hagernesh
2026-06-11 19:26:21 +00:00
709 changed files with 68627 additions and 9486 deletions

View File

@@ -1,32 +1,15 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
ArrowRight,
Calendar,
Clock,
FileText,
Inbox,
LayoutList,
Package,
RefreshCw,
Search,
User,
X,
} from "lucide-react";
import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react";
import {
Container,
Stack,
Group,
Title,
Text,
Card,
TextInput,
ActionIcon,
Badge as MantineBadge,
Button as MantineButton,
ThemeIcon,
Paper,
Tabs,
} from "@mantine/core";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -35,15 +18,22 @@ import {
BookingStatusTabs,
type BookingStatusTabKey,
} from "@/components/bookings/BookingStatusTabs";
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
import {
useBookingDetail,
useBookingList,
useBookingListSummary,
} from "@/hooks/bookings/useBookings";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
@@ -53,8 +43,6 @@ import {
type ColumnDef,
usePagination,
Badge,
Button,
Input,
} from "@edr/ui-common";
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
@@ -63,11 +51,16 @@ function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
return match.statuses.join(",");
}
type OperationsSubTab = "ready" | "scheduled";
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval");
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
const suppressRowClickRef = useRef(false);
const suppressRowClick = useCallback(() => {
suppressRowClickRef.current = true;
@@ -77,20 +70,54 @@ export default function BookingRequestsPage() {
}, []);
const tabStatuses = getStatusesForTab(activeTab);
const isOperationsTab = activeTab === "operations";
const filter: BookingListFilter = useMemo(
() => ({
const filter: BookingListFilter = useMemo(() => {
if (isOperationsTab) {
if (operationsSubTab === "ready") {
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "NOT_SCHEDULED,HOLDING,ELIGIBLE",
assignedToSchedule: "false",
sortBy: "isGovernment",
sortOrder: "DESC",
tab: activeTab,
};
}
return {
page: 1,
pageSize: 100,
statuses: "PAID",
schedulingStatuses: "SCHEDULED,DISPATCHED",
sortBy: "scheduledDate",
sortOrder: "ASC",
tab: activeTab,
};
}
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
);
};
}, [
isOperationsTab,
operationsSubTab,
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined,
);
const {
data: summary,
isLoading: summaryLoading,
@@ -115,14 +142,24 @@ export default function BookingRequestsPage() {
const metrics = summary?.metrics;
const tabCounts = summary?.tabs;
const statValue = (value: number | undefined) =>
summaryLoading ? "—" : (value ?? 0);
const handleRefresh = useCallback(() => {
void refetch();
void refetchSummary();
}, [refetch, refetchSummary]);
const handleAllocateFromQueue = useCallback(
(ids: string[]) => {
const selected = rows.filter((b) => ids.includes(b.id));
const sorted = [...selected].sort(
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
);
setAllocateIds(sorted.map((b) => b.id));
setAllocateOpen(true);
},
[rows],
);
const handleRowClick = useCallback(
(row: BookingListRow) => {
if (suppressRowClickRef.current) return;
@@ -259,108 +296,25 @@ export default function BookingRequestsPage() {
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
<Container size="xxl" py="xl">
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
{/*
<Card
p="lg"
radius="lg"
withBorder
mb="xl"
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
}}
>
<Group justify="space-between" align="flex-start">
<Group gap="md" align="flex-start">
<ThemeIcon
size="lg"
radius="lg"
color="green"
variant="light"
>
<Inbox size={28} />
</ThemeIcon>
<Stack gap={8}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
Operations
</Text>
<Title order={1} size="h2">
Booking Requests
</Title>
<Text size="sm" c="dimmed" maw="500px">
Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals.
</Text>
</Stack>
</Group>
<MantineButton
variant="light"
color="green"
leftSection={<RefreshCw size={18} />}
disabled={isFetching}
onClick={handleRefresh}
loading={isFetching}
>
Refresh
</MantineButton>
</Group>
</Card> */}
<div className="mt-6"></div>
<Stack gap="lg">
<BookingStatGrid
items={[
{
label: "In queue",
value: statValue(metrics?.inQueue),
hint: "Total matching filter",
icon: LayoutList,
},
{
label: "On this page",
value: statValue(metrics?.onThisPage),
hint: "Current page",
icon: FileText,
},
{
label: "Needs action",
value: statValue(metrics?.needsAction),
hint: "Submitted or pending approval",
icon: Clock,
accent:
!summaryLoading && (metrics?.needsAction ?? 0) > 0
? "amber"
: "default",
},
{
label: "Urgent",
value: statValue(metrics?.urgent),
hint: "High priority score",
icon: AlertCircle,
accent:
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
},
]}
<Stack gap="lg" mt="md">
<BookingRequestsHeader
metrics={metrics}
tabs={tabCounts}
loading={summaryLoading}
isFetching={isFetching}
onCreate={() => navigate("/dashboard/booking-requests/new")}
onRefresh={handleRefresh}
/>
<Paper
p="md"
radius="lg"
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<BookingStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={tabCounts}
/>
</Paper>
counts={tabCounts}
/>
<Card
p="md"
@@ -399,7 +353,39 @@ export default function BookingRequestsPage() {
</Text>
</Group>
{showEmpty ? (
{isOperationsTab ? (
<Stack gap="md">
<Tabs
value={operationsSubTab}
onChange={(value) =>
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
}
>
<Tabs.List>
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs.List>
</Tabs>
{isError ? (
<BookingTableEmpty
isError
hasSearch={false}
onRetry={handleRefresh}
/>
) : operationsSubTab === "ready" ? (
<OperationsBookingQueue
bookings={rows}
isLoading={isLoading}
onAllocate={handleAllocateFromQueue}
/>
) : (
<OperationsScheduledBookings
bookings={rows}
isLoading={isLoading}
/>
)}
</Stack>
) : showEmpty ? (
<BookingTableEmpty
isError={isError}
hasSearch={hasSearch}
@@ -438,6 +424,19 @@ export default function BookingRequestsPage() {
</Stack>
</Card>
</Stack>
{allocateBooking ? (
<AllocateBookingWizard
booking={allocateBooking}
opened={allocateOpen}
onClose={() => {
setAllocateOpen(false);
setAllocateIds([]);
void refetch();
}}
initialBookingIds={allocateIds}
/>
) : null}
</Container>
</div>
);

View File

@@ -1,12 +1,237 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
Button,
Card,
Container,
Group,
NumberInput,
Select,
Stack,
Switch,
Text,
TextInput,
Title,
} from "@mantine/core";
import toast from "react-hot-toast";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { bookingsService } from "@/services/bookings.service";
interface ReferenceData {
yard?: Array<{ id: string; name: string; code: string }>;
service?: Array<{ id: string; name: string; code: string }>;
containers?: Array<{
size: string;
types: Array<{ id: string; name: string; code: string }>;
}>;
cargo_type?: Array<{ id: string; name: string; code: string }>;
}
export default function NewBookingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isGovernment, setIsGovernment] = useState(false);
const [governmentInstitution, setGovernmentInstitution] = useState("");
const [freightType, setFreightType] = useState<"CONTAINER" | "BULK">("CONTAINER");
const [originYardId, setOriginYardId] = useState<string | null>(null);
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
const [scheduledDate, setScheduledDate] = useState("");
const [weight, setWeight] = useState<number>(100);
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const { data: refData, isLoading } = useQuery({
queryKey: ["bookings", "reference-data"],
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
});
const createMutation = useMutation({
mutationFn: () =>
bookingsService.create({
isGovernment,
governmentInstitution: isGovernment ? governmentInstitution : undefined,
freightType,
contractType: "NEW",
equipmentReturn: "NA",
tradeDirection: "IMPORT",
paymentCurrency: "ETB",
scheduledDate: scheduledDate || new Date().toISOString(),
originYardId,
destinationYardId,
serviceTypeId,
cargoTotalWeightVgm: weight,
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
containers:
freightType === "CONTAINER" && containerTypeId
? [{ containerTypeId, quantity: 1, vgmPerUnitTons: weight }]
: undefined,
}),
onSuccess: async (booking) => {
if (isGovernment) {
await bookingsService.governmentExpedite(booking.id);
toast.success("Government booking created and expedited to scheduling");
} else {
toast.success("Booking created as draft");
}
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
navigate(`/dashboard/booking-requests/${booking.id}`);
},
onError: () => toast.error("Failed to create booking"),
});
const yards = (refData?.yard ?? []).map((y) => ({
value: y.id,
label: y.name ?? y.code,
}));
const services = (refData?.service ?? []).map((s) => ({
value: s.id,
label: s.name ?? s.code,
}));
const containerTypes =
refData?.containers?.flatMap((g) =>
g.types.map((t) => ({ value: t.id, label: `${g.size} · ${t.code}` })),
) ?? [];
const cargoTypes = (refData?.cargo_type ?? []).map((c) => ({
value: c.id,
label: c.name ?? c.code,
}));
const canSubmit =
originYardId &&
destinationYardId &&
serviceTypeId &&
scheduledDate &&
(!isGovernment || governmentInstitution.trim().length >= 2) &&
(freightType === "BULK" ? cargoTypeId : containerTypeId);
const NewBookingPage = () => {
return (
<FeaturePlaceholder
title="Create Booking"
description="Capture and validate new freight bookings from the backoffice workflow."
/>
);
};
<Container size="md" py="xl">
<Breadcrumbs
items={[
{ label: "Operations" },
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: "Create" },
]}
/>
<Title order={2} mt="lg" mb="md">
Create booking (staff)
</Title>
export default NewBookingPage;
<Card withBorder padding="lg" radius="lg">
<Stack gap="md">
<Switch
label="Government booking"
description="No company required — institution name instead. Expedited to scheduling queue."
checked={isGovernment}
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
/>
{isGovernment ? (
<TextInput
label="Government institution"
placeholder="e.g. Ministry of Transport"
value={governmentInstitution}
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
required
/>
) : null}
<Select
label="Freight type"
data={[
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
]}
value={freightType}
onChange={(v) => setFreightType((v as "CONTAINER" | "BULK") ?? "CONTAINER")}
/>
<Group grow>
<Select
label="Origin yard"
data={yards}
value={originYardId}
onChange={setOriginYardId}
searchable
disabled={isLoading}
/>
<Select
label="Destination yard"
data={yards}
value={destinationYardId}
onChange={setDestinationYardId}
searchable
disabled={isLoading}
/>
</Group>
<Select
label="Service type"
data={services}
value={serviceTypeId}
onChange={setServiceTypeId}
searchable
disabled={isLoading}
/>
<TextInput
label="Preferred departure"
type="datetime-local"
value={scheduledDate}
onChange={(e) => setScheduledDate(e.target.value)}
/>
<NumberInput
label="Total weight (tons)"
value={weight}
onChange={(v) => setWeight(Number(v) || 0)}
min={0}
/>
{freightType === "CONTAINER" ? (
<Select
label="Container type"
data={containerTypes}
value={containerTypeId}
onChange={setContainerTypeId}
searchable
/>
) : (
<Select
label="Cargo type"
data={cargoTypes}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
/>
)}
<Group justify="flex-end">
<Button
variant="default"
onClick={() => navigate("/dashboard/booking-requests")}
>
Cancel
</Button>
<Button
loading={createMutation.isPending}
disabled={!canSubmit}
onClick={() => createMutation.mutate()}
>
{isGovernment ? "Create & expedite" : "Create draft"}
</Button>
</Group>
{isGovernment ? (
<Text size="xs" c="dimmed">
Government bookings skip the commercial 3-hour hold and appear in the
priority lane on the Operations tab.
</Text>
) : null}
</Stack>
</Card>
</Container>
);
}

View File

@@ -1,35 +0,0 @@
import { useCargoes } from '@/hooks/useCargoes';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function CargoesPage() {
const { data: cargoes, refetch, isLoading } = useCargoes();
if (isLoading) return <div>Loading cargoes...</div>;
return (
<Card>
<CardHeader><CardTitle>All Cargoes</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Reference</TableHead><TableHead>Description</TableHead><TableHead>Quantity</TableHead><TableHead>Weight</TableHead><TableHead>Status</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
<TableBody>
{cargoes?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.cargoReference}</TableCell>
<TableCell>{c.description || '-'}</TableCell>
<TableCell>{c.quantity}</TableCell>
<TableCell>{c.weight} kg</TableCell>
<TableCell><Badge variant="outline">{c.status}</Badge></TableCell>
<TableCell>
{c.status === 'PENDING' && <LoadCargoDialog cargoId={c.id} onSuccess={() => refetch()} />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -1,314 +0,0 @@
import { useState, useMemo } from 'react';
import { useCargoes } from '@/hooks/useCargoes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Trash2, Edit, Plus, Search, AlertCircle } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import CargoFormDialog from '@/components/cargoes/CargoFormDialog';
interface Cargo {
id: string;
cargoReference: string;
description: string;
quantity: number;
weight: number;
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED';
remarks?: string;
createdAt: Date;
updatedAt: Date;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoesPageEnhanced() {
const { data: cargoes = [], isLoading, refetch } = useCargoes();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCargo, setEditingCargo] = useState<Cargo | null>(null);
const deleteMutation = useMutation({
mutationFn: (cargoId: string) =>
axios.delete(`${API_BASE_URL}/api/cargoes/${cargoId}`),
onSuccess: () => {
toast.success('Cargo deleted successfully');
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargo'
: 'Failed to delete cargo';
toast.error(message);
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: string[]) =>
Promise.all(ids.map(id => axios.delete(`${API_BASE_URL}/api/cargoes/${id}`))),
onSuccess: () => {
toast.success('Cargoes deleted successfully');
setSelectedIds(new Set());
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargoes'
: 'Failed to delete cargoes';
toast.error(message);
},
});
const filteredCargoes = useMemo(() => {
let result = cargoes;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
result = result.filter(
cargo =>
cargo.cargoReference?.toLowerCase().includes(lower) ||
cargo.description?.toLowerCase().includes(lower)
);
}
if (statusFilter) {
result = result.filter(cargo => cargo.status === statusFilter);
}
return result;
}, [cargoes, searchTerm, statusFilter]);
const toggleSelect = (cargoId: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(cargoId)) {
newSelected.delete(cargoId);
} else {
newSelected.add(cargoId);
}
setSelectedIds(newSelected);
};
const toggleSelectAll = () => {
if (selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredCargoes.map(c => c.id)));
}
};
const handleFormSuccess = () => {
setIsFormOpen(false);
setEditingCargo(null);
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
};
const handleEdit = (cargo: Cargo) => {
setEditingCargo(cargo);
setIsFormOpen(true);
};
const handleDelete = (cargoId: string) => {
if (window.confirm('Are you sure you want to delete this cargo?')) {
deleteMutation.mutate(cargoId);
}
};
const handleBulkDelete = () => {
if (selectedIds.size === 0) {
toast.error('Please select at least one cargo');
return;
}
if (window.confirm(`Delete ${selectedIds.size} cargo(s)?`)) {
bulkDeleteMutation.mutate(Array.from(selectedIds));
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'PENDING':
return 'bg-gray-100 text-gray-800';
case 'LOADED':
return 'bg-blue-100 text-blue-800';
case 'IN_TRANSIT':
return 'bg-purple-100 text-purple-800';
case 'DELIVERED':
return 'bg-green-100 text-green-800';
case 'CANCELLED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const statuses = ['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'CANCELLED'];
if (isLoading) {
return <div className="p-6">Loading cargoes...</div>;
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Cargoes Management</h1>
<Button onClick={() => {
setEditingCargo(null);
setIsFormOpen(true);
}}>
<Plus className="mr-2 h-4 w-4" />
New Cargo
</Button>
</div>
{/* Filters and Search */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-1 block">Search</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by reference or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-48">
<label className="text-sm font-medium mb-1 block">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
{statuses.map(status => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
</div>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 bg-blue-50 p-3 rounded-md">
<span className="text-sm text-gray-600">{selectedIds.size} selected</span>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleteMutation.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Cargoes Table */}
<Card>
<CardHeader>
<CardTitle>All Cargoes ({filteredCargoes.length})</CardTitle>
</CardHeader>
<CardContent>
{filteredCargoes.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<AlertCircle className="mr-2 h-5 w-5" />
No cargoes found
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0}
onChange={toggleSelectAll}
className="rounded"
/>
</TableHead>
<TableHead>Cargo Reference</TableHead>
<TableHead>Description</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Weight (kg)</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCargoes.map((cargo) => (
<TableRow key={cargo.id}>
<TableCell>
<input
type="checkbox"
checked={selectedIds.has(cargo.id)}
onChange={() => toggleSelect(cargo.id)}
className="rounded"
/>
</TableCell>
<TableCell className="font-medium">{cargo.cargoReference}</TableCell>
<TableCell className="max-w-xs truncate">{cargo.description}</TableCell>
<TableCell>{cargo.quantity}</TableCell>
<TableCell>{cargo.weight}</TableCell>
<TableCell>
<Badge className={getStatusColor(cargo.status)}>
{cargo.status}
</Badge>
</TableCell>
<TableCell>
{new Date(cargo.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(cargo)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(cargo.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Form Dialog */}
<CargoFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
cargo={editingCargo}
onSuccess={handleFormSuccess}
/>
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { useContainers } from '@/hooks/useContainers';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function ContainersPage() {
const { data: containers, isLoading } = useContainers();
if (isLoading) return <div>Loading containers...</div>;
return (
<Card>
<CardHeader><CardTitle>All Containers</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Wagon</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{containers?.map((c:any) => (
<TableRow key={c.id}>
<TableCell>{c.containerNumber}</TableCell>
<TableCell>{c.containerTypeId}</TableCell>
<TableCell>{c.wagonId || 'Unassigned'}</TableCell>
<TableCell><Badge variant="outline">{c.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -1,11 +1,186 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useState } from "react";
import {
AlertCircle,
Banknote,
FileText,
Train,
UserCheck,
Users,
} from "lucide-react";
import {
Alert,
Badge,
Button,
Container,
Paper,
Skeleton,
Stack,
Tabs,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
import "@/components/overview/overview.css";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
const TAB_ITEMS: Array<{
value: OverviewTabKey;
label: string;
icon: typeof FileText;
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
metricKey: string;
}> = [
{
value: "bookings",
label: "Bookings",
icon: FileText,
kpiKey: "bookings",
metricKey: "totalActive",
},
{
value: "billing",
label: "Billing",
icon: Banknote,
kpiKey: "billing",
metricKey: "successfulPaymentsMtd",
},
{
value: "operations",
label: "Operations",
icon: Train,
kpiKey: "operations",
metricKey: "trainsActive",
},
{
value: "customers",
label: "Customers",
icon: Users,
kpiKey: "customers",
metricKey: "totalCustomers",
},
{
value: "staff",
label: "Staff",
icon: UserCheck,
kpiKey: "staff",
metricKey: "activeEmployees",
},
];
function HeaderSkeleton() {
return (
<Stack gap="md">
<Skeleton height={48} radius="md" />
<Skeleton height={52} radius="lg" />
</Stack>
);
}
const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
const queryClient = useQueryClient();
const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range);
const handleRefresh = () => {
void refetch();
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
};
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
if (!summary?.kpis) return 0;
const group = summary.kpis[tab.kpiKey] as Record<string, number>;
return group[tab.metricKey] ?? 0;
};
return (
<FeaturePlaceholder
title="Overview"
description="Track internal freight operations, monitor account administration, and review the latest backoffice activity from a single operational dashboard."
/>
<Container fluid px="md" py="md">
<Stack gap="lg">
{isLoading && !summary ? (
<HeaderSkeleton />
) : (
<OverviewPageHeader
range={range}
onRangeChange={setRange}
generatedAt={summary?.generatedAt}
onRefresh={handleRefresh}
isRefreshing={isFetching && !isLoading}
/>
)}
{isError && (
<Alert
icon={<AlertCircle size={16} />}
color="red"
title="Unable to load dashboard summary"
variant="light"
>
<Stack gap="sm" align="flex-start">
<span>Check your connection and try again.</span>
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
Retry
</Button>
</Stack>
</Alert>
)}
<Tabs
value={activeTab}
onChange={(value) => setActiveTab((value as OverviewTabKey) ?? "bookings")}
variant="pills"
color="green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
{TAB_ITEMS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.value;
return (
<Tabs.Tab
key={tab.value}
value={tab.value}
leftSection={<Icon size={17} />}
rightSection={
summary ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "green" : "gray"}
styles={
isActive
? { root: { background: "rgba(255,255,255,0.9)", color: "#15805f" } }
: undefined
}
>
{getTabBadge(tab)}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
{TAB_ITEMS.map((tab) => (
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
<OverviewTabContent tab={tab.value} range={range} />
</Tabs.Panel>
))}
</Tabs>
<Paper p="lg" radius="lg" withBorder>
<OverviewQuickLinks />
</Paper>
</Stack>
</Container>
);
};

View File

@@ -0,0 +1,361 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useCargoTypes } from "@/hooks/use-cargo-types";
import { useContainerTypes } from "@/hooks/use-container-types";
import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast";
import { useWagons } from "@/hooks/useWagons";
import {
FLEET_SELECT_NONE,
getFleetResource,
getFleetSlugFromPath,
type FleetFormFieldDef,
type FleetResourceSlug,
} from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const FleetResourcePage = () => {
const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
const config = getFleetResource(slug);
const { toast } = useToast();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const { data: allRows = [], isLoading, isError, error } = useFleetList(slug);
const { create, update, remove } = useFleetMutations(slug);
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { data: containerTypes = [], isLoading: containerTypesLoading } = useContainerTypes();
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useCargoTypes();
const { data: wagons = [], isLoading: wagonsLoading } = useWagons();
const { data: containers = [], isLoading: containersLoading } = useContainers();
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
setStatusFilter("ALL");
}, [slug, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const statusFilterOptions = useMemo(() => {
if (!hasStatusColumn) return [];
const statuses = new Set(
allRows
.map((row) => String((row as unknown as Record<string, unknown>).status ?? ""))
.filter(Boolean),
);
return [
{ value: "ALL", label: "All statuses" },
...[...statuses].sort().map((status) => ({ value: status, label: status })),
];
}, [allRows, hasStatusColumn]);
const dynamicOptions = useMemo(() => {
const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map(
(t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }),
);
const containerTypeOpts = (
containerTypes as Array<{ id: string; label?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.label ?? t.code ?? t.id }));
const cargoTypeOpts = (
cargoTypes as Array<{ id: string; cargoTypeName?: string; code?: string }>
).map((t) => ({ value: t.id, label: t.cargoTypeName ?? t.code ?? t.id }));
const wagonOpts = (wagons as Array<{ id: string; wagonNumber: string }>).map((w) => ({
value: w.id,
label: w.wagonNumber,
}));
const containerOpts = (containers as Array<{ id: string; containerNumber: string }>).map(
(c) => ({ value: c.id, label: c.containerNumber }),
);
return {
wagonTypes: wagonTypeOpts,
containerTypes: containerTypeOpts,
cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts],
wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts],
containers: containerOpts,
};
}, [wagonTypes, containerTypes, cargoTypes, wagons, containers]);
useEffect(() => {
registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes);
registerFleetOptionLabels("containerTypeId", dynamicOptions.containerTypes);
registerFleetOptionLabels(
"cargoTypeId",
dynamicOptions.cargoTypes.filter((o) => o.value !== FLEET_SELECT_NONE),
);
registerFleetOptionLabels("wagonId", dynamicOptions.wagons);
registerFleetOptionLabels("containerId", dynamicOptions.containers);
}, [dynamicOptions]);
const formFields = useMemo((): FleetFormFieldDef[] => {
if (!config) return [];
return config.formFields.map((field) => {
if (!field.dynamicOptions) return field;
const options = dynamicOptions[field.dynamicOptions] ?? [];
return { ...field, type: "select" as const, options };
});
}, [config, dynamicOptions]);
const selectOptionsLoading =
wagonTypesLoading || containerTypesLoading || cargoTypesLoading || wagonsLoading || containersLoading;
const filteredRows = useMemo(() => {
if (!config) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
return false;
}
if (!term) return true;
return config.searchKeys.some((key) =>
String(record[key] ?? "")
.toLowerCase()
.includes(term),
);
});
}, [allRows, search, statusFilter, config]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
const columns = useMemo((): ColumnDef<FleetRecord>[] => {
if (!config) return [];
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
const base: ColumnDef<FleetRecord>[] = config.columns.map((col) => ({
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
formatFleetCell(
(row.original as unknown as Record<string, unknown>)[col.accessorKey],
col.format,
col.accessorKey,
),
}));
base.push({
id: "actions",
header: "Actions",
size: 140,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<FleetRecordActions
record={row.original}
config={config}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
</div>
),
});
return base;
}, [config]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
if (!config) {
return <Navigate to="/dashboard/locomotives" replace />;
}
const handleFormSubmit = async (values: Record<string, unknown>) => {
try {
if (editing && "id" in editing) {
await update.mutateAsync({ id: String(editing.id), data: values });
toast({ title: `${config.entityLabel} updated` });
} else {
await create.mutateAsync(values);
toast({ title: `${config.entityLabel} created` });
}
setFormOpen(false);
setEditing(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Save failed";
toast({ title: "Save failed", description: String(message), variant: "destructive" });
}
};
const handleRemove = async () => {
if (!removeTarget || !("id" in removeTarget)) return;
try {
await remove.mutateAsync(String(removeTarget.id));
toast({
title: config.removeSuccessMessage ?? `${config.entityLabel} removed`,
});
setRemoveTarget(null);
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
"Remove failed";
toast({ title: "Remove failed", description: String(message), variant: "destructive" });
}
};
const itemLabel = config.label.toLowerCase();
return (
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch}
addLabel={config.addLabel}
onAdd={() => {
setEditing(null);
setFormOpen(true);
}}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
hasStatusColumn && statusFilterOptions.length > 1 ? (
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={statusFilterOptions}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
) : undefined
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={pagedRows}
status={tableStatus}
error={
isError
? {
message: "Failed to load data",
description: error instanceof Error ? error.message : "Unknown error",
}
: undefined
}
emptyMessage={`No ${itemLabel} found`}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: itemLabel } }}
/>
)}
/>
) : (
<FleetCardGrid
config={config}
rows={pagedRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found`}
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRows.length}
onPaginationChange={setPagination}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onRemove={setRemoveTarget}
/>
)}
</Stack>
</Card>
<FleetFormDialog
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open);
if (!open) setEditing(null);
}}
title={editing ? `Edit ${config.entityLabel}` : config.addLabel}
fields={formFields}
initialRecord={editing}
emptyValues={config.emptyValues}
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={selectOptionsLoading}
onSubmit={handleFormSubmit}
/>
<Modal
opened={Boolean(removeTarget)}
onClose={() => setRemoveTarget(null)}
title={<Text fw={600}>{config.removeActionLabel ?? "Delete"}</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm">
{config.removeConfirmMessage ??
`Are you sure you want to ${config.removeAction} this ${config.entityLabel.toLowerCase()}?`}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRemoveTarget(null)}>
Cancel
</Button>
<Button color="red" loading={remove.isPending} onClick={handleRemove}>
{config.removeActionLabel ?? "Delete"}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default FleetResourcePage;

View File

@@ -1,30 +1,45 @@
import { FormEvent, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { FormEvent, useMemo, useState } from "react";
import { Edit, Eye, Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useCreateRoute, useDeactivateRoute, useRouteYards, useRoutes, useUpdateRoute } from '@/hooks/useRoutes';
import { useToast } from '@/hooks/use-toast';
import type { RouteRecord, YardRef } from '@/services/routes.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Modal,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import {
useCreateRoute,
useDeactivateRoute,
useRouteYards,
useRoutes,
useUpdateRoute,
} from "@/hooks/useRoutes";
import { useToast } from "@/hooks/use-toast";
import type { RouteRecord, YardRef } from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type RouteFormState = {
name: string;
milestones: string[];
};
const emptyForm = (): RouteFormState => ({ name: '', milestones: ['', ''] });
const emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
const routeStops = (route: RouteRecord) =>
(route.milestones ?? [])
@@ -33,22 +48,26 @@ const routeStops = (route: RouteRecord) =>
const normalizeRouteError = (error: unknown) => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
const data = responseData && typeof responseData === 'object' ? (responseData as Record<string, unknown>) : undefined;
const data =
responseData && typeof responseData === "object"
? (responseData as Record<string, unknown>)
: undefined;
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage)
? rawMessage.join(', ')
? rawMessage.join(", ")
: rawMessage
? String(rawMessage)
: 'Save failed';
: "Save failed";
};
export default function RoutesPage() {
const [search, setSearch] = useState('');
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [viewing, setViewing] = useState<RouteRecord | null>(null);
const [editing, setEditing] = useState<RouteRecord | null>(null);
const [form, setForm] = useState<RouteFormState>(emptyForm());
const { viewMode, setViewMode } = useFleetViewMode("routes");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { toast } = useToast();
const routesQuery = useRoutes();
@@ -60,7 +79,6 @@ export default function RoutesPage() {
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
route.name,
@@ -71,13 +89,18 @@ export default function RoutesPage() {
...routeStops(route),
]
.filter(Boolean)
.join(' ')
.join(" ")
.toLowerCase();
return searchable.includes(query);
});
}, [routesQuery.data, search]);
const pageCount = Math.max(1, Math.ceil(filteredRoutes.length / pagination.pageSize));
const pagedRoutes = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRoutes.slice(start, start + pagination.pageSize);
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((yard) => ({
@@ -120,7 +143,7 @@ export default function RoutesPage() {
};
const addMilestone = () => {
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
};
const removeMilestone = (index: number) => {
@@ -132,17 +155,15 @@ export default function RoutesPage() {
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!form.name.trim()) {
toast({ title: 'Save failed', description: 'Route name is required', variant: 'destructive' });
toast({ title: "Save failed", description: "Route name is required", variant: "destructive" });
return;
}
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
toast({
title: 'Save failed',
description: 'Select at least an origin and destination yard',
variant: 'destructive',
title: "Save failed",
description: "Select at least an origin and destination yard",
variant: "destructive",
});
return;
}
@@ -153,29 +174,25 @@ export default function RoutesPage() {
milestones: form.milestones.map((yardId) => ({ yardId })),
isActive: editing?.isActive ?? true,
};
if (editing) {
await updateMutation.mutateAsync({ id: editing.id, data: payload });
toast({ title: 'Route updated' });
toast({ title: "Route updated" });
} else {
await createMutation.mutateAsync(payload);
toast({ title: 'Route created' });
toast({ title: "Route created" });
}
resetForm();
} catch (error) {
toast({ title: 'Save failed', description: normalizeRouteError(error), variant: 'destructive' });
toast({ title: "Save failed", description: normalizeRouteError(error), variant: "destructive" });
}
};
const handleDeactivate = async (route: RouteRecord) => {
if (!window.confirm('Deactivate this route?')) return;
try {
await deactivateMutation.mutateAsync(route.id);
toast({ title: 'Route deactivated' });
toast({ title: "Route deactivated" });
} catch {
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
}
};
@@ -185,195 +202,288 @@ export default function RoutesPage() {
const selectedByOthers = new Set(
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
);
return yardOptions.filter(
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
);
};
const tableStatus = routesQuery.isLoading
? "loading"
: routesQuery.isError
? "error"
: "success";
const columns = useMemo((): ColumnDef<RouteRecord>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{ id: "name", header: "Name", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.name },
{
id: "origin",
header: "Origin",
meta: { headerClassName, cellClassName },
cell: ({ row }) => yardLabel(row.original.originYard),
},
{
id: "destination",
header: "Destination",
meta: { headerClassName, cellClassName },
cell: ({ row }) => yardLabel(row.original.destinationYard),
},
{
id: "milestones",
header: "Milestones",
meta: { headerClassName, cellClassName },
cell: ({ row }) => Math.max((row.original.milestones?.length ?? 0) - 2, 0),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge color={row.original.isActive ? "green" : "gray"} variant="light" size="sm">
{row.original.isActive ? "Active" : "Inactive"}
</Badge>
),
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={4} justify="flex-end" wrap="nowrap">
<Tooltip label="View">
<ActionIcon variant="subtle" color="gray" onClick={() => setViewing(row.original)}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit">
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
<Edit size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Deactivate">
<ActionIcon
variant="subtle"
color="red"
disabled={!row.original.isActive || deactivateMutation.isPending}
onClick={() => handleDeactivate(row.original)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
),
},
];
}, [deactivateMutation.isPending]);
return (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Routes</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build train routes from an ordered yard list where the first stop is the origin and the last stop is the destination.
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Add Route
</Button>
</div>
<Stack gap="md">
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search routes…"
addLabel="Add Route"
onAdd={openCreate}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder="Search routes"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Origin</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Milestones</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRoutes.map((route) => (
<TableRow key={route.id}>
<TableCell>{route.name}</TableCell>
<TableCell>{yardLabel(route.originYard)}</TableCell>
<TableCell>{yardLabel(route.destinationYard)}</TableCell>
<TableCell>{Math.max((route.milestones?.length ?? 0) - 2, 0)}</TableCell>
<TableCell>{route.isActive ? 'Active' : 'Inactive'}</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewing(route)} title="View">
<Eye className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(route)} title="Edit">
<Edit className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeactivate(route)}
title="Deactivate"
disabled={!route.isActive || deactivateMutation.isPending}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!routesQuery.isLoading && filteredRoutes.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
No routes found.
</TableCell>
</TableRow>
) : null}
{routesQuery.isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? resetForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Edit Route' : 'Add Route'}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="route-name">Name</Label>
<Input
id="route-name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
{viewMode === "table" ? (
<DataTable
columns={columns}
data={pagedRoutes}
status={tableStatus}
emptyMessage="No routes found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRoutes.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "routes" } }}
/>
)}
/>
) : (
<Stack gap={0}>
{tableStatus === "loading" ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
Loading
</Text>
) : !pagedRoutes.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
No routes found
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{pagedRoutes.map((route) => (
<Card key={route.id} radius="lg" padding="lg" withBorder>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>{route.name}</Text>
<Badge color={route.isActive ? "green" : "gray"} variant="light" size="sm">
{route.isActive ? "Active" : "Inactive"}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{yardLabel(route.originYard)} {yardLabel(route.destinationYard)}
</Text>
<Text size="xs" c="dimmed">
{Math.max((route.milestones?.length ?? 0) - 2, 0)} intermediate milestones
</Text>
<Group gap={6} justify="flex-end">
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
View
</Button>
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
Edit
</Button>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filteredRoutes.length}
itemLabel="routes"
onPaginationChange={setPagination}
/>
</div>
</Stack>
)}
</Stack>
</Card>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Stops</Label>
<Button type="button" variant="outline" size="sm" onClick={addMilestone}>
<Plus className="size-4" />
Add next milestone
</Button>
</div>
{form.milestones.map((yardId, index) => {
const role = index === 0 ? 'Origin' : index === form.milestones.length - 1 ? 'Destination' : 'Milestone';
const availableOptions = availableOptionsForIndex(index);
return (
<div key={`${role}-${index}`} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-[120px,1fr,auto] sm:items-center">
<p className="text-sm font-medium">{role}</p>
<Select value={yardId} onValueChange={(value) => setMilestone(index, value)}>
<SelectTrigger>
<SelectValue placeholder="Select yard" />
</SelectTrigger>
<SelectContent>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeMilestone(index)}
disabled={form.milestones.length <= 2}
title="Remove stop"
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={resetForm}>
<Modal
opened={formOpen}
onClose={resetForm}
title={<Text fw={600}>{editing ? "Edit Route" : "Add Route"}</Text>}
size="lg"
radius="lg"
centered
>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label="Name"
value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.currentTarget.value }))}
/>
<Group justify="space-between">
<Text size="sm" fw={500}>
Stops
</Text>
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
Add milestone
</Button>
</Group>
{form.milestones.map((yardId, index) => {
const role =
index === 0
? "Origin"
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap">
<Text w={100} size="sm" fw={500}>
{role}
</Text>
<Select
style={{ flex: 1 }}
data={availableOptionsForIndex(index)}
value={yardId || null}
onChange={(value) => value && setMilestone(index, value)}
placeholder="Select yard"
searchable
/>
<ActionIcon
variant="subtle"
color="red"
disabled={form.milestones.length <= 2}
onClick={() => removeMilestone(index)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
);
})}
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
<Button color="green" type="submit" loading={isSaving}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Group>
</Stack>
</form>
</Modal>
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Route details</DialogTitle>
</DialogHeader>
{viewing ? (
<div className="space-y-3 text-sm">
<div>
<p className="font-medium">Name</p>
<p className="text-muted-foreground">{viewing.name}</p>
</div>
<div>
<p className="font-medium">Status</p>
<p className="text-muted-foreground">{viewing.isActive ? 'Active' : 'Inactive'}</p>
</div>
<div>
<p className="font-medium">Stops</p>
<div className="mt-2 space-y-2">
{routeStops(viewing).map((stop, index, stops) => (
<div key={`${stop}-${index}`} className="rounded-md border px-3 py-2 text-muted-foreground">
{index === 0 ? 'Origin' : index === stops.length - 1 ? 'Destination' : `Milestone ${index}`}:
{' '}
{stop}
</div>
))}
</div>
</div>
<Modal
opened={Boolean(viewing)}
onClose={() => setViewing(null)}
title={<Text fw={600}>Route details</Text>}
radius="lg"
centered
>
{viewing ? (
<Stack gap="sm">
<div>
<Text size="sm" fw={500}>
Name
</Text>
<Text size="sm" c="dimmed">
{viewing.name}
</Text>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
<div>
<Text size="sm" fw={500}>
Status
</Text>
<Text size="sm" c="dimmed">
{viewing.isActive ? "Active" : "Inactive"}
</Text>
</div>
<div>
<Text size="sm" fw={500}>
Stops
</Text>
<Stack gap={6} mt={6}>
{routeStops(viewing).map((stop, index, stops) => (
<Text key={`${stop}-${index}`} size="sm" c="dimmed">
{index === 0
? "Origin"
: index === stops.length - 1
? "Destination"
: `Milestone ${index}`}
: {stop}
</Text>
))}
</Stack>
</div>
</Stack>
) : null}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,318 @@
import { Freight } from "@edr/types";
import type { ColumnFormat, FormFieldDef } from "@/pages/ruleEngine/config/resources";
export type FleetResourceSlug =
| "locomotives"
| "trains"
| "wagons"
| "containers"
| "cargoes";
export const FLEET_SELECT_NONE = "__none__";
export type FleetDynamicOptions =
| "wagonTypes"
| "containerTypes"
| "cargoTypes"
| "wagons"
| "containers";
export interface FleetResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat | "statusBadge";
}
export interface FleetFormFieldDef extends FormFieldDef {
dynamicOptions?: FleetDynamicOptions;
noneOption?: boolean;
}
export interface FleetResourceConfig {
slug: FleetResourceSlug;
label: string;
subtitle: string;
basePath: string;
addLabel: string;
entityLabel: string;
searchPlaceholder: string;
supportsSearch: boolean;
columns: FleetResourceColumn[];
formFields: FleetFormFieldDef[];
emptyValues: Record<string, unknown>;
removeAction: "delete" | "decommission";
removeActionLabel?: string;
removeConfirmMessage?: string;
removeSuccessMessage?: string;
detailPath?: string;
cardTitleKey?: string;
cardCodeKey?: string;
cardSubtitleKey?: string;
searchKeys: string[];
}
export const FLEET_BASE_PATH_BY_SLUG: Record<FleetResourceSlug, string> = {
locomotives: "/dashboard/locomotives",
trains: "/dashboard/trains",
wagons: "/dashboard/wagons",
containers: "/dashboard/containers",
cargoes: "/dashboard/cargoes",
};
const LOCOMOTIVE_TYPE_OPTIONS = [
{ label: "Diesel", value: "DIESEL" },
{ label: "Electric", value: "ELECTRIC" },
];
const LOCOMOTIVE_STATUS_OPTIONS = [
{ label: "Available", value: "AVAILABLE" },
{ label: "Maintenance", value: "MAINTENANCE" },
{ label: "Assigned", value: "ASSIGNED" },
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
const WAGON_STATUS_OPTIONS = [
{ label: "Available", value: Freight.WagonStatus.Available },
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
{ label: "Maintenance", value: Freight.WagonStatus.Maintenance },
{ label: "Retired", value: Freight.WagonStatus.Retired },
];
const WAGON_READINESS_OPTIONS = [
{ label: "Import ready", value: Freight.WagonReadiness.ImportReady },
{ label: "Export ready", value: Freight.WagonReadiness.ExportReady },
];
export const FLEET_RESOURCES: FleetResourceConfig[] = [
{
slug: "locomotives",
label: "Locomotives",
subtitle: "Manage locomotive master data used by train scheduling and fleet operations",
basePath: "/dashboard/locomotives",
addLabel: "Add Locomotive",
entityLabel: "Locomotive",
searchPlaceholder: "Search locomotives…",
supportsSearch: true,
removeAction: "decommission",
removeActionLabel: "Decommission",
removeConfirmMessage: "Decommission this locomotive?",
removeSuccessMessage: "Locomotive decommissioned",
cardTitleKey: "name",
cardCodeKey: "code",
cardSubtitleKey: "locomotiveType",
searchKeys: ["code", "name", "locomotiveType", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "name", header: "Name", accessorKey: "name" },
{ id: "locomotiveType", header: "Type", accessorKey: "locomotiveType" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
{ name: "maxPullWeightTons", label: "Max pulling weight (tons)", type: "number", required: true },
{ name: "maxTrainLengthMeters", label: "Max train length (meters)", type: "number", required: true },
{ name: "powerKw", label: "Power (kW)", type: "number" },
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
code: "",
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
maxPullWeightTons: 0,
maxTrainLengthMeters: 760,
powerKw: "",
tractionForceKn: "",
maxSpeedKmh: "",
},
},
{
slug: "trains",
label: "Trains",
subtitle: "Manage train master data independently from train scheduling",
basePath: "/dashboard/trains",
addLabel: "Add Train",
entityLabel: "Train",
searchPlaceholder: "Search trains…",
supportsSearch: true,
removeAction: "delete",
detailPath: "/dashboard/trains/:id",
cardTitleKey: "trainName",
cardCodeKey: "code",
cardSubtitleKey: "trainNumber",
searchKeys: ["code", "trainNumber", "trainName", "status"],
columns: [
{ id: "code", header: "Code", accessorKey: "code", format: "code" },
{ id: "trainNumber", header: "Number", accessorKey: "trainNumber" },
{ id: "trainName", header: "Name", accessorKey: "trainName" },
{ id: "capacityTons", header: "Capacity (tons)", accessorKey: "capacityTons", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "trainNumber", label: "Train number", type: "text" },
{ name: "trainName", label: "Train name", type: "text" },
{ name: "locomotiveNumber", label: "Locomotive number", type: "text" },
{ name: "status", label: "Status", type: "text" },
{ name: "notes", label: "Notes", type: "textarea" },
{ name: "remarks", label: "Remarks", type: "textarea" },
],
emptyValues: {
code: "",
capacityTons: 0,
trainNumber: "",
trainName: "",
locomotiveNumber: "",
status: "AVAILABLE",
notes: "",
remarks: "",
},
},
{
slug: "wagons",
label: "Wagons",
subtitle: "Manage wagon master data. Operational scheduling uses train schedules separately",
basePath: "/dashboard/wagons",
addLabel: "Add Wagon",
entityLabel: "Wagon",
searchPlaceholder: "Search wagons…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "wagonNumber",
cardSubtitleKey: "readiness",
searchKeys: ["wagonNumber", "wagonTypeId", "trainId", "status", "readiness"],
columns: [
{ id: "wagonNumber", header: "Number", accessorKey: "wagonNumber", format: "code" },
{ id: "wagonTypeId", header: "Type", accessorKey: "wagonTypeId", format: "entityLabel" },
{ id: "maxPayloadWeight", header: "Max payload", accessorKey: "maxPayloadWeight", format: "number" },
{ id: "readiness", header: "Readiness", accessorKey: "readiness", format: "statusBadge" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxPayloadWeight", label: "Max payload weight", type: "number", required: true },
{ name: "readiness", label: "Readiness", type: "select", required: true, options: WAGON_READINESS_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {
wagonNumber: "",
wagonTypeId: "",
tareWeight: 0,
maxPayloadWeight: 0,
readiness: Freight.WagonReadiness.ImportReady,
status: Freight.WagonStatus.Available,
notes: "",
},
},
{
slug: "containers",
label: "Containers",
subtitle: "Manage container master data and wagon assignments",
basePath: "/dashboard/containers",
addLabel: "Add Container",
entityLabel: "Container",
searchPlaceholder: "Search containers…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "containerNumber",
cardSubtitleKey: "status",
searchKeys: ["containerNumber", "containerTypeId", "wagonId", "status"],
columns: [
{ id: "containerNumber", header: "Number", accessorKey: "containerNumber", format: "code" },
{ id: "containerTypeId", header: "Type", accessorKey: "containerTypeId", format: "entityLabel" },
{ id: "wagonId", header: "Wagon", accessorKey: "wagonId", format: "entityLabel" },
{ id: "maxGrossWeight", header: "Max gross", accessorKey: "maxGrossWeight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "containerNumber", label: "Container number", type: "text", required: true },
{ name: "containerTypeId", label: "Container type", type: "select", required: true, dynamicOptions: "containerTypes" },
{ name: "wagonId", label: "Wagon", type: "select", dynamicOptions: "wagons", noneOption: true },
{ name: "position", label: "Position", type: "number" },
{ name: "tareWeight", label: "Tare weight", type: "number", required: true },
{ name: "maxGrossWeight", label: "Max gross weight", type: "number", required: true },
{ name: "sealNumber", label: "Seal number", type: "text" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
containerNumber: "",
containerTypeId: "",
wagonId: "",
position: "",
tareWeight: 0,
maxGrossWeight: 0,
sealNumber: "",
status: "AVAILABLE",
},
},
{
slug: "cargoes",
label: "Cargoes",
subtitle: "Manage cargo records linked to containers",
basePath: "/dashboard/cargoes",
addLabel: "Add Cargo",
entityLabel: "Cargo",
searchPlaceholder: "Search cargoes…",
supportsSearch: true,
removeAction: "delete",
cardTitleKey: "cargoReference",
cardSubtitleKey: "status",
searchKeys: ["cargoReference", "description", "containerId", "status"],
columns: [
{ id: "cargoReference", header: "Reference", accessorKey: "cargoReference", format: "code" },
{ id: "cargoTypeId", header: "Cargo type", accessorKey: "cargoTypeId", format: "entityLabel" },
{ id: "containerId", header: "Container", accessorKey: "containerId", format: "entityLabel" },
{ id: "quantity", header: "Quantity", accessorKey: "quantity", format: "number" },
{ id: "weight", header: "Weight", accessorKey: "weight", format: "number" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
],
formFields: [
{ name: "cargoReference", label: "Cargo reference", type: "text", required: true },
{ name: "shipmentId", label: "Shipment ID", type: "text", required: true },
{ name: "containerId", label: "Container", type: "select", required: true, dynamicOptions: "containers" },
{ name: "cargoTypeId", label: "Cargo type", type: "select", dynamicOptions: "cargoTypes", noneOption: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "quantity", label: "Quantity", type: "number", required: true },
{ name: "weight", label: "Weight", type: "number", required: true },
{ name: "volume", label: "Volume", type: "number" },
{ name: "status", label: "Status", type: "text" },
],
emptyValues: {
cargoReference: "",
shipmentId: "",
containerId: "",
cargoTypeId: "",
description: "",
quantity: 0,
weight: 0,
volume: "",
status: "PENDING",
},
},
];
export const getFleetResource = (slug: string): FleetResourceConfig | undefined =>
FLEET_RESOURCES.find((resource) => resource.slug === slug);
export const getFleetSlugFromPath = (pathname: string): FleetResourceSlug | undefined => {
const normalized = pathname.toLowerCase();
return FLEET_RESOURCES.find((resource) => normalized === resource.basePath.toLowerCase())?.slug;
};
export const getFleetRouteMeta = () =>
FLEET_RESOURCES.map((resource) => ({
prefix: resource.basePath,
meta: { title: resource.label, subtitle: resource.subtitle },
}));

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
@@ -8,7 +8,10 @@ import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import RuleEngineOrderControls from "@/components/ruleEngine/RuleEngineOrderControls";
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
import { getOrderItemLabel } from "@/components/ruleEngine/ruleEngineOrder.utils";
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -29,6 +32,8 @@ import {
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useRuleEngineOrderMutations,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
@@ -65,6 +70,7 @@ const RuleEngineResourcePage = () => {
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
const [chainOpen, setChainOpen] = useState(false);
const [orderDialogOpen, setOrderDialogOpen] = useState(false);
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
@@ -81,10 +87,27 @@ const RuleEngineResourcePage = () => {
search: config?.supportsSearch ? search.trim() || undefined : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(config?.orderConfig
? {
sortBy: config.orderConfig.field,
sortOrder: "ASC" as const,
}
: {}),
}),
[config?.supportsSearch, search, pagination.pageIndex, pagination.pageSize],
[
config?.orderConfig,
config?.supportsSearch,
search,
pagination.pageIndex,
pagination.pageSize,
],
);
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
}, [config?.slug, setPagination]);
const { data, isLoading, isError, error } = useRuleEngineList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
listParams,
@@ -93,6 +116,14 @@ const RuleEngineResourcePage = () => {
const { create, update, remove } = useRuleEngineMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const { reorder, moveOrder } = useRuleEngineOrderMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const { data: orderListData, isLoading: orderListLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(orderDialogOpen && config?.orderConfig),
config?.orderConfig?.field,
);
const { submit, approve } = useRateWorkflow();
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
chainOpen && config?.slug === "approval-rules",
@@ -149,25 +180,24 @@ const RuleEngineResourcePage = () => {
const rows = data?.data ?? [];
const meta = data?.meta;
const pageCount = meta?.totalPages ?? 1;
const totalCount = meta?.total ?? rows.length;
const filteredRows = useMemo(() => {
if (config?.supportsSearch || !search.trim()) return rows;
const q = search.trim().toLowerCase();
return rows.filter((row) =>
JSON.stringify(row).toLowerCase().includes(q),
);
}, [rows, search, config?.supportsSearch]);
const paginationState = useMemo(
() => ({
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: meta?.total ?? filteredRows.length,
}),
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
const { data: createPositionList, isLoading: createPositionLoading } = useRuleEngineOrderList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
Boolean(formOpen && !editing && config?.orderConfig),
config?.orderConfig?.field,
);
const createPositionOptions = useMemo(() => {
if (!config?.orderConfig || !createPositionList?.data?.length) return undefined;
return createPositionList.data
.filter((row) => row.id)
.map((row) => ({
label: getOrderItemLabel(row, config.slug),
value: String(row.id),
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
const handleApproveRate = useCallback(
(record: RuleEngineRecord) => {
@@ -176,6 +206,13 @@ const RuleEngineResourcePage = () => {
[approve],
);
const handleMoveOrder = useCallback(
(id: string, direction: "up" | "down") => {
moveOrder.mutate({ id, direction });
},
[moveOrder],
);
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
if (!config) return [];
@@ -192,36 +229,47 @@ const RuleEngineResourcePage = () => {
base.push({
id: "actions",
header: "Actions",
size: 140,
minSize: 120,
size: config.orderConfig ? 200 : 140,
minSize: config.orderConfig ? 180 : 120,
meta: {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
},
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
<RuleEngineRecordActions
record={row.original}
config={config}
layout="row"
readOnly={!canManage}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
/>
<Group gap="xs" wrap="nowrap" justify="flex-end">
{config.orderConfig && canManage ? (
<RuleEngineOrderControls
record={row.original}
orderConfig={config.orderConfig}
totalCount={totalCount}
disabled={moveOrder.isPending}
onMove={handleMoveOrder}
/>
) : null}
<RuleEngineRecordActions
record={row.original}
config={config}
layout="row"
readOnly={!canManage}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
onApproveRate={canManage ? handleApproveRate : undefined}
/>
</Group>
</div>
),
});
return base;
}, [canManage, config, submit, handleApproveRate]);
}, [canManage, config, submit, handleApproveRate, handleMoveOrder, moveOrder.isPending, totalCount]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -276,13 +324,21 @@ const RuleEngineResourcePage = () => {
<Stack gap="md">
<RuleEngineToolbar
search={search}
onSearchChange={(v) => {
setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
onSearchChange={
config.supportsSearch
? (v) => {
setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}
: undefined
}
showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder}
onAdd={canManage ? openCreate : undefined}
addLabel={`Add ${config.label.replace(/s$/, "")}`}
onManageOrder={
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
@@ -290,7 +346,7 @@ const RuleEngineResourcePage = () => {
{viewMode === "table" ? (
<DataTable
columns={columns}
data={filteredRows}
data={rows}
status={tableStatus}
error={
isError
@@ -302,7 +358,12 @@ const RuleEngineResourcePage = () => {
: undefined
}
emptyMessage={`No ${itemLabel} found.`}
pagination={paginationState}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount,
}}
tableOptions={{
manualPagination: true,
pageCount,
@@ -328,11 +389,14 @@ const RuleEngineResourcePage = () => {
) : (
<RuleEngineCardGrid
config={config}
rows={filteredRows}
rows={rows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found.`}
itemLabel={itemLabel}
pagination={paginationState}
pagination={pagination}
pageCount={pageCount}
totalCount={totalCount}
onPaginationChange={setPagination}
readOnly={!canManage}
onEdit={canManage ? openEdit : undefined}
onDelete={canManage ? setDeleteTarget : undefined}
@@ -363,9 +427,27 @@ const RuleEngineResourcePage = () => {
(usesContainerTypeField && containerTypeOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}
onSubmit={handleFormSubmit}
/>
{config.orderConfig ? (
<ManageRuleEngineOrderDialog
open={orderDialogOpen}
onOpenChange={setOrderDialogOpen}
config={config}
items={orderListData?.data ?? []}
isLoading={orderListLoading}
isSaving={reorder.isPending}
onSave={(payload) => {
reorder.mutate(payload, {
onSuccess: () => setOrderDialogOpen(false),
});
}}
/>
) : null}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}

View File

@@ -36,6 +36,12 @@ export interface FormFieldDef {
placeholder?: string;
}
export interface RuleEngineOrderConfig {
field: "displayOrder" | "stepOrder";
scopeField?: "requiresDirectorApproval";
label: string;
}
export interface RuleEngineResourceConfig {
slug: RuleEngineResourceSlug;
label: string;
@@ -45,6 +51,7 @@ export interface RuleEngineResourceConfig {
columns: ResourceColumn[];
formFields: FormFieldDef[];
supportsSearch?: boolean;
orderConfig?: RuleEngineOrderConfig;
/** Primary line on card view (inferred from columns when omitted). */
cardTitleKey?: string;
/** Secondary line under title on card view (inferred when omitted). */
@@ -130,6 +137,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "Manage freight cargo classification and approval rules",
searchPlaceholder: "Search cargo types by name or code...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
@@ -154,7 +162,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -163,9 +170,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Configure container sizes and wagon capacity",
searchPlaceholder: "Search container types...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
activeColumn,
@@ -177,7 +186,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isReefer", label: "Reefer", type: "boolean" },
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -254,9 +262,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "Freight service offerings and booking options",
searchPlaceholder: "Search service types...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
activeColumn,
],
@@ -269,7 +279,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -350,6 +359,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Terminal and yard locations",
searchPlaceholder: "Search yards...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
@@ -361,7 +371,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "country", label: "Country", type: "text", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
@@ -436,6 +445,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardSubtitleKey: "requiredRole",
subtitle: "Multi-step booking approval chain",
searchPlaceholder: "Search approval rules...",
orderConfig: {
field: "stepOrder",
scopeField: "requiresDirectorApproval",
label: "Step order",
},
columns: [
{
id: "requiresDirectorApproval",
@@ -450,7 +464,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" },
{ name: "stepOrder", label: "Step order", type: "number", required: true },
{
name: "requiredRole",
label: "Required role",

View File

@@ -0,0 +1,921 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { isAxiosError } from "axios";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Container as ContainerIcon,
Eye,
LayoutGrid,
Package,
Route as RouteIcon,
Send,
Train,
Weight,
} from "lucide-react";
import {
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Paper,
RingProgress,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import {
autoFillPlacements,
mergePlacementsWithSaved,
placementsFromScheduleWagons,
validateLocalPlacements,
} from "@/components/trainScheduling/containerPlacement.util";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import {
useEligibleBookings,
useScheduleDetail,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type {
ContainerPlacement,
FreightType,
TrainSchedulePreviewResponse,
} from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const message = data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
const violations = data?.violations;
if (Array.isArray(violations)) return violations.join(", ");
}
return fallback;
};
export default function TrainScheduleV2DetailPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [activeStep, setActiveStep] = useState(0);
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [forceAssign, setForceAssign] = useState(false);
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const autoPreviewedRef = useRef(false);
const detailQuery = useScheduleDetail(scheduleId);
const schedule = detailQuery.data;
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
const eligibleFilters = useMemo(
() =>
schedule
? {
originStationId: schedule.originStation?.id,
destinationStationId: schedule.destinationStation?.id,
}
: undefined,
[schedule],
);
const eligibleFreightType =
freightType === "CONTAINER" || freightType === "BULK" ? freightType : undefined;
const eligibleQuery = useEligibleBookings(
eligibleFilters,
Boolean(schedule),
eligibleFreightType,
);
const { preview, assign, unassign, finalize, dispatch } = useScheduleMutations(scheduleId);
const assignedIds = useMemo(
() => (schedule?.bookings ?? []).map((b) => b.id),
[schedule?.bookings],
);
const allSelectedIds = useMemo(() => {
const merged = new Set([...assignedIds, ...selectedBookingIds]);
return [...merged];
}, [assignedIds, selectedBookingIds]);
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: freightType,
bookingFreightTypes: [
...(schedule?.bookings ?? []).map((b) => b.freightType),
...(eligibleQuery.data?.items ?? [])
.filter((item) => allSelectedIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allSelectedIds,
containerUnits.length,
eligibleQuery.data?.items,
freightType,
schedule?.bookings,
],
);
const displayWagonPlan = useMemo(() => {
const savedWagons = schedule?.trainSet?.wagons ?? [];
// Map each slot to its reserved physical wagon number (from the wagon table) so the
// plan shows real wagon ids (e.g. WGN-DEMO-001) instead of generic "Wagon #1".
const physicalBySeq = new Map(
savedWagons.map((w) => [w.sequenceNo, w.physicalWagonNumber ?? null]),
);
if (previewResult?.wagonPlan?.length) {
return previewResult.wagonPlan.map((slot) => ({
...slot,
physicalWagonNumber: physicalBySeq.get(slot.sequenceNo) ?? null,
}));
}
if (savedWagons.length) return savedWagons;
return [];
}, [previewResult?.wagonPlan, schedule?.trainSet?.wagons]);
const runPreview = useCallback(
async (options?: { silent?: boolean; advanceStep?: boolean }) => {
if (!schedule || !scheduleId) return null;
if (!allSelectedIds.length) {
if (!options?.silent) {
toast({ title: "Select at least one booking", variant: "destructive" });
}
return null;
}
const originStationId = schedule.originStation?.id;
const destinationStationId = schedule.destinationStation?.id;
if (!originStationId || !destinationStationId) {
if (!options?.silent) {
toast({ title: "Schedule missing origin or destination", variant: "destructive" });
}
return null;
}
try {
const result = await preview.mutateAsync({
freightType,
payload: {
bookingIds: allSelectedIds,
scheduleDate: schedule.scheduledDepartureDate,
originStationId,
destinationStationId,
targetScheduleId: scheduleId,
},
});
setPreviewResult(result);
if (result.containerUnits?.length && result.containerSlotSequenceNos?.length) {
const autoFilled = autoFillPlacements(
result.containerUnits,
result.containerSlotSequenceNos,
);
const saved = schedule.trainSet?.wagons
? placementsFromScheduleWagons(schedule.trainSet.wagons)
: [];
setContainerPlacements(
saved.length ? mergePlacementsWithSaved(autoFilled, saved) : autoFilled,
);
} else {
setContainerPlacements([]);
}
if (!options?.silent) {
if (!result.valid) {
toast({ title: "Preview has violations", variant: "destructive" });
} else if (options?.advanceStep !== false) {
setActiveStep(1);
}
}
return result;
} catch (err) {
if (!options?.silent) {
toast({
title: "Preview failed",
description: parseError(err, "Could not preview"),
variant: "destructive",
});
}
return null;
}
},
[allSelectedIds, freightType, preview, schedule, scheduleId, toast],
);
useEffect(() => {
if (!schedule || !scheduleId || autoPreviewedRef.current) return;
if (!assignedIds.length) return;
autoPreviewedRef.current = true;
void runPreview({ silent: true, advanceStep: false });
}, [assignedIds.length, runPreview, schedule, scheduleId]);
const savedPlacementsFromSchedule = useMemo(
() =>
schedule?.trainSet?.wagons
? placementsFromScheduleWagons(schedule.trainSet.wagons)
: [],
[schedule?.trainSet?.wagons],
);
useEffect(() => {
if (!containerUnits.length || !containerSlots.length) return;
setContainerPlacements((current) => {
if (current.length && current.some((p) => p.containerNumber?.trim())) {
return current;
}
const autoFilled = autoFillPlacements(containerUnits, containerSlots);
if (savedPlacementsFromSchedule.length) {
return mergePlacementsWithSaved(autoFilled, savedPlacementsFromSchedule);
}
if (current.length) return current;
return autoFilled;
});
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
if (detailQuery.isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
if (!schedule || !scheduleId) {
return (
<Text c="dimmed" py="xl">
Schedule not found
</Text>
);
}
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
const canDispatch = schedule.status === "SCHEDULED";
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
const handleAssign = async () => {
if (!allSelectedIds.length) return;
if (hasContainerStep) {
const issues = validateLocalPlacements(containerUnits, containerPlacements);
if (issues.length) {
toast({
title: "Complete container assignments",
description: issues.join(", "),
variant: "destructive",
});
return;
}
}
try {
const result = await assign.mutateAsync({
id: scheduleId,
freightType,
payload: {
bookingIds: allSelectedIds,
forceAssign,
containerPlacements: hasContainerStep ? containerPlacements : undefined,
},
});
toast({ title: "Bookings assigned — wagons auto-pinned" });
const refreshed = await detailQuery.refetch();
const saved = refreshed.data?.trainSet?.wagons
? placementsFromScheduleWagons(refreshed.data.trainSet.wagons)
: [];
if (saved.length) {
setContainerPlacements(saved);
}
autoPreviewedRef.current = false;
setActiveStep(finalizeStep);
if (result.deferredBookings?.length) {
toast({
title: "Partial assignment",
description: `${result.deferredBookings.length} booking(s) deferred to next train`,
});
}
} catch (err) {
toast({
title: "Assign failed",
description: parseError(err, "Could not assign"),
variant: "destructive",
});
}
};
const handleUnassign = async (bookingId: string) => {
try {
await unassign.mutateAsync({ id: scheduleId, bookingId });
toast({ title: "Booking unassigned" });
setSelectedBookingIds((ids) => ids.filter((id) => id !== bookingId));
setPreviewResult(null);
autoPreviewedRef.current = false;
} catch (err) {
toast({
title: "Unassign failed",
description: parseError(err, "Could not unassign"),
variant: "destructive",
});
}
};
const containerComplete =
hasContainerStep &&
containerUnits.length > 0 &&
validateLocalPlacements(containerUnits, containerPlacements).length === 0;
const finalizeComplete = ["SCHEDULED", "DISPATCHED", "ARRIVED"].includes(
schedule.status,
);
const stepsMeta = [
{
key: "bookings",
icon: Package,
title: "Bookings",
subtitle: "Select cargo & preview the plan",
complete: Boolean(previewResult) || assignedIds.length > 0,
},
{
key: "wagon",
icon: LayoutGrid,
title: "Wagon plan",
subtitle: "Review generated allocations",
complete: displayWagonPlan.length > 0,
},
...(hasContainerStep
? [
{
key: "container",
icon: ContainerIcon,
title: "Containers",
subtitle: "Map units to wagon slots",
complete: containerComplete,
},
]
: []),
{
key: "finalize",
icon: CheckCircle2,
title: "Finalize",
subtitle: "Lock the plan & dispatch",
complete: finalizeComplete,
},
];
const completedCount = stepsMeta.filter((s) => s.complete).length;
const progressPct = Math.round((completedCount / stepsMeta.length) * 100);
const toggleStep = (i: number) => setActiveStep((cur) => (cur === i ? -1 : i));
const renderStepRightSlot = (key: string) => {
if (key === "bookings") {
if (previewResult) {
return (
<Badge
variant="light"
color={previewResult.valid ? "green" : "red"}
radius="sm"
>
{previewResult.valid ? "Plan valid" : "Has issues"}
</Badge>
);
}
return allSelectedIds.length ? (
<Badge variant="light" color="green" radius="sm">
{allSelectedIds.length} selected
</Badge>
) : null;
}
if (key === "wagon" && displayWagonPlan.length) {
return (
<Badge variant="light" color="green" radius="sm">
{displayWagonPlan.length} wagons
</Badge>
);
}
if (key === "container" && containerUnits.length) {
return (
<Badge
variant="light"
color={containerComplete ? "green" : "yellow"}
radius="sm"
>
{containerUnits.length} units
</Badge>
);
}
if (key === "finalize") {
return <StatusPill status={schedule.status} />;
}
return null;
};
const renderStepBody = (key: string) => {
if (key === "bookings") {
return (
<Stack gap="md">
<ScheduleBookingsStep
assignedBookings={(schedule.bookings ?? []).map((b) => ({
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
selectedIds={allSelectedIds}
onSelectionChange={(ids) => {
const assigned = new Set(assignedIds);
setSelectedBookingIds(ids.filter((id) => !assigned.has(id)));
}}
assignedIds={assignedIds}
freightType={freightType}
canRemove={canModifyBookings}
onRemove={handleUnassign}
/>
{canEditBookings ? (
<Group
align="center"
justify="space-between"
wrap="wrap"
gap="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Checkbox
label="Force assign (bypass hold / overweight warnings)"
checked={forceAssign}
onChange={(e) => setForceAssign(e.currentTarget.checked)}
size="sm"
/>
<Button
color="green"
radius="md"
leftSection={<Eye size={16} />}
loading={preview.isPending}
onClick={() => void runPreview()}
>
Preview plan
</Button>
</Group>
) : null}
{previewResult ? (
<Stack gap="sm">
<ScheduleWarningsAlert
violations={previewResult.violations}
warnings={previewResult.warnings}
/>
<FleetAvailabilitySummary
fleetAvailability={previewResult.fleetAvailability}
deferredBookings={previewResult.deferredBookings}
/>
<PreviewSummary summary={previewResult.summary} />
</Stack>
) : null}
</Stack>
);
}
if (key === "wagon") {
return (
<Stack gap="md">
{!displayWagonPlan.length && !previewResult ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run a preview from the Bookings step to generate the wagon plan.
</Text>
</Paper>
) : null}
<FleetAvailabilitySummary
fleetAvailability={previewResult?.fleetAvailability}
deferredBookings={previewResult?.deferredBookings}
/>
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
<Group>
{!hasContainerStep ? (
<Button
color="green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
) : (
<Button
color="green"
radius="md"
rightSection={<ContainerIcon size={16} />}
onClick={() => setActiveStep(2)}
>
Continue to containers
</Button>
)}
<Button variant="default" radius="md" onClick={() => void runPreview()}>
Refresh preview
</Button>
</Group>
) : null}
</Stack>
);
}
if (key === "container") {
return (
<Stack gap="md">
{!containerUnits.length ? (
<Paper p="md" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed">
Run preview from the Bookings step to load container units for numbering.
</Text>
</Paper>
) : (
<ContainerPlacementGrid
units={containerUnits}
containerSlots={containerSlots}
placements={containerPlacements}
onChange={setContainerPlacements}
/>
)}
{canEditBookings ? (
<Group>
<Button
color="green"
radius="md"
loading={assign.isPending}
onClick={handleAssign}
>
{assignedIds.length ? "Save assignments" : "Assign bookings"}
</Button>
<Button
variant="default"
radius="md"
onClick={() => setActiveStep(finalizeStep)}
>
Skip to finalize
</Button>
</Group>
) : null}
</Stack>
);
}
// finalize
return (
<Stack gap="md">
<TrainCompositionDiagram
locomotive={schedule.trainSet?.locomotive}
wagons={
schedule.trainSet?.wagons?.length
? schedule.trainSet.wagons
: displayWagonPlan
}
freightType={freightType}
trainNumber={schedule.trainNumber}
totalLengthMeters={schedule.trainSet?.totalLengthMeters}
/>
<Paper
p="lg"
radius="lg"
withBorder
style={{
background: scheduleBrand.softSurface,
borderColor: scheduleBrand.mutedBorder,
}}
>
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon size={44} radius="md" variant="light" color="green">
<CheckCircle2 size={22} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={600}>Ready to depart</Text>
<Text size="sm" c="dimmed">
Finalizing locks the plan and moves the schedule to{" "}
<Text span fw={600} c="green.7">
SCHEDULED
</Text>
. Dispatch then begins rail movement and notifies the yard.
</Text>
</Stack>
</Group>
</Paper>
<Group>
{canFinalize ? (
<Button
color="green"
size="md"
radius="md"
leftSection={<CheckCircle2 size={18} />}
loading={finalize.isPending}
onClick={async () => {
try {
await finalize.mutateAsync(scheduleId);
toast({ title: "Schedule finalized" });
} catch (err) {
toast({
title: "Finalize failed",
description: parseError(err, "Could not finalize"),
variant: "destructive",
});
}
}}
>
Finalize schedule
</Button>
) : null}
{canDispatch ? (
<Button
color="green"
size="md"
radius="md"
leftSection={<Send size={18} />}
loading={dispatch.isPending}
onClick={async () => {
try {
await dispatch.mutateAsync(scheduleId);
toast({ title: "Train dispatched" });
} catch (err) {
toast({
title: "Dispatch failed",
description: parseError(err, "Could not dispatch"),
variant: "destructive",
});
}
}}
>
Dispatch train
</Button>
) : null}
{!canFinalize && !canDispatch ? (
<Text size="sm" c="dimmed">
No actions available for this schedule status.
</Text>
) : null}
</Group>
</Stack>
);
};
return (
<Stack gap="lg">
<Button
component={Link}
to="/dashboard/operations/train-scheduling-v2"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to schedules
</Button>
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
<Box
style={{
position: "absolute",
top: -90,
right: -50,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} c="white" fw={700}>
{schedule.route?.name ?? "Train schedule"}
</Title>
{schedule.trainNumber ? (
<Badge
variant="white"
c="green.8"
radius="sm"
style={{ fontWeight: 600 }}
>
{schedule.trainNumber}
</Badge>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
onDark
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
</Group>
</Stack>
</Group>
{schedule.status !== "DISPATCHED" ? (
<Button
variant="white"
c="green.8"
radius="lg"
size="sm"
onClick={() => setMaintenanceOpen(true)}
>
Reschedule train
</Button>
) : null}
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile
onDark
icon={Train}
label="Locomotive"
value={schedule.trainSet?.locomotive?.code ?? "—"}
/>
<StatTile
onDark
icon={Package}
label="Bookings"
value={schedule.bookings?.length ?? 0}
/>
<StatTile
onDark
icon={Weight}
label="Wagons / load"
value={`${schedule.trainSet?.wagonCount ?? displayWagonPlan.length} · ${
schedule.trainSet?.totalWeightTons ?? 0
}T`}
/>
<StatTile
onDark
icon={CalendarClock}
label="Departure"
value={new Date(schedule.scheduledDepartureDate).toLocaleDateString(
"en",
{ month: "short", day: "2-digit" },
)}
hint={new Date(schedule.scheduledDepartureDate).toLocaleTimeString("en", {
hour: "2-digit",
minute: "2-digit",
})}
/>
</SimpleGrid>
{previewResult ? (
<Badge
size="lg"
radius="sm"
variant="white"
c={previewResult.valid ? "green.8" : "red.7"}
leftSection={
<Box
w={8}
h={8}
style={{
borderRadius: 999,
background: previewResult.valid
? "var(--mantine-color-green-6)"
: "var(--mantine-color-red-6)",
}}
/>
}
>
Preview {previewResult.valid ? "valid" : "has issues"}
</Badge>
) : null}
</Stack>
</Paper>
<Paper radius="xl" p="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="lg">
{/* Workflow header with ring progress */}
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="gradient"
gradient={{ from: "green", to: "teal", deg: 135 }}
>
<RouteIcon size={22} />
</ThemeIcon>
<Stack gap={2}>
<Title order={4} fw={700}>
Scheduling workflow
</Title>
<Text size="sm" c="dimmed">
{completedCount} of {stepsMeta.length} steps complete · expand any
step to edit
</Text>
</Stack>
</Group>
<RingProgress
size={64}
thickness={6}
roundCaps
sections={[{ value: progressPct, color: "green" }]}
label={
<Text ta="center" size="xs" fw={700} c="green.7">
{progressPct}%
</Text>
}
/>
</Group>
<WorkflowRail>
{stepsMeta.map((step, index) => (
<WorkflowStep
key={step.key}
index={index}
icon={step.icon}
title={step.title}
subtitle={step.subtitle}
state={
activeStep === index
? "active"
: step.complete
? "complete"
: "upcoming"
}
open={activeStep === index}
onToggle={() => toggleStep(index)}
rightSlot={renderStepRightSlot(step.key)}
>
{renderStepBody(step.key)}
</WorkflowStep>
))}
</WorkflowRail>
</Stack>
</Paper>
{scheduleId ? (
<RescheduleTrainDialog
scheduleId={scheduleId}
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
opened={maintenanceOpen}
onClose={() => setMaintenanceOpen(false)}
onComplete={() => void detailQuery.refetch()}
/>
) : null}
</Stack>
);
}

View File

@@ -0,0 +1,687 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
Button,
Card,
Group,
Modal,
Paper,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { ArrowRight, CalendarClock, Send, Train, Weight } from "lucide-react";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
StatTile,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRoutes } from "@/hooks/useRoutes";
import {
useAvailableLocomotives,
useScheduleList,
useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const splitDate = (value?: string | null) => {
if (!value) return { day: "—", time: "" };
const date = new Date(value);
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
return {
day: new Intl.DateTimeFormat("en", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(date),
time: new Intl.DateTimeFormat("en", {
hour: "2-digit",
minute: "2-digit",
}).format(date),
};
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
export default function TrainScheduleV2ListPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [createOpen, setCreateOpen] = useState(false);
const [routeId, setRouteId] = useState("");
const [scheduleDate, setScheduleDate] = useState("");
const [locomotiveId, setLocomotiveId] = useState("");
const schedulesQuery = useScheduleList();
const routesQuery = useRoutes();
const locomotivesQuery = useAvailableLocomotives();
const { create, cancel } = useScheduleMutations();
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.isActive),
[routesQuery.data],
);
const allSchedules = schedulesQuery.data ?? [];
const stats = useMemo(() => {
const base = {
total: allSchedules.length,
scheduled: 0,
dispatched: 0,
draft: 0,
weight: 0,
};
for (const s of allSchedules) {
if (s.status === "SCHEDULED") base.scheduled += 1;
if (s.status === "DISPATCHED") base.dispatched += 1;
if (s.status === "DRAFT") base.draft += 1;
base.weight += s.totalWeightTons ?? 0;
}
return base;
}, [allSchedules]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (!query) return true;
const haystack = [
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
s.freightType,
s.status,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [allSchedules, search, statusFilter, freightFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "date",
header: "Departure",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.scheduleDate);
return (
<Group gap="sm" wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: 9,
background: "var(--mantine-color-green-0)",
color: "var(--mantine-color-green-7)",
flexShrink: 0,
}}
>
<CalendarClock size={16} />
</Box>
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "route",
header: "Route",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
destination={row.original.destination}
variant="compact"
/>
</Box>
</Stack>
),
},
{
id: "freight",
header: "Freight",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <FreightTypeBadge freightType={row.original.freightType} />,
},
{
id: "loco",
header: "Locomotive",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
row.original.locomotive?.code ? (
<Group gap={6} wrap="nowrap">
<Train size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{row.original.locomotive.code}
</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
</Text>
),
},
{
id: "metrics",
header: "Load",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<MetricChip value={row.original.bookingsCount} label="bkg" />
<MetricChip value={row.original.wagonCount} label="wgn" />
<MetricChip value={`${row.original.totalWeightTons}T`} label="" subtle />
</Group>
),
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
id: "actions",
header: "Actions",
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => (
<Group gap={6} justify="flex-end" wrap="nowrap">
<Button
variant="light"
color="green"
size="compact-sm"
rightSection={<ArrowRight size={14} />}
onClick={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
}
>
Open
</Button>
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
<Button
variant="subtle"
color="red"
size="compact-sm"
loading={cancel.isPending}
onClick={async () => {
try {
await cancel.mutateAsync({
id: row.original.id,
freightType: row.original.freightType ?? "CONTAINER",
});
toast({ title: "Schedule cancelled" });
} catch (err) {
toast({
title: "Cancel failed",
description: parseError(err, "Could not cancel"),
variant: "destructive",
});
}
}}
>
Cancel
</Button>
) : null}
</Group>
),
},
];
}, [navigate, cancel.isPending, cancel, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !locomotiveId) {
toast({ title: "Select route, date, and locomotive", variant: "destructive" });
return;
}
try {
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveId },
});
toast({ title: "Train schedule created" });
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
toast({
title: "Create failed",
description: parseError(err, "Could not create schedule"),
variant: "destructive",
});
}
};
const tableStatus = schedulesQuery.isLoading
? "loading"
: schedulesQuery.isError
? "error"
: "success";
return (
<Stack gap="lg">
{/* Hero banner */}
<Paper
radius="xl"
p="xl"
style={{
position: "relative",
overflow: "hidden",
background: scheduleBrand.heroGradient,
boxShadow: scheduleBrand.shadow,
}}
>
{/* decorative glow */}
<Box
style={{
position: "absolute",
top: -90,
right: -60,
width: 280,
height: 280,
borderRadius: "50%",
background: "rgba(255,255,255,0.12)",
filter: "blur(8px)",
pointerEvents: "none",
}}
/>
<Box
style={{
position: "absolute",
bottom: -120,
right: 120,
width: 220,
height: 220,
borderRadius: "50%",
background: "rgba(255,255,255,0.06)",
pointerEvents: "none",
}}
/>
<Stack gap="lg" style={{ position: "relative" }}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="center" wrap="nowrap">
<ThemeIcon
size={56}
radius="lg"
variant="white"
style={{ color: "var(--mantine-color-green-7)" }}
>
<Train size={28} />
</ThemeIcon>
<Stack gap={4}>
<Title order={2} c="white" fw={700}>
Train Schedules
</Title>
<Text size="sm" c="rgba(255,255,255,0.85)" maw={520}>
Plan departures, allocate bookings, and dispatch trains across
every corridor.
</Text>
</Stack>
</Group>
<Button
size="md"
radius="lg"
variant="white"
c="green.8"
leftSection={<Train size={18} />}
onClick={() => setCreateOpen(true)}
>
New schedule
</Button>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
<StatTile onDark icon={Train} label="Total trains" value={stats.total} />
<StatTile
onDark
icon={CalendarClock}
label="Scheduled"
value={stats.scheduled}
/>
<StatTile onDark icon={Send} label="Dispatched" value={stats.dispatched} />
<StatTile
onDark
icon={Weight}
label="Planned load"
value={`${Math.round(stats.weight)}T`}
/>
</SimpleGrid>
</Stack>
</Paper>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search schedules…"
addLabel="Create schedule"
onAdd={() => setCreateOpen(true)}
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<>
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "CANCELLED", label: "Cancelled" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={freightFilter}
onChange={(v) => v && setFreightFilter(v)}
data={[
{ value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
{ value: "MIXED", label: "Mixed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</>
}
/>
</Box>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
status={tableStatus}
emptyMessage="No train schedules found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
}}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{ labels: { items: "schedules" } }}
/>
)}
/>
) : (
<Stack gap={0}>
{!paged.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
No train schedules found
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{paged.map((schedule) => (
<ScheduleCard
key={schedule.id}
schedule={schedule}
onOpen={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}`,
)
}
/>
))}
</SimpleGrid>
)}
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filtered.length}
itemLabel="schedules"
onPaginationChange={setPagination}
/>
</Stack>
)}
</Stack>
</Card>
<Modal
opened={createOpen}
onClose={() => setCreateOpen(false)}
title={<Text fw={600}>Create train schedule</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Schedules support both container and bulk bookings once assigned.
</Text>
<Select
label="Route"
placeholder="Select route"
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
value={routeId || null}
onChange={(v) => setRouteId(v ?? "")}
searchable
/>
<TextInput
label="Departure date"
type="datetime-local"
value={scheduleDate ? scheduleDate.slice(0, 16) : ""}
onChange={(e) => {
const raw = e.currentTarget.value;
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/>
<Select
label="Locomotive"
placeholder="Select locomotive"
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
value={locomotiveId || null}
onChange={(v) => setLocomotiveId(v ?? "")}
searchable
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button color="green" loading={create.isPending} onClick={handleCreate}>
Create
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
function MetricChip({
value,
label,
subtle = false,
}: {
value: string | number;
label: string;
subtle?: boolean;
}) {
return (
<Group
gap={4}
wrap="nowrap"
style={{
padding: "2px 8px",
borderRadius: 8,
background: subtle
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-green-0)",
border: `1px solid ${
subtle ? "var(--mantine-color-gray-2)" : "var(--mantine-color-green-1)"
}`,
}}
>
<Text size="sm" fw={700} c={subtle ? "gray.7" : "green.8"} lh={1.2}>
{value}
</Text>
{label ? (
<Text size="xs" c="dimmed" lh={1.2}>
{label}
</Text>
) : null}
</Group>
);
}
function ScheduleCard({
schedule,
onOpen,
}: {
schedule: TrainScheduleListItem;
onOpen: () => void;
}) {
const { day, time } = splitDate(schedule.scheduleDate);
return (
<Card
radius="lg"
padding={0}
withBorder
onClick={onOpen}
style={{
cursor: "pointer",
overflow: "hidden",
borderColor: "var(--mantine-color-gray-2)",
transition: "box-shadow 150ms ease, transform 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = scheduleBrand.shadowSm;
e.currentTarget.style.transform = "translateY(-2px)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "";
e.currentTarget.style.transform = "";
}}
>
{/* accent strip */}
<Box style={{ height: 4, background: scheduleBrand.heroGradient }} />
<Stack gap="sm" p="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={38} radius="md" variant="light" color="green">
<Train size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
<Text size="xs" c="dimmed">
{day} · {time}
</Text>
</Stack>
</Group>
<StatusPill status={schedule.status} />
</Group>
<Box
p="xs"
style={{
borderRadius: 10,
background: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-1)",
}}
>
<RouteCorridor origin={schedule.origin} destination={schedule.destination} />
</Box>
<Group justify="space-between" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" />
<MetricChip value={`${schedule.totalWeightTons}T`} label="" subtle />
</Group>
</Group>
<Button
variant="light"
color="green"
size="sm"
radius="md"
fullWidth
rightSection={<ArrowRight size={15} />}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
Open schedule
</Button>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { Button, Card, Group, NumberInput, Stack, Text, Title } from "@mantine/core";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
useEffect(() => {
void (async () => {
try {
const rules = await trainSchedulingService.getGlobalRules();
setForm(rules);
} catch {
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
} finally {
setLoading(false);
}
})();
}, [toast]);
const handleSave = async () => {
setSaving(true);
try {
const updated = await trainSchedulingService.updateGlobalRules({
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
maxTrainWeightTons: Number(form.maxTrainWeightTons),
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
});
setForm(updated);
toast({ title: "Train scheduling rules saved" });
} catch {
toast({ title: "Failed to save rules", variant: "destructive" });
} finally {
setSaving(false);
}
};
return (
<Stack gap="lg" maw={720}>
<Stack gap={4}>
<Title order={3}>Train scheduling rules</Title>
<Text size="sm" c="dimmed">
Global limits applied when previewing and assigning bookings to trains.
</Text>
</Stack>
<Card radius="xl" padding="lg" withBorder>
<Stack gap="md">
<NumberInput
label="Max train length (m)"
description="Sum of all wagon lengths must not exceed this"
value={form.maxTrainLengthMeters ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Max train weight (T)"
description="Total container and bulk cargo weight must not exceed this"
value={form.maxTrainWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Max 20ft container weight (T)"
description="Each individual 20ft container gross weight limit"
value={form.max20ftContainerWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftContainerWeightTons: Number(value),
}))
}
min={0.001}
disabled={loading}
/>
<NumberInput
label="Max 20ft pair weight difference (T)"
description="When two 20ft containers share a wagon, |weight1 weight2| must not exceed this"
value={form.max20ftPairWeightDiffTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftPairWeightDiffTons: Number(value),
}))
}
min={0}
disabled={loading}
/>
<Group justify="flex-end">
<Button color="teal" loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules
</Button>
</Group>
</Stack>
</Card>
</Stack>
);
}

View File

@@ -1,36 +1,110 @@
import { useParams } from 'react-router-dom';
import { useTrain } from '@/hooks/useTrains';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
// import { Skeleton } from '@/components/ui/skeleton';
// import { AssignWagonDialog } from '@/components/AssignWagonDialog';
// import { WagonsTable } from '@/components/WagonsTable';
import { Card, CardContent, CardHeader, CardTitle, Skeleton } from '@edr/ui-common';
import { AssignWagonDialog } from '@/components/wagons/AssignWagonDialog';
import { WagonsTable } from '@/components/wagons/WagonsTable';
import { useParams, Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Badge, Button, Card, Group, Loader, SimpleGrid, Stack, Text } from "@mantine/core";
import { AssignWagonDialog } from "@/components/wagons/AssignWagonDialog";
import { WagonsTable } from "@/components/wagons/WagonsTable";
import { useTrain } from "@/hooks/useTrains";
export default function TrainDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: train, isLoading } = useTrain(id!);
if (isLoading) return <Skeleton className="h-96 w-full" />;
if (!train) return <div>Train not found</div>;
if (isLoading) {
return (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
);
}
if (!train) {
return (
<Text c="dimmed" py="xl">
Train not found
</Text>
);
}
const title = train.trainNumber || train.code;
const subtitle = train.trainName || "Unnamed train";
return (
<div className="space-y-6">
<Card>
<CardHeader><CardTitle>{train.trainNumber || train.code} - {train.trainName || 'Unnamed'}</CardTitle></CardHeader>
<CardContent className="grid md:grid-cols-2 gap-4">
<div><span className="font-medium">Status:</span> {train.status}</div>
<div><span className="font-medium">Capacity:</span> {train.capacityTons} tons</div>
<div><span className="font-medium">Origin Station:</span> {train.originStationId || '-'}</div>
<div><span className="font-medium">Destination:</span> {train.destinationStationId || '-'}</div>
</CardContent>
<Stack gap="md">
<Button
component={Link}
to="/dashboard/trains"
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
>
Back to trains
</Button>
<Card radius="lg" padding="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<Stack gap={4}>
<Text fw={700} size="lg">
{title}
</Text>
<Text size="sm" c="dimmed">
{subtitle}
</Text>
</Stack>
<Badge variant="light" color="gray" size="lg">
{train.status}
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
<Stack gap={2}>
<Text size="xs" c="dimmed">
Code
</Text>
<Text size="sm" fw={500}>
{train.code}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Capacity
</Text>
<Text size="sm" fw={500}>
{train.capacityTons} tons
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Locomotive
</Text>
<Text size="sm" fw={500}>
{train.locomotiveNumber || "—"}
</Text>
</Stack>
<Stack gap={2}>
<Text size="xs" c="dimmed">
Origin station
</Text>
<Text size="sm" fw={500}>
{train.originStationId || "—"}
</Text>
</Stack>
</SimpleGrid>
</Stack>
</Card>
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold">Wagons</h2>
<AssignWagonDialog trainId={train.id} />
</div>
<WagonsTable trainId={train.id} />
</div>
<Card radius="lg" padding="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap="md">
<Group justify="space-between" align="center">
<Text fw={600}>Assigned wagons</Text>
<AssignWagonDialog trainId={train.id} />
</Group>
<WagonsTable trainId={train.id} />
</Stack>
</Card>
</Stack>
);
}
}

View File

@@ -1,109 +0,0 @@
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useWagonTypes } from '@/hooks/use-wagon-types';
const wagonSchema = z.object({
wagonNumber: z.string().min(1, 'Required'),
wagonTypeId: z.string().min(1, 'Required'),
maxPayloadWeight: z.coerce.number().min(0),
});
type WagonFormValues = z.infer<typeof wagonSchema>;
interface WagonFormProps {
initialValues?: Partial<WagonFormValues>;
onSubmit: (values: WagonFormValues) => void;
}
export function WagonForm({ initialValues, onSubmit }: WagonFormProps) {
const { data: wagonTypes, isLoading: loadingTypes } = useWagonTypes();
const form = useForm<WagonFormValues>({
resolver: zodResolver(wagonSchema),
defaultValues: {
wagonNumber: initialValues?.wagonNumber || '',
wagonTypeId: initialValues?.wagonTypeId || '',
maxPayloadWeight: initialValues?.maxPayloadWeight || 0,
},
});
const selectedTypeId = form.watch('wagonTypeId');
// Autofill maxPayloadWeight when type changes
useEffect(() => {
if (selectedTypeId && wagonTypes) {
const type = wagonTypes.find((t) => t.id === selectedTypeId);
if (type) {
// Only autofill if it's a new selection and field is at default or empty
const currentWeight = form.getValues('maxPayloadWeight');
if (!initialValues?.wagonTypeId || selectedTypeId !== initialValues.wagonTypeId) {
form.setValue('maxPayloadWeight', Number(type.capacityTons));
}
}
}
}, [selectedTypeId, wagonTypes, form, initialValues?.wagonTypeId]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="wagonNumber"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Number</FormLabel>
<FormControl>
<Input placeholder="e.g. W12345" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="wagonTypeId"
render={({ field }) => (
<FormItem>
<FormLabel>Wagon Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={loadingTypes}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{wagonTypes?.map((type) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="maxPayloadWeight"
render={({ field }) => (
<FormItem>
<FormLabel>Max Payload Weight (Tons)</FormLabel>
<FormControl>
<Input type="number" step="0.001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}

View File

@@ -1,31 +0,0 @@
import { useWagons } from '@/hooks/useWagons';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
// import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@edr/ui-common';
export default function WagonsPage() {
const { data: wagons, isLoading } = useWagons();
if (isLoading) return <div>Loading wagons...</div>;
return (
<Card>
<CardHeader><CardTitle>All Wagons</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Number</TableHead><TableHead>Type</TableHead><TableHead>Train</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{wagons?.map((w:any) => (
<TableRow key={w.id}>
<TableCell>{w.wagonNumber}</TableCell>
<TableCell>{w.wagonTypeId}</TableCell>
<TableCell>{w.trainId || 'Unassigned'}</TableCell>
<TableCell><Badge variant="outline">{w.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}