- 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.
892 lines
34 KiB
JavaScript
892 lines
34 KiB
JavaScript
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();
|
||
}
|