mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
2031 lines
70 KiB
TypeScript
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>
|
|
);
|
|
}
|