mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
1147 lines
44 KiB
TypeScript
1147 lines
44 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } 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 { apiClient } from '@/lib/api-client';
|
|
import { routeCoachTemplatesApi } from '@/lib/api';
|
|
import { formatDateTime } from '@/lib/utils';
|
|
|
|
interface Schedule {
|
|
id: string;
|
|
trainId: string;
|
|
routeId: string;
|
|
departureAt: string;
|
|
arrivalAt: string;
|
|
status: string;
|
|
stopsCount: number;
|
|
train?: { id: string; name: string; number: string };
|
|
originStation?: { id: string; name: string };
|
|
destinationStation?: { id: string; name: string };
|
|
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
|
isPackageOnly?: boolean;
|
|
}
|
|
|
|
interface Train {
|
|
id: string;
|
|
name: string;
|
|
number: string;
|
|
}
|
|
|
|
interface Route {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
}
|
|
|
|
interface Coach {
|
|
id: string;
|
|
number: string;
|
|
coachNumber?: string;
|
|
capacity: number;
|
|
sequence?: number;
|
|
coachType?: { name: string };
|
|
}
|
|
|
|
export default function SchedulesPage() {
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
|
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string; cascade?: boolean; cascadeChecked?: boolean }>(
|
|
{ isOpen: false, item: null }
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const queryClient = useQueryClient();
|
|
|
|
const [bulkForm, setBulkForm] = useState({
|
|
trainId: '',
|
|
routeId: '',
|
|
startDateTime: '',
|
|
durationHours: '10',
|
|
repeatEveryDays: '2',
|
|
forNextDays: '15',
|
|
});
|
|
|
|
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
|
|
|
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
|
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
|
|
|
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
|
|
queryKey: ['route-coaches', addForm.routeId],
|
|
queryFn: () => routeCoachTemplatesApi.get(addForm.routeId),
|
|
enabled: !!addForm.routeId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!addForm.routeId) { setAddCoachRows([]); return; }
|
|
const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? [];
|
|
setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []);
|
|
}, [singleRouteTemplate, addForm.routeId]);
|
|
|
|
// Fetch route coach template when route changes
|
|
const { data: routeTemplate, isLoading: templateLoading } = useQuery({
|
|
queryKey: ['route-coaches', bulkForm.routeId],
|
|
queryFn: () => routeCoachTemplatesApi.get(bulkForm.routeId),
|
|
enabled: !!bulkForm.routeId,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!bulkForm.routeId) { setBulkCoachRows([]); return; }
|
|
const rows: any[] = Array.isArray(routeTemplate) ? routeTemplate : (routeTemplate as any)?.coaches ?? [];
|
|
setBulkCoachRows(
|
|
rows.length
|
|
? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber }))
|
|
: []
|
|
);
|
|
}, [routeTemplate, bulkForm.routeId]);
|
|
|
|
const [editForm, setEditForm] = useState({
|
|
departureAt: '',
|
|
arrivalAt: '',
|
|
status: 'SCHEDULED',
|
|
coachIds: [] as string[],
|
|
isPackageOnly: false,
|
|
});
|
|
|
|
const [filters, setFilters] = useState({
|
|
search: '',
|
|
trainId: '',
|
|
routeId: '',
|
|
date: '',
|
|
});
|
|
|
|
const { data: schedulesData, isLoading: schedulesLoading } = useQuery({
|
|
queryKey: ['schedules', filters],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams();
|
|
if (filters.trainId) params.append('trainId', filters.trainId);
|
|
if (filters.routeId) params.append('routeId', filters.routeId);
|
|
if (filters.date) params.append('date', filters.date);
|
|
return apiClient.get<Schedule[]>(`/schedules?${params.toString()}`);
|
|
},
|
|
retry: 1,
|
|
});
|
|
|
|
const { data: trainsData } = useQuery({
|
|
queryKey: ['trains'],
|
|
queryFn: () => apiClient.get<Train[]>('/fleet/trains'),
|
|
retry: 1,
|
|
});
|
|
|
|
const { data: routesData } = useQuery({
|
|
queryKey: ['routes'],
|
|
queryFn: () => apiClient.get<Route[]>('/routes'),
|
|
retry: 1,
|
|
});
|
|
|
|
const { data: coachesData } = useQuery({
|
|
queryKey: ['coaches'],
|
|
queryFn: () => apiClient.get<Coach[]>('/fleet/coaches'),
|
|
retry: 1,
|
|
});
|
|
|
|
const bulkGenerateMutation = useMutation({
|
|
mutationFn: (data: any) => apiClient.post('/schedules/bulk-generate', data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
|
setShowModal(false);
|
|
setBulkForm({
|
|
trainId: '',
|
|
routeId: '',
|
|
startDateTime: '',
|
|
durationHours: '12',
|
|
repeatEveryDays: '1',
|
|
forNextDays: '30',
|
|
});
|
|
setBulkCoachRows([]);
|
|
setError(null);
|
|
},
|
|
onError: (err: any) => {
|
|
setError(err.response?.data?.message || 'Failed to generate schedules');
|
|
},
|
|
});
|
|
|
|
const createScheduleMutation = useMutation({
|
|
mutationFn: (data: any) => apiClient.post('/schedules', data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
|
setShowAddModal(false);
|
|
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
|
setAddCoachRows([]);
|
|
setError(null);
|
|
},
|
|
onError: (err: any) => {
|
|
setError(err.response?.data?.message || 'Failed to create schedule');
|
|
},
|
|
});
|
|
|
|
const updateScheduleMutation = useMutation({
|
|
mutationFn: (data: { id: string; payload: any }) =>
|
|
apiClient.patch(`/schedules/${data.id}`, data.payload),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
|
setShowEditModal(false);
|
|
setEditingSchedule(null);
|
|
setError(null);
|
|
},
|
|
onError: (err: any) => {
|
|
setError(err.response?.data?.message || 'Failed to update schedule');
|
|
},
|
|
});
|
|
|
|
const deleteScheduleMutation = useMutation({
|
|
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
|
},
|
|
onError: (err: any) => {
|
|
const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule';
|
|
const isFkError = msg?.includes('Cannot delete') || err?.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 bulkDeleteMutation = useMutation({
|
|
mutationFn: async (ids: string[]) => {
|
|
await Promise.all(ids.map(id => apiClient.delete(`/schedules/${id}`)));
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
|
setSelectedSchedules(new Set());
|
|
},
|
|
onError: (err: any) => {
|
|
const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedules';
|
|
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
|
|
},
|
|
});
|
|
|
|
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
if (!bulkForm.trainId || !bulkForm.routeId || !bulkForm.startDateTime) {
|
|
setError('Train, route, and start date/time are required');
|
|
return;
|
|
}
|
|
|
|
const payload: any = {
|
|
trainId: bulkForm.trainId,
|
|
routeId: bulkForm.routeId,
|
|
startDateTime: bulkForm.startDateTime,
|
|
durationHours: parseInt(bulkForm.durationHours),
|
|
repeatEveryDays: parseInt(bulkForm.repeatEveryDays),
|
|
forNextDays: parseInt(bulkForm.forNextDays),
|
|
};
|
|
|
|
const validCoaches = bulkCoachRows.filter((r) => r.coachId);
|
|
if (validCoaches.length > 0) {
|
|
payload.coachIds = validCoaches.map((r) => r.coachId);
|
|
}
|
|
|
|
await bulkGenerateMutation.mutateAsync(payload);
|
|
};
|
|
|
|
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
const dep = new Date(addForm.departureAt);
|
|
const arr = new Date(addForm.arrivalAt);
|
|
if (arr <= dep) { setError('Arrival must be after departure'); return; }
|
|
const validCoaches = addCoachRows.filter((r) => r.coachId);
|
|
await createScheduleMutation.mutateAsync({
|
|
trainId: addForm.trainId,
|
|
routeId: addForm.routeId,
|
|
departureAt: dep.toISOString(),
|
|
arrivalAt: arr.toISOString(),
|
|
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
|
});
|
|
};
|
|
|
|
const handleEditSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
if (!editingSchedule) return;
|
|
|
|
// Convert local datetime-local values to UTC for API
|
|
const depLocal = new Date(editForm.departureAt);
|
|
const arrLocal = new Date(editForm.arrivalAt);
|
|
|
|
if (arrLocal <= depLocal) {
|
|
setError('Arrival time must be after departure time');
|
|
return;
|
|
}
|
|
|
|
const payload: any = {
|
|
departureAt: depLocal.toISOString(),
|
|
arrivalAt: arrLocal.toISOString(),
|
|
status: editForm.status,
|
|
isPackageOnly: editForm.isPackageOnly,
|
|
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
|
coachId,
|
|
positionNumber: idx + 1,
|
|
})),
|
|
};
|
|
|
|
await updateScheduleMutation.mutateAsync({
|
|
id: editingSchedule.id,
|
|
payload,
|
|
});
|
|
};
|
|
|
|
const handleDelete = (item: Schedule) => {
|
|
setDeleteConfirm({ isOpen: true, item, isBulk: false });
|
|
};
|
|
|
|
const handleBulkDelete = () => {
|
|
if (selectedSchedules.size === 0) return;
|
|
setDeleteConfirm({ isOpen: true, item: Array.from(selectedSchedules), isBulk: true });
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
setDeleteConfirm(prev => ({ ...prev, error: undefined }));
|
|
try {
|
|
if (deleteConfirm.isBulk) {
|
|
const ids = deleteConfirm.item as string[];
|
|
await bulkDeleteMutation.mutateAsync(ids);
|
|
} else if (deleteConfirm.item) {
|
|
await deleteScheduleMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked });
|
|
}
|
|
setDeleteConfirm({ isOpen: false, item: null });
|
|
} catch {
|
|
// error is set by onError handler
|
|
}
|
|
};
|
|
|
|
const handleEditClick = (schedule: Schedule) => {
|
|
setEditingSchedule(schedule);
|
|
|
|
// Convert UTC dates to local time for datetime-local input
|
|
// datetime-local expects local time (no timezone info)
|
|
const dep = new Date(schedule.departureAt);
|
|
const arr = new Date(schedule.arrivalAt);
|
|
|
|
// Convert to local time by adding the timezone offset
|
|
const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000);
|
|
const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000);
|
|
|
|
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
|
|
const depStr = depLocal.toISOString().slice(0, 16);
|
|
const arrStr = arrLocal.toISOString().slice(0, 16);
|
|
|
|
setEditForm({
|
|
departureAt: depStr,
|
|
arrivalAt: arrStr,
|
|
status: schedule.status,
|
|
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
|
|
isPackageOnly: schedule.isPackageOnly ?? false,
|
|
});
|
|
setError(null);
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || [];
|
|
const trains = Array.isArray(trainsData) ? trainsData : (trainsData as any)?.items || [];
|
|
const routes = Array.isArray(routesData) ? routesData : (routesData as any)?.items || [];
|
|
const coaches = Array.isArray(coachesData) ? coachesData : (coachesData as any)?.items || [];
|
|
|
|
const filteredSchedules = schedules.filter((schedule: Schedule) => {
|
|
if (!filters.search) return true;
|
|
const search = filters.search.toLowerCase();
|
|
return (
|
|
schedule.train?.name.toLowerCase().includes(search) ||
|
|
schedule.train?.number.toLowerCase().includes(search) ||
|
|
schedule.originStation?.name.toLowerCase().includes(search) ||
|
|
schedule.destinationStation?.name.toLowerCase().includes(search) ||
|
|
schedule.status.toLowerCase().includes(search)
|
|
);
|
|
});
|
|
|
|
const statusMap: Record<string, string> = {
|
|
SCHEDULED: 'edr-badge-info',
|
|
BOARDING: 'edr-badge-warning',
|
|
EN_ROUTE: 'edr-badge-success',
|
|
ARRIVED: 'edr-badge-secondary',
|
|
CANCELLED: 'edr-badge-danger',
|
|
};
|
|
|
|
const scheduleColumns = [
|
|
{
|
|
key: 'checkbox',
|
|
label: (
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSchedules.size === filteredSchedules.length && filteredSchedules.length > 0}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setSelectedSchedules(new Set(filteredSchedules.map((s: Schedule) => s.id)));
|
|
} else {
|
|
setSelectedSchedules(new Set());
|
|
}
|
|
}}
|
|
className="rounded"
|
|
/>
|
|
),
|
|
render: (schedule: Schedule) => (
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSchedules.has(schedule.id)}
|
|
onChange={(e) => {
|
|
const newSelected = new Set(selectedSchedules);
|
|
if (e.target.checked) {
|
|
newSelected.add(schedule.id);
|
|
} else {
|
|
newSelected.delete(schedule.id);
|
|
}
|
|
setSelectedSchedules(newSelected);
|
|
}}
|
|
className="rounded"
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'train.name',
|
|
label: 'Train',
|
|
sortable: true,
|
|
render: (schedule: Schedule) => (
|
|
<div className="font-medium font-mono">
|
|
{schedule.train?.number}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'route',
|
|
label: 'Route',
|
|
sortable: true,
|
|
render: (schedule: Schedule) => (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">
|
|
{schedule.originStation?.name || 'Unknown'}
|
|
</span>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span className="text-sm font-medium">
|
|
{schedule.destinationStation?.name || 'Unknown'}
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'departureAt',
|
|
label: 'Departure',
|
|
sortable: true,
|
|
render: (schedule: Schedule) => (
|
|
<span className="font-mono text-sm">{formatDateTime(schedule.departureAt)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'arrivalAt',
|
|
label: 'Arrival',
|
|
sortable: true,
|
|
render: (schedule: Schedule) => (
|
|
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'coachAssignments',
|
|
label: 'Coaches',
|
|
render: (schedule: Schedule) => (
|
|
<span className="text-sm font-medium">
|
|
{schedule.coachAssignments?.length || 0}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
render: (schedule: Schedule) => (
|
|
<div className="flex items-center gap-2">
|
|
<span className={`edr-badge ${statusMap[schedule.status] || 'edr-badge-info'}`}>
|
|
{schedule.status}
|
|
</span>
|
|
{schedule.isPackageOnly && (
|
|
<span className="edr-badge edr-badge-warning">PKG</span>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
] as any;
|
|
|
|
const cancelScheduleMutation = useMutation({
|
|
mutationFn: (id: string) => apiClient.patch(`/schedules/${id}/status`, { status: 'CANCELLED' }),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }),
|
|
});
|
|
|
|
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
|
|
|
const scheduleActions = [
|
|
{
|
|
label: 'Edit',
|
|
onClick: handleEditClick,
|
|
variant: 'secondary' as const,
|
|
icon: Edit,
|
|
},
|
|
{
|
|
label: 'Cancel',
|
|
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
|
variant: 'danger' as const,
|
|
icon: X,
|
|
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
|
},
|
|
{
|
|
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-3xl font-bold text-foreground">Schedule Management</h1>
|
|
<p className="text-muted-foreground mt-1">Create and manage train schedules</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selectedSchedules.size > 0 && (
|
|
<ActionButton
|
|
onClick={handleBulkDelete}
|
|
variant="danger"
|
|
loading={bulkDeleteMutation.isPending}
|
|
>
|
|
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
|
|
</ActionButton>
|
|
)}
|
|
<ActionButton
|
|
icon={Plus}
|
|
variant="secondary"
|
|
onClick={() => { setError(null); setShowAddModal(true); }}
|
|
>
|
|
Add Schedule
|
|
</ActionButton>
|
|
<ActionButton
|
|
icon={Zap}
|
|
onClick={() => {
|
|
setError(null);
|
|
setShowModal(true);
|
|
}}
|
|
>
|
|
Bulk Generate
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="mb-6 p-4 border-b border-border">
|
|
<div className="space-y-4">
|
|
<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 train name, number, station, or status..."
|
|
value={filters.search}
|
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
|
className="input pl-10 w-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<div>
|
|
<label className="label text-sm">Train</label>
|
|
<select
|
|
value={filters.trainId}
|
|
onChange={(e) => setFilters({ ...filters, trainId: e.target.value })}
|
|
className="input"
|
|
>
|
|
<option value="">All Trains</option>
|
|
{trains.map((train: Train) => (
|
|
<option key={train.id} value={train.id}>
|
|
{train.name} ({train.number})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label text-sm">Route</label>
|
|
<select
|
|
value={filters.routeId}
|
|
onChange={(e) => setFilters({ ...filters, routeId: e.target.value })}
|
|
className="input"
|
|
>
|
|
<option value="">All Routes</option>
|
|
{routes.map((route: Route) => (
|
|
<option key={route.id} value={route.id}>
|
|
{route.name} ({route.code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label text-sm">Date</label>
|
|
<input
|
|
type="date"
|
|
value={filters.date}
|
|
onChange={(e) => setFilters({ ...filters, date: e.target.value })}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-end">
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => setFilters({ search: '', trainId: '', routeId: '', date: '' })}
|
|
>
|
|
Clear Filters
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-6">
|
|
{schedulesLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : filteredSchedules.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No schedules found. {filters.search && 'Try adjusting your search.'}
|
|
</div>
|
|
) : (
|
|
<DataTable
|
|
columns={scheduleColumns}
|
|
data={filteredSchedules}
|
|
actions={scheduleActions}
|
|
loading={false}
|
|
emptyMessage="No schedules found."
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
isOpen={cancelConfirm.isOpen}
|
|
onClose={() => setCancelConfirm({ isOpen: false, item: null })}
|
|
onConfirm={async () => {
|
|
if (cancelConfirm.item) {
|
|
await cancelScheduleMutation.mutateAsync(cancelConfirm.item.id);
|
|
setCancelConfirm({ isOpen: false, item: null });
|
|
}
|
|
}}
|
|
title="Cancel Schedule"
|
|
message={`Cancel the schedule departing ${cancelConfirm.item ? formatDateTime(cancelConfirm.item.departureAt) : ''}? Passengers with bookings will need to be notified separately.`}
|
|
confirmText="Cancel Schedule"
|
|
isDanger={true}
|
|
isLoading={cancelScheduleMutation.isPending}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirm.isOpen}
|
|
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
|
onConfirm={confirmDelete}
|
|
title={deleteConfirm.isBulk ? 'Delete Multiple Schedules' : 'Delete Schedule'}
|
|
message={
|
|
deleteConfirm.isBulk
|
|
? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.`
|
|
: `Are you sure you want to delete this schedule departing on ${
|
|
deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : ''
|
|
}?`
|
|
}
|
|
confirmText="Delete"
|
|
isDanger={true}
|
|
isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending}
|
|
error={deleteConfirm.error}
|
|
warning={!deleteConfirm.cascade ? "Schedules with existing bookings cannot be deleted." : undefined}
|
|
cascadeWarning={deleteConfirm.cascade ? "This schedule has related bookings or tickets that will also be permanently deleted." : undefined}
|
|
cascadeChecked={deleteConfirm.cascadeChecked}
|
|
onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
|
|
/>
|
|
|
|
<Modal
|
|
isOpen={showAddModal}
|
|
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
|
title="Add Schedule"
|
|
size="lg"
|
|
>
|
|
<form onSubmit={handleAddSubmit} className="space-y-4">
|
|
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Train *</label>
|
|
<select className="input" value={addForm.trainId} onChange={(e) => setAddForm({ ...addForm, trainId: e.target.value })} required>
|
|
<option value="">Select Train</option>
|
|
{trains.map((t: Train) => <option key={t.id} value={t.id}>{t.number} ({t.name})</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Route *</label>
|
|
<select className="input" value={addForm.routeId} onChange={(e) => setAddForm({ ...addForm, routeId: e.target.value })} required>
|
|
<option value="">Select Route</option>
|
|
{routes.map((r: Route) => <option key={r.id} value={r.id}>{r.code} ({r.name})</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Departure *</label>
|
|
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
|
|
</div>
|
|
<div>
|
|
<label className="label">Arrival *</label>
|
|
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="label mb-0">Coaches</label>
|
|
<div className="flex items-center gap-3">
|
|
{singleTemplateLoading && addForm.routeId && (
|
|
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template…</span>
|
|
)}
|
|
{!addForm.routeId && <span className="text-xs text-muted-foreground">Select a route to load its coach template</span>}
|
|
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
|
|
disabled={addCoachRows.filter(r => r.coachId).length >= coaches.length}
|
|
onClick={() => setAddCoachRows([...addCoachRows, { coachId: '', positionNumber: addCoachRows.length + 1 }])}>
|
|
Add Coach
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
{addCoachRows.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">No coaches assigned.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{addCoachRows.length > 1 && <p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>}
|
|
{addCoachRows.map((row, i) => {
|
|
const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean));
|
|
return (
|
|
<div key={i} draggable
|
|
onDragStart={(e) => e.dataTransfer.setData('add-coach-idx', i.toString())}
|
|
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
|
|
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
|
|
onDrop={(e) => {
|
|
e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '1';
|
|
const src = parseInt(e.dataTransfer.getData('add-coach-idx'));
|
|
if (src === i) return;
|
|
const reordered = [...addCoachRows];
|
|
const [moved] = reordered.splice(src, 1);
|
|
reordered.splice(i, 0, moved);
|
|
setAddCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
|
}}
|
|
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
|
>
|
|
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
|
|
<select className="input input-sm flex-1" value={row.coachId}
|
|
onChange={(e) => { const u = [...addCoachRows]; u[i] = { ...u[i], coachId: e.target.value }; setAddCoachRows(u); }}>
|
|
<option value="">Select Coach</option>
|
|
{coaches.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId).map((c: Coach) => (
|
|
<option key={c.id} value={c.id}>{c.number || c.coachNumber} — {c.coachType?.name} (Cap: {c.capacity})</option>
|
|
))}
|
|
</select>
|
|
<button type="button" onClick={() => setAddCoachRows(addCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
|
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showModal}
|
|
onClose={() => {
|
|
setShowModal(false);
|
|
setError(null);
|
|
setBulkForm({
|
|
trainId: '',
|
|
routeId: '',
|
|
startDateTime: '',
|
|
durationHours: '12',
|
|
repeatEveryDays: '1',
|
|
forNextDays: '30',
|
|
});
|
|
setBulkCoachRows([]);
|
|
}}
|
|
title="Bulk Generate Schedules"
|
|
size="lg"
|
|
>
|
|
<form onSubmit={handleBulkSubmit} className="space-y-4">
|
|
{error && (
|
|
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Train *</label>
|
|
<select
|
|
value={bulkForm.trainId}
|
|
onChange={(e) => setBulkForm({ ...bulkForm, trainId: e.target.value })}
|
|
className="input"
|
|
required
|
|
>
|
|
<option value="">Select Train</option>
|
|
{trains.map((train: Train) => (
|
|
<option key={train.id} value={train.id}>
|
|
{train.number} ({train.name})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Route *</label>
|
|
<select
|
|
value={bulkForm.routeId}
|
|
onChange={(e) => setBulkForm({ ...bulkForm, routeId: e.target.value })}
|
|
className="input"
|
|
required
|
|
>
|
|
<option value="">Select Route</option>
|
|
{routes.map((route: Route) => (
|
|
<option key={route.id} value={route.id}>
|
|
{route.code} ({route.name})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Departure Date & Time *</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={bulkForm.startDateTime}
|
|
onChange={(e) => setBulkForm({ ...bulkForm, startDateTime: e.target.value })}
|
|
className="input"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="label">Duration (Hours)</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
placeholder={bulkForm.durationHours}
|
|
onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Repeat Every (Days)</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
placeholder={bulkForm.repeatEveryDays}
|
|
onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">For Next (Days)</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
placeholder={bulkForm.forNextDays}
|
|
onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t pt-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="label mb-0">Coaches</label>
|
|
<div className="flex items-center gap-3">
|
|
{templateLoading && bulkForm.routeId && (
|
|
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template…</span>
|
|
)}
|
|
{!bulkForm.routeId && (
|
|
<span className="text-xs text-muted-foreground">Select a route to load its coach template</span>
|
|
)}
|
|
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
|
|
disabled={bulkCoachRows.filter(r => r.coachId).length >= coaches.length}
|
|
onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}>
|
|
Add Coach
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
|
|
{bulkCoachRows.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-2">No coaches assigned — schedules will be created without coach assignments.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{bulkCoachRows.length > 1 && (
|
|
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>
|
|
)}
|
|
{bulkCoachRows.map((row, i) => {
|
|
const selectedIds = new Set(bulkCoachRows.map((r) => r.coachId).filter(Boolean));
|
|
return (
|
|
<div
|
|
key={i}
|
|
draggable
|
|
onDragStart={(e) => e.dataTransfer.setData('bulk-coach-idx', i.toString())}
|
|
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
|
|
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
(e.currentTarget as HTMLElement).style.opacity = '1';
|
|
const src = parseInt(e.dataTransfer.getData('bulk-coach-idx'));
|
|
if (src === i) return;
|
|
const reordered = [...bulkCoachRows];
|
|
const [moved] = reordered.splice(src, 1);
|
|
reordered.splice(i, 0, moved);
|
|
setBulkCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
|
}}
|
|
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
|
>
|
|
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
|
|
<select
|
|
className="input input-sm flex-1"
|
|
value={row.coachId}
|
|
onChange={(e) => {
|
|
const updated = [...bulkCoachRows];
|
|
updated[i] = { ...updated[i], coachId: e.target.value };
|
|
setBulkCoachRows(updated);
|
|
}}
|
|
>
|
|
<option value="">Select Coach</option>
|
|
{coaches
|
|
.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId)
|
|
.map((c: Coach) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.number || c.coachNumber} — {c.coachType?.name} (Cap: {c.capacity})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => setBulkCoachRows(bulkCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))}
|
|
className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-blue-50 p-4 rounded-lg">
|
|
<p className="text-sm text-blue-900">
|
|
<strong>Preview:</strong> Will create approximately{' '}
|
|
<strong>{Math.ceil(parseInt(bulkForm.forNextDays) / parseInt(bulkForm.repeatEveryDays))}</strong>{' '}
|
|
schedules, starting from the specified date, repeating every{' '}
|
|
<strong>{bulkForm.repeatEveryDays}</strong> days for the next{' '}
|
|
<strong>{bulkForm.forNextDays}</strong> days.
|
|
{bulkCoachRows.filter(r => r.coachId).length > 0 && (
|
|
<>
|
|
{' '}
|
|
Each schedule will have <strong>{bulkCoachRows.filter(r => r.coachId).length}</strong> coach(es) assigned.
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowModal(false);
|
|
setError(null);
|
|
setBulkForm({
|
|
trainId: '',
|
|
routeId: '',
|
|
startDateTime: '',
|
|
durationHours: '12',
|
|
repeatEveryDays: '1',
|
|
forNextDays: '30',
|
|
});
|
|
setBulkCoachRows([]);
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton type="submit" loading={bulkGenerateMutation.isPending}>
|
|
Generate Schedules
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showEditModal}
|
|
onClose={() => {
|
|
setShowEditModal(false);
|
|
setEditingSchedule(null);
|
|
setError(null);
|
|
}}
|
|
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`}
|
|
size="lg"
|
|
>
|
|
{editingSchedule && (
|
|
<form onSubmit={handleEditSubmit} className="space-y-4">
|
|
{error && (
|
|
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Departure Date & Time *</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={editForm.departureAt}
|
|
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })}
|
|
className="input"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Arrival Date & Time *</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={editForm.arrivalAt}
|
|
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })}
|
|
className="input"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Status</label>
|
|
<select
|
|
value={editForm.status}
|
|
onChange={(e) => setEditForm({ ...editForm, status: e.target.value })}
|
|
className="input"
|
|
>
|
|
<option value="SCHEDULED">Scheduled</option>
|
|
<option value="BOARDING">Boarding</option>
|
|
<option value="EN_ROUTE">En Route</option>
|
|
<option value="ARRIVED">Arrived</option>
|
|
<option value="CANCELLED">Cancelled</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
|
|
<input
|
|
type="checkbox"
|
|
id="isPackageOnly"
|
|
checked={editForm.isPackageOnly}
|
|
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
|
|
className="w-4 h-4 rounded"
|
|
/>
|
|
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
|
|
<span className="font-medium">Package Only</span>
|
|
<span className="block text-xs text-muted-foreground">Hide from public search — reserved for package bookings</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<label className="label">Coaches (Optional)</label>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (editForm.coachIds.length === coaches.length) {
|
|
setEditForm({ ...editForm, coachIds: [] });
|
|
} else {
|
|
setEditForm({ ...editForm, coachIds: coaches.map((c: Coach) => c.id) });
|
|
}
|
|
}}
|
|
className="text-xs text-primary hover:underline"
|
|
>
|
|
{editForm.coachIds.length === coaches.length ? 'Deselect All' : 'Select All'}
|
|
</button>
|
|
</div>
|
|
<div className="border border-border rounded-lg p-3 max-h-64 overflow-y-auto space-y-2">
|
|
{coaches.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No coaches available</p>
|
|
) : (
|
|
coaches.map((coach: Coach) => (
|
|
<label key={coach.id} className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={editForm.coachIds.includes(coach.id)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setEditForm({
|
|
...editForm,
|
|
coachIds: [...editForm.coachIds, coach.id],
|
|
});
|
|
} else {
|
|
setEditForm({
|
|
...editForm,
|
|
coachIds: editForm.coachIds.filter((id) => id !== coach.id),
|
|
});
|
|
}
|
|
}}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-sm">
|
|
{coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
|
|
</span>
|
|
</label>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowEditModal(false);
|
|
setEditingSchedule(null);
|
|
setError(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton type="submit" loading={updateScheduleMutation.isPending}>
|
|
Update Schedule
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|