feat: add schedule import functionality and quality control page

- Implemented the schedule import feature with a dedicated page for importing schedules from the transport website.
- Added a new QualityPage to check the quality of routes, stops, schedules, and notifications without making automatic changes.
- Created CSS styles for both the ScheduleImportPage and QualityPage to enhance the user interface.
- Introduced API calls for fetching schedule data and quality reports.
- Added error handling and loading states for better user experience during data fetching.
- Included functionality for filtering quality issues and displaying detailed reports.
This commit is contained in:
2026-09-16 21:55:50 +03:00
parent 6804ca5cdb
commit 595ad6456b
36 changed files with 2496 additions and 72 deletions

View File

@@ -0,0 +1,252 @@
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 <a href={url} target="_blank" rel="noreferrer" className="import-source-link">
{children}<ExternalLink size={14} aria-hidden="true" />
</a>;
}
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 <p className="text-sm text-gray-500">{empty}</p>;
return [...groups.entries()].map(([label, items]) => <div key={label} className="mt-3">
<p className="text-sm font-medium">{label}</p>
<div className="import-times mt-2">{items.map((trip, index) => <span key={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}`}
</span>)}</div>
</div>);
}
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 <div className="schedule-import-page space-y-6" aria-busy={Boolean(busy)}>
<header>
<h1 className="page-title">Импорт расписаний</h1>
<p className="mt-2 text-gray-500">Расписания и новости официального сайта автопарка Полоцка.</p>
</header>
<div className="segmented-control" aria-label="Раздел импорта">
{[['schedule', 'Расписания'], ['news', `Новости${news.filter(item => !item.reviewed).length ? ` · ${news.filter(item => !item.reviewed).length}` : ''}`], ['history', 'История']].map(([value, label]) =>
<button type="button" key={value} className="segmented-option" aria-pressed={tab === value}
disabled={Boolean(busy)} onClick={() => setTab(value)}>{label}</button>)}
</div>
{error && <div className="import-message import-message-error" role="alert"><AlertCircle size={20} /><div>{error}</div></div>}
{notice && <div className="import-message import-message-success" role="status"><Check size={20} /><div>{notice}</div></div>}
{busy && <p className="text-sm text-gray-500" role="status">{busy === 'apply' ? 'Проверяем источник и сохраняем расписание…' : 'Загружаем данные…'}</p>}
{tab === 'schedule' && <>
<section className="card space-y-4">
<div className="import-section-heading"><h2>1. Выберите маршрут</h2>
<button type="button" className="btn-secondary" disabled={Boolean(busy)} onClick={() => run('loading', loadInitial)}><RefreshCw size={16} /> Обновить список</button></div>
<div className="import-grid">
<label className="block"><span className="label">Маршрут на сайте</span>
<select className="input" value={sourceUrl} disabled={Boolean(busy)} onChange={event => {
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));
}}><option value="">Выберите маршрут</option>{catalog.map(item => <option key={item.url} value={item.url}>№{item.number}</option>)}</select>
</label>
<fieldset className="min-w-0"><legend className="label">Направления в админке</legend>
{!sourceUrl ? <p className="text-sm text-gray-500">Сначала выберите номер на сайте.</p> : matchingRoutes.length ? <>
<p className="text-sm text-gray-500 mb-2">Все направления №{selectedSource.number} выбираются автоматически. Оставьте отмеченными те, которые нужно обновить.</p>
{matchingRoutes.map(route => <label key={route.id} className="flex items-start gap-3 py-2 text-sm">
<input type="checkbox" className="mt-1" checked={routeIds.includes(route.id)} disabled={Boolean(busy)} onChange={event => {
resetDraft(); setRouteIds(current => event.target.checked ? [...current, route.id] : current.filter(id => id !== route.id));
}} /><span>{route.name} <span className="text-gray-500">· №{route.route_number}</span></span>
</label>)}
</> : <p className="text-sm text-gray-500">Маршрутов с этим номером нет. Добавьте нужные направления в разделе <Link className="text-primary-600" to="/routes">«Маршруты»</Link>.</p>}
</fieldset>
</div>
<div className="import-actions">
<button type="button" className="btn-primary" disabled={Boolean(busy) || !sourceUrl || !routeIds.length} onClick={() => 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);
})}><Download size={16} /> Загрузить таблицы</button>
{sourceUrl && <SourceLink url={sourceUrl} />}
</div>
{!routes.length && !busy && <p className="text-sm text-gray-500">Сначала создайте маршрут и добавьте его остановки в разделе <Link className="text-primary-600" to="/routes">«Маршруты»</Link>.</p>}
</section>
{inspection && <section className="card space-y-5">
<div className="import-section-heading"><h2>2. Проверьте направления</h2><span className="text-sm text-gray-500">{inspection.source.title}</span></div>
<p className="text-sm text-gray-600">Каждое направление получит времена отправления со своей начальной остановки. Начало и конец уже заданы порядком остановок в админке — выбирать их заново не нужно.</p>
{inspection.source.rows.some(row => row.departures?.some(trip => trip.viaStopNames.length || trip.daysOfWeek.length || trip.exceptDaysOfWeek.length)) &&
<p className="text-sm text-gray-600">Примечания «по дням недели», «кроме» и «через остановку» распознаны и будут сохранены у соответствующих рейсов. Проверьте условия в предпросмотре. Если остановка не найдена в направлении, условие «через» сохранится текстом.</p>}
{inspection.source.warnings.length > 0 && <div className="import-message import-message-error" role="alert"><div>
<strong>Нужна ручная проверка источника</strong><ul className="list-disc pl-5 mt-2">{inspection.source.warnings.map((warning, i) => <li key={i}>{warning}</li>)}</ul>
</div></div>}
{inspection.source.directions.length > 0 && <details><summary className="cursor-pointer text-sm font-medium">Остановки и направления на сайте</summary>
{inspection.source.directions.map((direction, i) => <p key={i} className="text-sm text-gray-500 mt-2"><strong>{direction.label}:</strong> {direction.stops.join(' → ')}</p>)}
</details>}
{inspection.directions.map(({ route, stops }) => <div key={route.id} className="import-mapping space-y-3">
<h3 className="font-semibold">№{route.route_number} · {route.name}</h3>
{stops.length > 1 ? <p className="text-sm text-gray-600">Направление в админке: <strong>{stops[0].name} → {stops.at(-1).name}</strong></p>
: <p className="text-sm text-red-700">Добавьте остановки этого направления в редакторе маршрута.</p>}
<label className="block"><span className="label">Строка отправлений на сайте · {route.name}</span>
<select className="input" disabled={Boolean(busy)} value={mappings[route.id] || ''} onChange={event => changeMapping(route.id, event.target.value)}>
<option value="">Выберите начальную остановку на сайте</option>
{inspection.departureStops.map(name => <option key={name} value={name}>Отправления: {name}</option>)}
</select>
</label>
{mappings[route.id] && <p className="text-sm text-gray-600">В этот маршрут попадут времена из строки «{mappings[route.id]}».</p>}
{inspection.source.rows.filter(row => row.stopName === mappings[route.id]).map(row => <details key={row.key}>
<summary className="text-sm text-gray-600 cursor-pointer">{row.days.map(day => DAY_LABELS[day]).join(', ')} · {row.times.length} времён</summary>
<p className="text-sm text-gray-600 mt-2 leading-7">{row.rawTimes}</p>
</details>)}
</div>)}
{checkpointNames.length > 0 && <div className="rounded-lg bg-gray-50 p-4 text-sm text-gray-600">
<h3 className="font-medium text-gray-900">Времена на промежуточных остановках</h3>
<p className="mt-1">{checkpointNames.join(', ')} — строки прохождения уже отправившихся автобусов. Они не создают дополнительные рейсы. В этой версии импорта времена на промежуточных остановках не переносятся; используется время проезда между остановками, заданное в маршруте.</p>
</div>}
{unusedOrigins.length > 0 && <p className="text-sm text-amber-800">Не выбраны отправления от: {unusedOrigins.join(', ')}. Они не будут импортированы. Чтобы загрузить их, выберите соответствующие направления в шаге 1.</p>}
<label className="block max-w-xs"><span className="label">Расписание действует с</span><input type="date" className="input" value={validFrom} min={inspection.today} disabled={Boolean(busy)} onChange={event => { setValidFrom(event.target.value); setPreview(null); setConfirmed(false); }} /></label>
<p className="text-sm text-gray-500">«Выходной день» загружается для субботы и воскресенья. Время проезда между остановками берётся из настроек маршрута.</p>
<button type="button" className="btn-primary" disabled={Boolean(busy) || !complete || !validFrom || inspection.source.warnings.length > 0} onClick={() => run('preview', async () => {
setPreview(null); setConfirmed(false);
const { data } = await scheduleImportApi.preview({ inspectionId: inspection.id, mappings, validFrom });
if (alive.current) setPreview(data);
})}>Показать изменения</button>
</section>}
{preview && <section className="card space-y-5">
<div className="import-section-heading"><h2>3. Проверьте изменения</h2><span className="text-sm text-gray-500">С {dateLabel(preview.validFrom)}</span></div>
<p className="text-sm text-gray-600">Для перечисленных типов дней расписание будет заменено целиком. Удаляемые рейсы и их особые условия показаны ниже. Праздничное расписание не меняется.</p>
{preview.plans.map(plan => <section key={plan.routeId} className="space-y-3">
<h3 className="font-semibold text-lg">№{plan.route.route_number} · {plan.route.name}</h3>
<p className="text-sm text-gray-500">Источник отправлений: {plan.sourceStopName}</p>
{plan.notices?.map(notice => <p key={notice} className="text-sm text-amber-800">{notice}</p>)}
{plan.changes.map(change => <div key={change.dayType} className="import-mapping">
<h3 className="font-semibold">{change.label}</h3>
<p className="text-sm text-gray-500 mt-1">Было {change.before.length} · Станет {change.after.length} · Без изменений {change.unchanged}</p>
<div className="import-grid mt-4">
<details open={change.added.length > 0}><summary className="text-sm font-medium text-green-800 cursor-pointer">Добавится: {change.added.length}</summary><TripList trips={change.added} stops={plan.stops} empty="Новых рейсов нет" /></details>
<details open={change.removed.length > 0}><summary className="text-sm font-medium text-red-700 cursor-pointer">Удалится: {change.removed.length}</summary><TripList trips={change.removed} stops={plan.stops} empty="Удалений нет" /></details>
</div>
</div>)}</section>)}
{preview.hasChanges ? <>
<label className="flex items-start gap-3 text-sm"><input className="mt-1" type="checkbox" checked={confirmed} disabled={Boolean(busy)} onChange={event => setConfirmed(event.target.checked)} />
<span>Я проверил отправления для каждого направления, дату начала и замену расписаний.</span></label>
<button type="button" className="btn-primary" disabled={Boolean(busy) || !confirmed} onClick={() => 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. */ }
})}><Check size={16} /> Применить к выбранным направлениям</button>
</> : <p className="import-message import-message-success">Расписание уже совпадает с источником. Сохранять повторно не нужно.</p>}
</section>}
</>}
{tab === 'news' && <section className="space-y-4">
<div className="card space-y-3"><div className="import-section-heading"><h2>Новости автопарка</h2>
<button type="button" className="btn-primary" disabled={Boolean(busy)} onClick={() => run('news', async () => { const { data } = await scheduleImportApi.refreshNews(); if (alive.current) setNews(data); })}><RefreshCw size={16} /> Проверить новости</button></div>
<p className="text-sm text-gray-600">Загружаются публикации с главной страницы, ранее загруженные сохраняются. Отмены, переносы и даты действия проверяются вручную. Отметка «Проверено» не изменяет расписание.</p>
<SourceLink url="https://ap2polotsk.of.by/ap2/" />
</div>
{!news.length && !busy && <div className="card text-gray-500">Новостей пока нет. Нажмите «Проверить новости».</div>}
{news.map(item => <article className="card space-y-3" key={item.url}>
<div className="flex items-center flex-wrap gap-3 text-sm text-gray-500"><span>Опубликовано {dateLabel(item.published_date)}</span><span className={item.reviewed ? 'text-green-800' : 'text-amber-700'}>{item.reviewed ? 'Проверено' : 'Требует проверки'}</span></div>
<h3 className="font-semibold text-lg">{item.title}</h3>
<p className="text-sm whitespace-pre-wrap leading-7">{item.content}</p>
<div className="import-actions"><SourceLink url={item.url} />
{!item.reviewed && <button type="button" className="btn-secondary" disabled={Boolean(busy)} onClick={() => 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));
})}><Check size={16} /> Отметить проверенной</button>}
</div>
</article>)}
</section>}
{tab === 'history' && <section className="card space-y-4"><h2>Применённые импорты</h2>
{!history.length && <p className="text-gray-500">После первого применения здесь появится запись об импорте.</p>}
{history.map(item => <div key={item.id} className="import-history-row">
<div><p className="font-medium">{item.directions?.length ? item.directions.map(route => `№${route.route_number} · ${route.name}`).join('; ') : `Маршрут №${item.route_number || '—'} · ${item.route_name || 'Удалён'}`}</p>
<p className="text-sm text-gray-500">Действует с {dateLabel(item.valid_from)} · Импорт {new Date(item.applied_at).toLocaleString('ru-RU')}</p></div>
<SourceLink url={item.source_url} />
</div>)}
</section>}
</div>;
}