mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Plus, Edit, Trash2, Train, Search, RotateCcw } from 'lucide-react';
|
|
import DataTable from '@/components/ui/DataTable';
|
|
import ActionButton from '@/components/ui/ActionButton';
|
|
import Modal from '@/components/ui/Modal';
|
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
import Badge from '@/components/ui/Badge';
|
|
import { fleetApi } from '@/lib/api';
|
|
import { Train as TrainType } from '@/types';
|
|
import { formatDate } from '@/lib/utils';
|
|
|
|
export default function TrainsPage() {
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; train: TrainType | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, train: null });
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: trainsData, isLoading: trainsLoading } = useQuery({
|
|
queryKey: ['trains'],
|
|
queryFn: () => fleetApi.getTrains(),
|
|
});
|
|
|
|
const createTrainMutation = useMutation({
|
|
mutationFn: fleetApi.createTrain,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
|
setShowModal(false);
|
|
setEditingTrain(null);
|
|
},
|
|
onError: (error: any) => {
|
|
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
|
},
|
|
});
|
|
|
|
const updateTrainMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateTrain(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
|
setShowModal(false);
|
|
setEditingTrain(null);
|
|
},
|
|
onError: (error: any) => {
|
|
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
|
},
|
|
});
|
|
|
|
const deleteTrainMutation = useMutation({
|
|
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => fleetApi.deleteTrain(id, cascade),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
|
},
|
|
onError: (error: any) => {
|
|
const msg = error?.response?.data?.message || error?.message || 'Failed to delete train';
|
|
const isFkError = msg?.includes('Cannot delete') || error?.response?.status === 400;
|
|
if (isFkError && !deleteConfirm.cascade) {
|
|
setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg }));
|
|
} else {
|
|
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
|
|
}
|
|
},
|
|
});
|
|
|
|
const restoreTrainMutation = useMutation({
|
|
mutationFn: (id: string) => fleetApi.restoreTrain(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
|
},
|
|
onError: (error: any) => {
|
|
alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error'));
|
|
},
|
|
});
|
|
|
|
const handleDelete = (train: TrainType) => {
|
|
setDeleteConfirm({ isOpen: true, train, error: undefined });
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
if (!deleteConfirm.train) return;
|
|
try {
|
|
await deleteTrainMutation.mutateAsync({ id: deleteConfirm.train.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
|
|
setDeleteConfirm({ isOpen: false, train: null });
|
|
} catch {
|
|
// error is set by onError handler
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (formData: FormData) => {
|
|
const trainData = {
|
|
number: formData.get('number') as string,
|
|
name: formData.get('name') as string,
|
|
operatorId: formData.get('operatorId') as string,
|
|
operatorName: formData.get('operatorName') as string,
|
|
description: formData.get('description') as string,
|
|
isActive: formData.get('isActive') === 'true',
|
|
};
|
|
|
|
if (editingTrain) {
|
|
await updateTrainMutation.mutateAsync({ id: editingTrain.id, data: trainData });
|
|
} else {
|
|
await createTrainMutation.mutateAsync(trainData);
|
|
}
|
|
};
|
|
|
|
const trains = trainsData?.items || [];
|
|
|
|
const filteredTrains = trains.filter((train: any) => {
|
|
if (!search) return true;
|
|
const searchLower = search.toLowerCase();
|
|
return (
|
|
train.number.toLowerCase().includes(searchLower) ||
|
|
train.name.toLowerCase().includes(searchLower) ||
|
|
train.operatorName?.toLowerCase().includes(searchLower) ||
|
|
train.description?.toLowerCase().includes(searchLower)
|
|
);
|
|
});
|
|
|
|
const trainColumns = [
|
|
{
|
|
key: 'number',
|
|
label: 'Train Number',
|
|
sortable: true,
|
|
render: (train: TrainType) => (
|
|
<div className="flex items-center gap-2">
|
|
<Train className="h-4 w-4 text-[rgb(20,113,76)]" />
|
|
<span className="font-mono font-semibold">{train.number}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'name',
|
|
label: 'Train Name',
|
|
sortable: true,
|
|
render: (train: TrainType) => (
|
|
<div>
|
|
<div className="font-medium">{train.name}</div>
|
|
<div className="text-sm text-gray-500">{train.description}</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'operatorName',
|
|
label: 'Operator',
|
|
render: (train: TrainType) => (
|
|
<span>{train.operatorName || train.operatorId}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'isActive',
|
|
label: 'Status',
|
|
render: (train: TrainType) => (
|
|
<Badge variant="status" status={train.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
|
{train.isActive ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
label: 'Created',
|
|
render: (train: TrainType) => formatDate(train.createdAt),
|
|
},
|
|
];
|
|
|
|
const actions = [
|
|
{
|
|
label: 'Edit',
|
|
onClick: (train: TrainType) => {
|
|
setEditingTrain(train);
|
|
setShowModal(true);
|
|
},
|
|
variant: 'secondary' as const,
|
|
icon: Edit,
|
|
},
|
|
{
|
|
label: 'Restore',
|
|
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
|
|
variant: 'secondary' as const,
|
|
icon: RotateCcw,
|
|
show: (train: TrainType) => !train.isActive,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClick: handleDelete,
|
|
variant: 'danger' as const,
|
|
icon: Trash2,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Train Management</h1>
|
|
<p className="text-gray-600 dark:text-gray-400">Manage trains in the system</p>
|
|
</div>
|
|
<ActionButton
|
|
onClick={() => {
|
|
setEditingTrain(null);
|
|
setShowModal(true);
|
|
}}
|
|
icon={Plus}
|
|
>
|
|
Add Train
|
|
</ActionButton>
|
|
</div>
|
|
|
|
{/* Search Filter */}
|
|
<div className="mb-6">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by number, name, or operator..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="input pl-10 w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Trains Table */}
|
|
<DataTable
|
|
data={filteredTrains}
|
|
columns={trainColumns}
|
|
actions={actions}
|
|
loading={trainsLoading}
|
|
emptyMessage={search ? "No trains match your search" : "No trains found"}
|
|
/>
|
|
|
|
{/* Delete Confirmation */}
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirm.isOpen}
|
|
onClose={() => setDeleteConfirm({ isOpen: false, train: null })}
|
|
onConfirm={confirmDelete}
|
|
title="Delete Train"
|
|
message={`Are you sure you want to delete train ${deleteConfirm.train?.number}?`}
|
|
confirmText="Delete"
|
|
isDanger={true}
|
|
isLoading={deleteTrainMutation.isPending}
|
|
error={deleteConfirm.error}
|
|
warning={!deleteConfirm.cascade ? "This train may be assigned to schedules and trips. Deleting it may impact these systems and associated bookings." : undefined}
|
|
cascadeWarning={deleteConfirm.cascade ? "This train has related schedules that will also be permanently deleted." : undefined}
|
|
cascadeChecked={deleteConfirm.cascadeChecked}
|
|
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
|
|
/>
|
|
|
|
{/* Add/Edit Modal */}
|
|
<Modal
|
|
isOpen={showModal}
|
|
onClose={() => {
|
|
setShowModal(false);
|
|
setEditingTrain(null);
|
|
}}
|
|
title={`${editingTrain ? 'Edit' : 'Add'} Train`}
|
|
size="lg"
|
|
>
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
await handleSubmit(formData);
|
|
}}
|
|
className="space-y-4"
|
|
>
|
|
{editingTrain && (
|
|
<div className="rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 p-3 text-sm text-yellow-800 dark:text-yellow-200">
|
|
<p className="font-semibold">⚠ Warning</p>
|
|
<p className="mt-1">Editing this train may impact schedules and bookings that reference it. Proceed with caution.</p>
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Train Number *</label>
|
|
<input
|
|
type="text"
|
|
name="number"
|
|
className="input"
|
|
defaultValue={editingTrain?.number}
|
|
required
|
|
placeholder="e.g., EDR-101"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Train Name *</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
className="input"
|
|
defaultValue={editingTrain?.name}
|
|
required
|
|
placeholder="e.g., Express Service"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Operator ID</label>
|
|
<input
|
|
type="text"
|
|
name="operatorId"
|
|
className="input"
|
|
defaultValue={editingTrain?.operatorId || 'op_edr'}
|
|
placeholder="op_edr"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Operator Name</label>
|
|
<input
|
|
type="text"
|
|
name="operatorName"
|
|
className="input"
|
|
defaultValue={editingTrain?.operatorName}
|
|
placeholder="Ethio-Djibouti Railway"
|
|
/>
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className="label">Description</label>
|
|
<textarea
|
|
name="description"
|
|
className="input"
|
|
rows={3}
|
|
defaultValue={editingTrain?.description}
|
|
placeholder="Train description..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select
|
|
name="isActive"
|
|
className="input"
|
|
defaultValue={editingTrain?.isActive?.toString() || 'true'}
|
|
>
|
|
<option value="true">Active</option>
|
|
<option value="false">Inactive</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowModal(false);
|
|
setEditingTrain(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
type="submit"
|
|
loading={createTrainMutation.isPending || updateTrainMutation.isPending}
|
|
>
|
|
{editingTrain ? 'Update' : 'Create'} Train
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|