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

@@ -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' }}
/>
);
}