Files
ridebus-admin/frontend/src/pages/ScheduleImportPage.jsx

253 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}