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

{empty}

; return [...groups.entries()].map(([label, items]) =>

{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]) => )}
{error &&
{error}
} {notice &&
{notice}
} {busy &&

{busy === 'apply' ? 'Проверяем источник и сохраняем расписание…' : 'Загружаем данные…'}

} {tab === 'schedule' && <>

1. Выберите маршрут

Направления в админке {!sourceUrl ?

Сначала выберите номер на сайте.

: matchingRoutes.length ? <>

Все направления №{selectedSource.number} выбираются автоматически. Оставьте отмеченными те, которые нужно обновить.

{matchingRoutes.map(route => )} :

Маршрутов с этим номером нет. Добавьте нужные направления в разделе «Маршруты».

}
{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}

:

Добавьте остановки этого направления в редакторе маршрута.

} {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.

}

«Выходной день» загружается для субботы и воскресенья. Время проезда между остановками берётся из настроек маршрута.

} {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 ? <> :

Расписание уже совпадает с источником. Сохранять повторно не нужно.

}
} } {tab === 'news' &&

Новости автопарка

Загружаются публикации с главной страницы, ранее загруженные сохраняются. Отмены, переносы и даты действия проверяются вручную. Отметка «Проверено» не изменяет расписание.

{!news.length && !busy &&
Новостей пока нет. Нажмите «Проверить новости».
} {news.map(item =>
Опубликовано {dateLabel(item.published_date)}{item.reviewed ? 'Проверено' : 'Требует проверки'}

{item.title}

{item.content}

{!item.reviewed && }
)}
} {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')}

)}
}
; }