mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
Inbox,
|
||||
LayoutList,
|
||||
Package,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
User,
|
||||
@@ -18,15 +19,12 @@ 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";
|
||||
@@ -38,12 +36,19 @@ import {
|
||||
import { BookingStatGrid } from "@/components/bookings/BookingStatGrid";
|
||||
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";
|
||||
@@ -63,11 +68,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 +87,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,
|
||||
@@ -123,6 +167,18 @@ export default function BookingRequestsPage() {
|
||||
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;
|
||||
@@ -394,12 +450,53 @@ export default function BookingRequestsPage() {
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="filled"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => navigate("/dashboard/booking-requests/new")}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</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 +535,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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,732 +0,0 @@
|
||||
import { FormEvent, ReactNode, useMemo, useState } from 'react';
|
||||
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useCargoTypes } from '@/hooks/use-cargo-types';
|
||||
import { useContainerTypes } from '@/hooks/use-container-types';
|
||||
import { useWagonTypes } from '@/hooks/use-wagon-types';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes';
|
||||
import {
|
||||
useContainers,
|
||||
useCreateContainer,
|
||||
useDeleteContainer,
|
||||
useUpdateContainer,
|
||||
} from '@/hooks/useContainers';
|
||||
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
|
||||
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
|
||||
import {
|
||||
useCreateLocomotive,
|
||||
useDecommissionLocomotive,
|
||||
useLocomotives,
|
||||
useUpdateLocomotive,
|
||||
} from '@/hooks/useLocomotives';
|
||||
import type { Cargo } from '@/services/cargoService';
|
||||
import type { Container } from '@/services/containerService';
|
||||
import type { Locomotive } from '@/services/locomotives.service';
|
||||
import type { Train } from '@/services/trains.service';
|
||||
import type { Wagon } from '@/services/wagon.service';
|
||||
|
||||
type FormValue = string | number;
|
||||
|
||||
type Field = {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'text' | 'number' | 'select';
|
||||
required?: boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
onValueChange?: (
|
||||
value: string,
|
||||
current: Record<string, FormValue>,
|
||||
) => Partial<Record<string, FormValue>>;
|
||||
};
|
||||
|
||||
type Column<T> = {
|
||||
key: keyof T | string;
|
||||
label: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
};
|
||||
|
||||
type FleetCrudPageProps<T extends { id: string }> = {
|
||||
title: string;
|
||||
description: string;
|
||||
addLabel: string;
|
||||
entityLabel?: string;
|
||||
data?: T[];
|
||||
isLoading: boolean;
|
||||
columns: Column<T>[];
|
||||
fields: Field[];
|
||||
emptyValues: Record<string, FormValue>;
|
||||
searchText: (item: T) => string;
|
||||
create: { mutateAsync: (data: Record<string, unknown>) => Promise<unknown>; isPending: boolean };
|
||||
update: { mutateAsync: (data: { id: string; data: Record<string, unknown> }) => Promise<unknown>; isPending: boolean };
|
||||
remove: { mutateAsync: (id: string) => Promise<unknown>; isPending: boolean };
|
||||
removeActionLabel?: string;
|
||||
removeConfirmMessage?: string;
|
||||
removeSuccessMessage?: string;
|
||||
hideViewAction?: boolean;
|
||||
};
|
||||
|
||||
const normalizePayload = (values: Record<string, FormValue>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
|
||||
.filter(([, value]) => value !== ''),
|
||||
);
|
||||
|
||||
const extractBackendErrors = (error: unknown) => {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
const data = responseData && typeof responseData === 'object' ? responseData as Record<string, unknown> : undefined;
|
||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||
const rawErrors = data?.errors;
|
||||
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
if (rawErrors && typeof rawErrors === 'object' && !Array.isArray(rawErrors)) {
|
||||
Object.entries(rawErrors as Record<string, unknown>).forEach(([field, value]) => {
|
||||
fieldErrors[field] = Array.isArray(value) ? value.join(', ') : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
const message = Array.isArray(rawMessage)
|
||||
? rawMessage.join(', ')
|
||||
: rawMessage
|
||||
? String(rawMessage)
|
||||
: 'Save failed';
|
||||
|
||||
return { message, fieldErrors };
|
||||
};
|
||||
|
||||
const validateForm = (fields: Field[], values: Record<string, FormValue>) => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
fields.forEach((field) => {
|
||||
const value = values[field.key];
|
||||
const stringValue = typeof value === 'string' ? value.trim() : String(value ?? '');
|
||||
|
||||
if (field.required && stringValue === '') {
|
||||
errors[field.key] = `${field.label} is required`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === 'number' && stringValue !== '' && !Number.isFinite(Number(value))) {
|
||||
errors[field.key] = `${field.label} must be a valid number`;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
function FleetCrudPage<T extends { id: string }>({
|
||||
title,
|
||||
description,
|
||||
addLabel,
|
||||
entityLabel,
|
||||
data,
|
||||
isLoading,
|
||||
columns,
|
||||
fields,
|
||||
emptyValues,
|
||||
searchText,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
removeActionLabel = 'Delete',
|
||||
removeConfirmMessage,
|
||||
removeSuccessMessage,
|
||||
hideViewAction = false,
|
||||
}: FleetCrudPageProps<T>) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<string>('');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<T | null>(null);
|
||||
const [viewing, setViewing] = useState<T | null>(null);
|
||||
const [form, setForm] = useState(emptyValues);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const { toast } = useToast();
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return data ?? [];
|
||||
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
|
||||
}, [data, search, searchText]);
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return filtered;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const left = (a as Record<string, unknown>)[sortKey];
|
||||
const right = (b as Record<string, unknown>)[sortKey];
|
||||
const result = String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true });
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
}, [filtered, sortDirection, sortKey]);
|
||||
const pageSize = 10;
|
||||
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
const toggleSort = (key: string) => {
|
||||
setPage(1);
|
||||
if (sortKey === key) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(key);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyValues);
|
||||
setFieldErrors({});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: T) => {
|
||||
setEditing(item);
|
||||
setForm(
|
||||
Object.fromEntries(
|
||||
Object.keys(emptyValues).map((key) => [key, (item as Record<string, string | number | null | undefined>)[key] ?? '']),
|
||||
),
|
||||
);
|
||||
setFieldErrors({});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyValues);
|
||||
setFieldErrors({});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const validationErrors = validateForm(fields, form);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setFieldErrors(validationErrors);
|
||||
toast({
|
||||
title: 'Save failed',
|
||||
description: Object.values(validationErrors)[0],
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = normalizePayload(form);
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await update.mutateAsync({ id: editing.id, data: payload });
|
||||
toast({ title: `${title.slice(0, -1)} updated` });
|
||||
} else {
|
||||
await create.mutateAsync(payload);
|
||||
toast({ title: `${title.slice(0, -1)} created` });
|
||||
}
|
||||
closeForm();
|
||||
} catch (error) {
|
||||
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
|
||||
setFieldErrors(backendFieldErrors);
|
||||
toast({ title: 'Save failed', description: message, variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (item: T) => {
|
||||
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
|
||||
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
|
||||
try {
|
||||
await remove.mutateAsync(item.id);
|
||||
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
|
||||
} catch {
|
||||
toast({ title: `${removeActionLabel} failed`, description: 'This record may still be referenced.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const isSaving = create.isPending || update.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">{title}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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 ${title.toLowerCase()}`}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={String(column.key)}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 font-medium"
|
||||
onClick={() => toggleSort(String(column.key))}
|
||||
>
|
||||
{column.label}
|
||||
{sortKey === column.key ? (sortDirection === 'asc' ? 'ASC' : 'DESC') : null}
|
||||
</button>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="w-[150px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paged.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={String(column.key)}>
|
||||
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1">
|
||||
{!hideViewAction ? (
|
||||
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
|
||||
<Edit className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!isLoading && filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
|
||||
No records found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={(open) => (!open ? closeForm() : setFormOpen(true))}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? `Edit ${title.slice(0, -1)}` : addLabel}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{fields.map((field) => {
|
||||
const value = form[field.key] ?? '';
|
||||
const inputValue = field.type === 'number' && value !== '' && !Number.isFinite(Number(value))
|
||||
? ''
|
||||
: value;
|
||||
return (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={field.key}>{field.label}</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(selectedValue) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[field.key]: selectedValue,
|
||||
...(field.onValueChange?.(selectedValue, current) ?? {}),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={field.key}>
|
||||
<SelectValue placeholder={field.placeholder ?? `Select ${field.label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type ?? 'text'}
|
||||
value={inputValue}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[field.key]: field.type === 'number' && event.target.value !== ''
|
||||
? Number(event.target.value)
|
||||
: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{fieldErrors[field.key] ? (
|
||||
<p className="text-sm text-destructive">{fieldErrors[field.key]}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={closeForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title.slice(0, -1)} details</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 text-sm">
|
||||
{viewing
|
||||
? Object.entries(viewing).map(([key, value]) => (
|
||||
<div key={key} className="grid grid-cols-[150px,1fr] gap-3 border-b pb-2">
|
||||
<span className="font-medium">{key}</span>
|
||||
<span className="break-all text-muted-foreground">{value == null ? '-' : String(value)}</span>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
|
||||
|
||||
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
|
||||
options.find((option) => option.value === value)?.label ?? value ?? '-';
|
||||
|
||||
export function TrainMasterDataPage() {
|
||||
const query = useTrains();
|
||||
return (
|
||||
<FleetCrudPage<Train>
|
||||
title="Trains"
|
||||
description="Manage train master data independently from train scheduling."
|
||||
addLabel="Add Train"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateTrain()}
|
||||
update={useUpdateTrain()}
|
||||
remove={useDeleteTrain()}
|
||||
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'trainNumber', label: 'Number', render: (train) => train.trainNumber || '-' },
|
||||
{ key: 'trainName', label: 'Name', render: (train) => train.trainName || '-' },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)' },
|
||||
{ key: 'status', label: 'Status', render: (train) => statusBadge(train.status) },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
|
||||
{ key: 'trainNumber', label: 'Train number' },
|
||||
{ key: 'trainName', label: 'Train name' },
|
||||
{ key: 'locomotiveNumber', label: 'Locomotive number' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'remarks', label: 'Remarks' },
|
||||
]}
|
||||
emptyValues={{ code: '', capacityTons: 0, trainNumber: '', trainName: '', locomotiveNumber: '', status: 'AVAILABLE', notes: '', remarks: '' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WagonsCrudPage() {
|
||||
const query = useWagons();
|
||||
const { data: wagonTypes = [] } = useWagonTypes();
|
||||
const wagonTypeOptions = wagonTypes.map((type: any) => ({
|
||||
value: type.id,
|
||||
label: `${type.code} - ${type.name}`,
|
||||
}));
|
||||
return (
|
||||
<FleetCrudPage<Wagon>
|
||||
title="Wagons"
|
||||
description="Manage wagon master data. Booking-based train assignment is handled in train scheduling."
|
||||
addLabel="Add Wagon"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateWagon()}
|
||||
update={useUpdateWagon()}
|
||||
remove={useDeleteWagon()}
|
||||
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
|
||||
columns={[
|
||||
{ key: 'wagonNumber', label: 'Number' },
|
||||
{ key: 'wagonTypeId', label: 'Type', render: (wagon) => optionLabel(wagonTypeOptions, wagon.wagonTypeId) },
|
||||
{ key: 'maxPayloadWeight', label: 'Max payload' },
|
||||
{ key: 'status', label: 'Status', render: (wagon) => statusBadge(wagon.status) },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'wagonNumber', label: 'Wagon number', required: true },
|
||||
{
|
||||
key: 'wagonTypeId',
|
||||
label: 'Wagon type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: wagonTypeOptions,
|
||||
onValueChange: (value, current) => {
|
||||
const selectedType = wagonTypes.find((type: any) => type.id === value);
|
||||
if (!selectedType || Number(current.maxPayloadWeight) > 0) return {};
|
||||
return { maxPayloadWeight: Number(selectedType.capacityTons) };
|
||||
},
|
||||
},
|
||||
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
|
||||
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
]}
|
||||
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContainersCrudPage() {
|
||||
const query = useContainers();
|
||||
const { data: containerTypes = [] } = useContainerTypes();
|
||||
const { data: wagons = [] } = useWagons();
|
||||
const containerTypeOptions = containerTypes.map((type: any) => ({
|
||||
value: type.id,
|
||||
label: type.label ?? type.name ?? type.code,
|
||||
}));
|
||||
const wagonOptions = wagons.map((wagon: Wagon) => ({
|
||||
value: wagon.id,
|
||||
label: wagon.wagonNumber,
|
||||
}));
|
||||
return (
|
||||
<FleetCrudPage<Container>
|
||||
title="Containers"
|
||||
description="Manage container master data and wagon assignments."
|
||||
addLabel="Add Container"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateContainer()}
|
||||
update={useUpdateContainer()}
|
||||
remove={useDeleteContainer()}
|
||||
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
|
||||
columns={[
|
||||
{ key: 'containerNumber', label: 'Number' },
|
||||
{ key: 'containerTypeId', label: 'Type', render: (container) => optionLabel(containerTypeOptions, container.containerTypeId) },
|
||||
{ key: 'wagonId', label: 'Wagon', render: (container) => optionLabel(wagonOptions, container.wagonId) },
|
||||
{ key: 'maxGrossWeight', label: 'Max gross' },
|
||||
{ key: 'status', label: 'Status', render: (container) => statusBadge(container.status) },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'containerNumber', label: 'Container number', required: true },
|
||||
{
|
||||
key: 'containerTypeId',
|
||||
label: 'Container type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: containerTypeOptions,
|
||||
},
|
||||
{
|
||||
key: 'wagonId',
|
||||
label: 'Wagon',
|
||||
type: 'select',
|
||||
options: [{ value: 'none', label: 'Unassigned' }, ...wagonOptions],
|
||||
onValueChange: (value) => (value === 'none' ? { wagonId: '' } : {}),
|
||||
},
|
||||
{ key: 'position', label: 'Position', type: 'number' },
|
||||
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
|
||||
{ key: 'maxGrossWeight', label: 'Max gross weight', type: 'number', required: true },
|
||||
{ key: 'sealNumber', label: 'Seal number' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]}
|
||||
emptyValues={{ containerNumber: '', containerTypeId: '', wagonId: '', position: '', tareWeight: 0, maxGrossWeight: 0, sealNumber: '', status: 'AVAILABLE' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CargoesCrudPage() {
|
||||
const query = useCargoes();
|
||||
const { data: cargoTypes = [] } = useCargoTypes();
|
||||
const { data: containers = [] } = useContainers();
|
||||
const cargoTypeOptions = cargoTypes.map((type: any) => ({
|
||||
value: type.id,
|
||||
label: type.cargoTypeName ?? type.cargo_type_name ?? type.name ?? type.code,
|
||||
}));
|
||||
const containerOptions = containers.map((container: Container) => ({
|
||||
value: container.id,
|
||||
label: container.containerNumber,
|
||||
}));
|
||||
return (
|
||||
<FleetCrudPage<Cargo>
|
||||
title="Cargoes"
|
||||
description="Manage cargo records linked to containers."
|
||||
addLabel="Add Cargo"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateCargo()}
|
||||
update={useUpdateCargo()}
|
||||
remove={useDeleteCargo()}
|
||||
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
|
||||
columns={[
|
||||
{ key: 'cargoReference', label: 'Reference' },
|
||||
{ key: 'cargoTypeId', label: 'Cargo type', render: (cargo) => optionLabel(cargoTypeOptions, cargo.cargoTypeId) },
|
||||
{ key: 'containerId', label: 'Container', render: (cargo) => optionLabel(containerOptions, cargo.containerId) },
|
||||
{ key: 'quantity', label: 'Quantity' },
|
||||
{ key: 'weight', label: 'Weight' },
|
||||
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'cargoReference', label: 'Cargo reference', required: true },
|
||||
{ key: 'shipmentId', label: 'Shipment ID', required: true },
|
||||
{
|
||||
key: 'containerId',
|
||||
label: 'Container',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: containerOptions,
|
||||
},
|
||||
{
|
||||
key: 'cargoTypeId',
|
||||
label: 'Cargo type',
|
||||
type: 'select',
|
||||
options: cargoTypeOptions,
|
||||
},
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'quantity', label: 'Quantity', type: 'number', required: true },
|
||||
{ key: 'weight', label: 'Weight', type: 'number', required: true },
|
||||
{ key: 'volume', label: 'Volume', type: 'number' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]}
|
||||
emptyValues={{ cargoReference: '', shipmentId: '', containerId: '', cargoTypeId: '', description: '', quantity: 0, weight: 0, volume: '', status: 'PENDING' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LocomotivesCrudPage() {
|
||||
const query = useLocomotives();
|
||||
|
||||
return (
|
||||
<FleetCrudPage<Locomotive>
|
||||
title="Locomotives"
|
||||
entityLabel="Locomotive"
|
||||
description="Manage locomotive master data used by train scheduling and fleet operations."
|
||||
addLabel="Add Locomotive"
|
||||
data={query.data}
|
||||
isLoading={query.isLoading}
|
||||
create={useCreateLocomotive()}
|
||||
update={useUpdateLocomotive()}
|
||||
remove={useDecommissionLocomotive()}
|
||||
removeActionLabel="Decommission"
|
||||
removeConfirmMessage="Decommission this locomotive?"
|
||||
removeSuccessMessage="Locomotive decommissioned"
|
||||
searchText={(locomotive) =>
|
||||
[
|
||||
locomotive.code,
|
||||
locomotive.name,
|
||||
locomotive.locomotiveType,
|
||||
locomotive.status,
|
||||
].join(' ')
|
||||
}
|
||||
columns={[
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name', render: (locomotive) => locomotive.name || '-' },
|
||||
{ key: 'locomotiveType', label: 'Type' },
|
||||
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
||||
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
||||
]}
|
||||
fields={[
|
||||
{ key: 'code', label: 'Code', required: true },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{
|
||||
key: 'locomotiveType',
|
||||
label: 'Locomotive type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'DIESEL', label: 'Diesel' },
|
||||
{ value: 'ELECTRIC', label: 'Electric' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'AVAILABLE', label: 'Available' },
|
||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
||||
],
|
||||
},
|
||||
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
||||
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
||||
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||
]}
|
||||
emptyValues={{
|
||||
code: '',
|
||||
name: '',
|
||||
locomotiveType: 'DIESEL',
|
||||
status: 'AVAILABLE',
|
||||
maxPullWeightTons: 0,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: '',
|
||||
tractionForceKn: '',
|
||||
maxSpeedKmh: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
@@ -0,0 +1,634 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { isAxiosError } from "axios";
|
||||
import { ArrowLeft, Train } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
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,
|
||||
ScheduleStatusBadge,
|
||||
} from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { SchedulingWorkflowHeader } from "@/components/trainScheduling/SchedulingWorkflowHeader";
|
||||
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
|
||||
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
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(() => {
|
||||
if (previewResult?.wagonPlan?.length) return previewResult.wagonPlan;
|
||||
if (schedule?.trainSet?.wagons?.length) return schedule.trainSet.wagons;
|
||||
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 stepLabels = [
|
||||
"Bookings",
|
||||
"Wagon plan",
|
||||
...(hasContainerStep ? ["Containers"] : []),
|
||||
"Finalize",
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<Card
|
||||
radius={schedulingWorkflow.card.radius}
|
||||
padding={schedulingWorkflow.card.padding}
|
||||
withBorder
|
||||
style={{
|
||||
background: "linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start">
|
||||
<Paper p="sm" radius="xl" bg="teal.1">
|
||||
<Train size={24} color="var(--mantine-color-teal-7)" />
|
||||
</Paper>
|
||||
<Stack gap={4}>
|
||||
<Title order={3}>{schedule.route?.name ?? "Train schedule"}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.originStation?.label ?? schedule.originStation?.code} →{" "}
|
||||
{schedule.destinationStation?.label ?? schedule.destinationStation?.code}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Departure {new Date(schedule.scheduledDepartureDate).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{schedule.status !== "DISPATCHED" ? (
|
||||
<Button variant="light" size="compact-sm" onClick={() => setMaintenanceOpen(true)}>
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
<Group gap="xl">
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Locomotive
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.trainSet?.locomotive?.code ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Bookings
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.bookings?.length ?? 0}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Wagons
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.trainSet?.wagonCount ?? displayWagonPlan.length} ·{" "}
|
||||
{schedule.trainSet?.totalWeightTons ?? 0}T
|
||||
</Text>
|
||||
</Stack>
|
||||
{previewResult ? (
|
||||
<Badge variant="light" color={previewResult.valid ? "green" : "red"}>
|
||||
Preview {previewResult.valid ? "valid" : "has issues"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card radius={schedulingWorkflow.card.radius} padding={schedulingWorkflow.card.padding} withBorder>
|
||||
<Stack gap="lg">
|
||||
<SchedulingWorkflowHeader
|
||||
title="Scheduling workflow"
|
||||
subtitle={`${schedule.route?.name ?? "Train schedule"} · ${schedule.originStation?.code ?? ""} → ${schedule.destinationStation?.code ?? ""}`}
|
||||
activeStep={activeStep}
|
||||
totalSteps={stepLabels.length}
|
||||
stepLabel={stepLabels[activeStep] ?? ""}
|
||||
stepDescription={
|
||||
activeStep === 0
|
||||
? "Select & preview"
|
||||
: activeStep === 1
|
||||
? "Allocations"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "Map units"
|
||||
: "Depart"
|
||||
}
|
||||
stepIcon={
|
||||
activeStep === 0
|
||||
? "package"
|
||||
: activeStep === 1
|
||||
? "layout"
|
||||
: hasContainerStep && activeStep === 2
|
||||
? "container"
|
||||
: "check"
|
||||
}
|
||||
/>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
color={schedulingWorkflow.stepper.color}
|
||||
iconSize={schedulingWorkflow.stepper.iconSize}
|
||||
size={schedulingWorkflow.stepper.size}
|
||||
>
|
||||
<Stepper.Step label="Bookings" description="Select & preview">
|
||||
<Stack gap="md" mt="lg">
|
||||
<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" wrap="wrap">
|
||||
<Button
|
||||
variant="filled"
|
||||
loading={preview.isPending}
|
||||
onClick={() => void runPreview()}
|
||||
>
|
||||
Preview plan
|
||||
</Button>
|
||||
<Checkbox
|
||||
label="Force assign (bypass hold/overweight warnings)"
|
||||
checked={forceAssign}
|
||||
onChange={(e) => setForceAssign(e.currentTarget.checked)}
|
||||
/>
|
||||
</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>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step label="Wagon plan" description="Allocations">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!displayWagonPlan.length && !previewResult ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Run a preview from the Bookings step to generate the wagon plan.
|
||||
</Text>
|
||||
) : null}
|
||||
<FleetAvailabilitySummary
|
||||
fleetAvailability={previewResult?.fleetAvailability}
|
||||
deferredBookings={previewResult?.deferredBookings}
|
||||
/>
|
||||
<WagonPlanGrid wagonPlan={displayWagonPlan} freightType={freightType} />
|
||||
{canEditBookings && (previewResult || displayWagonPlan.length) ? (
|
||||
<Group>
|
||||
{!hasContainerStep ? (
|
||||
<Button color="green" loading={assign.isPending} onClick={handleAssign}>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="light" onClick={() => setActiveStep(2)}>
|
||||
Continue to containers
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onClick={() => void runPreview()}>
|
||||
Refresh preview
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
{hasContainerStep ? (
|
||||
<Stepper.Step label="Containers" description="Map units">
|
||||
<Stack gap="md" mt="lg">
|
||||
{!containerUnits.length ? (
|
||||
<Paper p="md" radius="xl" 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" loading={assign.isPending} onClick={handleAssign}>
|
||||
{assignedIds.length ? "Save assignments" : "Assign bookings"}
|
||||
</Button>
|
||||
<Button variant="light" onClick={() => setActiveStep(finalizeStep)}>
|
||||
Skip to finalize
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
) : null}
|
||||
|
||||
<Stepper.Step label="Finalize" description="Depart">
|
||||
<Stack gap="md" mt="lg">
|
||||
<Paper p="md" radius="lg" withBorder bg="gray.0">
|
||||
<Text size="sm" c="dimmed">
|
||||
Finalize moves the schedule to SCHEDULED. Dispatch begins rail movement.
|
||||
</Text>
|
||||
</Paper>
|
||||
<Group>
|
||||
{canFinalize ? (
|
||||
<Button
|
||||
color="green"
|
||||
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="blue"
|
||||
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}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{scheduleId ? (
|
||||
<RescheduleTrainDialog
|
||||
scheduleId={scheduleId}
|
||||
currentBookingIds={(schedule.bookings ?? []).map((b) => b.id)}
|
||||
opened={maintenanceOpen}
|
||||
onClose={() => setMaintenanceOpen(false)}
|
||||
onComplete={() => void detailQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
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 { Train } from "lucide-react";
|
||||
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import {
|
||||
FreightTypeBadge,
|
||||
ScheduleStatusBadge,
|
||||
} from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
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 { FreightType, TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
import { schedulingWorkflow } from "@/components/trainScheduling/schedulingWorkflow.styles";
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
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 filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (schedulesQuery.data ?? []).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);
|
||||
});
|
||||
}, [schedulesQuery.data, 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 }) => formatDate(row.original.scheduleDate),
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: "Route",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.routeName ?? "—",
|
||||
},
|
||||
{
|
||||
id: "corridor",
|
||||
header: "Corridor",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => `${row.original.origin ?? "—"} → ${row.original.destination ?? "—"}`,
|
||||
},
|
||||
{
|
||||
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 ?? "—",
|
||||
},
|
||||
{
|
||||
id: "metrics",
|
||||
header: "Bookings / Wagons",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) =>
|
||||
`${row.original.bookingsCount} / ${row.original.wagonCount} · ${row.original.totalWeightTons}T`,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <ScheduleStatusBadge 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"
|
||||
size="compact-sm"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${row.original.id}`)
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
{["DRAFT", "SCHEDULED"].includes(row.original.status) ? (
|
||||
<Button
|
||||
variant="light"
|
||||
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, 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="md">
|
||||
<Paper
|
||||
p="lg"
|
||||
radius={schedulingWorkflow.card.radius}
|
||||
withBorder
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--mantine-color-teal-0) 0%, white 55%, var(--mantine-color-gray-0) 100%)",
|
||||
}}
|
||||
>
|
||||
<Group gap="md" align="center">
|
||||
<ThemeIcon size={48} radius="xl" variant="gradient" gradient={{ from: "teal", to: "green", deg: 135 }}>
|
||||
<Train size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Title order={3}>Train Schedules</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Plan departures, allocate bookings, and dispatch trains across corridors.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</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) => (
|
||||
<Card key={schedule.id} radius="lg" padding="lg" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
<ScheduleStatusBadge status={schedule.status} />
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(schedule.scheduleDate)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.origin} → {schedule.destination}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{schedule.bookingsCount} bookings · {schedule.wagonCount} wagons
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||
}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user