253 lines
22 KiB
JavaScript
253 lines
22 KiB
JavaScript
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.dayLabel || 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>;
|
||
}
|