Files
ridebus-admin/frontend/src/pages/SchedulesPage.jsx
Zakhar Timoshenko 6804ca5cdb feat: add AppLayout, BrandLogo, ProfileMenu, and RouteBadge components
- Implemented AppLayout component for application shell with navigation and sidebar.
- Created BrandLogo component to display the application logo.
- Developed ProfileMenu component for user profile management with dropdown functionality.
- Added RouteBadge component to visually represent route numbers with color coding.

style: add CSS styles for Dashboard and Routes pages

- Introduced Dashboard.css for styling the dashboard layout and components.
- Created RoutesPage.css for styling the routes page, including filters and table layout.
- Added SchedulesPage.css for styling the schedules layout and route options.
2026-09-15 22:11:10 +03:00

892 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <PageLoader />;
}
return (
<div>
<div className="flex justify-between items-center mb-4 sm:mb-6">
<h1 className="page-title">Расписания</h1>
</div>
{/* Upcoming Holidays Info */}
{upcomingHolidays.length > 0 && (
<div className="card mb-4 sm:mb-6 bg-amber-50 border-amber-200">
<div className="flex items-start">
<AlertCircle className="h-5 w-5 text-amber-600 mr-2 sm:mr-3 mt-0.5 flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-amber-800 text-sm sm:text-base">Ближайшие праздники</h3>
<p className="text-xs sm:text-sm text-amber-700 mb-2">
Транспорт идет по расписанию воскресенья
</p>
<div className="flex flex-wrap gap-1 sm:gap-2">
{upcomingHolidays.slice(0, 3).map((holiday, idx) => (
<span key={idx} className="bg-amber-100 text-amber-800 px-2 py-0.5 sm:py-1 rounded text-xs">
{new Date(holiday.next_date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })} — {holiday.name}
</span>
))}
</div>
</div>
</div>
</div>
)}
{/* Mobile: Route selector dropdown */}
<div className="lg:hidden mb-4">
<label htmlFor="schedule-route" className="label">Выберите маршрут</label>
<select
id="schedule-route"
value={selectedRoute?.id || ''}
onChange={(e) => {
const route = routes.find(r => String(r.id) === e.target.value);
setSelectedRoute(route);
}}
className="input"
>
{routes.map(route => (
<option key={route.id} value={route.id}>
{route.route_number} — {route.name}
</option>
))}
</select>
</div>
<div className="schedules-layout">
<section className="card schedule-route-panel hidden lg:block" aria-labelledby="schedule-routes-heading">
<h2 id="schedule-routes-heading" className="text-lg font-semibold mb-4">Маршруты</h2>
<div className="schedule-route-list">
{routes.map(route => (
<button type="button" key={route.id} onClick={() => setSelectedRoute(route)}
aria-pressed={selectedRoute?.id === route.id} className="schedule-route-option">
<RouteBadge route={route} />
<span className="schedule-route-text">
<span className="schedule-route-name">{route.name}</span>
<span className="schedule-route-count">Остановок: {route.stops_count ?? 0}</span>
</span>
</button>
))}
</div>
</section>
<div className="schedule-details" aria-busy={detailsLoading}>
{selectedRoute ? (
<>
<div className="schedule-selected-route">
<RouteBadge route={selectedRoute} />
<div className="min-w-0">
<h2 className="text-base sm:text-xl font-semibold break-words">{selectedRoute.name}</h2>
<p className="text-xs sm:text-sm text-gray-500">Расписание движения</p>
</div>
</div>
{detailsLoading ? <PageLoader /> : detailsError ? (
<div className="card" role="alert">
<p className="text-red-700 mb-3">{detailsError}</p>
<button type="button" className="btn-secondary" onClick={() => loadRouteDetails(selectedRoute.id)}>Повторить</button>
</div>
) : (
<div className="schedule-days-grid">
{DAY_TYPES.map((dayType, index) => {
const schedule = getScheduleForDayType(dayType.value);
const isExpanded = expandedSchedule === dayType.value;
return (
<div
key={dayType.value}
className="card card-hover list-item-animated"
style={{ animationDelay: `${index * 100}ms` }}
>
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{dayType.label}</h3>
<p className="text-sm text-gray-500">{dayType.description}</p>
</div>
{schedule ? (
<span className="bg-green-100 text-green-800 px-2 py-1 rounded text-xs">
{getScheduleTrips(schedule).length} рейсов
</span>
) : (
<span className="bg-gray-100 text-gray-600 px-2 py-1 rounded text-xs">
Не задано
</span>
)}
</div>
{schedule ? (
<>
<button
onClick={() => setExpandedSchedule(isExpanded ? null : dayType.value)}
className="w-full text-left text-sm text-gray-600 hover:text-gray-900 flex items-center justify-between py-2 border-t border-gray-100"
>
<span>Показать расписание</span>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="mt-2 p-3 bg-gray-50 rounded-lg max-h-48 overflow-y-auto animate-[fadeSlideIn_0.2s_ease-out]">
<div className="space-y-1 text-sm">
{getScheduleTrips(schedule).map((trip, idx) => (
<div
key={`${trip.departureTime}-${idx}`}
className="flex flex-wrap items-center gap-1 text-gray-700 list-item-animated"
style={{ animationDelay: `${idx * 10}ms` }}
>
<span className="font-mono font-medium w-12">
{formatScheduleTime(trip.departureTime)}
</span>
{getTripLabels(trip, routeStops).map((label) => (
<span
key={label}
className="rounded bg-white px-1.5 py-0.5 text-xs text-gray-600 border border-gray-200"
>
{label}
</span>
))}
</div>
))}
</div>
</div>
)}
<div className="flex space-x-2 mt-3">
<button
onClick={() => handleEditSchedule(schedule)}
className="btn-secondary flex-1 flex items-center justify-center text-sm py-1"
>
<Edit className="h-4 w-4 mr-1" />
Изменить
</button>
<button
onClick={() => handleDeleteSchedule(schedule.id)}
className="btn-danger px-2 py-1"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{/* Copy to other day types */}
{dayType.value !== 'holiday' && (
<div className="mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500 mb-1">Копировать в:</p>
<div className="flex flex-wrap gap-1">
{DAY_TYPES.filter(dt =>
dt.value !== dayType.value &&
!getScheduleForDayType(dt.value)
).map(dt => (
<button
key={dt.value}
onClick={() => handleCopySchedule(dayType.value, dt.value)}
className="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded-lg"
>
{dt.label}
</button>
))}
</div>
</div>
)}
</>
) : (
<button
onClick={() => handleCreateSchedule(dayType.value)}
className="w-full btn-primary flex items-center justify-center mt-2"
>
<Plus className="h-4 w-4 mr-1" />
Создать расписание
</button>
)}
</div>
);
})}
</div>
)}
</>
) : (
<div className="text-center py-12">
<Calendar className="h-16 w-16 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">Выберите маршрут для просмотра расписания</p>
</div>
)}
</div>
</div>
{/* Schedule Editor Modal */}
{showEditor && editingSchedule && (
<ScheduleEditor
schedule={editingSchedule}
routeName={selectedRoute?.name}
routeStops={routeStops}
onSave={handleSaveSchedule}
onCancel={() => {
setShowEditor(false);
setEditingSchedule(null);
}}
/>
)}
</div>
);
}
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50">
<div className="bg-white w-full sm:max-w-2xl sm:mx-4 sm:rounded-lg rounded-t-2xl max-h-[95vh] overflow-hidden flex flex-col">
<div className="p-4 sm:p-6 border-b border-gray-200 flex-shrink-0">
<h2 className="text-lg sm:text-xl font-semibold">
{schedule.id ? 'Редактировать' : 'Создать'} расписание
</h2>
<p className="text-sm text-gray-500">
{routeName} — {dayTypeLabel}
</p>
</div>
<div className="p-4 sm:p-6 overflow-y-auto flex-1 smooth-scroll">
{/* Quick Generation */}
<div className="mb-4 sm:mb-6 p-3 sm:p-4 bg-gray-50 rounded-lg">
<h3 className="font-medium mb-2 text-sm sm:text-base">Быстрое создание</h3>
<div className="flex flex-wrap gap-2">
<button
onClick={() => generateQuickTimes(6, 22, 30)}
className="text-xs bg-white border border-gray-300 px-2 sm:px-3 py-1.5 rounded hover:bg-gray-100"
>
06-22 × 30мин
</button>
<button
onClick={() => generateQuickTimes(6, 22, 20)}
className="text-xs bg-white border border-gray-300 px-2 sm:px-3 py-1.5 rounded hover:bg-gray-100"
>
06-22 × 20мин
</button>
<button
onClick={() => generateQuickTimes(7, 20, 60)}
className="text-xs bg-white border border-gray-300 px-2 sm:px-3 py-1.5 rounded hover:bg-gray-100"
>
07-20 × 60мин
</button>
<button
onClick={() => setTrips([])}
className="text-xs bg-red-50 text-red-600 border border-red-200 px-2 sm:px-3 py-1.5 rounded hover:bg-red-100"
>
Очистить
</button>
</div>
</div>
{/* Add Single Time */}
<div className="mb-4">
<label className="label">Добавить время</label>
<div className="flex space-x-2">
<input
type="time"
value={newTime}
onChange={(e) => setNewTime(e.target.value)}
className="input flex-1"
/>
<button onClick={handleAddTime} className="btn-primary px-3">
<Plus className="h-5 w-5" />
</button>
<button
onClick={() => setShowBulkInput(!showBulkInput)}
className="btn-secondary text-xs sm:text-sm px-2 sm:px-3"
>
<span className="hidden sm:inline">Массовый</span>
<span className="sm:hidden">+</span>
</button>
</div>
</div>
{/* Bulk Input */}
{showBulkInput && (
<div className="mb-4 p-3 sm:p-4 bg-blue-50 rounded-lg">
<label className="label">Массовый ввод</label>
<p className="text-xs text-gray-600 mb-2">
Введите рейсы через | или с новой строки: 15:10 по пятницам, 18:00 кроме пятницы, 06:05 через Мариненко
</p>
<textarea
value={bulkInput}
onChange={(e) => setBulkInput(e.target.value)}
className="input mb-2"
rows="2"
placeholder="06:00, 06:30, 07:00..."
/>
<button onClick={handleBulkAdd} className="btn-primary text-sm">
Добавить все
</button>
</div>
)}
{/* Trips List */}
<div className="mb-4">
<label className="label">
Рейсы ({trips.length})
</label>
{trips.length > 0 ? (
<div className="space-y-3 max-h-[42vh] overflow-y-auto p-2 bg-gray-50 rounded-lg">
{trips.map((trip, index) => (
<div
key={`${trip.departureTime}-${index}`}
className="bg-white border border-gray-200 rounded-lg p-3"
>
<div className="flex items-center gap-2 mb-3">
<input
type="time"
value={trip.departureTime}
onChange={(e) => handleUpdateTrip(index, { departureTime: e.target.value })}
className="input max-w-[130px]"
/>
<div className="flex-1 flex flex-wrap gap-1">
{getTripLabels(toApiTrip(trip), routeStops).map(label => (
<span key={label} className="rounded bg-gray-100 px-2 py-1 text-xs text-gray-700">
{label}
</span>
))}
</div>
<button
onClick={() => handleRemoveTrip(index)}
className="text-red-500 hover:text-red-700 px-2 py-1 text-lg leading-none"
title="Удалить рейс"
>
×
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<label className="text-xs text-gray-600">
С остановки
<select
value={trip.startStopId || ''}
onChange={(e) => handleUpdateTrip(index, { startStopId: e.target.value ? Number(e.target.value) : null })}
className="input mt-1 text-sm"
>
<option value="">От начала маршрута</option>
{routeStops.map(stop => (
<option key={stop.stop_id} value={stop.stop_id}>{stop.name}</option>
))}
</select>
</label>
<label className="text-xs text-gray-600">
До остановки
<select
value={trip.endStopId || ''}
onChange={(e) => handleUpdateTrip(index, { endStopId: e.target.value ? Number(e.target.value) : null })}
className="input mt-1 text-sm"
>
<option value="">До конца маршрута</option>
{routeStops.map(stop => (
<option key={stop.stop_id} value={stop.stop_id}>{stop.name}</option>
))}
</select>
</label>
<label className="text-xs text-gray-600">
Через остановку
<select
value={trip.viaStopIds?.[0] || ''}
onChange={(e) => handleUpdateTrip(index, { viaStopIds: e.target.value ? [Number(e.target.value)] : [] })}
className="input mt-1 text-sm"
>
<option value="">Без отметки</option>
{routeStops.map(stop => (
<option key={stop.stop_id} value={stop.stop_id}>{stop.name}</option>
))}
</select>
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
<div>
<div className="text-xs text-gray-600 mb-1">Только по дням</div>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map(day => (
<button
key={day.value}
type="button"
onClick={() => handleToggleDay(index, 'daysOfWeek', day.value)}
className={`px-2 py-1 rounded text-xs border ${
trip.daysOfWeek?.includes(day.value)
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white text-gray-700 border-gray-200'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<div>
<div className="text-xs text-gray-600 mb-1">Кроме дней</div>
<div className="flex flex-wrap gap-1">
{WEEKDAYS.map(day => (
<button
key={day.value}
type="button"
onClick={() => handleToggleDay(index, 'exceptDaysOfWeek', day.value)}
className={`px-2 py-1 rounded text-xs border ${
trip.exceptDaysOfWeek?.includes(day.value)
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-700 border-gray-200'
}`}
>
{day.label}
</button>
))}
</div>
</div>
</div>
<label className="text-xs text-gray-600 block mt-3">
Примечание
<input
type="text"
value={trip.note || ''}
onChange={(e) => handleUpdateTrip(index, { note: e.target.value })}
className="input mt-1 text-sm"
placeholder="например: по пятницам, через Мариненко"
/>
</label>
</div>
))}
</div>
) : (
<div className="text-center py-6 sm:py-8 bg-gray-50 rounded-lg text-gray-500 text-sm">
Нет времени отправления
</div>
)}
</div>
</div>
<div className="p-4 sm:p-6 border-t border-gray-200 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 flex-shrink-0 safe-bottom">
<button onClick={onCancel} className="btn-secondary w-full sm:w-auto">
Отмена
</button>
<button onClick={handleSave} className="btn-primary w-full sm:w-auto">
Сохранить
</button>
</div>
</div>
</div>
);
}
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();
}