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

@@ -1,10 +1,16 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useContainers, useAssignContainerToWagon } from '@/hooks/useContainers';
import { useContainers, useAssignContainerToWagon } from './use-containers';
import { useToast } from '@/hooks/use-toast';
import { Plus } from 'lucide-react';

View File

@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { useCargoTypes } from './use-cargo-types';
import { useCargoMutations } from './use-cargoes';
import { Loader2 } from 'lucide-react';
interface Cargo {
id: string;
cargoNumber: string;
cargoTypeId: string;
weight: number;
remarks?: string;
}
interface CargoFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
cargo?: Cargo | null;
onSuccess?: () => void;
}
export default function CargoFormDialog({
open,
onOpenChange,
cargo,
onSuccess,
}: CargoFormDialogProps) {
const { data: cargoTypes } = useCargoTypes();
const { createCargo, updateCargo } = useCargoMutations();
const [formData, setFormData] = useState<Partial<Cargo>>({
cargoNumber: '',
cargoTypeId: '',
weight: 0,
remarks: '',
});
useEffect(() => {
if (cargo) {
setFormData(cargo);
} else {
setFormData({
cargoNumber: '',
cargoTypeId: '',
weight: 0,
remarks: '',
});
}
}, [cargo, open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (cargo?.id) {
updateCargo.mutate(
{ id: cargo.id, data: formData },
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
);
} else {
createCargo.mutate(formData, {
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
});
}
};
const isLoading = createCargo.isPending || updateCargo.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader><DialogTitle>{cargo ? 'Edit Cargo' : 'Create New Cargo'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Cargo Number *</Label>
<Input value={formData.cargoNumber} onChange={e => setFormData({...formData, cargoNumber: e.target.value})} required />
</div>
<div>
<Label>Cargo Type *</Label>
<Select
value={formData.cargoTypeId || ''}
onValueChange={(val) => setFormData({ ...formData, cargoTypeId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select cargo type..." />
</SelectTrigger>
<SelectContent>
{cargoTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.cargo_type_name || type.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Weight (kg) *</Label>
<Input type="number" value={formData.weight} onChange={e => setFormData({...formData, weight: parseFloat(e.target.value)})} required />
</div>
<div>
<Label>Remarks</Label>
<Textarea value={formData.remarks} onChange={e => setFormData({...formData, remarks: e.target.value})} rows={3} />
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}{cargo ? 'Update' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,5 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import {
Dialog,
DialogContent,
@@ -12,7 +10,16 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { useContainerTypes } from './use-container-types';
import { useContainerMutations } from './use-containers';
import { Loader2 } from 'lucide-react';
interface Container {
@@ -39,8 +46,6 @@ interface ContainerFormDialogProps {
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function ContainerFormDialog({
open,
onOpenChange,
@@ -48,7 +53,8 @@ export default function ContainerFormDialog({
wagons = [],
onSuccess,
}: ContainerFormDialogProps) {
const queryClient = useQueryClient();
const { data: containerTypes } = useContainerTypes();
const { createContainer, updateContainer } = useContainerMutations();
const [formData, setFormData] = useState<Partial<Container>>({
containerNumber: '',
containerTypeId: '',
@@ -73,40 +79,6 @@ export default function ContainerFormDialog({
}
}, [container, open]);
const createMutation = useMutation({
mutationFn: (data: Partial<Container>) =>
axios.post(`${API_BASE_URL}/api/containers`, data),
onSuccess: () => {
toast.success('Container created successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['containers'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to create container'
: 'Failed to create container';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Container>) =>
axios.patch(`${API_BASE_URL}/api/containers/${container?.id}`, data),
onSuccess: () => {
toast.success('Container updated successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['containers'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to update container'
: 'Failed to update container';
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.containerNumber || !formData.containerTypeId) {
@@ -114,13 +86,18 @@ export default function ContainerFormDialog({
return;
}
if (container?.id) {
updateMutation.mutate(formData);
updateContainer.mutate(
{ id: container.id, data: formData },
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
);
} else {
createMutation.mutate(formData);
createContainer.mutate(formData, {
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
});
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
const isLoading = createContainer.isPending || updateContainer.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -145,53 +122,59 @@ export default function ContainerFormDialog({
/>
</div>
<div>
<Label htmlFor="containerTypeId">Type *</Label>
<Input
id="containerTypeId"
value={formData.containerTypeId || ''}
onChange={(e) =>
setFormData({ ...formData, containerTypeId: e.target.value })
}
placeholder="e.g., 20ft Box"
required
/>
<Label htmlFor="containerTypeId">Container Type *</Label>
<Select
value={formData.containerTypeId || ''}
onValueChange={(val:any) => setFormData({ ...formData, containerTypeId: val })}
>
<SelectTrigger id="containerTypeId">
<SelectValue placeholder="Select container type..." />
</SelectTrigger>
<SelectContent>
{containerTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.name || type.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="wagonId">Wagon (Optional)</Label>
<select
id="wagonId"
value={formData.wagonId || ''}
onChange={(e) =>
setFormData({ ...formData, wagonId: e.target.value || undefined })
}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
<Select
value={formData.wagonId || 'none'}
onValueChange={(val:any) => setFormData({ ...formData, wagonId: val === 'none' ? undefined : val })}
>
<option value="">Select a wagon...</option>
{wagons.map(wagon => (
<option key={wagon.id} value={wagon.id}>
{wagon.wagonNumber}
</option>
))}
</select>
<SelectTrigger id="wagonId">
<SelectValue placeholder="Select a wagon..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{wagons.map((wagon) => (
<SelectItem key={wagon.id} value={wagon.id}>
{wagon.wagonNumber}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
<Select
value={formData.status || 'AVAILABLE'}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value as any })
}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
onValueChange={(val:any) => setFormData({ ...formData, status: val })}
>
<option value="AVAILABLE">Available</option>
<option value="IN_USE">In Use</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="RETIRED">Retired</option>
</select>
<SelectTrigger id="status">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="AVAILABLE">Available</SelectItem>
<SelectItem value="IN_USE">In Use</SelectItem>
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
<SelectItem value="RETIRED">Retired</SelectItem>
</SelectContent>
</Select>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { useContainersByWagon, useUnassignContainer } from '@/hooks/useContainers';
import { useContainersByWagon, useUnassignContainer } from './use-containers';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import type { Container } from '@/services/containerService';
import type { Container } from './container.service';
export function ContainersTable({ wagonId }: { wagonId: string }) {
const { data: containers, refetch } = useContainersByWagon(wagonId);

View File

@@ -0,0 +1,15 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const cargoTypesService = {
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -0,0 +1,19 @@
import { api } from "../../auth/http";
export const cargoService = {
async getCargoes() {
const response = await api.get('/cargoes');
return response.data;
},
async createCargo(data: any) {
const response = await api.post('/cargoes', data);
return response.data;
},
async updateCargo(id: string, data: any) {
const response = await api.patch(`/cargoes/${id}`, data);
return response.data;
},
async deleteCargo(id: string) {
await api.delete(`/cargoes/${id}`);
},
};

View File

@@ -0,0 +1,15 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const containerTypesService = {
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -0,0 +1,31 @@
import { api } from "../../auth/http";
export const containerService = {
async getContainers() {
const response = await api.get('/containers');
return response.data;
},
async getContainersByWagon(wagonId: string) {
const response = await api.get('/containers', { params: { wagonId } });
return response.data;
},
async createContainer(data: any) {
const response = await api.post('/containers', data);
return response.data;
},
async updateContainer(id: string, data: any) {
const response = await api.patch(`/containers/${id}`, data);
return response.data;
},
async deleteContainer(id: string) {
await api.delete(`/containers/${id}`);
},
async assignToWagon(containerId: string, wagonId: string, position?: number) {
const response = await api.post(`/containers/${containerId}/assign-wagon`, { wagonId, position });
return response.data;
},
async unassignFromWagon(containerId: string) {
const response = await api.post(`/containers/${containerId}/unassign-wagon`);
return response.data;
},
};

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { cargoTypesService } from './cargo-types.service';
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
export function useCargoTypes() {
return useQuery({
queryKey: CARGO_TYPES_QUERY_KEY,
queryFn: () => cargoTypesService.getCargoTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,42 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { cargoService } from './cargo.service';
import { toast } from 'sonner';
export const CARGOES_QUERY_KEY = ['cargoes'];
export function useCargoes() {
return useQuery({
queryKey: CARGOES_QUERY_KEY,
queryFn: () => cargoService.getCargoes(),
});
}
export function useCargoMutations() {
const queryClient = useQueryClient();
const createCargo = useMutation({
mutationFn: (data: any) => cargoService.createCargo(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo created successfully');
},
});
const updateCargo = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => cargoService.updateCargo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo updated successfully');
},
});
const deleteCargo = useMutation({
mutationFn: (id: string) => cargoService.deleteCargo(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo deleted successfully');
},
});
return { createCargo, updateCargo, deleteCargo };
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { containerTypesService } from './container-types.service';
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
export function useContainerTypes() {
return useQuery({
queryKey: CONTAINER_TYPES_QUERY_KEY,
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,73 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { containerService } from './container.service';
import { toast } from 'sonner';
export const CONTAINERS_QUERY_KEY = ['containers'];
export function useContainers() {
return useQuery({
queryKey: CONTAINERS_QUERY_KEY,
queryFn: () => containerService.getContainers(),
});
}
export function useContainersByWagon(wagonId: string) {
return useQuery({
queryKey: [...CONTAINERS_QUERY_KEY, 'wagon', wagonId],
queryFn: () => containerService.getContainersByWagon(wagonId),
enabled: !!wagonId,
});
}
export function useContainerMutations() {
const queryClient = useQueryClient();
const createContainer = useMutation({
mutationFn: (data: any) => containerService.createContainer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container created successfully');
},
});
const updateContainer = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => containerService.updateContainer(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container updated successfully');
},
});
const deleteContainer = useMutation({
mutationFn: (id: string) => containerService.deleteContainer(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container deleted successfully');
},
});
return { createContainer, updateContainer, deleteContainer };
}
export function useUnassignContainer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => containerService.unassignFromWagon(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container unassigned from wagon');
},
});
}
export function useAssignContainerToWagon() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ containerId, wagonId, position }: { containerId: string; wagonId: string; position?: number }) =>
containerService.assignToWagon(containerId, wagonId, position),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CONTAINERS_QUERY_KEY });
toast.success('Container assigned to wagon');
},
});
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { wagonTypesService } from './wagon-types.service';
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
export function useWagonTypes() {
return useQuery({
queryKey: WAGON_TYPES_QUERY_KEY,
queryFn: () => wagonTypesService.getWagonTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { wagonService } from './wagon.service';
import { toast } from 'sonner';
export const WAGONS_QUERY_KEY = ['wagons'];
export function useWagons() {
return useQuery({
queryKey: WAGONS_QUERY_KEY,
queryFn: () => wagonService.getWagons(),
});
}
export function useWagonMutations() {
const queryClient = useQueryClient();
const createWagon = useMutation({
mutationFn: (data: any) => wagonService.createWagon(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon created successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to create wagon');
},
});
const updateWagon = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => wagonService.updateWagon(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon updated successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update wagon');
},
});
const deleteWagon = useMutation({
mutationFn: (id: string) => wagonService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WAGONS_QUERY_KEY });
toast.success('Wagon deleted successfully');
},
});
return { createWagon, updateWagon, deleteWagon };
}

View File

@@ -0,0 +1,13 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const wagonTypesService = {
async getWagonTypes() {
const response = await api.get<ListResponse<unknown>>('/wagon-types');
return asList(response.data);
},
};

View File

@@ -0,0 +1,23 @@
import { api } from "../../auth/http";
export const wagonService = {
async getWagons() {
const response = await api.get('/wagons');
return response.data;
},
async getWagonById(id: string) {
const response = await api.get(`/wagons/${id}`);
return response.data;
},
async createWagon(data: any) {
const response = await api.post('/wagons', data);
return response.data;
},
async updateWagon(id: string, data: any) {
const response = await api.patch(`/wagons/${id}`, data);
return response.data;
},
async deleteWagon(id: string) {
await api.delete(`/wagons/${id}`);
},
};

View File

@@ -4,6 +4,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { useWagonTypes } from '@/hooks/use-wagon-types';
import { useCreateWagon, useUpdateWagon } from '@/hooks/useWagons';
import { useToast } from '@/hooks/use-toast';
@@ -25,6 +27,7 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
});
const createWagon = useCreateWagon();
const updateWagon = useUpdateWagon();
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const { toast } = useToast();
useEffect(() => {
@@ -40,6 +43,10 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.wagonNumber || !form.wagonTypeId) {
toast({ title: 'Missing required field', description: 'Please select a wagon type.', variant: 'destructive' });
return;
}
try {
if (wagon) await updateWagon.mutateAsync({ id: wagon.id, data: form });
else await createWagon.mutateAsync(form);
@@ -57,10 +64,37 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
<DialogContent>
<DialogHeader><DialogTitle>{wagon ? 'Edit Wagon' : 'Create Wagon'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div><Label>Wagon Number*</Label><Input required value={form.wagonNumber} onChange={e => setForm({...form, wagonNumber: e.target.value})} /></div>
<div><Label>Wagon Type ID*</Label><Input required value={form.wagonTypeId} onChange={e => setForm({...form, wagonTypeId: e.target.value})} /></div>
<div><Label>Tare Weight (kg)*</Label><Input type="number" required value={form.tareWeight} onChange={e => setForm({...form, tareWeight: parseFloat(e.target.value)})} /></div>
<div><Label>Max Payload (kg)*</Label><Input type="number" required value={form.maxPayloadWeight} onChange={e => setForm({...form, maxPayloadWeight: parseFloat(e.target.value)})} /></div>
<div><Label>Wagon Number*</Label><Input value={form.wagonNumber} onChange={e => setForm({...form, wagonNumber: e.target.value})} /></div>
<div>
<Label>Wagon Type*</Label>
<Select
value={form.wagonTypeId}
disabled={wagonTypesLoading}
onValueChange={(value) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
setForm((current) => ({
...current,
wagonTypeId: value,
maxPayloadWeight: current.maxPayloadWeight > 0
? current.maxPayloadWeight
: Number(selectedType?.capacityTons ?? current.maxPayloadWeight),
}));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
<SelectContent>
{wagonTypes.map((type: any) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div><Label>Tare Weight (kg)*</Label><Input type="number" value={form.tareWeight} onChange={e => setForm({...form, tareWeight: Number(e.target.value)})} /></div>
<div><Label>Max Payload (kg)*</Label><Input type="number" value={form.maxPayloadWeight} onChange={e => setForm({...form, maxPayloadWeight: Number(e.target.value)})} /></div>
<div><Label>Status</Label><Input value={form.status} onChange={e => setForm({...form, status: e.target.value})} /></div>
<div><Label>Notes</Label><Input value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} /></div>
<Button type="submit" disabled={createWagon.isPending || updateWagon.isPending}>Save</Button>
@@ -68,4 +102,4 @@ export function WagonFormDialog({ trigger, wagon, onSuccess }: WagonFormDialogPr
</DialogContent>
</Dialog>
);
}
}

View File

@@ -12,8 +12,10 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { useWagonTypes } from '@/hooks/use-wagon-types';
interface Wagon {
id: string;
@@ -49,6 +51,7 @@ export default function WagonFormDialog({
onSuccess,
}: WagonFormDialogProps) {
const queryClient = useQueryClient();
const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useWagonTypes();
const [formData, setFormData] = useState<Partial<Wagon>>({
wagonNumber: '',
wagonTypeId: '',
@@ -146,15 +149,31 @@ export default function WagonFormDialog({
</div>
<div>
<Label htmlFor="wagonTypeId">Type *</Label>
<Input
id="wagonTypeId"
<Select
value={formData.wagonTypeId || ''}
onChange={(e) =>
setFormData({ ...formData, wagonTypeId: e.target.value })
}
placeholder="e.g., Flat Bed"
required
/>
disabled={wagonTypesLoading}
onValueChange={(value) => {
const selectedType = wagonTypes.find((type: any) => type.id === value);
setFormData({
...formData,
wagonTypeId: value,
capacity: formData.capacity && formData.capacity > 0
? formData.capacity
: Number(selectedType?.capacityTons ?? 0),
});
}}
>
<SelectTrigger id="wagonTypeId">
<SelectValue placeholder="Select wagon type" />
</SelectTrigger>
<SelectContent>
{wagonTypes.map((type: any) => (
<SelectItem key={type.id} value={type.id}>
{type.code} - {type.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { cargoTypesService } from '@/services/cargo-types.service';
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
export function useCargoTypes() {
return useQuery({
queryKey: CARGO_TYPES_QUERY_KEY,
queryFn: () => cargoTypesService.getCargoTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1 @@
export * from '@/components/container_management/use-cargoes';

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { containerTypesService } from '@/services/container-types.service';
export const CONTAINER_TYPES_QUERY_KEY = ['container-types'];
export function useContainerTypes() {
return useQuery({
queryKey: CONTAINER_TYPES_QUERY_KEY,
queryFn: () => containerTypesService.getContainerTypes(),
staleTime: Infinity,
});
}

View File

@@ -0,0 +1 @@
export * from '@/components/container_management/use-containers';

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { wagonTypesService } from '@/services/wagon-types.service';
export const WAGON_TYPES_QUERY_KEY = ['wagon-types'];
export function useWagonTypes() {
return useQuery({
queryKey: WAGON_TYPES_QUERY_KEY,
queryFn: () => wagonTypesService.getWagonTypes(),
staleTime: Infinity,
});
}

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>
);
}

View File

@@ -0,0 +1,15 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const cargoTypesService = {
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -0,0 +1,15 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const containerTypesService = {
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -0,0 +1,13 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const wagonTypesService = {
async getWagonTypes() {
const response = await api.get<ListResponse<unknown>>('/wagon-types');
return asList(response.data);
},
};