mantine added to Train, wagon, containers and cargoes

This commit is contained in:
hagiye
2026-06-08 09:29:34 +03:00
105 changed files with 6510 additions and 3248 deletions

View File

@@ -1,5 +1,24 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import {
ActionIcon,
Badge as MantineBadge,
Box,
Button as MantineButton,
Group,
Modal,
NumberInput,
Pagination,
Paper,
ScrollArea,
Select as MantineSelect,
SimpleGrid,
Stack,
Table as MantineTable,
Text,
TextInput,
Title,
} from '@mantine/core';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -32,8 +51,15 @@ import {
} 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';
import type { WagonType } from '@/services/wagon-types.service';
@@ -63,6 +89,7 @@ type FleetCrudPageProps<T extends { id: string }> = {
title: string;
description: string;
addLabel: string;
entityLabel?: string;
data?: T[];
isLoading: boolean;
columns: Column<T>[];
@@ -72,6 +99,10 @@ type FleetCrudPageProps<T extends { id: 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>) =>
@@ -139,6 +170,7 @@ function FleetCrudPage<T extends { id: string }>({
title,
description,
addLabel,
entityLabel,
data,
isLoading,
columns,
@@ -148,6 +180,10 @@ function FleetCrudPage<T extends { id: string }>({
create,
update,
remove,
removeActionLabel = 'Delete',
removeConfirmMessage,
removeSuccessMessage,
hideViewAction = false,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
@@ -249,12 +285,13 @@ function FleetCrudPage<T extends { id: string }>({
};
const handleDelete = async (item: T) => {
if (!window.confirm(`Delete this ${title.slice(0, -1).toLowerCase()}?`)) return;
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
try {
await remove.mutateAsync(item.id);
toast({ title: `${title.slice(0, -1)} deleted` });
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
} catch {
toast({ title: 'Delete failed', description: 'This record may still be referenced.', variant: 'destructive' });
toast({ title: `${removeActionLabel} failed`, description: 'This record may still be referenced.', variant: 'destructive' });
}
};
@@ -315,13 +352,15 @@ function FleetCrudPage<T extends { id: string }>({
))}
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
</Button>
{!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="Delete">
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
<Trash2 className="size-4" />
</Button>
</div>
@@ -502,64 +541,351 @@ export function TrainMasterDataPage() {
export function WagonTypesCrudPage() {
const query = useWagonTypes();
const create = useCreateWagonType();
const update = useUpdateWagonType();
const remove = useDeleteWagonType();
const { toast } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<WagonType | null>(null);
const [viewing, setViewing] = useState<WagonType | null>(null);
const [form, setForm] = useState<Record<string, FormValue>>({
code: '',
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const pageSize = 10;
const filtered = useMemo(() => {
const queryText = search.trim().toLowerCase();
const rows = query.data ?? [];
if (!queryText) return rows;
return rows.filter((type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
.join(' ')
.toLowerCase()
.includes(queryText),
);
}, [query.data, search]);
const sorted = useMemo(() => {
return [...filtered].sort((left, right) => {
const result = String(left[sortKey] ?? '').localeCompare(String(right[sortKey] ?? ''), undefined, {
numeric: true,
});
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const isSaving = create.isPending || update.isPending;
const toggleSort = (key: keyof WagonType) => {
setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDirection('asc');
};
const closeForm = () => {
setFormOpen(false);
setEditing(null);
setFieldErrors({});
setForm({
code: '',
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
});
};
const openEdit = (type: WagonType) => {
setEditing(type);
setFieldErrors({});
setForm({
code: type.code ?? '',
name: type.name ?? '',
capacityTons: type.capacityTons ?? 0,
lengthMeters: type.lengthMeters ?? 0,
maxWagonsPerTrain: type.maxWagonsPerTrain ?? '',
supportedLoadTypes: type.supportedLoadTypes?.join(', ') ?? '',
isActive: type.isActive,
});
setFormOpen(true);
};
const validateWagonType = () => {
const errors: Record<string, string> = {};
if (!String(form.code ?? '').trim()) errors.code = 'Code is required';
if (!String(form.name ?? '').trim()) errors.name = 'Name is required';
if (!Number.isFinite(Number(form.capacityTons))) errors.capacityTons = 'Capacity must be a valid number';
if (!Number.isFinite(Number(form.lengthMeters))) errors.lengthMeters = 'Length must be a valid number';
return errors;
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const errors = validateWagonType();
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
toast({ title: 'Save failed', description: Object.values(errors)[0], variant: 'destructive' });
return;
}
const payload = normalizePayload(form);
setFieldErrors({});
try {
if (editing) {
await update.mutateAsync({ id: editing.id, data: payload });
toast({ title: 'Wagon Type updated' });
} else {
await create.mutateAsync(payload);
toast({ title: 'Wagon Type created' });
}
closeForm();
} catch (error) {
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
setFieldErrors(backendFieldErrors);
toast({ title: 'Save failed', description: message, variant: 'destructive' });
}
};
const handleDelete = async (type: WagonType) => {
if (!window.confirm('Delete this wagon type?')) return;
try {
await remove.mutateAsync(type.id);
toast({ title: 'Wagon Type deleted' });
} catch {
toast({ title: 'Delete failed', description: 'This wagon type may still be referenced.', variant: 'destructive' });
}
};
const sortLabel = (key: keyof WagonType) => (sortKey === key ? (sortDirection === 'asc' ? ' ASC' : ' DESC') : '');
return (
<FleetCrudPage<WagonType>
title="Wagon Types"
description="Manage wagon type capacities and load compatibility used by wagon master data."
addLabel="Add Wagon Type"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagonType()}
update={useUpdateWagonType()}
remove={useDeleteWagonType()}
searchText={(type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ')
}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'name', label: 'Name' },
{ key: 'capacityTons', label: 'Capacity (tons)' },
{ key: 'lengthMeters', label: 'Length (m)' },
{
key: 'supportedLoadTypes',
label: 'Load types',
render: (type) => type.supportedLoadTypes?.join(', ') || '-',
},
{ key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'name', label: 'Name', required: true },
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
{ key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true },
{ key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' },
{
key: 'supportedLoadTypes',
label: 'Supported load types',
placeholder: 'container, break-bulk',
},
{
key: 'isActive',
label: 'Status',
type: 'select',
options: [
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
],
onValueChange: (value) => ({ isActive: value === 'true' }),
},
]}
emptyValues={{
code: '',
name: '',
capacityTons: 0,
lengthMeters: 0,
maxWagonsPerTrain: '',
supportedLoadTypes: '',
isActive: true,
}}
/>
<Box p="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-end">
<Box>
<Title order={2}>Wagon Types</Title>
<Text size="sm" c="dimmed" mt={4}>
Manage wagon type capacities and load compatibility used by wagon master data.
</Text>
</Box>
<MantineButton
leftSection={<Plus size={16} />}
onClick={() => {
closeForm();
setFormOpen(true);
}}
>
Add Wagon Type
</MantineButton>
</Group>
<TextInput
maw={420}
leftSection={<Search size={16} />}
placeholder="Search wagon types"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Paper withBorder radius="md">
<ScrollArea>
<MantineTable striped highlightOnHover verticalSpacing="sm" miw={900}>
<MantineTable.Thead>
<MantineTable.Tr>
<MantineTable.Th>
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('code')}>
Code{sortLabel('code')}
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('name')}>
Name{sortLabel('name')}
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>
<MantineButton variant="subtle" size="compact-sm" onClick={() => toggleSort('capacityTons')}>
Capacity (tons){sortLabel('capacityTons')}
</MantineButton>
</MantineTable.Th>
<MantineTable.Th>Length (m)</MantineTable.Th>
<MantineTable.Th>Load types</MantineTable.Th>
<MantineTable.Th>Status</MantineTable.Th>
<MantineTable.Th ta="right">Actions</MantineTable.Th>
</MantineTable.Tr>
</MantineTable.Thead>
<MantineTable.Tbody>
{paged.map((type) => (
<MantineTable.Tr key={type.id}>
<MantineTable.Td fw={600}>{type.code}</MantineTable.Td>
<MantineTable.Td>{type.name}</MantineTable.Td>
<MantineTable.Td>{type.capacityTons}</MantineTable.Td>
<MantineTable.Td>{type.lengthMeters}</MantineTable.Td>
<MantineTable.Td>{type.supportedLoadTypes?.join(', ') || '-'}</MantineTable.Td>
<MantineTable.Td>
<MantineBadge color={type.isActive === false ? 'gray' : 'green'} variant="light">
{type.isActive === false ? 'Inactive' : 'Active'}
</MantineBadge>
</MantineTable.Td>
<MantineTable.Td>
<Group gap="xs" justify="flex-end">
<ActionIcon variant="subtle" aria-label="View wagon type" onClick={() => setViewing(type)}>
<Eye size={16} />
</ActionIcon>
<ActionIcon variant="subtle" aria-label="Edit wagon type" onClick={() => openEdit(type)}>
<Edit size={16} />
</ActionIcon>
<ActionIcon
color="red"
variant="subtle"
aria-label="Delete wagon type"
onClick={() => handleDelete(type)}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</MantineTable.Td>
</MantineTable.Tr>
))}
{!query.isLoading && filtered.length === 0 ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<Text ta="center" c="dimmed" py="xl">
No wagon types found.
</Text>
</MantineTable.Td>
</MantineTable.Tr>
) : null}
{query.isLoading ? (
<MantineTable.Tr>
<MantineTable.Td colSpan={7}>
<Text ta="center" c="dimmed" py="xl">
Loading...
</Text>
</MantineTable.Td>
</MantineTable.Tr>
) : null}
</MantineTable.Tbody>
</MantineTable>
</ScrollArea>
</Paper>
<Group justify="space-between">
<Text size="sm" c="dimmed">
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
{sorted.length}
</Text>
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
</Group>
</Stack>
<Modal opened={formOpen} onClose={closeForm} title={editing ? 'Edit Wagon Type' : 'Add Wagon Type'} centered>
<form onSubmit={handleSubmit}>
<Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label="Code"
required
value={String(form.code ?? '')}
error={fieldErrors.code}
onChange={(event) => setForm((current) => ({ ...current, code: event.currentTarget.value }))}
/>
<TextInput
label="Name"
required
value={String(form.name ?? '')}
error={fieldErrors.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.currentTarget.value }))}
/>
<NumberInput
label="Capacity (tons)"
required
min={0}
value={Number(form.capacityTons ?? 0)}
error={fieldErrors.capacityTons}
onChange={(value) => setForm((current) => ({ ...current, capacityTons: value }))}
/>
<NumberInput
label="Length (meters)"
required
min={0}
value={Number(form.lengthMeters ?? 0)}
error={fieldErrors.lengthMeters}
onChange={(value) => setForm((current) => ({ ...current, lengthMeters: value }))}
/>
<NumberInput
label="Max wagons per train"
min={0}
value={form.maxWagonsPerTrain === '' ? '' : Number(form.maxWagonsPerTrain)}
onChange={(value) => setForm((current) => ({ ...current, maxWagonsPerTrain: value }))}
/>
<MantineSelect
label="Status"
value={form.isActive ? 'true' : 'false'}
data={[
{ value: 'true', label: 'Active' },
{ value: 'false', label: 'Inactive' },
]}
onChange={(value) => setForm((current) => ({ ...current, isActive: value !== 'false' }))}
/>
</SimpleGrid>
<TextInput
label="Supported load types"
placeholder="container, break-bulk"
value={Array.isArray(form.supportedLoadTypes) ? form.supportedLoadTypes.join(', ') : String(form.supportedLoadTypes ?? '')}
onChange={(event) => setForm((current) => ({ ...current, supportedLoadTypes: event.currentTarget.value }))}
/>
<Group justify="flex-end">
<MantineButton variant="default" type="button" onClick={closeForm}>
Cancel
</MantineButton>
<MantineButton type="submit" loading={isSaving}>
Save
</MantineButton>
</Group>
</Stack>
</form>
</Modal>
<Modal opened={Boolean(viewing)} onClose={() => setViewing(null)} title="Wagon Type details" centered>
<Stack gap="xs">
{viewing
? Object.entries(viewing).map(([key, value]) => (
<Group key={key} justify="space-between" align="flex-start" wrap="nowrap">
<Text size="sm" fw={600}>
{key}
</Text>
<Text size="sm" c="dimmed" ta="right">
{Array.isArray(value) ? value.join(', ') : value == null ? '-' : String(value)}
</Text>
</Group>
))
: null}
</Stack>
</Modal>
</Box>
);
}
@@ -725,3 +1051,82 @@ export function CargoesCrudPage() {
/>
);
}
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: '',
}}
/>
);
}

