Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx
2026-07-21 00:24:32 +03:00

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>
);
}