import { useState, useEffect, useRef } from 'react';
import { Calendar, Plus, Edit, Trash2, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
import { routesApi, routeStopsApi, schedulesApi, holidaysApi } from '../services/api';
import { PageLoader } from '../components/AnimatedComponents';
import RouteBadge from '../components/RouteBadge';
import './SchedulesPage.css';
const DAY_TYPES = [
{ value: 'weekday', label: 'Будние дни', description: 'Пн-Пт' },
{ value: 'saturday', label: 'Суббота', description: 'Сб' },
{ value: 'sunday', label: 'Воскресенье', description: 'Вс' },
{ value: 'holiday', label: 'Праздники', description: 'По расписанию воскресенья' }
];
const WEEKDAYS = [
{ value: 1, label: 'Пн' },
{ value: 2, label: 'Вт' },
{ value: 3, label: 'Ср' },
{ value: 4, label: 'Чт' },
{ value: 5, label: 'Пт' },
{ value: 6, label: 'Сб' },
{ value: 7, label: 'Вс' }
];
export default function SchedulesPage() {
const [routes, setRoutes] = useState([]);
const [selectedRoute, setSelectedRoute] = useState(null);
const [routeStops, setRouteStops] = useState([]);
const [schedules, setSchedules] = useState([]);
const [loading, setLoading] = useState(true);
const [showEditor, setShowEditor] = useState(false);
const [editingSchedule, setEditingSchedule] = useState(null);
const [upcomingHolidays, setUpcomingHolidays] = useState([]);
const [expandedSchedule, setExpandedSchedule] = useState(null);
const [detailsLoading, setDetailsLoading] = useState(false);
const [detailsError, setDetailsError] = useState('');
const detailsRequest = useRef(0);
const activeRouteId = useRef(null);
useEffect(() => {
loadRoutes();
loadUpcomingHolidays();
}, []);
useEffect(() => {
activeRouteId.current = selectedRoute?.id;
setSchedules([]);
setRouteStops([]);
setExpandedSchedule(null);
setShowEditor(false);
if (selectedRoute) loadRouteDetails(selectedRoute.id);
return () => { activeRouteId.current = null; detailsRequest.current += 1; };
}, [selectedRoute?.id]);
const loadRoutes = async () => {
try {
const response = await routesApi.getAll();
setRoutes(response.data);
if (response.data.length > 0) {
setSelectedRoute(response.data[0]);
}
} catch (error) {
console.error('Error loading routes:', error);
} finally {
setLoading(false);
}
};
const loadRouteDetails = async (routeId) => {
if (routeId !== activeRouteId.current) return;
const request = ++detailsRequest.current;
setDetailsLoading(true);
setDetailsError('');
try {
const [scheduleResponse, stopsResponse] = await Promise.all([
schedulesApi.getRouteSchedules(routeId),
routeStopsApi.getRouteStops(routeId),
]);
if (request !== detailsRequest.current) return;
setSchedules(scheduleResponse.data);
setRouteStops(stopsResponse.data);
} catch {
if (request === detailsRequest.current) {
setDetailsError('Не удалось загрузить расписание и остановки маршрута.');
}
} finally {
if (request === detailsRequest.current) setDetailsLoading(false);
}
};
const loadUpcomingHolidays = async () => {
try {
const response = await holidaysApi.getUpcoming(5);
setUpcomingHolidays(response.data);
} catch (error) {
console.error('Error loading holidays:', error);
}
};
const handleCreateSchedule = (dayType) => {
setEditingSchedule({
routeId: selectedRoute.id,
dayType: dayType,
departureTimes: [],
trips: []
});
setShowEditor(true);
};
const handleEditSchedule = (schedule) => {
setEditingSchedule(schedule);
setShowEditor(true);
};
const handleDeleteSchedule = async (id) => {
if (!confirm('Удалить это расписание?')) return;
try {
await schedulesApi.delete(id);
loadRouteDetails(selectedRoute.id);
} catch (error) {
console.error('Error deleting schedule:', error);
alert('Ошибка удаления расписания');
}
};
const handleCopySchedule = async (fromDayType, toDayType) => {
try {
await schedulesApi.copy(selectedRoute.id, fromDayType, toDayType);
loadRouteDetails(selectedRoute.id);
} catch (error) {
console.error('Error copying schedule:', error);
alert('Ошибка копирования расписания');
}
};
const handleSaveSchedule = async (data) => {
try {
if (editingSchedule.id) {
await schedulesApi.update(editingSchedule.id, data);
} else {
await schedulesApi.create(data);
}
setShowEditor(false);
setEditingSchedule(null);
loadRouteDetails(selectedRoute.id);
} catch (error) {
console.error('Error saving schedule:', error);
alert(error.response?.data?.message || 'Ошибка сохранения расписания');
}
};
const getScheduleForDayType = (dayType) => {
return schedules.find(s => s.day_type === dayType);
};
if (loading) {
return ;
}
return (
Расписания
{/* Upcoming Holidays Info */}
{upcomingHolidays.length > 0 && (
Ближайшие праздники
Транспорт идет по расписанию воскресенья
{upcomingHolidays.slice(0, 3).map((holiday, idx) => (
{new Date(holiday.next_date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })} — {holiday.name}
))}
)}
{/* Mobile: Route selector dropdown */}
Маршруты
{routes.map(route => (
))}
{selectedRoute ? (
<>
{selectedRoute.name}
Расписание движения
{detailsLoading ?
: detailsError ? (
{detailsError}
) : (
{DAY_TYPES.map((dayType, index) => {
const schedule = getScheduleForDayType(dayType.value);
const isExpanded = expandedSchedule === dayType.value;
return (
{dayType.label}
{dayType.description}
{schedule ? (
{getScheduleTrips(schedule).length} рейсов
) : (
Не задано
)}
{schedule ? (
<>
{isExpanded && (
{getScheduleTrips(schedule).map((trip, idx) => (
{formatScheduleTime(trip.departureTime)}
{getTripLabels(trip, routeStops).map((label) => (
{label}
))}
))}
)}
{/* Copy to other day types */}
{dayType.value !== 'holiday' && (
Копировать в:
{DAY_TYPES.filter(dt =>
dt.value !== dayType.value &&
!getScheduleForDayType(dt.value)
).map(dt => (
))}
)}
>
) : (
)}
);
})}
)}
>
) : (
Выберите маршрут для просмотра расписания
)}
{/* Schedule Editor Modal */}
{showEditor && editingSchedule && (
{
setShowEditor(false);
setEditingSchedule(null);
}}
/>
)}
);
}
function ScheduleEditor({ schedule, routeName, routeStops, onSave, onCancel }) {
const [trips, setTrips] = useState(() => normalizeTripsForEditor(schedule));
const [newTime, setNewTime] = useState('');
const [bulkInput, setBulkInput] = useState('');
const [showBulkInput, setShowBulkInput] = useState(false);
const dayTypeLabel = DAY_TYPES.find(dt => dt.value === schedule.dayType || dt.value === schedule.day_type)?.label;
const handleAddTime = () => {
if (!newTime) return;
const timeRegex = /^([0-1]?[0-9]|2[0-3]):([0-5][0-9])$/;
if (!timeRegex.test(newTime)) {
alert('Неверный формат времени. Используйте ЧЧ:ММ');
return;
}
setTrips(sortTrips([...trips, createEditorTrip(newTime)]));
setNewTime('');
};
const handleRemoveTrip = (index) => {
setTrips(trips.filter((_, i) => i !== index));
};
const handleUpdateTrip = (index, patch) => {
setTrips(trips.map((trip, i) => (
i === index ? { ...trip, ...patch } : trip
)));
};
const handleToggleDay = (index, field, day) => {
const trip = trips[index];
const current = trip[field] || [];
const next = current.includes(day)
? current.filter(value => value !== day)
: [...current, day].sort();
const oppositeField = field === 'daysOfWeek' ? 'exceptDaysOfWeek' : 'daysOfWeek';
handleUpdateTrip(index, {
[field]: next,
[oppositeField]: (trip[oppositeField] || []).filter(value => value !== day)
});
};
const handleBulkAdd = () => {
const inputTrips = bulkInput
.split(/\s*\|\s*|[,;\n]+/)
.map(t => t.trim())
.filter(t => t);
const validTrips = [];
const invalidItems = [];
for (const item of inputTrips) {
const parsedTrip = parseBulkTrip(item, routeStops);
if (parsedTrip) {
validTrips.push(parsedTrip);
} else {
invalidItems.push(item);
}
}
if (invalidItems.length > 0) {
alert(`Неверный формат: ${invalidItems.join(', ')}`);
}
if (validTrips.length > 0) {
setTrips(sortTrips([...trips, ...validTrips]));
setBulkInput('');
setShowBulkInput(false);
}
};
const handleSave = () => {
if (trips.length === 0) {
alert('Добавьте хотя бы одно время отправления');
return;
}
const apiTrips = sortTrips(trips).map(toApiTrip);
onSave({
routeId: schedule.routeId || schedule.route_id,
dayType: schedule.dayType || schedule.day_type,
departureTimes: apiTrips.map(trip => trip.departureTime),
trips: apiTrips
});
};
// Generate quick add times
const generateQuickTimes = (startHour, endHour, interval) => {
const existingTimes = new Set(trips.map(trip => trip.departureTime));
const generatedTrips = [];
for (let h = startHour; h <= endHour; h++) {
for (let m = 0; m < 60; m += interval) {
const time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
if (!existingTimes.has(time)) {
generatedTrips.push(createEditorTrip(time));
}
}
}
setTrips(sortTrips([...trips, ...generatedTrips]));
};
return (
{schedule.id ? 'Редактировать' : 'Создать'} расписание
{routeName} — {dayTypeLabel}
{/* Quick Generation */}
Быстрое создание
{/* Add Single Time */}
{/* Bulk Input */}
{showBulkInput && (
Введите рейсы через | или с новой строки: 15:10 по пятницам, 18:00 кроме пятницы, 06:05 через Мариненко
)}
{/* Trips List */}
{trips.length > 0 ? (
{trips.map((trip, index) => (
Только по дням
{WEEKDAYS.map(day => (
))}
Кроме дней
{WEEKDAYS.map(day => (
))}
))}
) : (
Нет времени отправления
)}
);
}
function getScheduleTrips(schedule) {
if (Array.isArray(schedule.trips) && schedule.trips.length > 0) {
return schedule.trips;
}
return (schedule.departure_times || []).map(departureTime => ({
departureTime,
startStopId: null,
endStopId: null,
viaStopIds: [],
daysOfWeek: [],
exceptDaysOfWeek: [],
note: ''
}));
}
function normalizeTripsForEditor(schedule) {
return getScheduleTrips(schedule).map(trip => ({
departureTime: formatScheduleTime(trip.departureTime),
startStopId: trip.startStopId || null,
endStopId: trip.endStopId || null,
viaStopIds: Array.isArray(trip.viaStopIds) ? trip.viaStopIds : [],
daysOfWeek: Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.map(Number) : [],
exceptDaysOfWeek: Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek.map(Number) : [],
note: trip.note || ''
}));
}
function createEditorTrip(departureTime) {
return {
departureTime,
startStopId: null,
endStopId: null,
viaStopIds: [],
daysOfWeek: [],
exceptDaysOfWeek: [],
note: ''
};
}
function toApiTrip(trip) {
return {
departureTime: `${formatScheduleTime(trip.departureTime)}:00`,
startStopId: trip.startStopId || null,
endStopId: trip.endStopId || null,
viaStopIds: Array.isArray(trip.viaStopIds) ? trip.viaStopIds.filter(Boolean).map(Number) : [],
daysOfWeek: Array.isArray(trip.daysOfWeek) ? trip.daysOfWeek.filter(Boolean).map(Number) : [],
exceptDaysOfWeek: Array.isArray(trip.exceptDaysOfWeek) ? trip.exceptDaysOfWeek.filter(Boolean).map(Number) : [],
note: (trip.note || '').trim()
};
}
function sortTrips(items) {
return [...items].sort((a, b) => a.departureTime.localeCompare(b.departureTime));
}
function formatScheduleTime(time) {
return (time || '').substring(0, 5);
}
function getTripLabels(trip, routeStops) {
const labels = [];
const startStop = findRouteStop(routeStops, trip.startStopId);
const endStop = findRouteStop(routeStops, trip.endStopId);
const viaStops = (trip.viaStopIds || [])
.map(stopId => findRouteStop(routeStops, stopId))
.filter(Boolean);
if (startStop) labels.push(`с ${startStop.name}`);
if (endStop) labels.push(`до ${endStop.name}`);
viaStops.forEach(stop => labels.push(`через ${stop.name}`));
if (trip.daysOfWeek?.length > 0) {
labels.push(`по ${trip.daysOfWeek.map(getWeekdayLabel).join(', ')}`);
}
if (trip.exceptDaysOfWeek?.length > 0) {
labels.push(`кроме ${trip.exceptDaysOfWeek.map(getWeekdayLabel).join(', ')}`);
}
if (trip.note) labels.push(trip.note);
return [...new Set(labels)];
}
function getWeekdayLabel(value) {
return WEEKDAYS.find(day => day.value === Number(value))?.label || value;
}
function findRouteStop(routeStops, stopId) {
if (!stopId) return null;
return routeStops.find(stop => Number(stop.stop_id) === Number(stopId));
}
function parseBulkTrip(value, routeStops) {
const match = value.match(/^([0-1]?[0-9]|2[0-3]):([0-5][0-9])(?:\s+(.+))?$/i);
if (!match) return null;
const trip = createEditorTrip(`${match[1].padStart(2, '0')}:${match[2]}`);
const note = (match[3] || '').trim();
if (!note) return trip;
const normalizedNote = normalizeText(note);
if (/по\s+пятниц/.test(normalizedNote)) {
trip.daysOfWeek = [5];
}
if (/кроме\s+пятниц/.test(normalizedNote)) {
trip.exceptDaysOfWeek = [5];
}
const viaStopId = findStopIdInText(extractStopText(normalizedNote, 'через'), routeStops);
const endStopId = findStopIdInText(extractStopText(normalizedNote, 'до'), routeStops);
const startStopId = findStopIdInText(extractStopText(normalizedNote, 'с'), routeStops);
if (viaStopId) trip.viaStopIds = [viaStopId];
if (endStopId) trip.endStopId = endStopId;
if (startStopId) trip.startStopId = startStopId;
trip.note = note;
return trip;
}
function extractStopText(text, marker) {
const match = text.match(new RegExp(`\\b${marker}\\s+(.+?)(?=\\s+(?:по|кроме)\\s+|$)`));
return match?.[1] || '';
}
function findStopIdInText(value, routeStops) {
if (!value) return null;
const normalizedValue = normalizeText(value);
const looseValue = normalizeStopLookup(normalizedValue);
const match = routeStops.find(stop => {
const stopName = normalizeText(stop.name);
const looseStopName = normalizeStopLookup(stopName);
return normalizedValue.includes(stopName) ||
stopName.includes(normalizedValue) ||
looseValue.includes(looseStopName) ||
looseStopName.includes(looseValue);
});
return match?.stop_id || null;
}
function normalizeStopLookup(value) {
return value
.split(/\s+/)
.map(word => word.replace(/[аяеуыиюь]$/u, ''))
.join(' ');
}
function normalizeText(value) {
return value
.toLowerCase()
.replace(/[«»"']/g, '')
.replace(/ё/g, 'е')
.replace(/[.,]/g, '')
.trim();
}