feat: complete train scheduling integration

This commit is contained in:
hagiye
2026-06-06 05:52:47 +03:00
parent 29247d4140
commit 788de5381e
56 changed files with 1191 additions and 141 deletions

View File

@@ -12,7 +12,11 @@ import {
} 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 {
@@ -28,11 +32,19 @@ import type { Container } from '@/services/containerService';
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';
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> = {
@@ -49,20 +61,62 @@ type FleetCrudPageProps<T extends { id: string }> = {
isLoading: boolean;
columns: Column<T>[];
fields: Field[];
emptyValues: Record<string, string | number>;
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 };
};
const normalizePayload = (values: Record<string, string | number>) =>
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,
@@ -85,6 +139,7 @@ function FleetCrudPage<T extends { id: string }>({
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(() => {
@@ -118,6 +173,7 @@ function FleetCrudPage<T extends { id: string }>({
const openCreate = () => {
setEditing(null);
setForm(emptyValues);
setFieldErrors({});
setFormOpen(true);
};
@@ -128,6 +184,7 @@ function FleetCrudPage<T extends { id: string }>({
Object.keys(emptyValues).map((key) => [key, (item as Record<string, string | number | null | undefined>)[key] ?? '']),
),
);
setFieldErrors({});
setFormOpen(true);
};
@@ -135,11 +192,24 @@ function FleetCrudPage<T extends { id: string }>({
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) {
@@ -150,8 +220,10 @@ function FleetCrudPage<T extends { id: string }>({
toast({ title: `${title.slice(0, -1)} created` });
}
closeForm();
} catch {
toast({ title: 'Save failed', description: 'Please check the fields and try again.', variant: 'destructive' });
} catch (error) {
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
setFieldErrors(backendFieldErrors);
toast({ title: 'Save failed', description: message, variant: 'destructive' });
}
};
@@ -273,23 +345,57 @@ function FleetCrudPage<T extends { id: string }>({
<DialogTitle>{editing ? `Edit ${title.slice(0, -1)}` : addLabel}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{fields.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={field.key}>{field.label}</Label>
<Input
id={field.key}
type={field.type ?? 'text'}
required={field.required}
value={form[field.key] ?? ''}
onChange={(event) =>
setForm((current) => ({
...current,
[field.key]: field.type === 'number' ? Number(event.target.value) : event.target.value,
}))
}
/>
</div>
))}
{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
@@ -325,6 +431,9 @@ function FleetCrudPage<T extends { id: string }>({
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 (
@@ -362,6 +471,11 @@ export function TrainMasterDataPage() {
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"
@@ -375,13 +489,24 @@ export function WagonsCrudPage() {
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
columns={[
{ key: 'wagonNumber', label: 'Number' },
{ key: 'wagonTypeId', label: 'Type ID' },
{ 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 ID', 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' },
@@ -394,6 +519,16 @@ export function WagonsCrudPage() {
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"
@@ -407,15 +542,27 @@ export function ContainersCrudPage() {
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
{ key: 'containerTypeId', label: 'Type ID' },
{ key: 'wagonId', label: 'Wagon', render: (container) => container.wagonId || 'Unassigned' },
{ 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 ID', required: true },
{ key: 'wagonId', label: 'Wagon ID' },
{
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 },
@@ -429,6 +576,16 @@ export function ContainersCrudPage() {
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"
@@ -442,7 +599,8 @@ export function CargoesCrudPage() {
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
{ key: 'containerId', label: 'Container ID' },
{ 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) },
@@ -450,8 +608,19 @@ export function CargoesCrudPage() {
fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', required: true },
{ key: 'containerId', label: 'Container ID', required: true },
{ key: 'cargoTypeId', label: 'Cargo type ID' },
{
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 },

View File

@@ -80,7 +80,7 @@ const deriveFromBooking = (
const TrainsPage = () => {
const qc = useQueryClient();
const [filters, setFilters] = useState<TrainScheduleFilters>({ status: 'PAID' });
const [filters, setFilters] = useState<TrainScheduleFilters>({});
const [selectedBookingIds, setSelectedBookingIds] = useState<string[]>([]);
const [preview, setPreview] = useState<TrainSchedulePreviewResponse | null>(null);
const [selectedLocomotiveId, setSelectedLocomotiveId] = useState('');
@@ -361,7 +361,7 @@ const TrainsPage = () => {
<div className="space-y-2">
<label className="text-sm font-medium">Booking status</label>
<Select
value={filters.status ?? 'PAID'}
value={filters.status ?? '__all__'}
onValueChange={(value) =>
setFilters((current) => ({
...current,
@@ -370,9 +370,10 @@ const TrainsPage = () => {
}
>
<SelectTrigger>
<SelectValue placeholder="Paid bookings" />
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All eligible statuses</SelectItem>
<SelectItem value="PAID">Paid</SelectItem>
<SelectItem value="FULLY_EXECUTED">Fully executed</SelectItem>
<SelectItem value="APPROVED">Approved</SelectItem>

View File

@@ -0,0 +1,109 @@
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>
);
}