Trains management CRUD issues solved

This commit is contained in:
hagiye
2026-06-05 21:07:04 +03:00
parent 9676a6bb56
commit dc42838a43
41 changed files with 2192 additions and 1830 deletions

View File

@@ -24,7 +24,7 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage";
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
import RolesPage from "./pages/dashboard/user-management/RolesPage";
@@ -34,13 +34,15 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
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 { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
//import TrainsPage from "./pages/trains/TrainsPage";
import TrainDetailPage from "./pages/trains/TrainDetailPage";
import WagonsPage from "./pages/wagons/WagonsPage";
import ContainersPage from "./pages/containers_management/ContainersPage";
import CargoesPage from "./pages/cargoes/CargoesPage";
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";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -239,14 +241,13 @@ const App = () => {
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
{/* <Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainsPage />} /> */}
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsPage />} />
<Route path="containers" element={<ContainersPage />} />
<Route path="cargoes" element={<CargoesPage />} />
/>
<Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainMasterDataPage />} />
<Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsCrudPage />} />
<Route path="containers" element={<ContainersCrudPage />} />
<Route path="cargoes" element={<CargoesCrudPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />

View File

@@ -0,0 +1,238 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
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 { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
interface Cargo {
id: string;
cargoReference: string;
description: string;
quantity: number;
weight: number;
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED';
remarks?: string;
}
interface CargoFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
cargo?: Cargo | null;
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoFormDialog({
open,
onOpenChange,
cargo,
onSuccess,
}: CargoFormDialogProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<Partial<Cargo>>({
cargoReference: '',
description: '',
quantity: 0,
weight: 0,
status: 'PENDING',
remarks: '',
});
useEffect(() => {
if (cargo) {
setFormData(cargo);
} else {
setFormData({
cargoReference: '',
description: '',
quantity: 0,
weight: 0,
status: 'PENDING',
remarks: '',
});
}
}, [cargo, open]);
const createMutation = useMutation({
mutationFn: (data: Partial<Cargo>) =>
axios.post(`${API_BASE_URL}/api/cargoes`, data),
onSuccess: () => {
toast.success('Cargo created successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to create cargo'
: 'Failed to create cargo';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Cargo>) =>
axios.patch(`${API_BASE_URL}/api/cargoes/${cargo?.id}`, data),
onSuccess: () => {
toast.success('Cargo updated successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to update cargo'
: 'Failed to update cargo';
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.cargoReference || !formData.description) {
toast.error('Please fill in all required fields');
return;
}
if (cargo?.id) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.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-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="cargoReference">Cargo Reference *</Label>
<Input
id="cargoReference"
value={formData.cargoReference || ''}
onChange={(e) =>
setFormData({ ...formData, cargoReference: e.target.value })
}
placeholder="e.g., CRG001"
required
/>
</div>
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
value={formData.status || 'PENDING'}
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"
>
<option value="PENDING">Pending</option>
<option value="LOADED">Loaded</option>
<option value="IN_TRANSIT">In Transit</option>
<option value="DELIVERED">Delivered</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
</div>
<div>
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
value={formData.description || ''}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe the cargo contents..."
rows={3}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="quantity">Quantity *</Label>
<Input
id="quantity"
type="number"
value={formData.quantity || ''}
onChange={(e) =>
setFormData({
...formData,
quantity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="weight">Weight (kg) *</Label>
<Input
id="weight"
type="number"
value={formData.weight || ''}
onChange={(e) =>
setFormData({
...formData,
weight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={2}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<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

@@ -0,0 +1,263 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
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 { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
interface Container {
id: string;
containerNumber: string;
containerTypeId: string;
wagonId?: string;
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
capacity: number;
weight: number;
remarks?: string;
}
interface Wagon {
id: string;
wagonNumber: string;
}
interface ContainerFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
container?: Container | null;
wagons: Wagon[];
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function ContainerFormDialog({
open,
onOpenChange,
container,
wagons = [],
onSuccess,
}: ContainerFormDialogProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<Partial<Container>>({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
useEffect(() => {
if (container) {
setFormData(container);
} else {
setFormData({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
}
}, [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) {
toast.error('Please fill in all required fields');
return;
}
if (container?.id) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{container ? 'Edit Container' : 'Create New Container'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="containerNumber">Container Number *</Label>
<Input
id="containerNumber"
value={formData.containerNumber || ''}
onChange={(e) =>
setFormData({ ...formData, containerNumber: e.target.value })
}
placeholder="e.g., CNT001"
required
/>
</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
/>
</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"
>
<option value="">Select a wagon...</option>
{wagons.map(wagon => (
<option key={wagon.id} value={wagon.id}>
{wagon.wagonNumber}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
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"
>
<option value="AVAILABLE">Available</option>
<option value="IN_USE">In Use</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="RETIRED">Retired</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="capacity">Capacity *</Label>
<Input
id="capacity"
type="number"
value={formData.capacity || ''}
onChange={(e) =>
setFormData({
...formData,
capacity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="weight">Weight (kg)</Label>
<Input
id="weight"
type="number"
value={formData.weight || ''}
onChange={(e) =>
setFormData({
...formData,
weight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{container ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,263 @@
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
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 { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
interface Wagon {
id: string;
wagonNumber: string;
wagonTypeId: string;
trainId?: string;
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
capacity: number;
emptyWeight: number;
remarks?: string;
}
interface Train {
id: string;
trainNumber: string;
}
interface WagonFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
wagon?: Wagon | null;
trains: Train[];
onSuccess?: () => void;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function WagonFormDialog({
open,
onOpenChange,
wagon,
trains = [],
onSuccess,
}: WagonFormDialogProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<Partial<Wagon>>({
wagonNumber: '',
wagonTypeId: '',
status: 'AVAILABLE',
capacity: 0,
emptyWeight: 0,
remarks: '',
});
useEffect(() => {
if (wagon) {
setFormData(wagon);
} else {
setFormData({
wagonNumber: '',
wagonTypeId: '',
status: 'AVAILABLE',
capacity: 0,
emptyWeight: 0,
remarks: '',
});
}
}, [wagon, open]);
const createMutation = useMutation({
mutationFn: (data: Partial<Wagon>) =>
axios.post(`${API_BASE_URL}/api/wagons`, data),
onSuccess: () => {
toast.success('Wagon created successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['wagons'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to create wagon'
: 'Failed to create wagon';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<Wagon>) =>
axios.patch(`${API_BASE_URL}/api/wagons/${wagon?.id}`, data),
onSuccess: () => {
toast.success('Wagon updated successfully');
onOpenChange(false);
queryClient.invalidateQueries({ queryKey: ['wagons'] });
onSuccess?.();
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to update wagon'
: 'Failed to update wagon';
toast.error(message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.wagonNumber || !formData.wagonTypeId) {
toast.error('Please fill in all required fields');
return;
}
if (wagon?.id) {
updateMutation.mutate(formData);
} else {
createMutation.mutate(formData);
}
};
const isLoading = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{wagon ? 'Edit Wagon' : 'Create New Wagon'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="wagonNumber">Wagon Number *</Label>
<Input
id="wagonNumber"
value={formData.wagonNumber || ''}
onChange={(e) =>
setFormData({ ...formData, wagonNumber: e.target.value })
}
placeholder="e.g., W001"
required
/>
</div>
<div>
<Label htmlFor="wagonTypeId">Type *</Label>
<Input
id="wagonTypeId"
value={formData.wagonTypeId || ''}
onChange={(e) =>
setFormData({ ...formData, wagonTypeId: e.target.value })
}
placeholder="e.g., Flat Bed"
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="trainId">Train (Optional)</Label>
<select
id="trainId"
value={formData.trainId || ''}
onChange={(e) =>
setFormData({ ...formData, trainId: 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"
>
<option value="">Select a train...</option>
{trains.map(train => (
<option key={train.id} value={train.id}>
{train.trainNumber}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<select
id="status"
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"
>
<option value="AVAILABLE">Available</option>
<option value="IN_USE">In Use</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="RETIRED">Retired</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="capacity">Capacity *</Label>
<Input
id="capacity"
type="number"
value={formData.capacity || ''}
onChange={(e) =>
setFormData({
...formData,
capacity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="emptyWeight">Empty Weight (kg)</Label>
<Input
id="emptyWeight"
type="number"
value={formData.emptyWeight || ''}
onChange={(e) =>
setFormData({
...formData,
emptyWeight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{wagon ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,7 +1,7 @@
import { useWagonsByTrain, useUnassignWagon, useReorderWagons } from '@/hooks/useWagons';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Trash2, GripVertical } from 'lucide-react';
//import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
//import { Button } from '@/components/ui/button';
//import { Trash2, GripVertical } from 'lucide-react';
// import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
export function WagonsTable({ trainId }: { trainId: string }) {

View File

@@ -77,6 +77,7 @@ export const URL_CONSTANTS = {
BOOKINGS: {
BASE: "/bookings",
REFERENCE_DATA: "/bookings/reference-data",
LIST_SUMMARY: "/bookings/list-summary",
BY_ID: (id: string) => `/bookings/${id}`,
QUEUE: (queue: string) => `/bookings/queues/${queue}`,

View File

@@ -4,16 +4,44 @@ import { cargoService } from '@/services/cargoService';
export const cargoKeys = {
all: ['cargoes'] as const,
byContainer: (containerId: string) => [...cargoKeys.all, 'container', containerId] as const,
details: () => [...cargoKeys.all, 'detail'] as const,
detail: (id: string) => [...cargoKeys.details(), id] as const,
};
export function useCargoes() {
return useQuery({ queryKey: cargoKeys.all, queryFn: () => cargoService.getAll().then(res => res.data) });
}
export const useGetCargoes = useCargoes;
export function useCargoesByContainer(containerId: string) {
return useQuery({ queryKey: cargoKeys.byContainer(containerId), queryFn: () => cargoService.getByContainer(containerId).then(res => res.data), enabled: !!containerId });
}
export function useCargo(id: string) {
return useQuery({ queryKey: cargoKeys.detail(id), queryFn: () => cargoService.getById(id).then(res => res.data), enabled: !!id });
}
export const useGetCargo = useCargo;
export function useCreateCargo() {
const qc = useQueryClient();
return useMutation({ mutationFn: cargoService.create, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) });
}
export function useUpdateCargo() {
const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, data }: any) => cargoService.update(id, data), onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: cargoKeys.all });
qc.invalidateQueries({ queryKey: cargoKeys.detail(id) });
} });
}
export function useDeleteCargo() {
const qc = useQueryClient();
return useMutation({ mutationFn: cargoService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all }) });
}
export function useLoadCargo() {
const qc = useQueryClient();
return useMutation({
@@ -36,4 +64,4 @@ export function useUnloadCargo() {
mutationFn: (id: string) => cargoService.unload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: cargoKeys.all })
});
}
}

View File

@@ -4,16 +4,44 @@ import { containerService } from '@/services/containerService';
export const containerKeys = {
all: ['containers'] as const,
byWagon: (wagonId: string) => [...containerKeys.all, 'wagon', wagonId] as const,
details: () => [...containerKeys.all, 'detail'] as const,
detail: (id: string) => [...containerKeys.details(), id] as const,
};
export function useContainers() {
return useQuery({ queryKey: containerKeys.all, queryFn: () => containerService.getAll().then(res => res.data) });
}
export const useGetContainers = useContainers;
export function useContainersByWagon(wagonId: string) {
return useQuery({ queryKey: containerKeys.byWagon(wagonId), queryFn: () => containerService.getByWagon(wagonId).then(res => res.data), enabled: !!wagonId });
}
export function useContainer(id: string) {
return useQuery({ queryKey: containerKeys.detail(id), queryFn: () => containerService.getById(id).then(res => res.data), enabled: !!id });
}
export const useGetContainer = useContainer;
export function useCreateContainer() {
const qc = useQueryClient();
return useMutation({ mutationFn: containerService.create, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) });
}
export function useUpdateContainer() {
const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, data }: any) => containerService.update(id, data), onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: containerKeys.all });
qc.invalidateQueries({ queryKey: containerKeys.detail(id) });
} });
}
export function useDeleteContainer() {
const qc = useQueryClient();
return useMutation({ mutationFn: containerService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all }) });
}
export function useAssignContainerToWagon() {
const qc = useQueryClient();
return useMutation({
@@ -28,4 +56,4 @@ export function useUnassignContainer() {
mutationFn: containerService.unassign,
onSuccess: () => qc.invalidateQueries({ queryKey: containerKeys.all })
});
}
}

View File

@@ -12,10 +12,14 @@ export function useTrains() {
return useQuery({ queryKey: trainKeys.lists(), queryFn: () => trainService.getAll().then(res => res.data) });
}
export const useGetTrains = useTrains;
export function useTrain(id: string) {
return useQuery({ queryKey: trainKeys.detail(id), queryFn: () => trainService.getById(id).then(res => res.data), enabled: !!id });
}
export const useGetTrain = useTrain;
export function useCreateTrain() {
const qc = useQueryClient();
return useMutation({ mutationFn: trainService.create, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
@@ -32,4 +36,4 @@ export function useUpdateTrain() {
export function useDeleteTrain() {
const qc = useQueryClient();
return useMutation({ mutationFn: trainService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: trainKeys.lists() }) });
}
}

View File

@@ -5,16 +5,25 @@ export const wagonKeys = {
all: ['wagons'] as const,
byTrain: (trainId: string) => [...wagonKeys.all, 'train', trainId] as const,
details: () => [...wagonKeys.all, 'detail'] as const,
detail: (id: string) => [...wagonKeys.details(), id] as const,
};
export function useWagons() {
return useQuery({ queryKey: wagonKeys.all, queryFn: () => wagonService.getAll().then(res => res.data) });
}
export const useGetWagons = useWagons;
export function useWagonsByTrain(trainId: string) {
return useQuery({ queryKey: wagonKeys.byTrain(trainId), queryFn: () => wagonService.getByTrain(trainId).then(res => res.data), enabled: !!trainId });
}
export function useWagon(id: string) {
return useQuery({ queryKey: wagonKeys.detail(id), queryFn: () => wagonService.getById(id).then(res => res.data), enabled: !!id });
}
export const useGetWagon = useWagon;
export function useAssignWagonToTrain() {
const qc = useQueryClient();
return useMutation({ mutationFn: ({ wagonId, trainId, sequenceNumber }: any) => wagonService.assignToTrain(wagonId, trainId, sequenceNumber), onSuccess: (_, { trainId }) => qc.invalidateQueries({ queryKey: wagonKeys.byTrain(trainId) }) });
@@ -37,5 +46,13 @@ export function useCreateWagon() {
export function useUpdateWagon() {
const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
}
return useMutation({ mutationFn: ({ id, data }: any) => wagonService.update(id, data), onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: wagonKeys.all });
qc.invalidateQueries({ queryKey: wagonKeys.detail(id) });
} });
}
export function useDeleteWagon() {
const qc = useQueryClient();
return useMutation({ mutationFn: wagonService.delete, onSuccess: () => qc.invalidateQueries({ queryKey: wagonKeys.all }) });
}

View File

@@ -0,0 +1,314 @@
import { useState, useMemo } from 'react';
import { useCargoes } from '@/hooks/useCargoes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Trash2, Edit, Plus, Search, AlertCircle } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import axios from 'axios';
import CargoFormDialog from '@/components/cargoes/CargoFormDialog';
interface Cargo {
id: string;
cargoReference: string;
description: string;
quantity: number;
weight: number;
status: 'PENDING' | 'LOADED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED';
remarks?: string;
createdAt: Date;
updatedAt: Date;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function CargoesPageEnhanced() {
const { data: cargoes = [], isLoading, refetch } = useCargoes();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCargo, setEditingCargo] = useState<Cargo | null>(null);
const deleteMutation = useMutation({
mutationFn: (cargoId: string) =>
axios.delete(`${API_BASE_URL}/api/cargoes/${cargoId}`),
onSuccess: () => {
toast.success('Cargo deleted successfully');
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargo'
: 'Failed to delete cargo';
toast.error(message);
},
});
const bulkDeleteMutation = useMutation({
mutationFn: (ids: string[]) =>
Promise.all(ids.map(id => axios.delete(`${API_BASE_URL}/api/cargoes/${id}`))),
onSuccess: () => {
toast.success('Cargoes deleted successfully');
setSelectedIds(new Set());
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
},
onError: (error) => {
const message = axios.isAxiosError(error)
? error.response?.data?.message || 'Failed to delete cargoes'
: 'Failed to delete cargoes';
toast.error(message);
},
});
const filteredCargoes = useMemo(() => {
let result = cargoes;
if (searchTerm) {
const lower = searchTerm.toLowerCase();
result = result.filter(
cargo =>
cargo.cargoReference?.toLowerCase().includes(lower) ||
cargo.description?.toLowerCase().includes(lower)
);
}
if (statusFilter) {
result = result.filter(cargo => cargo.status === statusFilter);
}
return result;
}, [cargoes, searchTerm, statusFilter]);
const toggleSelect = (cargoId: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(cargoId)) {
newSelected.delete(cargoId);
} else {
newSelected.add(cargoId);
}
setSelectedIds(newSelected);
};
const toggleSelectAll = () => {
if (selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(filteredCargoes.map(c => c.id)));
}
};
const handleFormSuccess = () => {
setIsFormOpen(false);
setEditingCargo(null);
refetch();
queryClient.invalidateQueries({ queryKey: ['cargoes'] });
};
const handleEdit = (cargo: Cargo) => {
setEditingCargo(cargo);
setIsFormOpen(true);
};
const handleDelete = (cargoId: string) => {
if (window.confirm('Are you sure you want to delete this cargo?')) {
deleteMutation.mutate(cargoId);
}
};
const handleBulkDelete = () => {
if (selectedIds.size === 0) {
toast.error('Please select at least one cargo');
return;
}
if (window.confirm(`Delete ${selectedIds.size} cargo(s)?`)) {
bulkDeleteMutation.mutate(Array.from(selectedIds));
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'PENDING':
return 'bg-gray-100 text-gray-800';
case 'LOADED':
return 'bg-blue-100 text-blue-800';
case 'IN_TRANSIT':
return 'bg-purple-100 text-purple-800';
case 'DELIVERED':
return 'bg-green-100 text-green-800';
case 'CANCELLED':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const statuses = ['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'CANCELLED'];
if (isLoading) {
return <div className="p-6">Loading cargoes...</div>;
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Cargoes Management</h1>
<Button onClick={() => {
setEditingCargo(null);
setIsFormOpen(true);
}}>
<Plus className="mr-2 h-4 w-4" />
New Cargo
</Button>
</div>
{/* Filters and Search */}
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1">
<label className="text-sm font-medium mb-1 block">Search</label>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by reference or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="w-48">
<label className="text-sm font-medium mb-1 block">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All statuses</option>
{statuses.map(status => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</div>
</div>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 bg-blue-50 p-3 rounded-md">
<span className="text-sm text-gray-600">{selectedIds.size} selected</span>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={bulkDeleteMutation.isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Cargoes Table */}
<Card>
<CardHeader>
<CardTitle>All Cargoes ({filteredCargoes.length})</CardTitle>
</CardHeader>
<CardContent>
{filteredCargoes.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<AlertCircle className="mr-2 h-5 w-5" />
No cargoes found
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={selectedIds.size === filteredCargoes.length && filteredCargoes.length > 0}
onChange={toggleSelectAll}
className="rounded"
/>
</TableHead>
<TableHead>Cargo Reference</TableHead>
<TableHead>Description</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Weight (kg)</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCargoes.map((cargo) => (
<TableRow key={cargo.id}>
<TableCell>
<input
type="checkbox"
checked={selectedIds.has(cargo.id)}
onChange={() => toggleSelect(cargo.id)}
className="rounded"
/>
</TableCell>
<TableCell className="font-medium">{cargo.cargoReference}</TableCell>
<TableCell className="max-w-xs truncate">{cargo.description}</TableCell>
<TableCell>{cargo.quantity}</TableCell>
<TableCell>{cargo.weight}</TableCell>
<TableCell>
<Badge className={getStatusColor(cargo.status)}>
{cargo.status}
</Badge>
</TableCell>
<TableCell>
{new Date(cargo.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(cargo)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(cargo.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Form Dialog */}
<CargoFormDialog
open={isFormOpen}
onOpenChange={setIsFormOpen}
cargo={editingCargo}
onSuccess={handleFormSuccess}
/>
</div>
);
}

View File

@@ -0,0 +1,464 @@
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useToast } from '@/hooks/use-toast';
import { useCreateCargo, useDeleteCargo, useCargoes, useUpdateCargo } from '@/hooks/useCargoes';
import {
useContainers,
useCreateContainer,
useDeleteContainer,
useUpdateContainer,
} from '@/hooks/useContainers';
import { useCreateTrain, useDeleteTrain, useTrains, useUpdateTrain } from '@/hooks/useTrains';
import { useCreateWagon, useDeleteWagon, useUpdateWagon, useWagons } from '@/hooks/useWagons';
import type { Cargo } from '@/services/cargoService';
import type { Container } from '@/services/containerService';
import type { Train } from '@/services/trains.service';
import type { Wagon } from '@/services/wagon.service';
type Field = {
key: string;
label: string;
type?: 'text' | 'number';
required?: boolean;
};
type Column<T> = {
key: keyof T | string;
label: string;
render?: (item: T) => ReactNode;
};
type FleetCrudPageProps<T extends { id: string }> = {
title: string;
description: string;
addLabel: string;
data?: T[];
isLoading: boolean;
columns: Column<T>[];
fields: Field[];
emptyValues: Record<string, string | number>;
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>) =>
Object.fromEntries(
Object.entries(values)
.map(([key, value]) => [key, typeof value === 'string' ? value.trim() : value])
.filter(([, value]) => value !== ''),
);
function FleetCrudPage<T extends { id: string }>({
title,
description,
addLabel,
data,
isLoading,
columns,
fields,
emptyValues,
searchText,
create,
update,
remove,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<T | null>(null);
const [viewing, setViewing] = useState<T | null>(null);
const [form, setForm] = useState(emptyValues);
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
const left = (a as Record<string, unknown>)[sortKey];
const right = (b as Record<string, unknown>)[sortKey];
const result = String(left ?? '').localeCompare(String(right ?? ''), undefined, { numeric: true });
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageSize = 10;
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
}
setSortKey(key);
setSortDirection('asc');
};
const openCreate = () => {
setEditing(null);
setForm(emptyValues);
setFormOpen(true);
};
const openEdit = (item: T) => {
setEditing(item);
setForm(
Object.fromEntries(
Object.keys(emptyValues).map((key) => [key, (item as Record<string, string | number | null | undefined>)[key] ?? '']),
),
);
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditing(null);
setForm(emptyValues);
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const payload = normalizePayload(form);
try {
if (editing) {
await update.mutateAsync({ id: editing.id, data: payload });
toast({ title: `${title.slice(0, -1)} updated` });
} else {
await create.mutateAsync(payload);
toast({ title: `${title.slice(0, -1)} created` });
}
closeForm();
} catch {
toast({ title: 'Save failed', description: 'Please check the fields and try again.', variant: 'destructive' });
}
};
const handleDelete = async (item: T) => {
if (!window.confirm(`Delete this ${title.slice(0, -1).toLowerCase()}?`)) return;
try {
await remove.mutateAsync(item.id);
toast({ title: `${title.slice(0, -1)} deleted` });
} catch {
toast({ title: 'Delete failed', description: 'This record may still be referenced.', variant: 'destructive' });
}
};
const isSaving = create.isPending || update.isPending;
return (
<div className="space-y-5 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
{addLabel}
</Button>
</div>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder={`Search ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead key={String(column.key)}>
<button
type="button"
className="inline-flex items-center gap-1 font-medium"
onClick={() => toggleSort(String(column.key))}
>
{column.label}
{sortKey === column.key ? (sortDirection === 'asc' ? 'ASC' : 'DESC') : null}
</button>
</TableHead>
))}
<TableHead className="w-[150px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paged.map((item) => (
<TableRow key={item.id}>
{columns.map((column) => (
<TableCell key={String(column.key)}>
{column.render ? column.render(item) : String((item as Record<string, unknown>)[column.key] ?? '-')}
</TableCell>
))}
<TableCell>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
<Eye className="size-4" />
</Button>
<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">
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!isLoading && filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
No records found.
</TableCell>
</TableRow>
) : null}
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="h-28 text-center text-muted-foreground">
Loading...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
Next
</Button>
</div>
</div>
<Dialog open={formOpen} onOpenChange={(open) => (!open ? closeForm() : setFormOpen(true))}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `Edit ${title.slice(0, -1)}` : addLabel}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{fields.map((field) => (
<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>
))}
<DialogFooter>
<Button type="button" variant="outline" onClick={closeForm}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(viewing)} onOpenChange={(open) => (!open ? setViewing(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title.slice(0, -1)} details</DialogTitle>
</DialogHeader>
<div className="grid gap-3 text-sm">
{viewing
? Object.entries(viewing).map(([key, value]) => (
<div key={key} className="grid grid-cols-[150px,1fr] gap-3 border-b pb-2">
<span className="font-medium">{key}</span>
<span className="break-all text-muted-foreground">{value == null ? '-' : String(value)}</span>
</div>
))
: null}
</div>
</DialogContent>
</Dialog>
</div>
);
}
const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'}</Badge>;
export function TrainMasterDataPage() {
const query = useTrains();
return (
<FleetCrudPage<Train>
title="Trains"
description="Manage train master data independently from train scheduling."
addLabel="Add Train"
data={query.data}
isLoading={query.isLoading}
create={useCreateTrain()}
update={useUpdateTrain()}
remove={useDeleteTrain()}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
{ key: 'trainNumber', label: 'Number', render: (train) => train.trainNumber || '-' },
{ key: 'trainName', label: 'Name', render: (train) => train.trainName || '-' },
{ key: 'capacityTons', label: 'Capacity (tons)' },
{ key: 'status', label: 'Status', render: (train) => statusBadge(train.status) },
]}
fields={[
{ key: 'code', label: 'Code', required: true },
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
{ key: 'trainNumber', label: 'Train number' },
{ key: 'trainName', label: 'Train name' },
{ key: 'locomotiveNumber', label: 'Locomotive number' },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
{ key: 'remarks', label: 'Remarks' },
]}
emptyValues={{ code: '', capacityTons: 0, trainNumber: '', trainName: '', locomotiveNumber: '', status: 'AVAILABLE', notes: '', remarks: '' }}
/>
);
}
export function WagonsCrudPage() {
const query = useWagons();
return (
<FleetCrudPage<Wagon>
title="Wagons"
description="Manage wagon master data. Booking-based train assignment is handled in train scheduling."
addLabel="Add Wagon"
data={query.data}
isLoading={query.isLoading}
create={useCreateWagon()}
update={useUpdateWagon()}
remove={useDeleteWagon()}
searchText={(wagon) => [wagon.wagonNumber, wagon.wagonTypeId, wagon.trainId, wagon.status].join(' ')}
columns={[
{ key: 'wagonNumber', label: 'Number' },
{ key: 'wagonTypeId', label: 'Type ID' },
{ 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: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxPayloadWeight', label: 'Max payload weight', type: 'number', required: true },
{ key: 'status', label: 'Status' },
{ key: 'notes', label: 'Notes' },
]}
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
/>
);
}
export function ContainersCrudPage() {
const query = useContainers();
return (
<FleetCrudPage<Container>
title="Containers"
description="Manage container master data and wagon assignments."
addLabel="Add Container"
data={query.data}
isLoading={query.isLoading}
create={useCreateContainer()}
update={useUpdateContainer()}
remove={useDeleteContainer()}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
{ key: 'containerTypeId', label: 'Type ID' },
{ key: 'wagonId', label: 'Wagon', render: (container) => container.wagonId || 'Unassigned' },
{ 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: 'position', label: 'Position', type: 'number' },
{ key: 'tareWeight', label: 'Tare weight', type: 'number', required: true },
{ key: 'maxGrossWeight', label: 'Max gross weight', type: 'number', required: true },
{ key: 'sealNumber', label: 'Seal number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ containerNumber: '', containerTypeId: '', wagonId: '', position: '', tareWeight: 0, maxGrossWeight: 0, sealNumber: '', status: 'AVAILABLE' }}
/>
);
}
export function CargoesCrudPage() {
const query = useCargoes();
return (
<FleetCrudPage<Cargo>
title="Cargoes"
description="Manage cargo records linked to containers."
addLabel="Add Cargo"
data={query.data}
isLoading={query.isLoading}
create={useCreateCargo()}
update={useUpdateCargo()}
remove={useDeleteCargo()}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
{ key: 'containerId', label: 'Container ID' },
{ key: 'quantity', label: 'Quantity' },
{ key: 'weight', label: 'Weight' },
{ key: 'status', label: 'Status', render: (cargo) => statusBadge(cargo.status) },
]}
fields={[
{ key: 'cargoReference', label: 'Cargo reference', required: true },
{ key: 'shipmentId', label: 'Shipment ID', required: true },
{ key: 'containerId', label: 'Container ID', required: true },
{ key: 'cargoTypeId', label: 'Cargo type ID' },
{ key: 'description', label: 'Description' },
{ key: 'quantity', label: 'Quantity', type: 'number', required: true },
{ key: 'weight', label: 'Weight', type: 'number', required: true },
{ key: 'volume', label: 'Volume', type: 'number' },
{ key: 'status', label: 'Status' },
]}
emptyValues={{ cargoReference: '', shipmentId: '', containerId: '', cargoTypeId: '', description: '', quantity: 0, weight: 0, volume: '', status: 'PENDING' }}
/>
);
}

View File

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

View File

@@ -19,8 +19,11 @@ export interface Cargo {
export const cargoService = {
getAll: () => apiClient.get<Cargo[]>('/cargoes'),
getById: (id: string) => apiClient.get<Cargo>(`/cargoes/${id}`),
getByContainer: (containerId: string) => apiClient.get<Cargo[]>(`/cargoes?containerId=${containerId}`),
create: (data: any) => apiClient.post('/cargoes', data),
create: (data: Partial<Cargo>) => apiClient.post('/cargoes', data),
update: (id: string, data: Partial<Cargo>) => apiClient.patch(`/cargoes/${id}`, data),
delete: (id: string) => apiClient.delete(`/cargoes/${id}`),
load: (cargoId: string, quantity: number, weight: number, volume?: number) =>
apiClient.post(`/cargoes/${cargoId}/load`, { quantity, weight, volume }),
deliver: (cargoId: string) => apiClient.post(`/cargoes/${cargoId}/deliver`),

View File

@@ -16,8 +16,12 @@ export interface Container {
export const containerService = {
getAll: () => apiClient.get<Container[]>('/containers'),
getById: (id: string) => apiClient.get<Container>(`/containers/${id}`),
getByWagon: (wagonId: string) => apiClient.get<Container[]>(`/containers?wagonId=${wagonId}`),
create: (data: Partial<Container>) => apiClient.post('/containers', data),
update: (id: string, data: Partial<Container>) => apiClient.patch(`/containers/${id}`, data),
delete: (id: string) => apiClient.delete(`/containers/${id}`),
assignToWagon: (containerId: string, wagonId: string, position?: number) =>
apiClient.post(`/containers/${containerId}/assign-wagon`, { wagonId, position }),
unassign: (containerId: string) => apiClient.post(`/containers/${containerId}/unassign-wagon`),
};
};

View File

@@ -14,7 +14,7 @@ import type {
} from '@/types/trainScheduling';
interface BookingReferenceDataResponse {
yard?: YardOption[];
yard?: Array<YardOption & { label?: string }>;
}
export const trainSchedulingService = {
@@ -82,6 +82,11 @@ export const trainSchedulingService = {
URL_CONSTANTS.BOOKINGS.REFERENCE_DATA,
);
const data = unwrap(response.data);
return data.yard ?? [];
return (data.yard ?? []).map((yard) => ({
id: yard.id,
name: yard.name ?? yard.label ?? yard.code,
code: yard.code,
country: yard.country,
}));
},
};

View File

@@ -14,13 +14,14 @@ export interface Wagon {
export const wagonService = {
getAll: () => apiClient.get<Wagon[]>('/wagons'),
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
reorder: (trainId: string, wagonIds: string[]) =>
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
};
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
};