diff --git a/backend/src/controllers/citiesController.js b/backend/src/controllers/citiesController.js index f486c5a..b45d090 100644 --- a/backend/src/controllers/citiesController.js +++ b/backend/src/controllers/citiesController.js @@ -65,6 +65,38 @@ class CitiesController { } } + async updateCity(req, res) { + const cityId = Number(req.params.cityId); + if (!Number.isInteger(cityId) || cityId <= 0) { + return res.status(400).json({ error: 'Некорректный идентификатор города.' }); + } + + const { name, slug, emblem, center_lat, center_lon } = req.body; + const validationError = validateCity({ name, slug, emblem, center_lat, center_lon }); + if (validationError) return res.status(400).json({ error: validationError }); + + try { + const result = await pool.query(` + UPDATE cities + SET name = $1, slug = $2, emblem = $3, center_lat = $4, center_lon = $5 + WHERE id = $6 + RETURNING id, slug, name, emblem, avatar_url, center_lat, center_lon, schedule_source_url, is_active, created_at + `, [ + String(name).trim(), normalizeSlug(slug), String(emblem || '🏙️').trim(), + center_lat === '' || center_lat === undefined ? null : Number(center_lat), + center_lon === '' || center_lon === undefined ? null : Number(center_lon), + cityId + ]); + + if (!result.rows.length) return res.status(404).json({ error: 'Город не найден.' }); + res.json(result.rows[0]); + } catch (error) { + if (error.code === '23505') return res.status(409).json({ error: 'Город с таким названием или кодом уже есть.' }); + logger.error('Error updating city', { cityId, message: error.message }); + res.status(500).json({ error: 'Не удалось сохранить изменения города.' }); + } + } + async uploadCityAvatar(req, res) { const cityId = Number(req.params.cityId); if (!Number.isInteger(cityId) || cityId <= 0) { diff --git a/backend/src/controllers/scheduleCenterController.js b/backend/src/controllers/scheduleCenterController.js new file mode 100644 index 0000000..674685b --- /dev/null +++ b/backend/src/controllers/scheduleCenterController.js @@ -0,0 +1,48 @@ +const logger = require('../config/logger'); +const { buildScheduleCenter, formatMinskDate, parseDate } = require('../services/scheduleCenterReport'); + +const SCHEDULE_CENTER_QUERY = ` + SELECT + r.id AS route_id, + r.route_number, + r.name AS route_name, + r.color, + s.id AS schedule_id, + s.day_type, + s.valid_from, + s.valid_until, + GREATEST( + COALESCE(cardinality(s.departure_times), 0), + CASE WHEN jsonb_typeof(s.trips) = 'array' THEN jsonb_array_length(s.trips) ELSE 0 END + )::integer AS departure_count + FROM routes r + LEFT JOIN schedules s ON s.route_id = r.id + WHERE r.city_id = $1 AND r.is_active = true + ORDER BY r.route_number, r.name, s.day_type, s.valid_from NULLS FIRST, s.id +`; + +class ScheduleCenterController { + constructor({ pool, report = buildScheduleCenter } = {}) { + this.pool = pool || require('../config/database'); + this.report = report; + } + + async getOverview(req, res) { + const targetDate = req.query.date || formatMinskDate(); + if (!parseDate(targetDate)) { + return res.status(400).json({ error: 'Дата должна быть в формате ГГГГ-ММ-ДД.' }); + } + + try { + const { rows } = await this.pool.query(SCHEDULE_CENTER_QUERY, [req.city.id]); + return res.json(this.report(rows, targetDate)); + } catch (error) { + logger.error('Error building schedule center report', { message: error.message, cityId: req.city?.id }); + return res.status(500).json({ error: 'Не удалось загрузить центр расписаний. Повторите попытку.' }); + } + } +} + +module.exports = new ScheduleCenterController(); +module.exports.ScheduleCenterController = ScheduleCenterController; +module.exports.SCHEDULE_CENTER_QUERY = SCHEDULE_CENTER_QUERY; diff --git a/backend/src/routes/api.js b/backend/src/routes/api.js index 3399f5c..0bfadb9 100644 --- a/backend/src/routes/api.js +++ b/backend/src/routes/api.js @@ -15,6 +15,7 @@ const { createPassengerTelemetryRateLimiter } = require('../middleware/passenger const { authenticateToken, requireAdmin } = require('../middleware/auth'); const { resolveCity } = require('../middleware/city'); const qualityController = require('../controllers/qualityController'); +const scheduleCenterController = require('../controllers/scheduleCenterController'); // City catalogue is global. Every transport-data endpoint below receives a // verified city context from X-City-Id (or falls back to the legacy Polotsk city). @@ -23,6 +24,7 @@ router.use(resolveCity); router.use('/schedule-import', require('./scheduleImport')); router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController)); +router.get('/schedule-center', authenticateToken, requireAdmin, scheduleCenterController.getOverview.bind(scheduleCenterController)); // Routes router.get('/routes', routesController.getAllRoutes.bind(routesController)); diff --git a/backend/src/routes/cities.js b/backend/src/routes/cities.js index 0b37520..b77e763 100644 --- a/backend/src/routes/cities.js +++ b/backend/src/routes/cities.js @@ -7,6 +7,7 @@ const router = express.Router(); router.get('/', citiesController.getAllCities.bind(citiesController)); router.post('/', authenticateToken, requireAdmin, citiesController.createCity.bind(citiesController)); +router.put('/:cityId', authenticateToken, requireAdmin, citiesController.updateCity.bind(citiesController)); router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController)); module.exports = router; diff --git a/backend/src/services/scheduleCenterReport.js b/backend/src/services/scheduleCenterReport.js new file mode 100644 index 0000000..4776d7c --- /dev/null +++ b/backend/src/services/scheduleCenterReport.js @@ -0,0 +1,194 @@ +const DAY_TYPES = [ + { value: 'weekday', label: 'Будние дни', shortLabel: 'Пн–Пт' }, + { value: 'saturday', label: 'Суббота', shortLabel: 'Сб' }, + { value: 'sunday', label: 'Воскресенье', shortLabel: 'Вс' }, +]; + +const DAY_TYPE_BY_VALUE = new Map(DAY_TYPES.map((dayType) => [dayType.value, dayType])); + +function toDateKey(value) { + if (!value) return null; + if (typeof value === 'string') return value.slice(0, 10); + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10); + return null; +} + +function parseDate(date) { + if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; + const parsed = new Date(`${date}T00:00:00.000Z`); + return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date ? null : parsed; +} + +function addDays(date, amount) { + const parsed = parseDate(date); + parsed.setUTCDate(parsed.getUTCDate() + amount); + return parsed.toISOString().slice(0, 10); +} + +function formatMinskDate(date = new Date()) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(date); +} + +function versionState(version, targetDate) { + if (version.valid_from && version.valid_until && version.valid_until < version.valid_from) return 'invalid'; + if ((!version.valid_from || version.valid_from <= targetDate) + && (!version.valid_until || version.valid_until >= targetDate)) return 'active'; + if (version.valid_from && version.valid_from > targetDate) return 'upcoming'; + return 'past'; +} + +function overlaps(left, right) { + return (!left.valid_until || !right.valid_from || left.valid_until >= right.valid_from) + && (!right.valid_until || !left.valid_from || right.valid_until >= left.valid_from); +} + +function buildCoverageGaps(versions, targetDate, windowEnd) { + const periods = versions + .filter((version) => version.state !== 'invalid') + .filter((version) => !version.valid_until || version.valid_until >= targetDate) + .filter((version) => !version.valid_from || version.valid_from <= windowEnd) + .map((version) => ({ + start: version.valid_from && version.valid_from > targetDate ? version.valid_from : targetDate, + end: version.valid_until && version.valid_until < windowEnd ? version.valid_until : windowEnd, + })) + .sort((left, right) => left.start.localeCompare(right.start)); + + const gaps = []; + let cursor = targetDate; + + for (const period of periods) { + if (period.start > cursor) { + gaps.push({ starts_on: cursor, ends_on: addDays(period.start, -1) }); + } + if (period.end >= cursor) cursor = addDays(period.end, 1); + if (cursor > windowEnd) break; + } + + if (cursor <= windowEnd) gaps.push({ starts_on: cursor, ends_on: windowEnd }); + return gaps; +} + +function makeIssue({ code, severity, route, dayType, description, versionIds = [], range = null }) { + const idParts = [code, route.route_id, dayType?.value || 'all', ...versionIds, range?.starts_on || '']; + return { + id: idParts.join(':'), + code, + severity, + title: code === 'schedule_overlap' ? 'Пересекаются версии' + : code === 'invalid_period' ? 'Некорректный период версии' + : 'Нет действующей версии', + description, + route_id: route.route_id, + route_label: `№${route.route_number} · ${route.route_name}`, + day_type: dayType?.value || null, + day_type_label: dayType?.label || null, + version_ids: versionIds, + range, + }; +} + +function createRoute(row) { + return { + route_id: row.route_id, + route_number: row.route_number, + route_name: row.route_name, + color: row.color || '#525252', + day_types: DAY_TYPES.map((dayType) => ({ + ...dayType, + versions: [], + gaps: [], + status: 'gap', + })), + }; +} + +function buildScheduleCenter(rows, targetDate = formatMinskDate(), { windowDays = 90 } = {}) { + if (!parseDate(targetDate)) throw new Error('Invalid target date'); + const windowEnd = addDays(targetDate, windowDays); + const routesById = new Map(); + + for (const row of rows) { + if (!routesById.has(row.route_id)) routesById.set(row.route_id, createRoute(row)); + if (!row.schedule_id || !DAY_TYPE_BY_VALUE.has(row.day_type)) continue; + + const route = routesById.get(row.route_id); + const dayType = route.day_types.find((item) => item.value === row.day_type); + const version = { + id: row.schedule_id, + valid_from: toDateKey(row.valid_from), + valid_until: toDateKey(row.valid_until), + departure_count: Number(row.departure_count || 0), + state: 'past', + has_conflict: false, + }; + version.state = versionState(version, targetDate); + dayType.versions.push(version); + } + + const routes = [...routesById.values()] + .sort((left, right) => String(left.route_number).localeCompare(String(right.route_number), 'ru', { numeric: true }) + || left.route_name.localeCompare(right.route_name, 'ru')); + const issues = []; + let active = 0; + let upcoming = 0; + let conflicts = 0; + let gaps = 0; + + for (const route of routes) { + for (const dayType of route.day_types) { + dayType.versions.sort((left, right) => (left.valid_from || '').localeCompare(right.valid_from || '') || left.id - right.id); + + for (const version of dayType.versions) { + if (version.state === 'active') active += 1; + if (version.state === 'upcoming') upcoming += 1; + if (version.state !== 'invalid') continue; + issues.push(makeIssue({ + code: 'invalid_period', severity: 'critical', route, dayType, versionIds: [version.id], + description: `Версия №${version.id}: дата окончания раньше даты начала.`, + })); + } + + for (let index = 0; index < dayType.versions.length; index += 1) { + for (let otherIndex = index + 1; otherIndex < dayType.versions.length; otherIndex += 1) { + const left = dayType.versions[index]; + const right = dayType.versions[otherIndex]; + if (left.state === 'invalid' || right.state === 'invalid' || !overlaps(left, right)) continue; + left.has_conflict = true; + right.has_conflict = true; + conflicts += 1; + issues.push(makeIssue({ + code: 'schedule_overlap', severity: 'critical', route, dayType, versionIds: [left.id, right.id], + description: `Версии №${left.id} и №${right.id} действуют одновременно.`, + })); + } + } + + dayType.gaps = buildCoverageGaps(dayType.versions, targetDate, windowEnd); + if (dayType.versions.some((version) => version.state === 'active')) dayType.status = 'active'; + else if (dayType.versions.some((version) => version.state === 'upcoming')) dayType.status = 'upcoming'; + + for (const gap of dayType.gaps) { + gaps += 1; + issues.push(makeIssue({ + code: 'schedule_gap', severity: 'warning', route, dayType, range: gap, + description: gap.starts_on === gap.ends_on + ? `На ${gap.starts_on} нет версии расписания.` + : `С ${gap.starts_on} по ${gap.ends_on} нет версии расписания.`, + })); + } + } + } + + return { + target_date: targetDate, + window_end: windowEnd, + summary: { routes: routes.length, active, upcoming, conflicts, gaps }, + routes, + issues: issues.sort((left, right) => left.severity.localeCompare(right.severity) + || left.route_label.localeCompare(right.route_label, 'ru') || left.id.localeCompare(right.id)), + }; +} + +module.exports = { DAY_TYPES, addDays, buildScheduleCenter, formatMinskDate, parseDate }; diff --git a/backend/test/scheduleCenterReport.test.js b/backend/test/scheduleCenterReport.test.js new file mode 100644 index 0000000..cae15e2 --- /dev/null +++ b/backend/test/scheduleCenterReport.test.js @@ -0,0 +1,36 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { buildScheduleCenter, parseDate } = require('../src/services/scheduleCenterReport'); + +test('schedule center separates active and future versions and detects gaps and overlaps', () => { + const report = buildScheduleCenter([ + { route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 1, day_type: 'weekday', valid_from: null, valid_until: null, departure_count: 12 }, + { route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 2, day_type: 'saturday', valid_from: '2026-10-01', valid_until: null, departure_count: 6 }, + { route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 3, day_type: 'sunday', valid_from: null, valid_until: '2026-09-30', departure_count: 5 }, + { route_id: 7, route_number: '10', route_name: 'Вокзал — Центр', color: '#2563eb', schedule_id: 4, day_type: 'sunday', valid_from: '2026-09-25', valid_until: null, departure_count: 5 }, + ], '2026-09-18'); + + assert.deepEqual(report.summary, { routes: 1, active: 2, upcoming: 2, conflicts: 1, gaps: 1 }); + assert.equal(report.window_end, '2026-12-17'); + const saturday = report.routes[0].day_types.find((dayType) => dayType.value === 'saturday'); + assert.equal(saturday.status, 'upcoming'); + assert.deepEqual(saturday.gaps, [{ starts_on: '2026-09-18', ends_on: '2026-09-30' }]); + assert.equal(report.issues.find((issue) => issue.code === 'schedule_overlap').route_id, 7); + assert.deepEqual(report.issues.find((issue) => issue.code === 'schedule_overlap').version_ids, [3, 4]); +}); + +test('schedule center reports uncovered day types for routes without versions', () => { + const report = buildScheduleCenter([ + { route_id: 3, route_number: '2', route_name: 'Рынок — Больница', color: '#dc2626', schedule_id: null }, + ], '2026-09-18', { windowDays: 7 }); + + assert.equal(report.summary.gaps, 3); + assert.equal(report.issues.filter((issue) => issue.code === 'schedule_gap').length, 3); + assert.equal(report.routes[0].day_types.every((dayType) => dayType.status === 'gap'), true); +}); + +test('date validation accepts calendar dates only', () => { + assert.ok(parseDate('2026-02-28')); + assert.equal(parseDate('2026-02-29'), null); + assert.equal(parseDate('18.09.2026'), null); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 381a80d..772e239 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,8 @@ import RouteEditorPage from './pages/RouteEditorPage'; import StopsPage from './pages/StopsPage'; import SchedulesPage from './pages/SchedulesPage'; import ScheduleImportPage from './pages/ScheduleImportPage'; +import ScheduleCenterPage from './pages/ScheduleCenterPage'; +import LiveMapPage from './pages/LiveMapPage'; import QualityPage from './pages/QualityPage'; import HolidaysPage from './pages/HolidaysPage'; import AlertsPage from './pages/AlertsPage'; @@ -70,6 +72,8 @@ function AnimatedRoutes({ cityId }) { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AppLayout.jsx b/frontend/src/components/AppLayout.jsx index d541cf5..70453f9 100644 --- a/frontend/src/components/AppLayout.jsx +++ b/frontend/src/components/AppLayout.jsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { Link, NavLink, useLocation } from 'react-router-dom'; -import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck } from 'lucide-react'; +import { Bus, Menu, X, LayoutDashboard, MapPin, CalendarDays, Gift, Bell, Users, User, Download, ShieldCheck, CalendarRange, Radio } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import BrandLogo from './BrandLogo'; import ProfileMenu from './ProfileMenu'; @@ -12,7 +12,9 @@ const navigationGroups = [ { label: 'Транспорт', items: [ { to: '/routes', label: 'Маршруты', icon: Bus }, { to: '/stops', label: 'Остановки', icon: MapPin }, + { to: '/live-map', label: 'Живая карта', icon: Radio }, { to: '/schedules', label: 'Расписания', icon: CalendarDays }, + { to: '/schedule-center', label: 'Центр расписаний', icon: CalendarRange, adminOnly: true }, { to: '/schedule-import', label: 'Импорт расписаний', icon: Download, adminOnly: true }, { to: '/holidays', label: 'Праздники', icon: Gift }, ] }, @@ -80,7 +82,7 @@ export default function AppLayout({ children }) { -
+
{children}
diff --git a/frontend/src/index.css b/frontend/src/index.css index 1d85301..0226899 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -78,15 +78,18 @@ .city-center-map { height: 320px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; } .city-center-coordinates { @apply text-sm text-gray-600; } .city-center-coordinates strong { @apply text-gray-900; } - .city-avatar-editor { @apply ml-auto flex shrink-0 flex-col items-end gap-2; } + .city-avatar-editor { @apply relative flex shrink-0 flex-col items-end gap-2; } .city-avatar-editor .btn-secondary { @apply inline-flex items-center gap-2; } - .city-avatar-editor-error { @apply max-w-[200px] text-right text-xs text-red-700; } + .city-avatar-editor-error { @apply absolute right-0 top-full z-10 mt-1 w-52 rounded-md bg-red-50 px-2 py-1 text-right text-xs text-red-700 shadow-sm; } .cities-grid { @apply grid gap-4 md:grid-cols-2; } - .city-network-card { @apply flex flex-wrap items-center gap-4; min-height: 104px; } - .city-network-card.is-active { border-color: #0958d9; box-shadow: inset 0 0 0 1px #0958d9; } + .city-network-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 24px; row-gap: 16px; min-height: 144px; } + .city-network-card.is-active { border: 2px solid #2f72e0; box-shadow: 0 0 0 2px rgb(47 114 224 / 10%); } + .city-card-summary { @apply flex min-w-0 items-center gap-4; } + .city-card-actions { @apply flex flex-wrap items-center justify-end gap-2; } + .city-edit-button { @apply inline-flex items-center gap-2; } .city-network-card h2 { @apply text-lg font-semibold; } .city-network-card p { @apply mt-1 text-sm text-gray-500; } - .city-current { @apply inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; } + .city-current { @apply inline-flex shrink-0 items-center gap-1 rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700; white-space: nowrap; } .city-isolation-note { @apply flex items-start gap-3 rounded-xl bg-primary-50 p-4 text-sm text-primary-700; } .city-isolation-note svg { @apply mt-0.5 shrink-0; } .login-surface { @@ -270,8 +273,14 @@ .login-card { padding: 28px 24px; } .login-device-card { padding: 24px; } } + @media (max-width: 1499px) { + .city-network-card { grid-template-columns: minmax(0, 1fr); } + .city-card-actions { justify-content: flex-start; } + } @media (max-width: 767px) { .app-main.app-main-wide { padding: 24px 16px; } + .city-network-card { grid-template-columns: minmax(0, 1fr); } + .city-card-actions { justify-content: flex-start; } } @media (min-width: 1024px) { .app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; } diff --git a/frontend/src/pages/CitiesPage.jsx b/frontend/src/pages/CitiesPage.jsx index 58cdcd5..03f2a58 100644 --- a/frontend/src/pages/CitiesPage.jsx +++ b/frontend/src/pages/CitiesPage.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { CheckCircle2, ImagePlus, Loader2, MapPinned, Plus } from 'lucide-react'; +import { CheckCircle2, ImagePlus, Loader2, MapPinned, Pencil, Plus } from 'lucide-react'; import { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet'; import L from 'leaflet'; import { citiesApi } from '../services/api'; @@ -26,6 +26,16 @@ function slugFromName(name) { .join('').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); } +function cityToForm(city) { + return { + name: city.name || '', + slug: city.slug || '', + emblem: city.emblem || '🏙️', + center_lat: city.center_lat == null ? '' : String(city.center_lat), + center_lon: city.center_lon == null ? '' : String(city.center_lon) + }; +} + function isImageValid(file) { if (!acceptedImageTypes.has(file.type)) return 'Выберите изображение в формате JPG, PNG или WEBP.'; if (file.size > 5 * 1024 * 1024) return 'Размер изображения не должен превышать 5 МБ.'; @@ -112,8 +122,9 @@ function CityAvatarEditor({ city }) { } export default function CitiesPage() { - const { cities, activeCity, addCity } = useCity(); + const { cities, activeCity, addCity, replaceCity } = useCity(); const [showForm, setShowForm] = useState(false); + const [editingCity, setEditingCity] = useState(null); const [form, setForm] = useState(emptyForm); const [slugEdited, setSlugEdited] = useState(false); const [avatarFile, setAvatarFile] = useState(null); @@ -121,23 +132,50 @@ export default function CitiesPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); + const isEditing = Boolean(editingCity); const selectedPosition = form.center_lat !== '' && form.center_lon !== '' ? [Number(form.center_lat), Number(form.center_lon)] : null; - const fallbackCenter = activeCity?.center_lat != null && activeCity?.center_lon != null - ? [Number(activeCity.center_lat), Number(activeCity.center_lon)] + const mapCity = editingCity || activeCity; + const fallbackCenter = mapCity?.center_lat != null && mapCity?.center_lon != null + ? [Number(mapCity.center_lat), Number(mapCity.center_lon)] : BELARUS_CENTER; useEffect(() => () => { if (avatarPreview) URL.revokeObjectURL(avatarPreview); }, [avatarPreview]); - function clearForm() { + function clearEditor() { setForm(emptyForm); setSlugEdited(false); setAvatarFile(null); setAvatarPreview(''); + setEditingCity(null); + } + + function closeEditor() { + clearEditor(); + setShowForm(false); + setError(''); + } + + function openCreate() { + clearEditor(); + setNotice(''); + setError(''); + setShowForm(true); + } + + function openEdit(city) { + setEditingCity(city); + setForm(cityToForm(city)); + setSlugEdited(true); + setAvatarFile(null); + setAvatarPreview(''); + setNotice(''); + setError(''); + setShowForm(true); } function updateForm(field, value) { @@ -175,24 +213,32 @@ export default function CitiesPage() { setError(''); setNotice(''); try { - let { data: city } = await citiesApi.create(form); - let avatarWarning = ''; + let city; + if (editingCity) { + const { data } = await citiesApi.update(editingCity.id, form); + city = data; + } else { + const { data } = await citiesApi.create(form); + city = data; + } + let avatarWarning = ''; if (avatarFile) { try { const upload = await citiesApi.uploadAvatar(city.id, avatarFile); city = upload.data.city; } catch (uploadError) { - avatarWarning = uploadError.response?.data?.error || 'Город создан, но изображение не удалось загрузить.'; + avatarWarning = uploadError.response?.data?.error || 'Изменения города сохранены, но изображение не удалось загрузить.'; } } - addCity(city); - clearForm(); - setShowForm(false); - if (avatarWarning) setNotice(avatarWarning); + if (editingCity) replaceCity(city); + else addCity(city); + const action = editingCity ? 'Изменения города сохранены.' : 'Город добавлен.'; + closeEditor(); + setNotice(avatarWarning || action); } catch (requestError) { - setError(requestError.response?.data?.error || 'Не удалось добавить город.'); + setError(requestError.response?.data?.error || (editingCity ? 'Не удалось сохранить изменения города.' : 'Не удалось добавить город.')); } finally { setSaving(false); } @@ -201,7 +247,7 @@ export default function CitiesPage() { return

Города

У каждого города — свои маршруты, остановки, расписания и уведомления.

-
@@ -209,24 +255,29 @@ export default function CitiesPage() { {notice &&

{notice}

} {showForm &&
-

Новый город

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

+

{isEditing ? `Редактирование: ${editingCity.name}` : 'Новый город'}

{isEditing ? 'Измените данные города и сохраните их. Данные его транспортной сети останутся без изменений.' : 'После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.'}

{error &&

{error}

}
- +
-
+
}
{cities.map(city =>
- -

{city.name}

{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}

- - {city.id === activeCity?.id && } +
+ +

{city.name}

{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}

+
+
+ {city.id === activeCity?.id && } + + +
)}
diff --git a/frontend/src/pages/LiveMapPage.css b/frontend/src/pages/LiveMapPage.css new file mode 100644 index 0000000..8e03431 --- /dev/null +++ b/frontend/src/pages/LiveMapPage.css @@ -0,0 +1,74 @@ +.live-map-page { display: flex; flex-direction: column; gap: 20px; } +.live-map-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; } +.live-map-heading p { max-width: 760px; margin-top: 8px; color: #737373; font-size: 14px; line-height: 21px; } +.live-map-refresh { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 8px; } +.live-map-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 18px; color: #525252; font-size: 13px; line-height: 20px; } +.live-map-status > span, .live-map-connection { display: inline-flex; align-items: center; gap: 7px; } +.live-map-connection { color: #2563eb; font-weight: 500; } +.live-map-connection.is-connected { color: #15803d; } +.live-map-connection.is-error { color: #b91c1c; } +.live-map-connection.is-reconnecting { color: #b45309; } +.live-map-error { display: flex; align-items: flex-start; gap: 10px; border-radius: 12px; padding: 13px 15px; color: #92400e; background: #fffbeb; font-size: 14px; line-height: 20px; } +.live-map-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; min-height: 660px; } +.live-map-panel, .live-map-sidebar { overflow: hidden; border-radius: 12px; background: #fff; } +.live-map-panel { display: flex; min-width: 0; flex-direction: column; } +.live-map-filters { display: flex; gap: 8px; overflow-x: auto; padding: 14px 16px; border-bottom: 1px solid #f0f0f0; } +.live-map-filters button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; flex: 0 0 auto; border-radius: 8px; padding: 7px 10px; color: #525252; background: #f5f5f5; font-size: 13px; line-height: 20px; } +.live-map-filters button[aria-pressed="true"] { color: #1d4ed8; background: #eff6ff; font-weight: 600; } +.live-map-filters span { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; border-radius: 999px; padding: 0 5px; color: inherit; background: rgb(0 0 0 / 7%); font-size: 11px; } +.live-map-canvas { position: relative; flex: 1; min-height: 560px; } +.live-map-leaflet { width: 100%; height: 100%; } +.live-map-empty { position: absolute; z-index: 500; top: 50%; left: 50%; display: flex; width: min(440px, calc(100% - 32px)); align-items: flex-start; gap: 14px; border-radius: 12px; padding: 18px; color: #737373; background: rgb(255 255 255 / 95%); box-shadow: 0 12px 30px rgb(0 0 0 / 12%); transform: translate(-50%, -50%); } +.live-map-empty svg { flex: 0 0 auto; color: #a3a3a3; } +.live-map-empty h2 { color: #262626; font-size: 16px; line-height: 22px; font-weight: 600; } +.live-map-empty p { margin-top: 4px; font-size: 13px; line-height: 19px; } +.live-map-sidebar { display: flex; flex-direction: column; min-height: 0; } +.live-map-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid #f0f0f0; } +.live-map-metrics > div { display: flex; min-width: 0; align-items: flex-start; gap: 8px; padding: 14px; } +.live-map-metrics > div + div { border-left: 1px solid #f0f0f0; } +.live-map-metrics span { display: flex; width: 28px; height: 28px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 7px; color: #15803d; background: #f0fdf4; } +.live-map-metrics > div + div span { color: #6d28d9; background: #f5f3ff; } +.live-map-metrics p { min-width: 0; color: #737373; font-size: 11px; line-height: 15px; } +.live-map-metrics strong { display: block; margin-top: 2px; color: #262626; font-size: 19px; line-height: 24px; font-weight: 600; } +.live-map-vehicles { display: flex; min-height: 0; flex: 1; flex-direction: column; } +.live-map-sidebar-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 16px 12px; } +.live-map-sidebar-heading h2 { color: #262626; font-size: 16px; line-height: 22px; font-weight: 600; } +.live-map-sidebar-heading p { margin-top: 2px; color: #737373; font-size: 12px; line-height: 18px; } +.live-map-sidebar-heading svg { color: #2563eb; } +.live-map-vehicle-list { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow-y: auto; padding: 0 8px 12px; } +.live-map-vehicle { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; border-radius: 8px; padding: 10px 8px; text-align: left; } +.live-map-vehicle:hover, .live-map-vehicle.is-selected { background: #f5f5f5; } +.live-map-vehicle.is-selected { box-shadow: inset 0 0 0 1px #bfdbfe; background: #eff6ff; } +.live-map-vehicle .route-badge { min-width: 36px; min-height: 36px; padding: 4px; font-size: 13px; line-height: 18px; } +.live-map-vehicle-main { min-width: 0; } +.live-map-vehicle-main strong, .live-map-vehicle-main small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.live-map-vehicle-main strong { color: #262626; font-size: 13px; line-height: 18px; font-weight: 600; } +.live-map-vehicle-main small { margin-top: 2px; color: #737373; font-size: 11px; line-height: 16px; } +.live-map-vehicle i { width: 8px; height: 8px; border-radius: 999px; background: #16a34a; } +.live-map-vehicle i.is-crowd { background: #7c3aed; } +.live-map-sidebar-empty { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px; color: #737373; font-size: 13px; text-align: center; } +.live-map-sidebar-empty svg { color: #d4d4d4; } +.live-map-routes-link { min-height: 48px; padding: 14px 16px; border-top: 1px solid #f0f0f0; color: #2563eb; font-size: 14px; font-weight: 500; } +.live-map-routes-link:hover { background: #fafafa; } +.live-map-marker { background: none; border: 0; } +.live-map-marker-label { display: inline-flex; min-width: 38px; max-width: 116px; height: 28px; align-items: center; gap: 5px; overflow: hidden; border: 2px solid var(--route-color); border-radius: 999px; padding: 0 8px; color: #262626; background: #fff; box-shadow: 0 4px 12px rgb(15 23 42 / 20%); font-size: 11px; line-height: 16px; font-weight: 700; white-space: nowrap; text-overflow: ellipsis; } +.live-map-marker-arrow { display: inline-block; flex: 0 0 auto; color: var(--route-color); font-size: 12px; line-height: 12px; } +.live-map-popup { min-width: 190px; padding: 2px; } +.live-map-popup > div { display: flex; align-items: center; gap: 8px; } +.live-map-popup .route-badge { min-width: 30px; min-height: 30px; padding: 3px; font-size: 12px; line-height: 16px; } +.live-map-popup strong { font-size: 13px; line-height: 18px; } +.live-map-popup > p { margin-top: 8px; color: #525252; font-size: 12px; line-height: 17px; } +.live-map-popup dl { display: grid; gap: 4px; margin-top: 9px; color: #525252; font-size: 12px; line-height: 17px; } +.live-map-popup dl > div { display: flex; justify-content: space-between; gap: 12px; } +.live-map-popup dt { color: #737373; } +.live-map-popup dd { text-align: right; } + +@media (max-width: 1100px) { .live-map-layout { grid-template-columns: 1fr; min-height: 0; } .live-map-sidebar { min-height: 340px; } } +@media (max-width: 639px) { + .live-map-heading { flex-direction: column; } + .live-map-refresh { width: 100%; justify-content: center; } + .live-map-status { gap: 8px 14px; } + .live-map-canvas { min-height: 500px; } + .live-map-empty { flex-direction: column; } + .live-map-sidebar { min-height: 300px; } +} diff --git a/frontend/src/pages/LiveMapPage.jsx b/frontend/src/pages/LiveMapPage.jsx new file mode 100644 index 0000000..9fbc742 --- /dev/null +++ b/frontend/src/pages/LiveMapPage.jsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet'; +import L from 'leaflet'; +import { AlertCircle, Bus, CheckCircle2, Clock3, MapPin, Radio, RefreshCw, Signal, Users } from 'lucide-react'; +import { realtimeApi, routesApi } from '../services/api'; +import { RouteRealtimeClient } from '../services/realtime'; +import { useCity } from '../contexts/CityContext'; +import { PageLoader } from '../components/AnimatedComponents'; +import RouteBadge from '../components/RouteBadge'; +import 'leaflet/dist/leaflet.css'; +import './LiveMapPage.css'; + +const DEFAULT_CENTER = [55.4869, 28.7856]; + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function safeColor(value) { + return /^#[0-9a-f]{3,8}$/i.test(String(value || '')) ? value : '#2563eb'; +} + +function vehicleLabel(vehicle) { + return vehicle.vehicle_number || vehicle.registration || `Транспорт ${vehicle.vehicle_id}`; +} + +function formatAge(vehicle, now) { + const seenAt = new Date(vehicle.last_seen_at).getTime(); + const seconds = Number.isFinite(seenAt) + ? Math.max(0, Math.floor((now - seenAt) / 1000)) + : Number(vehicle.seconds_since_update); + if (!Number.isFinite(seconds)) return 'нет данных'; + if (seconds < 15) return 'только что'; + if (seconds < 60) return `${seconds} сек назад`; + return `${Math.floor(seconds / 60)} мин назад`; +} + +function sourceLabel(vehicle) { + return vehicle.source === 'crowdsourced' ? 'По данным пассажиров' : 'Официальный GPS'; +} + +function createVehicleIcon(vehicle, color) { + const heading = Number.isFinite(Number(vehicle.heading)) ? Number(vehicle.heading) : 0; + const isCrowd = vehicle.source === 'crowdsourced'; + const markerColor = isCrowd ? '#7c3aed' : safeColor(color); + const label = escapeHtml(`${vehicle.route_number || '—'} · ${vehicleLabel(vehicle)}`); + + return L.divIcon({ + className: 'live-map-marker', + html: `
+ ▲ + ${label} +
`, + iconSize: [116, 32], + iconAnchor: [58, 16], + popupAnchor: [0, -18], + }); +} + +function MapViewport({ city, selectedVehicle }) { + const map = useMap(); + const cityId = city?.id; + + useEffect(() => { + const latitude = Number(city?.center_lat); + const longitude = Number(city?.center_lon); + map.setView(Number.isFinite(latitude) && Number.isFinite(longitude) ? [latitude, longitude] : DEFAULT_CENTER, 13); + }, [map, cityId]); + + useEffect(() => { + if (selectedVehicle && Number.isFinite(Number(selectedVehicle.latitude)) && Number.isFinite(Number(selectedVehicle.longitude))) { + map.flyTo([Number(selectedVehicle.latitude), Number(selectedVehicle.longitude)], Math.max(map.getZoom(), 14), { duration: 0.45 }); + } + }, [map, selectedVehicle?.vehicle_id]); + + return null; +} + +function replaceRouteVehicles(current, routeId, nextVehicles) { + const withoutRoute = current.filter((vehicle) => Number(vehicle.route_id) !== Number(routeId)); + return [...withoutRoute, ...(Array.isArray(nextVehicles) ? nextVehicles : [])]; +} + +function upsertVehicle(current, nextVehicle) { + const index = current.findIndex((vehicle) => String(vehicle.vehicle_id) === String(nextVehicle.vehicle_id)); + if (index === -1) return [...current, nextVehicle]; + return current.map((vehicle, currentIndex) => currentIndex === index ? nextVehicle : vehicle); +} + +function isMappable(vehicle) { + return Number.isFinite(Number(vehicle.latitude)) && Number.isFinite(Number(vehicle.longitude)); +} + +function realtimeStatusLabel(status) { + if (status === 'connected') return 'Realtime подключён'; + if (status === 'connecting' || status === 'authenticating' || status === 'authenticated') return 'Подключаем realtime…'; + if (status === 'reconnecting') return 'Realtime переподключается…'; + if (status === 'error') return 'Realtime недоступен'; + return 'Ожидание realtime'; +} + +export default function LiveMapPage() { + const { activeCity } = useCity(); + const [routes, setRoutes] = useState([]); + const [vehicles, setVehicles] = useState([]); + const [selectedRouteId, setSelectedRouteId] = useState('all'); + const [selectedVehicleId, setSelectedVehicleId] = useState(null); + const [connectionState, setConnectionState] = useState('idle'); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const [now, setNow] = useState(Date.now()); + const alive = useRef(true); + + const refreshSnapshot = useCallback(async (currentRoutes, { manual = false } = {}) => { + if (!currentRoutes.length) return; + if (manual) setRefreshing(true); + try { + const results = await Promise.allSettled(currentRoutes.map((route) => realtimeApi.getRouteVehicles(route.id))); + if (!alive.current) return; + const received = results.flatMap((result) => result.status === 'fulfilled' ? [result.value.data || []] : []); + const failed = results.some((result) => result.status === 'rejected'); + if (received.length) { + const nextVehicles = received.flat(); + setVehicles(nextVehicles); + setLastUpdatedAt(new Date()); + } + if (failed) setError('Не удалось обновить часть маршрутов. Карта показывает последний доступный снимок.'); + } catch { + if (alive.current) setError('Не удалось загрузить снимок транспорта. Повторите попытку.'); + } finally { + if (alive.current && manual) setRefreshing(false); + } + }, []); + + useEffect(() => { + alive.current = true; + async function loadRoutes() { + setError(''); + try { + const { data } = await routesApi.getAll(); + if (!alive.current) return; + setRoutes(data || []); + await refreshSnapshot(data || []); + } catch (requestError) { + if (alive.current) setError(requestError.response?.data?.error || 'Не удалось загрузить маршруты города.'); + } finally { + if (alive.current) setLoading(false); + } + } + loadRoutes(); + return () => { alive.current = false; }; + }, [refreshSnapshot]); + + useEffect(() => { + if (!routes.length) return undefined; + const realtimeClient = new RouteRealtimeClient({ + routeIds: routes.map((route) => route.id), + token: localStorage.getItem('accessToken'), + apiKey: import.meta.env.VITE_API_KEY || 'dev_key_12345678', + onStatusChange: (status) => { + if (!alive.current) return; + setConnectionState(status.state); + if (status.state === 'connected') setError(''); + }, + onSnapshot: (nextVehicles, routeId) => { + if (!alive.current) return; + setVehicles((current) => replaceRouteVehicles(current, routeId, nextVehicles)); + setLastUpdatedAt(new Date()); + }, + onVehiclePosition: (vehicle) => { + if (!alive.current) return; + setVehicles((current) => upsertVehicle(current, vehicle)); + setLastUpdatedAt(new Date()); + }, + onError: (message) => { + if (alive.current) setError(`${message}. Карта продолжит обновляться по снимкам.`); + }, + }); + realtimeClient.connect(); + const snapshotTimer = window.setInterval(() => refreshSnapshot(routes), 60_000); + return () => { + window.clearInterval(snapshotTimer); + realtimeClient.disconnect(); + }; + }, [refreshSnapshot, routes]); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 15_000); + return () => window.clearInterval(timer); + }, []); + + const routesById = useMemo(() => new Map(routes.map((route) => [Number(route.id), route])), [routes]); + const mappableVehicles = vehicles.filter(isMappable); + const visibleVehicles = mappableVehicles + .filter((vehicle) => selectedRouteId === 'all' || Number(vehicle.route_id) === Number(selectedRouteId)) + .sort((left, right) => Number(right.is_online) - Number(left.is_online) + || String(left.route_number || '').localeCompare(String(right.route_number || ''), 'ru', { numeric: true }) + || String(left.vehicle_number || left.vehicle_id).localeCompare(String(right.vehicle_number || right.vehicle_id))); + const selectedVehicle = visibleVehicles.find((vehicle) => String(vehicle.vehicle_id) === String(selectedVehicleId)) || null; + const officialCount = visibleVehicles.filter((vehicle) => vehicle.source !== 'crowdsourced').length; + const crowdCount = visibleVehicles.filter((vehicle) => vehicle.source === 'crowdsourced').length; + const cityLatitude = Number(activeCity?.center_lat); + const cityLongitude = Number(activeCity?.center_lon); + const center = Number.isFinite(cityLatitude) && Number.isFinite(cityLongitude) + ? [cityLatitude, cityLongitude] + : DEFAULT_CENTER; + + if (loading) return ; + + return
+
+

Живая карта

Положение транспорта обновляется через realtime. Если соединение временно пропало, остаётся последний доступный снимок.

+ +
+ +
+
+ + +
+ + {error &&
} + +
+
+

Карта живого транспорта

+
+ + {routes.map((route) => )} +
+
+ + + + {visibleVehicles.map((vehicle) => { + const route = routesById.get(Number(vehicle.route_id)); + const color = vehicle.route_color || route?.color; + return setSelectedVehicleId(vehicle.vehicle_id) }}> +
+
{vehicleLabel(vehicle)}
+

{route?.name || vehicle.route_name || 'Маршрут'}

+
Источник
{sourceLabel(vehicle)}
Обновлено
{formatAge(vehicle, now)}
Скорость
{vehicle.speed_kmh != null ? `${Math.round(Number(vehicle.speed_kmh))} км/ч` : 'нет данных'}
+
+
; + })} +
+ {!visibleVehicles.length &&
} +
+
+ + +
+
; +} diff --git a/frontend/src/pages/ScheduleCenterPage.css b/frontend/src/pages/ScheduleCenterPage.css new file mode 100644 index 0000000..15ef1b6 --- /dev/null +++ b/frontend/src/pages/ScheduleCenterPage.css @@ -0,0 +1,83 @@ +.schedule-center-page { display: flex; flex-direction: column; gap: 24px; } +.schedule-center-heading, .schedule-center-section-heading, .schedule-center-route > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.schedule-center-heading p, .schedule-center-section-heading p { max-width: 760px; margin-top: 8px; color: #737373; font-size: 14px; line-height: 21px; } +.schedule-center-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; } +.schedule-center-actions .btn-secondary, .schedule-center-route-action, .schedule-center-issue-action { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 8px; } +.schedule-center-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px; padding: 16px 20px; } +.schedule-center-controls > svg { color: #2563eb; } +.schedule-center-controls label { display: flex; align-items: center; gap: 10px; color: #404040; font-size: 14px; font-weight: 500; } +.schedule-center-controls .input { width: 178px; padding-top: 8px; padding-bottom: 8px; } +.schedule-center-controls p { color: #737373; font-size: 13px; } +.schedule-center-message { display: flex; align-items: center; gap: 12px; border-radius: 12px; padding: 14px 16px; color: #b91c1c; background: #fef2f2; font-size: 14px; } +.schedule-center-message button { margin-left: auto; font-weight: 600; text-decoration: underline; } +.schedule-center-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } +.schedule-center-summary-card { display: flex; min-height: 104px; align-items: flex-start; gap: 12px; border-radius: 12px; background: #fff; padding: 16px; } +.schedule-center-summary-card > span { display: flex; width: 38px; height: 38px; flex: 0 0 auto; align-items: center; justify-content: center; border-radius: 8px; color: #525252; background: #f5f5f5; } +.schedule-center-summary-card.is-active > span { color: #15803d; background: #f0fdf4; } +.schedule-center-summary-card.is-future > span { color: #2563eb; background: #eff6ff; } +.schedule-center-summary-card.is-critical > span { color: #dc2626; background: #fef2f2; } +.schedule-center-summary-card.is-warning > span { color: #b45309; background: #fffbeb; } +.schedule-center-summary-card p { color: #737373; font-size: 13px; line-height: 18px; } +.schedule-center-summary-card strong { display: block; margin-top: 5px; color: #171717; font-size: 26px; line-height: 30px; font-weight: 600; } +.schedule-center-calendar, .schedule-center-issues { border-radius: 12px; background: #fff; padding: 20px; } +.schedule-center-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; color: #525252; font-size: 12px; } +.schedule-center-legend span { display: inline-flex; align-items: center; gap: 5px; } +.schedule-center-legend span::before { width: 10px; height: 10px; border-radius: 3px; content: ''; background: #16a34a; } +.schedule-center-legend .is-upcoming::before { background: #2563eb; } +.schedule-center-legend .has-conflict::before { border: 2px solid #dc2626; background: #fecaca; } +.schedule-center-route-list { display: flex; flex-direction: column; margin-top: 20px; border-top: 1px solid #f0f0f0; } +.schedule-center-route { padding: 20px 0; border-bottom: 1px solid #f0f0f0; } +.schedule-center-route:last-child { padding-bottom: 0; border-bottom: 0; } +.schedule-center-route-title { display: flex; min-width: 0; align-items: center; gap: 12px; } +.schedule-center-route-title h3 { font-size: 16px; line-height: 22px; font-weight: 600; overflow-wrap: anywhere; } +.schedule-center-route-title p { margin-top: 2px; color: #737373; font-size: 13px; line-height: 18px; } +.schedule-center-route-action, .schedule-center-issue-action { flex: 0 0 auto; border-radius: 8px; padding: 9px 11px; color: #262626; background: #f5f5f5; font-size: 13px; font-weight: 500; white-space: nowrap; } +.schedule-center-route-action:hover, .schedule-center-issue-action:hover { background: #ebebeb; } +.schedule-center-date-scale { display: flex; justify-content: space-between; margin: 22px 0 6px 126px; color: #a3a3a3; font-size: 11px; line-height: 16px; } +.schedule-center-lanes { display: flex; flex-direction: column; gap: 12px; overflow-x: auto; } +.schedule-center-lane { display: grid; min-width: 720px; grid-template-columns: 110px minmax(0, 1fr); gap: 16px; } +.schedule-center-lane-label { padding-top: 7px; } +.schedule-center-lane-label strong { display: block; color: #262626; font-size: 13px; line-height: 18px; } +.schedule-center-lane-label span { display: block; margin-top: 2px; color: #a3a3a3; font-size: 12px; line-height: 16px; } +.schedule-center-lane-content { min-width: 0; } +.schedule-center-track { position: relative; overflow: hidden; border-radius: 8px; background-color: #fafafa; background-image: linear-gradient(to right, #e5e5e5 1px, transparent 1px); background-size: 33.333% 100%; } +.schedule-center-empty-track { position: absolute; inset: 0; display: grid; place-items: center; color: #a3a3a3; font-size: 12px; } +.schedule-center-version { position: absolute; display: flex; min-width: 28px; height: 22px; align-items: center; justify-content: center; overflow: hidden; border-radius: 5px; padding: 0 6px; color: #fff; background: #16a34a; font-size: 11px; line-height: 16px; font-weight: 600; white-space: nowrap; text-overflow: ellipsis; } +.schedule-center-version.is-upcoming { background: #2563eb; } +.schedule-center-version.is-past { background: #a3a3a3; } +.schedule-center-version.is-invalid, .schedule-center-version.has-conflict { outline: 2px solid #dc2626; outline-offset: -2px; background: #dc2626; } +.schedule-center-version-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; } +.schedule-center-version-meta, .schedule-center-gap-meta { border-radius: 999px; padding: 3px 8px; color: #525252; background: #f5f5f5; font-size: 11px; line-height: 16px; } +.schedule-center-version-meta.is-upcoming { color: #1d4ed8; background: #eff6ff; } +.schedule-center-version-meta.has-conflict { color: #b91c1c; background: #fef2f2; } +.schedule-center-gap-meta { color: #92400e; background: #fffbeb; } +.schedule-center-issues { overflow: hidden; } +.schedule-center-issue-list { display: flex; flex-direction: column; margin-top: 18px; border-top: 1px solid #f0f0f0; } +.schedule-center-issue { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: start; gap: 14px; padding: 16px 0; border-bottom: 1px solid #f0f0f0; } +.schedule-center-issue:last-child { padding-bottom: 0; border-bottom: 0; } +.schedule-center-issue > span { display: flex; width: 36px; height: 36px; align-items: center; justify-content: center; border-radius: 8px; } +.schedule-center-issue.is-critical > span { color: #dc2626; background: #fef2f2; } +.schedule-center-issue.is-warning > span { color: #b45309; background: #fffbeb; } +.schedule-center-issue-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.schedule-center-issue-heading h3 { color: #262626; font-size: 15px; line-height: 21px; font-weight: 600; } +.schedule-center-issue-heading small { border-radius: 999px; padding: 2px 8px; color: #737373; background: #f5f5f5; font-size: 12px; line-height: 18px; } +.schedule-center-issue p { margin-top: 5px; color: #525252; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; } +.schedule-center-clear { display: flex; min-height: 160px; align-items: center; justify-content: center; gap: 14px; color: #16a34a; } +.schedule-center-clear h3 { color: #262626; font-size: 16px; line-height: 23px; font-weight: 600; } +.schedule-center-clear p { max-width: 460px; margin-top: 4px; color: #737373; font-size: 14px; line-height: 21px; } + +@media (max-width: 1199px) { .schedule-center-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 767px) { + .schedule-center-heading, .schedule-center-section-heading, .schedule-center-route > header { flex-direction: column; } + .schedule-center-actions, .schedule-center-actions .btn-secondary, .schedule-center-route-action { width: 100%; } + .schedule-center-controls { align-items: flex-start; flex-direction: column; } + .schedule-center-controls label, .schedule-center-controls .input { width: 100%; } + .schedule-center-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .schedule-center-calendar, .schedule-center-issues { padding: 16px; } + .schedule-center-legend { justify-content: flex-start; } + .schedule-center-date-scale { margin-left: 0; min-width: 720px; } + .schedule-center-lane { grid-template-columns: 92px minmax(0, 1fr); gap: 10px; } + .schedule-center-issue { grid-template-columns: auto minmax(0, 1fr); } + .schedule-center-issue-action { grid-column: 2; justify-self: start; } + .schedule-center-clear { flex-direction: column; padding: 24px 8px; text-align: center; } +} diff --git a/frontend/src/pages/ScheduleCenterPage.jsx b/frontend/src/pages/ScheduleCenterPage.jsx new file mode 100644 index 0000000..1abbd85 --- /dev/null +++ b/frontend/src/pages/ScheduleCenterPage.jsx @@ -0,0 +1,204 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { AlertCircle, AlertTriangle, ArrowRight, CalendarClock, CheckCircle2, Clock3, Download, RefreshCw } from 'lucide-react'; +import { scheduleCenterApi } from '../services/api'; +import { useCity } from '../contexts/CityContext'; +import { PageLoader } from '../components/AnimatedComponents'; +import RouteBadge from '../components/RouteBadge'; +import './ScheduleCenterPage.css'; + +const DAY_STATUS = { + active: 'Есть версия на выбранную дату', + upcoming: 'Следующая версия ещё не вступила в силу', + gap: 'Нет версии в периоде обзора', +}; + +function minskDate() { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(new Date()); +} + +function dateValue(date) { + return date ? new Date(`${date}T12:00:00.000Z`) : null; +} + +function formatDate(date) { + const value = dateValue(date); + return value && !Number.isNaN(value.getTime()) + ? value.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'Europe/Minsk' }) + : 'без даты'; +} + +function formatPeriod(version) { + if (!version.valid_from && !version.valid_until) return 'Бессрочно'; + return `${version.valid_from ? `с ${formatDate(version.valid_from)}` : 'без даты начала'}${version.valid_until ? ` по ${formatDate(version.valid_until)}` : ''}`; +} + +function dayDistance(from, to) { + const fromValue = dateValue(from); + const toValue = dateValue(to); + return Math.round((toValue - fromValue) / 86400000); +} + +function visibleVersion(version, start, end) { + return (!version.valid_until || version.valid_until >= start) + && (!version.valid_from || version.valid_from <= end); +} + +function versionStyle(version, index, start, end) { + const blockStart = version.valid_from && version.valid_from > start ? version.valid_from : start; + const blockEnd = version.valid_until && version.valid_until < end ? version.valid_until : end; + const totalDays = dayDistance(start, end) + 1; + const left = (dayDistance(start, blockStart) / totalDays) * 100; + const width = Math.max(4, ((dayDistance(blockStart, blockEnd) + 1) / totalDays) * 100); + return { left: `${left}%`, width: `${width}%`, top: `${index * 30 + 7}px` }; +} + +function SummaryCard({ icon: Icon, label, value, tone }) { + return
+ +

{label}

{value}
+
; +} + +function TimelineLane({ dayType, start, end }) { + const versions = dayType.versions.filter((version) => visibleVersion(version, start, end)); + + return
+
+ {dayType.label}{dayType.shortLabel} +
+
+
+ {!versions.length && Нет версии} + {versions.map((version, index) => + №{version.id} + )} +
+
+ {versions.map((version) => + №{version.id} · {formatPeriod(version)} · {version.departure_count} рейс{version.departure_count === 1 ? '' : version.departure_count > 1 && version.departure_count < 5 ? 'а' : 'ов'} + )} + {dayType.gaps.map((gap) => + Пробел: {formatDate(gap.starts_on)}{gap.ends_on !== gap.starts_on && ` — ${formatDate(gap.ends_on)}`} + )} +
+
+
; +} + +function Issue({ issue }) { + const isCritical = issue.severity === 'critical'; + const Icon = isCritical ? AlertCircle : AlertTriangle; + return
+ +
+

{issue.title}

{issue.day_type_label}
+

{issue.route_label}: {issue.description}

+
+ + Исправить
; +} + +export default function ScheduleCenterPage() { + const { activeCity } = useCity(); + const [targetDate, setTargetDate] = useState(minskDate); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(''); + const requestId = useRef(0); + + const loadReport = useCallback(async ({ refresh = false } = {}) => { + const request = ++requestId.current; + setError(''); + if (refresh) setRefreshing(true); + else setLoading(true); + try { + const { data } = await scheduleCenterApi.getOverview(targetDate); + if (request === requestId.current) setReport(data); + } catch (requestError) { + if (request === requestId.current) { + setError(requestError.response?.data?.error || 'Не удалось загрузить центр расписаний. Повторите попытку.'); + } + } finally { + if (request === requestId.current) { + setLoading(false); + setRefreshing(false); + } + } + }, [targetDate]); + + useEffect(() => { + loadReport(); + return () => { requestId.current += 1; }; + }, [loadReport]); + + const summary = report?.summary || { routes: 0, active: 0, upcoming: 0, conflicts: 0, gaps: 0 }; + + return
+
+
+

Центр расписаний

+

Показывает, какие версии действуют на выбранную дату, что вступит в силу дальше и где периоды пересекаются или остаются без покрытия.

+
+
+ {activeCity?.schedule_source_url &&
+
+ +
+
+ + {loading && !report ? : <> + {error &&
} + + {report && <> +
+ + + + + +
+ +
+
+

Календарь версий

Каждая полоска — отдельная версия. Красная рамка означает пересечение с другой версией того же типа дня.

+
ДействуетБудущаяПересечение
+
+
+ {report.routes.map((route) =>
+
+

{route.route_name}

Версии для Пн–Пт, Сб и Вс

+ Открыть редактор
+ +
+ {route.day_types.map((dayType) => )} +
+
)} +
+
+ +
+

Требует внимания

Центр ничего не исправляет сам: откройте редактор только для нужного маршрута.

+ {!report.issues.length ?
+ :
{report.issues.map((issue) => )}
} +
+ } + } +
; +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index bf7fbef..5df8835 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -102,6 +102,7 @@ export const routesApi = { export const citiesApi = { getAll: () => api.get('/cities'), create: (data) => api.post('/cities', data), + update: (cityId, data) => api.put(`/cities/${cityId}`, data), uploadAvatar: (cityId, file) => { const formData = new FormData(); formData.append('avatar', file); @@ -149,6 +150,10 @@ export const qualityApi = { getReport: () => api.get('/quality') }; +export const scheduleCenterApi = { + getOverview: (date) => api.get('/schedule-center', { params: { date } }) +}; + // Schedules API (расписания) export const schedulesApi = { getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`), diff --git a/frontend/src/services/realtime.js b/frontend/src/services/realtime.js index 3653163..8f3d1d6 100644 --- a/frontend/src/services/realtime.js +++ b/frontend/src/services/realtime.js @@ -31,6 +31,7 @@ function buildRealtimeUrl() { export class RouteRealtimeClient { constructor({ routeId, + routeIds, token, apiKey = API_KEY, onStatusChange, @@ -38,7 +39,10 @@ export class RouteRealtimeClient { onVehiclePosition, onError }) { - this.routeId = routeId; + this.routeIds = [...new Set([...(Array.isArray(routeIds) ? routeIds : []), routeId] + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isInteger(value) && value > 0))]; + this.routeId = this.routeIds[0] || null; this.token = token; this.apiKey = apiKey; this.onStatusChange = onStatusChange; @@ -52,7 +56,7 @@ export class RouteRealtimeClient { } connect() { - if (!this.routeId || this.socket) { + if (!this.routeIds.length || this.socket) { return; } @@ -112,15 +116,14 @@ export class RouteRealtimeClient { })); } - subscribeToRoute() { + subscribeToRoutes() { if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { return; } - this.socket.send(JSON.stringify({ - type: 'subscribe:route', - routeId: this.routeId - })); + this.routeIds.forEach((routeId) => { + this.socket.send(JSON.stringify({ type: 'subscribe:route', routeId })); + }); } handleMessage(rawMessage) { @@ -132,7 +135,7 @@ export class RouteRealtimeClient { break; case 'auth:ok': this.updateStatus('authenticated', payload); - this.subscribeToRoute(); + this.subscribeToRoutes(); break; case 'subscription:ok': this.updateStatus('connected', payload); @@ -141,7 +144,7 @@ export class RouteRealtimeClient { this.updateStatus('disconnected', payload); break; case 'snapshot:route': - this.onSnapshot?.(payload.vehicles || []); + this.onSnapshot?.(payload.vehicles || [], payload.routeId); break; case 'vehicle:position': if (payload.vehicle) {