|
|
|
|
@@ -0,0 +1,826 @@
|
|
|
|
|
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 { 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 {
|
|
|
|
|
useCreateWagonType,
|
|
|
|
|
useDeleteWagonType,
|
|
|
|
|
useUpdateWagonType,
|
|
|
|
|
useWagonTypes,
|
|
|
|
|
} from '@/hooks/use-wagon-types';
|
|
|
|
|
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 {
|
|
|
|
|
useCreateLocomotive,
|
|
|
|
|
useDecommissionLocomotive,
|
|
|
|
|
useLocomotives,
|
|
|
|
|
useUpdateLocomotive,
|
|
|
|
|
} from '@/hooks/useLocomotives';
|
|
|
|
|
import type { Cargo } from '@/services/cargoService';
|
|
|
|
|
import type { Container } from '@/services/containerService';
|
|
|
|
|
import type { Locomotive } from '@/services/locomotives.service';
|
|
|
|
|
import type { Train } from '@/services/trains.service';
|
|
|
|
|
import type { Wagon } from '@/services/wagon.service';
|
|
|
|
|
import type { WagonType } from '@/services/wagon-types.service';
|
|
|
|
|
|
|
|
|
|
type FormValue = string | number | boolean | string[];
|
|
|
|
|
|
|
|
|
|
type Field = {
|
|
|
|
|
key: string;
|
|
|
|
|
label: string;
|
|
|
|
|
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> = {
|
|
|
|
|
key: keyof T | string;
|
|
|
|
|
label: string;
|
|
|
|
|
render?: (item: T) => ReactNode;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type FleetCrudPageProps<T extends { id: string }> = {
|
|
|
|
|
title: string;
|
|
|
|
|
description: string;
|
|
|
|
|
addLabel: string;
|
|
|
|
|
entityLabel?: string;
|
|
|
|
|
data?: T[];
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
columns: Column<T>[];
|
|
|
|
|
fields: Field[];
|
|
|
|
|
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 };
|
|
|
|
|
removeActionLabel?: string;
|
|
|
|
|
removeConfirmMessage?: string;
|
|
|
|
|
removeSuccessMessage?: string;
|
|
|
|
|
hideViewAction?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizePayload = (values: Record<string, FormValue>) =>
|
|
|
|
|
Object.fromEntries(
|
|
|
|
|
Object.entries(values)
|
|
|
|
|
.map(([key, value]) => [
|
|
|
|
|
key,
|
|
|
|
|
key === 'supportedLoadTypes' && typeof value === 'string'
|
|
|
|
|
? value
|
|
|
|
|
.split(',')
|
|
|
|
|
.map((entry) => entry.trim())
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
: Array.isArray(value)
|
|
|
|
|
? value
|
|
|
|
|
: typeof value === 'string'
|
|
|
|
|
? value.trim()
|
|
|
|
|
: value,
|
|
|
|
|
])
|
|
|
|
|
.filter(([, value]) => value !== '' && !(Array.isArray(value) && value.length === 0)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
addLabel,
|
|
|
|
|
entityLabel,
|
|
|
|
|
data,
|
|
|
|
|
isLoading,
|
|
|
|
|
columns,
|
|
|
|
|
fields,
|
|
|
|
|
emptyValues,
|
|
|
|
|
searchText,
|
|
|
|
|
create,
|
|
|
|
|
update,
|
|
|
|
|
remove,
|
|
|
|
|
removeActionLabel = 'Delete',
|
|
|
|
|
removeConfirmMessage,
|
|
|
|
|
removeSuccessMessage,
|
|
|
|
|
hideViewAction = false,
|
|
|
|
|
}: 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 [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
|
|
|
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);
|
|
|
|
|
setFieldErrors({});
|
|
|
|
|
setFormOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openEdit = (item: T) => {
|
|
|
|
|
setEditing(item);
|
|
|
|
|
setForm(
|
|
|
|
|
Object.fromEntries(
|
|
|
|
|
Object.keys(emptyValues).map((key) => [
|
|
|
|
|
key,
|
|
|
|
|
(item as Record<string, FormValue | null | undefined>)[key] ?? '',
|
|
|
|
|
]),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
setFieldErrors({});
|
|
|
|
|
setFormOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closeForm = () => {
|
|
|
|
|
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) {
|
|
|
|
|
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 (error) {
|
|
|
|
|
const { message, fieldErrors: backendFieldErrors } = extractBackendErrors(error);
|
|
|
|
|
setFieldErrors(backendFieldErrors);
|
|
|
|
|
toast({ title: 'Save failed', description: message, variant: 'destructive' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (item: T) => {
|
|
|
|
|
const normalizedEntityLabel = entityLabel ?? title.slice(0, -1);
|
|
|
|
|
if (!window.confirm(removeConfirmMessage ?? `${removeActionLabel} this ${normalizedEntityLabel.toLowerCase()}?`)) return;
|
|
|
|
|
try {
|
|
|
|
|
await remove.mutateAsync(item.id);
|
|
|
|
|
toast({ title: removeSuccessMessage ?? `${normalizedEntityLabel} ${removeActionLabel.toLowerCase()}ed` });
|
|
|
|
|
} catch {
|
|
|
|
|
toast({ title: `${removeActionLabel} 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">
|
|
|
|
|
{!hideViewAction ? (
|
|
|
|
|
<Button variant="ghost" size="icon" onClick={() => setViewing(item)} title="View">
|
|
|
|
|
<Eye className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
<Button variant="ghost" size="icon" onClick={() => openEdit(item)} title="Edit">
|
|
|
|
|
<Edit className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(item)} title={removeActionLabel}>
|
|
|
|
|
<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) => {
|
|
|
|
|
const value = form[field.key] ?? '';
|
|
|
|
|
const inputValue = field.type === 'number' && value !== '' && !Number.isFinite(Number(value))
|
|
|
|
|
? ''
|
|
|
|
|
: Array.isArray(value)
|
|
|
|
|
? value.join(', ')
|
|
|
|
|
: typeof value === 'boolean'
|
|
|
|
|
? String(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
|
|
|
|
|
</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>;
|
|
|
|
|
|
|
|
|
|
const activeBadge = (isActive?: boolean) => (
|
|
|
|
|
<Badge variant={isActive === false ? 'secondary' : 'outline'}>
|
|
|
|
|
{isActive === false ? 'Inactive' : 'Active'}
|
|
|
|
|
</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 (
|
|
|
|
|
<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 WagonTypesCrudPage() {
|
|
|
|
|
const query = useWagonTypes();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<FleetCrudPage<WagonType>
|
|
|
|
|
title="Wagon Types"
|
|
|
|
|
description="Manage wagon type capacities and load compatibility used by wagon master data."
|
|
|
|
|
addLabel="Add Wagon Type"
|
|
|
|
|
data={query.data}
|
|
|
|
|
isLoading={query.isLoading}
|
|
|
|
|
create={useCreateWagonType()}
|
|
|
|
|
update={useUpdateWagonType()}
|
|
|
|
|
remove={useDeleteWagonType()}
|
|
|
|
|
searchText={(type) =>
|
|
|
|
|
[type.code, type.name, type.supportedLoadTypes?.join(' '), String(type.isActive)].join(' ')
|
|
|
|
|
}
|
|
|
|
|
columns={[
|
|
|
|
|
{ key: 'code', label: 'Code' },
|
|
|
|
|
{ key: 'name', label: 'Name' },
|
|
|
|
|
{ key: 'capacityTons', label: 'Capacity (tons)' },
|
|
|
|
|
{ key: 'lengthMeters', label: 'Length (m)' },
|
|
|
|
|
{
|
|
|
|
|
key: 'supportedLoadTypes',
|
|
|
|
|
label: 'Load types',
|
|
|
|
|
render: (type) => type.supportedLoadTypes?.join(', ') || '-',
|
|
|
|
|
},
|
|
|
|
|
{ key: 'isActive', label: 'Status', render: (type) => activeBadge(type.isActive) },
|
|
|
|
|
]}
|
|
|
|
|
fields={[
|
|
|
|
|
{ key: 'code', label: 'Code', required: true },
|
|
|
|
|
{ key: 'name', label: 'Name', required: true },
|
|
|
|
|
{ key: 'capacityTons', label: 'Capacity (tons)', type: 'number', required: true },
|
|
|
|
|
{ key: 'lengthMeters', label: 'Length (meters)', type: 'number', required: true },
|
|
|
|
|
{ key: 'maxWagonsPerTrain', label: 'Max wagons per train', type: 'number' },
|
|
|
|
|
{
|
|
|
|
|
key: 'supportedLoadTypes',
|
|
|
|
|
label: 'Supported load types',
|
|
|
|
|
placeholder: 'container, break-bulk',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'isActive',
|
|
|
|
|
label: 'Status',
|
|
|
|
|
type: 'select',
|
|
|
|
|
options: [
|
|
|
|
|
{ value: 'true', label: 'Active' },
|
|
|
|
|
{ value: 'false', label: 'Inactive' },
|
|
|
|
|
],
|
|
|
|
|
onValueChange: (value) => ({ isActive: value === 'true' }),
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
emptyValues={{
|
|
|
|
|
code: '',
|
|
|
|
|
name: '',
|
|
|
|
|
capacityTons: 0,
|
|
|
|
|
lengthMeters: 0,
|
|
|
|
|
maxWagonsPerTrain: '',
|
|
|
|
|
supportedLoadTypes: '',
|
|
|
|
|
isActive: true,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
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', 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',
|
|
|
|
|
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' },
|
|
|
|
|
{ key: 'notes', label: 'Notes' },
|
|
|
|
|
]}
|
|
|
|
|
emptyValues={{ wagonNumber: '', wagonTypeId: '', tareWeight: 0, maxPayloadWeight: 0, status: 'AVAILABLE', notes: '' }}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
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', 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',
|
|
|
|
|
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 },
|
|
|
|
|
{ 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();
|
|
|
|
|
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"
|
|
|
|
|
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: '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) },
|
|
|
|
|
]}
|
|
|
|
|
fields={[
|
|
|
|
|
{ key: 'cargoReference', label: 'Cargo reference', required: true },
|
|
|
|
|
{ key: 'shipmentId', label: 'Shipment ID', required: true },
|
|
|
|
|
{
|
|
|
|
|
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 },
|
|
|
|
|
{ key: 'volume', label: 'Volume', type: 'number' },
|
|
|
|
|
{ key: 'status', label: 'Status' },
|
|
|
|
|
]}
|
|
|
|
|
emptyValues={{ cargoReference: '', shipmentId: '', containerId: '', cargoTypeId: '', description: '', quantity: 0, weight: 0, volume: '', status: 'PENDING' }}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function LocomotivesCrudPage() {
|
|
|
|
|
const query = useLocomotives();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<FleetCrudPage<Locomotive>
|
|
|
|
|
title="Locomotives"
|
|
|
|
|
entityLabel="Locomotive"
|
|
|
|
|
description="Manage locomotive master data used by train scheduling and fleet operations."
|
|
|
|
|
addLabel="Add Locomotive"
|
|
|
|
|
data={query.data}
|
|
|
|
|
isLoading={query.isLoading}
|
|
|
|
|
create={useCreateLocomotive()}
|
|
|
|
|
update={useUpdateLocomotive()}
|
|
|
|
|
remove={useDecommissionLocomotive()}
|
|
|
|
|
removeActionLabel="Decommission"
|
|
|
|
|
removeConfirmMessage="Decommission this locomotive?"
|
|
|
|
|
removeSuccessMessage="Locomotive decommissioned"
|
|
|
|
|
searchText={(locomotive) =>
|
|
|
|
|
[
|
|
|
|
|
locomotive.code,
|
|
|
|
|
locomotive.name,
|
|
|
|
|
locomotive.locomotiveType,
|
|
|
|
|
locomotive.status,
|
|
|
|
|
].join(' ')
|
|
|
|
|
}
|
|
|
|
|
columns={[
|
|
|
|
|
{ key: 'code', label: 'Code' },
|
|
|
|
|
{ key: 'name', label: 'Name', render: (locomotive) => locomotive.name || '-' },
|
|
|
|
|
{ key: 'locomotiveType', label: 'Type' },
|
|
|
|
|
{ key: 'status', label: 'Status', render: (locomotive) => statusBadge(locomotive.status) },
|
|
|
|
|
{ key: 'maxPullWeightTons', label: 'Max pull (tons)' },
|
|
|
|
|
{ key: 'maxTrainLengthMeters', label: 'Max length (m)' },
|
|
|
|
|
]}
|
|
|
|
|
fields={[
|
|
|
|
|
{ key: 'code', label: 'Code', required: true },
|
|
|
|
|
{ key: 'name', label: 'Name' },
|
|
|
|
|
{
|
|
|
|
|
key: 'locomotiveType',
|
|
|
|
|
label: 'Locomotive type',
|
|
|
|
|
type: 'select',
|
|
|
|
|
required: true,
|
|
|
|
|
options: [
|
|
|
|
|
{ value: 'DIESEL', label: 'Diesel' },
|
|
|
|
|
{ value: 'ELECTRIC', label: 'Electric' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'status',
|
|
|
|
|
label: 'Status',
|
|
|
|
|
type: 'select',
|
|
|
|
|
required: true,
|
|
|
|
|
options: [
|
|
|
|
|
{ value: 'AVAILABLE', label: 'Available' },
|
|
|
|
|
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
|
|
|
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
|
|
|
|
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
|
|
|
|
|
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },
|
|
|
|
|
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
|
|
|
|
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
|
|
|
|
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
|
|
|
|
]}
|
|
|
|
|
emptyValues={{
|
|
|
|
|
code: '',
|
|
|
|
|
name: '',
|
|
|
|
|
locomotiveType: 'DIESEL',
|
|
|
|
|
status: 'AVAILABLE',
|
|
|
|
|
maxPullWeightTons: 0,
|
|
|
|
|
maxTrainLengthMeters: 760,
|
|
|
|
|
powerKw: '',
|
|
|
|
|
tractionForceKn: '',
|
|
|
|
|
maxSpeedKmh: '',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|