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

2031 lines
70 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";
// ── 12-hour datetime picker ──────────────────────────────────────────────────
interface DTPProps {
label?: string;
value: string;
onChange: (v: string) => void;
required?: boolean;
placeholder?: string;
}
/** value / onChange use "YYYY-MM-DDTHH:mm" (24-hr, local) — same as datetime-local */
function DateTimePicker({ label, value, onChange, required }: DTPProps) {
const datePart = value.slice(0, 10);
const timePart = value.slice(11, 16); // HH:mm 24-hr
const hour24 = timePart ? parseInt(timePart.slice(0, 2), 10) : 12;
const minute = timePart ? timePart.slice(3, 5) : "00";
const period = hour24 >= 12 ? "PM" : "AM";
const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12;
const emit = (d: string, h12: number, m: string, p: string) => {
if (!d) return;
const h24 =
p === "AM" ? (h12 === 12 ? 0 : h12) : h12 === 12 ? 12 : h12 + 12;
onChange(`${d}T${String(h24).padStart(2, "0")}:${m}`);
};
return (
<div>
<label className="label">
{label}
{required && " *"}
</label>
<div className="flex gap-2">
<input
type="date"
className="input flex-1"
value={datePart}
required={required}
onChange={(e) => emit(e.target.value, hour12, minute, period)}
/>
<select
className="input w-20"
value={hour12}
onChange={(e) =>
emit(datePart, parseInt(e.target.value, 10), minute, period)
}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
<select
className="input w-20"
value={minute}
onChange={(e) => emit(datePart, hour12, e.target.value, period)}
>
{[
"00",
"05",
"10",
"15",
"20",
"25",
"30",
"35",
"40",
"45",
"50",
"55",
].map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<select
className="input w-20"
value={period}
onChange={(e) => emit(datePart, hour12, minute, e.target.value)}
>
<option value="AM">AM</option>
<option value="PM">PM</option>
</select>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
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";
// EAT is UTC+3. Convert without depending on the browser's own timezone.
const EAT_MS = 3 * 60 * 60 * 1000;
// UTC ISO string → EAT "YYYY-MM-DDTHH:mm" for DateTimePicker display
const isoToEAT = (iso: string): string =>
new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16);
// EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission
const eatToISO = (local: string): string =>
new Date(new Date(local + ":00Z").getTime() - EAT_MS).toISOString();
// Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time)
const isoToEATTimePart = (iso: string): string | null => {
if (!iso) return null;
const eatMs = new Date(iso).getTime() + EAT_MS;
const msIntoDay = eatMs % (24 * 60 * 60 * 1000);
const h = Math.floor(msIntoDay / 3600000);
const m = Math.floor((msIntoDay % 3600000) / 60000);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
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;
stopTimes?: Array<{
sequence: number;
stationId: string;
plannedDepartureAt: string | null;
plannedArrivalAt: string | null;
station?: { name: string };
}>;
}
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 [addStopTimes, setAddStopTimes] = useState<
{
sequence: number;
stationName: string;
plannedArrivalAt: string;
plannedDepartureAt: string;
}[]
>([]);
const [editStopTimes, setEditStopTimes] = useState<
{
sequence: number;
stationName: string;
plannedArrivalAt: string;
plannedDepartureAt: string;
}[]
>([]);
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } =
useQuery({
queryKey: ["route-coaches", addForm.routeId],
queryFn: () => routeCoachTemplatesApi.get(addForm.routeId),
enabled: !!addForm.routeId,
});
const { data: addRouteDetail } = useQuery({
queryKey: ["route-detail", addForm.routeId],
queryFn: () => apiClient.get<any>(`/routes/${addForm.routeId}`),
enabled: !!addForm.routeId,
});
const { data: editRouteDetail } = useQuery({
queryKey: ["route-detail", editingSchedule?.routeId],
queryFn: () => apiClient.get<any>(`/routes/${editingSchedule!.routeId}`),
enabled: !!editingSchedule?.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]);
useEffect(() => {
const stops: any[] = (addRouteDetail as any)?.stops ?? [];
if (!stops.length) {
setAddStopTimes([]);
return;
}
const eatDateStr = addForm.departureAt
? addForm.departureAt.slice(0, 10)
: null;
setAddStopTimes(
stops.map((s: any) => {
const arrTimePart = s.plannedArrivalTime
? isoToEATTimePart(s.plannedArrivalTime)
: null;
const depTimePart = s.plannedDepartureTime
? isoToEATTimePart(s.plannedDepartureTime)
: null;
return {
sequence: s.sequence,
stationName: s.station?.name ?? `Stop ${s.sequence}`,
plannedArrivalAt:
eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : "",
plannedDepartureAt:
eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : "",
};
}),
);
}, [addRouteDetail, addForm.departureAt]);
// 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,
});
useEffect(() => {
const stops: any[] = (editRouteDetail as any)?.stops ?? [];
if (!stops.length || !editingSchedule) return;
const hasRouteTimes = stops.some(
(s: any) => s.plannedArrivalTime || s.plannedDepartureTime,
);
if (!hasRouteTimes) return;
// If the schedule already has saved stop times, keep them — don't overwrite
// with route template times. The user can use "Auto-fill" if they want to reset.
if (editingSchedule.stopTimes && editingSchedule.stopTimes.length > 0) return;
const eatDateStr = editForm.departureAt
? editForm.departureAt.slice(0, 10)
: null;
setEditStopTimes(
stops.map((s: any) => {
const arrTimePart = s.plannedArrivalTime
? isoToEATTimePart(s.plannedArrivalTime)
: null;
const depTimePart = s.plannedDepartureTime
? isoToEATTimePart(s.plannedDepartureTime)
: null;
return {
sequence: s.sequence,
stationName: s.station?.name ?? `Stop ${s.sequence}`,
plannedArrivalAt:
eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : "",
plannedDepartureAt:
eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : "",
};
}),
);
}, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); // eslint-disable-line react-hooks/exhaustive-deps
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([]);
setAddStopTimes([]);
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);
setEditStopTimes([]);
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: new Date(bulkForm.startDateTime).toISOString(),
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);
if (!addForm.departureAt || !addForm.arrivalAt) {
setError("Please select departure and arrival date & time");
return;
}
if (
new Date(addForm.arrivalAt + ":00Z") <=
new Date(addForm.departureAt + ":00Z")
) {
setError("Arrival must be after departure");
return;
}
const filledStops = addStopTimes.filter(
(s) => s.plannedDepartureAt || s.plannedArrivalAt,
);
const plannedTimes =
filledStops.length === addStopTimes.length && addStopTimes.length > 0
? addStopTimes.map((s) => ({
sequence: s.sequence,
...(s.plannedArrivalAt
? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) }
: {}),
...(s.plannedDepartureAt
? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) }
: {}),
}))
: undefined;
const validCoaches = addCoachRows.filter((r) => r.coachId);
await createScheduleMutation.mutateAsync({
trainId: addForm.trainId,
routeId: addForm.routeId,
departureAt: eatToISO(addForm.departureAt),
arrivalAt: eatToISO(addForm.arrivalAt),
...(plannedTimes ? { plannedTimes } : {}),
...(validCoaches.length > 0 && {
coachIds: validCoaches.map((r) => r.coachId),
}),
});
};
const handleEditSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError(null);
if (!editingSchedule) return;
if (!editForm.departureAt || !editForm.arrivalAt) {
setError("Please select departure and arrival date & time");
return;
}
if (
new Date(editForm.arrivalAt + ":00Z") <=
new Date(editForm.departureAt + ":00Z")
) {
setError("Arrival time must be after departure time");
return;
}
const filledEditStops = editStopTimes.filter(
(s) => s.plannedDepartureAt || s.plannedArrivalAt,
);
const editPlannedTimes =
filledEditStops.length === editStopTimes.length &&
editStopTimes.length > 0
? editStopTimes.map((s) => ({
sequence: s.sequence,
...(s.plannedArrivalAt
? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) }
: {}),
...(s.plannedDepartureAt
? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) }
: {}),
}))
: undefined;
const payload: any = {
departureAt: eatToISO(editForm.departureAt),
arrivalAt: eatToISO(editForm.arrivalAt),
status: editForm.status,
isPackageOnly: editForm.isPackageOnly,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId,
positionNumber: idx + 1,
})),
...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}),
};
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);
setEditForm({
departureAt: isoToEAT(schedule.departureAt),
arrivalAt: isoToEAT(schedule.arrivalAt),
status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
isPackageOnly: schedule.isPackageOnly ?? false,
});
if (schedule.stopTimes && schedule.stopTimes.length > 0) {
const toDatetimeLocal = (iso: string | null) =>
iso ? isoToEAT(iso) : "";
setEditStopTimes(
schedule.stopTimes.map((st) => ({
sequence: st.sequence,
stationName: st.station?.name ?? `Stop ${st.sequence}`,
plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt),
plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt),
})),
);
} else {
setEditStopTimes([]);
}
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([]);
setAddStopTimes([]);
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>
<DateTimePicker
value={addForm.departureAt}
onChange={(v) => setAddForm({ ...addForm, departureAt: v })}
placeholder="Select departure"
/>
</div>
<div>
<label className="label">Arrival *</label>
<DateTimePicker
value={addForm.arrivalAt}
onChange={(v) => setAddForm({ ...addForm, arrivalAt: v })}
placeholder="Select arrival"
/>
</div>
</div>
{addStopTimes.length > 0 && (
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<div>
<label className="label mb-0">Stop Times</label>
<p className="text-xs text-muted-foreground mt-0.5">
Set planned times for each stop. Leave all blank to
auto-generate from distance.
</p>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
const dep = addForm.departureAt
? new Date(addForm.departureAt + ":00Z")
: null;
const arr = addForm.arrivalAt
? new Date(addForm.arrivalAt + ":00Z")
: null;
if (!dep || !arr || arr <= dep) return;
const stops = (addRouteDetail as any)?.stops ?? [];
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance =
stops[stops.length - 1]?.distanceKm || 1;
setAddStopTimes(
addStopTimes.map((s, i) => {
const stop = stops.find(
(st: any) => st.sequence === s.sequence,
);
const dist = stop?.distanceKm ?? 0;
const t = new Date(
dep.getTime() +
(dist / totalDistance) * totalDuration,
);
const fmt = t.toISOString().slice(0, 16);
return {
...s,
plannedArrivalAt: i === 0 ? "" : fmt,
plannedDepartureAt:
i === addStopTimes.length - 1 ? "" : fmt,
};
}),
);
}}
>
Auto-fill from departure/arrival
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Station</th>
<th className="pb-2 pr-3 font-medium">Planned Arrival</th>
<th className="pb-2 font-medium">Planned Departure</th>
</tr>
</thead>
<tbody className="divide-y">
{addStopTimes.map((stop, i) => {
const isFirst = i === 0;
const isLast = i === addStopTimes.length - 1;
return (
<tr key={stop.sequence}>
<td className="py-2 pr-3 text-muted-foreground">
{stop.sequence}
</td>
<td className="py-2 pr-3 font-medium whitespace-nowrap">
{stop.stationName}
</td>
<td className="py-2 pr-3 min-w-[200px]">
{isFirst ? (
<span className="text-xs text-muted-foreground italic">
</span>
) : (
<DateTimePicker
value={stop.plannedArrivalAt}
onChange={(v) => {
const updated = [...addStopTimes];
updated[i] = {
...updated[i],
plannedArrivalAt: v,
};
setAddStopTimes(updated);
}}
placeholder="Pick arrival"
/>
)}
</td>
<td className="py-2 min-w-[200px]">
{isLast ? (
<span className="text-xs text-muted-foreground italic">
</span>
) : (
<DateTimePicker
value={stop.plannedDepartureAt}
onChange={(v) => {
const updated = [...addStopTimes];
updated[i] = {
...updated[i],
plannedDepartureAt: v,
};
setAddStopTimes(updated);
}}
placeholder="Pick departure"
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</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>
<DateTimePicker
label="Departure Date & Time"
value={bulkForm.startDateTime}
onChange={(v) => setBulkForm({ ...bulkForm, startDateTime: v })}
required
/>
<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);
setEditStopTimes([]);
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>
<DateTimePicker
value={editForm.departureAt}
onChange={(v) => setEditForm({ ...editForm, departureAt: v })}
placeholder="Select departure"
/>
</div>
<div>
<label className="label">Arrival Date & Time *</label>
<DateTimePicker
value={editForm.arrivalAt}
onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })}
placeholder="Select arrival"
/>
</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>
{editStopTimes.length > 0 && (
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<div>
<label className="label mb-0">Stop Times</label>
<p className="text-xs text-muted-foreground mt-0.5">
Edit planned times for each stop. All stops must be filled
to update.
</p>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => {
const dep = editForm.departureAt
? new Date(editForm.departureAt + ":00Z")
: null;
const arr = editForm.arrivalAt
? new Date(editForm.arrivalAt + ":00Z")
: null;
if (!dep || !arr || arr <= dep) return;
const stops = (editRouteDetail as any)?.stops ?? [];
const totalDuration = arr.getTime() - dep.getTime();
const totalDistance =
stops[stops.length - 1]?.distanceKm || 1;
setEditStopTimes(
editStopTimes.map((s, i) => {
const stop = stops.find(
(st: any) => st.sequence === s.sequence,
);
const dist =
stops.length > 0 ? (stop?.distanceKm ?? 0) : 0;
const progress =
stops.length > 0 && totalDistance > 0
? dist / totalDistance
: i / Math.max(editStopTimes.length - 1, 1);
const t = new Date(
dep.getTime() + totalDuration * progress,
);
const fmt = t.toISOString().slice(0, 16);
return {
...s,
plannedArrivalAt: i === 0 ? "" : fmt,
plannedDepartureAt:
i === editStopTimes.length - 1 ? "" : fmt,
};
}),
);
}}
>
Auto-fill from departure/arrival
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Station</th>
<th className="pb-2 pr-3 font-medium">
Planned Arrival
</th>
<th className="pb-2 font-medium">Planned Departure</th>
</tr>
</thead>
<tbody className="divide-y">
{editStopTimes.map((stop, i) => {
const isFirst = i === 0;
const isLast = i === editStopTimes.length - 1;
return (
<tr key={stop.sequence}>
<td className="py-2 pr-3 text-muted-foreground">
{stop.sequence}
</td>
<td className="py-2 pr-3 font-medium whitespace-nowrap">
{stop.stationName}
</td>
<td className="py-2 pr-3 min-w-[200px]">
{isFirst ? (
<span className="text-xs text-muted-foreground italic">
</span>
) : (
<DateTimePicker
value={stop.plannedArrivalAt}
onChange={(v) => {
const updated = [...editStopTimes];
updated[i] = {
...updated[i],
plannedArrivalAt: v,
};
setEditStopTimes(updated);
}}
placeholder="Pick arrival"
/>
)}
</td>
<td className="py-2 min-w-[200px]">
{isLast ? (
<span className="text-xs text-muted-foreground italic">
</span>
) : (
<DateTimePicker
value={stop.plannedDepartureAt}
onChange={(v) => {
const updated = [...editStopTimes];
updated[i] = {
...updated[i],
plannedDepartureAt: v,
};
setEditStopTimes(updated);
}}
placeholder="Pick departure"
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</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);
setEditStopTimes([]);
setError(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={updateScheduleMutation.isPending}
>
Update Schedule
</ActionButton>
</div>
</form>
)}
</Modal>
</div>
);
}