mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat(freight): added routes and locomotives
This commit is contained in:
@@ -35,14 +35,16 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import {
|
||||
CargoesCrudPage,
|
||||
ContainersCrudPage,
|
||||
TrainMasterDataPage,
|
||||
WagonsCrudPage,
|
||||
} from "./pages/fleet/FleetCrudPages";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import {
|
||||
CargoesCrudPage,
|
||||
ContainersCrudPage,
|
||||
LocomotivesCrudPage,
|
||||
TrainMasterDataPage,
|
||||
WagonsCrudPage,
|
||||
} from "./pages/fleet/FleetCrudPages";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -54,26 +56,41 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/overview",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Booking requests",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Train scheduling",
|
||||
href: "/dashboard/operations/train-scheduling",
|
||||
icon: <Train />,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fleet Management",
|
||||
items: [
|
||||
{
|
||||
label: "Trains",
|
||||
href: "/dashboard/trains",
|
||||
icon: <Train />,
|
||||
{
|
||||
label: "Booking requests",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Operations",
|
||||
items: [
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling",
|
||||
icon: <Train />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fleet Management",
|
||||
items: [
|
||||
{
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Trains",
|
||||
href: "/dashboard/trains",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Wagons",
|
||||
@@ -223,8 +240,10 @@ const App = () => {
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||
<Route path="containers" element={<ContainersCrudPage />} />
|
||||
|
||||
@@ -35,6 +35,20 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Dashboard summary and key metrics",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/routes",
|
||||
meta: {
|
||||
title: "Routes",
|
||||
subtitle: "Manage route definitions built from freight yards",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/locomotives",
|
||||
meta: {
|
||||
title: "Locomotives",
|
||||
subtitle: "Manage locomotive master data and service status",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/user-management/employees",
|
||||
meta: {
|
||||
|
||||
@@ -111,6 +111,13 @@ export const URL_CONSTANTS = {
|
||||
|
||||
LOCOMOTIVES: {
|
||||
BASE: "/locomotives",
|
||||
BY_ID: (id: string) => `/locomotives/${id}`,
|
||||
DECOMMISSION: (id: string) => `/locomotives/${id}/decommission`,
|
||||
},
|
||||
|
||||
ROUTES: {
|
||||
BASE: '/routes',
|
||||
BY_ID: (id: string) => `/routes/${id}`,
|
||||
},
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
|
||||
47
apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts
Normal file
47
apps/edr-freight-web/backoffice/src/hooks/useLocomotives.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { locomotivesService } from '@/services/locomotives.service';
|
||||
|
||||
export const locomotiveKeys = {
|
||||
all: ['locomotives'] as const,
|
||||
details: () => [...locomotiveKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...locomotiveKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useLocomotives() {
|
||||
return useQuery({
|
||||
queryKey: locomotiveKeys.all,
|
||||
queryFn: () => locomotivesService.getAll().then((response) => response.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateLocomotive() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: locomotivesService.create,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: locomotiveKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLocomotive() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
locomotivesService.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.all });
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDecommissionLocomotive() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: locomotivesService.decommission,
|
||||
onSuccess: (_, id) => {
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.all });
|
||||
qc.invalidateQueries({ queryKey: locomotiveKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
55
apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts
Normal file
55
apps/edr-freight-web/backoffice/src/hooks/useRoutes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { routesService } from '@/services/routes.service';
|
||||
|
||||
export const routeKeys = {
|
||||
all: ['routes'] as const,
|
||||
yards: ['routes', 'yards'] as const,
|
||||
details: () => [...routeKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...routeKeys.details(), id] as const,
|
||||
};
|
||||
|
||||
export function useRoutes() {
|
||||
return useQuery({
|
||||
queryKey: routeKeys.all,
|
||||
queryFn: () => routesService.getAll().then((response) => response.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRouteYards() {
|
||||
return useQuery({
|
||||
queryKey: routeKeys.yards,
|
||||
queryFn: () => routesService.getYards().then((response) => response.data.data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRoute() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: routesService.create,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: routeKeys.all }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRoute() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
routesService.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: routeKeys.all });
|
||||
qc.invalidateQueries({ queryKey: routeKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeactivateRoute() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: routesService.deactivate,
|
||||
onSuccess: (_, id) => {
|
||||
qc.invalidateQueries({ queryKey: routeKeys.all });
|
||||
qc.invalidateQueries({ queryKey: routeKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -27,8 +27,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';
|
||||
|
||||
@@ -57,6 +64,7 @@ type FleetCrudPageProps<T extends { id: string }> = {
|
||||
title: string;
|
||||
description: string;
|
||||
addLabel: string;
|
||||
entityLabel?: string;
|
||||
data?: T[];
|
||||
isLoading: boolean;
|
||||
columns: Column<T>[];
|
||||
@@ -66,6 +74,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>) =>
|
||||
@@ -121,6 +133,7 @@ function FleetCrudPage<T extends { id: string }>({
|
||||
title,
|
||||
description,
|
||||
addLabel,
|
||||
entityLabel,
|
||||
data,
|
||||
isLoading,
|
||||
columns,
|
||||
@@ -130,6 +143,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);
|
||||
@@ -228,12 +245,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' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -294,13 +312,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>
|
||||
@@ -631,3 +651,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: '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal file
379
apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export type LocomotiveType = 'DIESEL' | 'ELECTRIC';
|
||||
export type LocomotiveStatus =
|
||||
| 'AVAILABLE'
|
||||
| 'MAINTENANCE'
|
||||
| 'ASSIGNED'
|
||||
| 'OUT_OF_SERVICE';
|
||||
|
||||
export interface Locomotive {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
locomotiveType: LocomotiveType;
|
||||
status: LocomotiveStatus;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters: number;
|
||||
powerKw?: number | null;
|
||||
tractionForceKn?: number | null;
|
||||
maxSpeedKmh?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type SaveLocomotivePayload = Omit<
|
||||
Locomotive,
|
||||
'id' | 'createdAt' | 'updatedAt'
|
||||
>;
|
||||
|
||||
export const locomotivesService = {
|
||||
getAll: () => apiClient.get<Locomotive[]>(URL_CONSTANTS.LOCOMOTIVES.BASE),
|
||||
getById: (id: string) => apiClient.get<Locomotive>(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id)),
|
||||
create: (data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.post(URL_CONSTANTS.LOCOMOTIVES.BASE, data),
|
||||
update: (id: string, data: Partial<SaveLocomotivePayload>) =>
|
||||
apiClient.patch(URL_CONSTANTS.LOCOMOTIVES.BY_ID(id), data),
|
||||
decommission: (id: string) => apiClient.post(URL_CONSTANTS.LOCOMOTIVES.DECOMMISSION(id), {}),
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export interface YardRef {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface RouteMilestone {
|
||||
id: string;
|
||||
routeId: string;
|
||||
yardId: string;
|
||||
sequenceNo: number;
|
||||
yard?: YardRef | null;
|
||||
}
|
||||
|
||||
export interface RouteRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
isActive: boolean;
|
||||
originYard?: YardRef | null;
|
||||
destinationYard?: YardRef | null;
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
export interface SaveRoutePayload {
|
||||
name: string;
|
||||
milestones: Array<{ yardId: string }>;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface YardListResponse {
|
||||
data: YardRef[];
|
||||
}
|
||||
|
||||
export const routesService = {
|
||||
getAll: () => apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE),
|
||||
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
|
||||
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
|
||||
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||
getYards: () =>
|
||||
apiClient.get<YardListResponse>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
|
||||
params: { isActive: true, pageSize: 200 },
|
||||
}),
|
||||
};
|
||||
@@ -56,8 +56,9 @@ export interface LocomotiveRecord {
|
||||
code: string;
|
||||
name?: string | null;
|
||||
maxPullWeightTons: number;
|
||||
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE';
|
||||
availableFrom?: string | null;
|
||||
maxTrainLengthMeters: number;
|
||||
status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'OUT_OF_SERVICE';
|
||||
locomotiveType?: 'DIESEL' | 'ELECTRIC';
|
||||
}
|
||||
|
||||
export interface TrainScheduleListItem {
|
||||
@@ -100,13 +101,14 @@ export interface TrainScheduleDetail {
|
||||
wagonCount: number;
|
||||
totalWeightTons: number;
|
||||
totalLengthMeters: number;
|
||||
locomotive?: {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
maxPullWeightTons: number;
|
||||
} | null;
|
||||
locomotive?: {
|
||||
id: string;
|
||||
code: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
maxPullWeightTons: number;
|
||||
maxTrainLengthMeters?: number;
|
||||
} | null;
|
||||
wagons: Array<{
|
||||
id: string;
|
||||
sequenceNo: number;
|
||||
|
||||
Reference in New Issue
Block a user