View File

@@ -0,0 +1,379 @@
import { FormEvent, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
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 { 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';
type RouteFormState = {
name: string;
milestones: string[];
};
const emptyForm = (): RouteFormState => ({ name: '', milestones: ['', ''] });
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : '-');
const routeStops = (route: RouteRecord) =>
(route.milestones ?? [])
.sort((left, right) => left.sequenceNo - right.sequenceNo)
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
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 rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage)
? rawMessage.join(', ')
: rawMessage
? String(rawMessage)
: 'Save failed';
};
export default function RoutesPage() {
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 { toast } = useToast();
const routesQuery = useRoutes();
const yardsQuery = useRouteYards();
const createMutation = useCreateRoute();
const updateMutation = useUpdateRoute();
const deactivateMutation = useDeactivateRoute();
const filteredRoutes = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return routesQuery.data ?? [];
return (routesQuery.data ?? []).filter((route) => {
const searchable = [
route.name,
route.originYard?.label,
route.originYard?.code,
route.destinationYard?.label,
route.destinationYard?.code,
...routeStops(route),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return searchable.includes(query);
});
}, [routesQuery.data, search]);
const yardOptions = useMemo(
() =>
(yardsQuery.data ?? []).map((yard) => ({
value: yard.id,
label: `${yard.label} (${yard.code})`,
})),
[yardsQuery.data],
);
const resetForm = () => {
setFormOpen(false);
setEditing(null);
setForm(emptyForm());
};
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setFormOpen(true);
};
const openEdit = (route: RouteRecord) => {
setEditing(route);
setForm({
name: route.name,
milestones: (route.milestones ?? [])
.sort((left, right) => left.sequenceNo - right.sequenceNo)
.map((milestone) => milestone.yardId),
});
setFormOpen(true);
};
const setMilestone = (index: number, yardId: string) => {
setForm((current) => ({
...current,
milestones: current.milestones.map((value, currentIndex) =>
currentIndex === index ? yardId : value,
),
}));
};
const addMilestone = () => {
setForm((current) => ({ ...current, milestones: [...current.milestones, ''] }));
};
const removeMilestone = (index: number) => {
setForm((current) => ({
...current,
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
}));
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!form.name.trim()) {
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',
});
return;
}
try {
const payload = {
name: form.name.trim(),
milestones: form.milestones.map((yardId) => ({ yardId })),
isActive: editing?.isActive ?? true,
};
if (editing) {
await updateMutation.mutateAsync({ id: editing.id, data: payload });
toast({ title: 'Route updated' });
} else {
await createMutation.mutateAsync(payload);
toast({ title: 'Route created' });
}
resetForm();
} catch (error) {
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' });
} catch {
toast({ title: 'Deactivate failed', description: 'Could not deactivate route', variant: 'destructive' });
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
const availableOptionsForIndex = (index: number) => {
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),
);
};
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>
<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 }))}
/>
</div>
<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}>
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>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>
</div>
) : null}
</DialogContent>
</Dialog>
</div>
);
}