import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { Download, ExternalLink, RefreshCw, Check, AlertCircle } from 'lucide-react';
import { routesApi, scheduleImportApi } from '../services/api';
import './ScheduleImportPage.css';
const DAY_LABELS = { weekday: 'Будние дни', saturday: 'Суббота', sunday: 'Воскресенье' };
const WEEKDAY_LABELS = { 1: 'пн', 2: 'вт', 3: 'ср', 4: 'чт', 5: 'пт', 6: 'сб', 7: 'вс' };
const weekdayLabels = days => days.map(day => WEEKDAY_LABELS[day] || day).join(', ');
const normNumber = value => String(value).toLowerCase().replace(/a/g, 'а').replace(/\s/g, '');
const dateLabel = value => value ? new Date(`${value.slice(0, 10)}T12:00:00`).toLocaleDateString('ru-RU') : '—';
function SourceLink({ url, children = 'Открыть источник' }) {
return
{children}
;
}
function TripList({ trips, stops, empty }) {
const groups = new Map();
const stopName = id => stops.find(stop => Number(stop.stop_id) === Number(id))?.name || (id ? `Остановка №${id}` : 'Без привязки');
trips.forEach(trip => {
const label = `${stopName(trip.startStopId)} → ${stopName(trip.endStopId)}`;
if (!groups.has(label)) groups.set(label, []);
groups.get(label).push(trip);
});
if (!trips.length) return
{label}
{items.map((trip, index) =>
{trip.departureTime?.slice(0, 5)}
{trip.daysOfWeek?.length > 0 && ` · только: ${weekdayLabels(trip.daysOfWeek)}`}
{trip.exceptDaysOfWeek?.length > 0 && ` · кроме: ${weekdayLabels(trip.exceptDaysOfWeek)}`}
{trip.viaStopIds?.length > 0 && ` · через: ${trip.viaStopIds.map(stopName).join(', ')}`}
{trip.note && ` · ${trip.note}`}
)}
);
}
export default function ScheduleImportPage() {
const [tab, setTab] = useState('schedule');
const [catalog, setCatalog] = useState([]);
const [routes, setRoutes] = useState([]);
const [sourceUrl, setSourceUrl] = useState('');
const [routeIds, setRouteIds] = useState([]);
const [inspection, setInspection] = useState(null);
const [mappings, setMappings] = useState({});
const [validFrom, setValidFrom] = useState('');
const [preview, setPreview] = useState(null);
const [confirmed, setConfirmed] = useState(false);
const [news, setNews] = useState([]);
const [history, setHistory] = useState([]);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const alive = useRef(true);
const operation = useRef(false);
async function run(name, action) {
if (operation.current) return;
operation.current = true;
setBusy(name); setError(''); setNotice('');
try { await action(); } catch (e) {
if (alive.current) setError(e.response?.data?.error || 'Не удалось выполнить запрос. Проверьте соединение и повторите попытку.');
} finally {
operation.current = false;
if (alive.current) setBusy('');
}
}
async function loadInitial() {
const results = await Promise.allSettled([
scheduleImportApi.catalog(), routesApi.getAll(), scheduleImportApi.news(), scheduleImportApi.history(),
]);
if (!alive.current) return;
[setCatalog, setRoutes, setNews, setHistory].forEach((setter, i) => {
if (results[i].status === 'fulfilled') setter(results[i].value.data);
});
const failed = results.find(result => result.status === 'rejected');
if (failed) throw failed.reason;
}
useEffect(() => {
alive.current = true;
run('loading', loadInitial);
return () => { alive.current = false; };
}, []);
function resetDraft() { setInspection(null); setPreview(null); setConfirmed(false); setError(''); setNotice(''); }
function changeMapping(routeId, sourceStopName) {
setMappings(current => ({ ...current, [routeId]: sourceStopName }));
setPreview(null); setConfirmed(false);
}
const names = [...new Set(inspection?.source.rows.map(row => row.stopName) || [])];
const selectedSource = catalog.find(item => item.url === sourceUrl);
const matchingRoutes = selectedSource ? routes.filter(route => normNumber(route.route_number) === normNumber(selectedSource.number)) : [];
const complete = inspection?.directions.length > 0 && inspection.directions.every(({ route, stops }) =>
mappings[route.id] && stops.length > 1 && stops[0].stop_id !== stops.at(-1).stop_id);
const usedNames = new Set(Object.values(mappings));
const checkpointNames = names.filter(name => !inspection?.departureStops.includes(name));
const unusedOrigins = (inspection?.departureStops || []).filter(name => !usedNames.has(name));
return
{[['schedule', 'Расписания'], ['news', `Новости${news.filter(item => !item.reviewed).length ? ` · ${news.filter(item => !item.reviewed).length}` : ''}`], ['history', 'История']].map(([value, label]) =>
setTab(value)}>{label} )}
{error &&
}
{notice &&
}
{busy &&
{busy === 'apply' ? 'Проверяем источник и сохраняем расписание…' : 'Загружаем данные…'}
}
{tab === 'schedule' && <>
1. Выберите маршрут
run('loading', loadInitial)}> Обновить список
Маршрут на сайте
{
resetDraft(); setSourceUrl(event.target.value);
const source = catalog.find(item => item.url === event.target.value);
const matching = routes.filter(route => normNumber(route.route_number) === normNumber(source?.number));
setRouteIds(matching.map(route => route.id));
}}>Выберите маршрут {catalog.map(item => №{item.number} )}
Направления в админке
{!sourceUrl ? Сначала выберите номер на сайте.
: matchingRoutes.length ? <>
Все направления №{selectedSource.number} выбираются автоматически. Оставьте отмеченными те, которые нужно обновить.
{matchingRoutes.map(route =>
{
resetDraft(); setRouteIds(current => event.target.checked ? [...current, route.id] : current.filter(id => id !== route.id));
}} />{route.name} · №{route.route_number}
)}
> : Маршрутов с этим номером нет. Добавьте нужные направления в разделе «Маршруты».
}
run('inspect', async () => {
setPreview(null); setInspection(null); setConfirmed(false);
const { data } = await scheduleImportApi.inspect({ routeIds, sourceUrl });
if (!alive.current) return;
setInspection(data); setMappings(data.mappings); setValidFrom(data.today);
})}> Загрузить таблицы
{sourceUrl && }
{!routes.length && !busy && Сначала создайте маршрут и добавьте его остановки в разделе «Маршруты».
}
{inspection &&
2. Проверьте направления {inspection.source.title}
Каждое направление получит времена отправления со своей начальной остановки. Начало и конец уже заданы порядком остановок в админке — выбирать их заново не нужно.
{inspection.source.rows.some(row => row.departures?.some(trip => trip.viaStopNames.length || trip.daysOfWeek.length || trip.exceptDaysOfWeek.length)) &&
Примечания «по дням недели», «кроме» и «через остановку» распознаны и будут сохранены у соответствующих рейсов. Проверьте условия в предпросмотре. Если остановка не найдена в направлении, условие «через» сохранится текстом.
}
{inspection.source.warnings.length > 0 &&
Нужна ручная проверка источника {inspection.source.warnings.map((warning, i) => {warning} )}
}
{inspection.source.directions.length > 0 && Остановки и направления на сайте
{inspection.source.directions.map((direction, i) => {direction.label}: {direction.stops.join(' → ')}
)}
}
{inspection.directions.map(({ route, stops }) =>
№{route.route_number} · {route.name}
{stops.length > 1 ?
Направление в админке: {stops[0].name} → {stops.at(-1).name}
:
Добавьте остановки этого направления в редакторе маршрута.
}
Строка отправлений на сайте · {route.name}
changeMapping(route.id, event.target.value)}>
Выберите начальную остановку на сайте
{inspection.departureStops.map(name => Отправления: {name} )}
{mappings[route.id] &&
В этот маршрут попадут времена из строки «{mappings[route.id]}».
}
{inspection.source.rows.filter(row => row.stopName === mappings[route.id]).map(row =>
{row.dayLabel || row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён
{row.rawTimes}
)}
)}
{checkpointNames.length > 0 &&
Времена на промежуточных остановках
{checkpointNames.join(', ')} — строки прохождения уже отправившихся автобусов. Они не создают дополнительные рейсы. В этой версии импорта времена на промежуточных остановках не переносятся; используется время проезда между остановками, заданное в маршруте.
}
{unusedOrigins.length > 0 && Не выбраны отправления от: {unusedOrigins.join(', ')}. Они не будут импортированы. Чтобы загрузить их, выберите соответствующие направления в шаге 1.
}
Расписание действует с { setValidFrom(event.target.value); setPreview(null); setConfirmed(false); }} />
«Выходной день» загружается для субботы и воскресенья. Время проезда между остановками берётся из настроек маршрута.
0} onClick={() => run('preview', async () => {
setPreview(null); setConfirmed(false);
const { data } = await scheduleImportApi.preview({ inspectionId: inspection.id, mappings, validFrom });
if (alive.current) setPreview(data);
})}>Показать изменения
}
{preview &&
3. Проверьте изменения С {dateLabel(preview.validFrom)}
Для перечисленных типов дней расписание будет заменено целиком. Удаляемые рейсы и их особые условия показаны ниже. Праздничное расписание не меняется.
{preview.plans.map(plan =>
№{plan.route.route_number} · {plan.route.name}
Источник отправлений: {plan.sourceStopName}
{plan.notices?.map(notice => {notice}
)}
{plan.changes.map(change =>
{change.label}
Было {change.before.length} · Станет {change.after.length} · Без изменений {change.unchanged}
0}>Добавится: {change.added.length}
0}>Удалится: {change.removed.length}
)} )}
{preview.hasChanges ? <>
setConfirmed(event.target.checked)} />
Я проверил отправления для каждого направления, дату начала и замену расписаний.
run('apply', async () => {
const { data } = await scheduleImportApi.apply(preview.id);
if (!alive.current) return;
setNotice(data.cacheWarning || 'Расписание сохранено. Импорт добавлен в историю.');
setPreview(null); setInspection(null); setConfirmed(false);
try { const result = await scheduleImportApi.history(); if (alive.current) setHistory(result.data); } catch { /* The save already succeeded. */ }
})}> Применить к выбранным направлениям
> : Расписание уже совпадает с источником. Сохранять повторно не нужно.
}
}
>}
{tab === 'news' &&
Новости автопарка
run('news', async () => { const { data } = await scheduleImportApi.refreshNews(); if (alive.current) setNews(data); })}> Проверить новости
Загружаются публикации с главной страницы, ранее загруженные сохраняются. Отмены, переносы и даты действия проверяются вручную. Отметка «Проверено» не изменяет расписание.
{!news.length && !busy && Новостей пока нет. Нажмите «Проверить новости».
}
{news.map(item =>
Опубликовано {dateLabel(item.published_date)} {item.reviewed ? 'Проверено' : 'Требует проверки'}
{item.title}
{item.content}
{!item.reviewed && run('review', async () => {
await scheduleImportApi.reviewNews({ url: item.url, contentHash: item.content_hash });
if (alive.current) setNews(current => current.map(n => n.url === item.url ? { ...n, reviewed: true } : n));
})}> Отметить проверенной }
)}
}
{tab === 'history' &&
Применённые импорты
{!history.length && После первого применения здесь появится запись об импорте.
}
{history.map(item =>
{item.directions?.length ? item.directions.map(route => `№${route.route_number} · ${route.name}`).join('; ') : `Маршрут №${item.route_number || '—'} · ${item.route_name || 'Удалён'}`}
Действует с {dateLabel(item.valid_from)} · Импорт {new Date(item.applied_at).toLocaleString('ru-RU')}
)}
}
;
}