feat: add LiveMapPage for real-time vehicle tracking and ScheduleCenterPage for schedule management
- Implemented LiveMapPage with vehicle tracking using Leaflet and real-time updates. - Added vehicle icons, status labels, and filtering options for routes. - Created ScheduleCenterPage to display schedule versions, conflicts, and gaps. - Introduced responsive design for both pages with dedicated CSS styles. - Integrated API calls for fetching routes and vehicle data.
This commit is contained in:
@@ -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) {
|
async uploadCityAvatar(req, res) {
|
||||||
const cityId = Number(req.params.cityId);
|
const cityId = Number(req.params.cityId);
|
||||||
if (!Number.isInteger(cityId) || cityId <= 0) {
|
if (!Number.isInteger(cityId) || cityId <= 0) {
|
||||||
|
|||||||
48
backend/src/controllers/scheduleCenterController.js
Normal file
48
backend/src/controllers/scheduleCenterController.js
Normal file
@@ -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;
|
||||||
@@ -15,6 +15,7 @@ const { createPassengerTelemetryRateLimiter } = require('../middleware/passenger
|
|||||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||||
const { resolveCity } = require('../middleware/city');
|
const { resolveCity } = require('../middleware/city');
|
||||||
const qualityController = require('../controllers/qualityController');
|
const qualityController = require('../controllers/qualityController');
|
||||||
|
const scheduleCenterController = require('../controllers/scheduleCenterController');
|
||||||
|
|
||||||
// City catalogue is global. Every transport-data endpoint below receives a
|
// 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).
|
// 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.use('/schedule-import', require('./scheduleImport'));
|
||||||
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
|
router.get('/quality', authenticateToken, requireAdmin, qualityController.getReport.bind(qualityController));
|
||||||
|
router.get('/schedule-center', authenticateToken, requireAdmin, scheduleCenterController.getOverview.bind(scheduleCenterController));
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
router.get('/routes', routesController.getAllRoutes.bind(routesController));
|
router.get('/routes', routesController.getAllRoutes.bind(routesController));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const router = express.Router();
|
|||||||
|
|
||||||
router.get('/', citiesController.getAllCities.bind(citiesController));
|
router.get('/', citiesController.getAllCities.bind(citiesController));
|
||||||
router.post('/', authenticateToken, requireAdmin, citiesController.createCity.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));
|
router.post('/:cityId/avatar', authenticateToken, requireAdmin, cityUpload.single('avatar'), citiesController.uploadCityAvatar.bind(citiesController));
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
194
backend/src/services/scheduleCenterReport.js
Normal file
194
backend/src/services/scheduleCenterReport.js
Normal file
@@ -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 };
|
||||||
36
backend/test/scheduleCenterReport.test.js
Normal file
36
backend/test/scheduleCenterReport.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -13,6 +13,8 @@ import RouteEditorPage from './pages/RouteEditorPage';
|
|||||||
import StopsPage from './pages/StopsPage';
|
import StopsPage from './pages/StopsPage';
|
||||||
import SchedulesPage from './pages/SchedulesPage';
|
import SchedulesPage from './pages/SchedulesPage';
|
||||||
import ScheduleImportPage from './pages/ScheduleImportPage';
|
import ScheduleImportPage from './pages/ScheduleImportPage';
|
||||||
|
import ScheduleCenterPage from './pages/ScheduleCenterPage';
|
||||||
|
import LiveMapPage from './pages/LiveMapPage';
|
||||||
import QualityPage from './pages/QualityPage';
|
import QualityPage from './pages/QualityPage';
|
||||||
import HolidaysPage from './pages/HolidaysPage';
|
import HolidaysPage from './pages/HolidaysPage';
|
||||||
import AlertsPage from './pages/AlertsPage';
|
import AlertsPage from './pages/AlertsPage';
|
||||||
@@ -70,6 +72,8 @@ function AnimatedRoutes({ cityId }) {
|
|||||||
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
|
<Route path="/routes/:id/edit" element={<ProtectedRoute><RouteEditorPage /></ProtectedRoute>} />
|
||||||
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
|
<Route path="/stops" element={<ProtectedRoute><StopsPage /></ProtectedRoute>} />
|
||||||
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
|
<Route path="/schedules" element={<ProtectedRoute><SchedulesPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/live-map" element={<ProtectedRoute><LiveMapPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/schedule-center" element={<ProtectedRoute requireAdmin><ScheduleCenterPage /></ProtectedRoute>} />
|
||||||
<Route path="/schedule-import" element={<ProtectedRoute requireAdmin><ScheduleImportPage /></ProtectedRoute>} />
|
<Route path="/schedule-import" element={<ProtectedRoute requireAdmin><ScheduleImportPage /></ProtectedRoute>} />
|
||||||
<Route path="/quality" element={<ProtectedRoute requireAdmin><QualityPage /></ProtectedRoute>} />
|
<Route path="/quality" element={<ProtectedRoute requireAdmin><QualityPage /></ProtectedRoute>} />
|
||||||
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
|
<Route path="/holidays" element={<ProtectedRoute><HolidaysPage /></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, NavLink, useLocation } from 'react-router-dom';
|
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 { useAuth } from '../contexts/AuthContext';
|
||||||
import BrandLogo from './BrandLogo';
|
import BrandLogo from './BrandLogo';
|
||||||
import ProfileMenu from './ProfileMenu';
|
import ProfileMenu from './ProfileMenu';
|
||||||
@@ -12,7 +12,9 @@ const navigationGroups = [
|
|||||||
{ label: 'Транспорт', items: [
|
{ label: 'Транспорт', items: [
|
||||||
{ to: '/routes', label: 'Маршруты', icon: Bus },
|
{ to: '/routes', label: 'Маршруты', icon: Bus },
|
||||||
{ to: '/stops', label: 'Остановки', icon: MapPin },
|
{ to: '/stops', label: 'Остановки', icon: MapPin },
|
||||||
|
{ to: '/live-map', label: 'Живая карта', icon: Radio },
|
||||||
{ to: '/schedules', label: 'Расписания', icon: CalendarDays },
|
{ to: '/schedules', label: 'Расписания', icon: CalendarDays },
|
||||||
|
{ to: '/schedule-center', label: 'Центр расписаний', icon: CalendarRange, adminOnly: true },
|
||||||
{ to: '/schedule-import', label: 'Импорт расписаний', icon: Download, adminOnly: true },
|
{ to: '/schedule-import', label: 'Импорт расписаний', icon: Download, adminOnly: true },
|
||||||
{ to: '/holidays', label: 'Праздники', icon: Gift },
|
{ to: '/holidays', label: 'Праздники', icon: Gift },
|
||||||
] },
|
] },
|
||||||
@@ -80,7 +82,7 @@ export default function AppLayout({ children }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules'].includes(pathname) ? 'app-main-wide' : ''}`}>
|
<main id="main-content" tabIndex={-1} className={`app-main ${pathname === '/' ? 'app-main-dashboard' : ['/routes', '/schedules', '/schedule-center', '/live-map'].includes(pathname) ? 'app-main-wide' : ''}`}>
|
||||||
<div className="app-content">{children}</div>
|
<div className="app-content">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,15 +78,18 @@
|
|||||||
.city-center-map { height: 320px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 12px; }
|
.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 { @apply text-sm text-gray-600; }
|
||||||
.city-center-coordinates strong { @apply text-gray-900; }
|
.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 .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; }
|
.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 { 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-color: #0958d9; box-shadow: inset 0 0 0 1px #0958d9; }
|
.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 h2 { @apply text-lg font-semibold; }
|
||||||
.city-network-card p { @apply mt-1 text-sm text-gray-500; }
|
.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 { @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; }
|
.city-isolation-note svg { @apply mt-0.5 shrink-0; }
|
||||||
.login-surface {
|
.login-surface {
|
||||||
@@ -270,8 +273,14 @@
|
|||||||
.login-card { padding: 28px 24px; }
|
.login-card { padding: 28px 24px; }
|
||||||
.login-device-card { padding: 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) {
|
@media (max-width: 767px) {
|
||||||
.app-main.app-main-wide { padding: 24px 16px; }
|
.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) {
|
@media (min-width: 1024px) {
|
||||||
.app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; }
|
.app-sidebar { position: fixed; inset: 0 auto 0 0; width: 264px; padding: 16px; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
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 { MapContainer, Marker, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { citiesApi } from '../services/api';
|
import { citiesApi } from '../services/api';
|
||||||
@@ -26,6 +26,16 @@ function slugFromName(name) {
|
|||||||
.join('').replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
.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) {
|
function isImageValid(file) {
|
||||||
if (!acceptedImageTypes.has(file.type)) return 'Выберите изображение в формате JPG, PNG или WEBP.';
|
if (!acceptedImageTypes.has(file.type)) return 'Выберите изображение в формате JPG, PNG или WEBP.';
|
||||||
if (file.size > 5 * 1024 * 1024) return 'Размер изображения не должен превышать 5 МБ.';
|
if (file.size > 5 * 1024 * 1024) return 'Размер изображения не должен превышать 5 МБ.';
|
||||||
@@ -112,8 +122,9 @@ function CityAvatarEditor({ city }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CitiesPage() {
|
export default function CitiesPage() {
|
||||||
const { cities, activeCity, addCity } = useCity();
|
const { cities, activeCity, addCity, replaceCity } = useCity();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingCity, setEditingCity] = useState(null);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [slugEdited, setSlugEdited] = useState(false);
|
const [slugEdited, setSlugEdited] = useState(false);
|
||||||
const [avatarFile, setAvatarFile] = useState(null);
|
const [avatarFile, setAvatarFile] = useState(null);
|
||||||
@@ -121,23 +132,50 @@ export default function CitiesPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [notice, setNotice] = useState('');
|
const [notice, setNotice] = useState('');
|
||||||
|
const isEditing = Boolean(editingCity);
|
||||||
|
|
||||||
const selectedPosition = form.center_lat !== '' && form.center_lon !== ''
|
const selectedPosition = form.center_lat !== '' && form.center_lon !== ''
|
||||||
? [Number(form.center_lat), Number(form.center_lon)]
|
? [Number(form.center_lat), Number(form.center_lon)]
|
||||||
: null;
|
: null;
|
||||||
const fallbackCenter = activeCity?.center_lat != null && activeCity?.center_lon != null
|
const mapCity = editingCity || activeCity;
|
||||||
? [Number(activeCity.center_lat), Number(activeCity.center_lon)]
|
const fallbackCenter = mapCity?.center_lat != null && mapCity?.center_lon != null
|
||||||
|
? [Number(mapCity.center_lat), Number(mapCity.center_lon)]
|
||||||
: BELARUS_CENTER;
|
: BELARUS_CENTER;
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
}, [avatarPreview]);
|
}, [avatarPreview]);
|
||||||
|
|
||||||
function clearForm() {
|
function clearEditor() {
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
setSlugEdited(false);
|
setSlugEdited(false);
|
||||||
setAvatarFile(null);
|
setAvatarFile(null);
|
||||||
setAvatarPreview('');
|
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) {
|
function updateForm(field, value) {
|
||||||
@@ -175,24 +213,32 @@ export default function CitiesPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setNotice('');
|
setNotice('');
|
||||||
try {
|
try {
|
||||||
let { data: city } = await citiesApi.create(form);
|
let city;
|
||||||
let avatarWarning = '';
|
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) {
|
if (avatarFile) {
|
||||||
try {
|
try {
|
||||||
const upload = await citiesApi.uploadAvatar(city.id, avatarFile);
|
const upload = await citiesApi.uploadAvatar(city.id, avatarFile);
|
||||||
city = upload.data.city;
|
city = upload.data.city;
|
||||||
} catch (uploadError) {
|
} catch (uploadError) {
|
||||||
avatarWarning = uploadError.response?.data?.error || 'Город создан, но изображение не удалось загрузить.';
|
avatarWarning = uploadError.response?.data?.error || 'Изменения города сохранены, но изображение не удалось загрузить.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addCity(city);
|
if (editingCity) replaceCity(city);
|
||||||
clearForm();
|
else addCity(city);
|
||||||
setShowForm(false);
|
const action = editingCity ? 'Изменения города сохранены.' : 'Город добавлен.';
|
||||||
if (avatarWarning) setNotice(avatarWarning);
|
closeEditor();
|
||||||
|
setNotice(avatarWarning || action);
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(requestError.response?.data?.error || 'Не удалось добавить город.');
|
setError(requestError.response?.data?.error || (editingCity ? 'Не удалось сохранить изменения города.' : 'Не удалось добавить город.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -201,7 +247,7 @@ export default function CitiesPage() {
|
|||||||
return <div className="cities-page">
|
return <div className="cities-page">
|
||||||
<header className="cities-heading">
|
<header className="cities-heading">
|
||||||
<div><h1 className="page-title">Города</h1><p>У каждого города — свои маршруты, остановки, расписания и уведомления.</p></div>
|
<div><h1 className="page-title">Города</h1><p>У каждого города — свои маршруты, остановки, расписания и уведомления.</p></div>
|
||||||
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={() => { setShowForm(current => !current); setError(''); setNotice(''); }}>
|
<button type="button" className="btn-primary inline-flex items-center gap-2" onClick={showForm ? closeEditor : openCreate}>
|
||||||
<Plus size={18} aria-hidden="true" />{showForm ? 'Закрыть форму' : 'Добавить город'}
|
<Plus size={18} aria-hidden="true" />{showForm ? 'Закрыть форму' : 'Добавить город'}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -209,24 +255,29 @@ export default function CitiesPage() {
|
|||||||
{notice && <p className="city-form-notice" role="status">{notice}</p>}
|
{notice && <p className="city-form-notice" role="status">{notice}</p>}
|
||||||
|
|
||||||
{showForm && <form className="card city-form" onSubmit={submit}>
|
{showForm && <form className="card city-form" onSubmit={submit}>
|
||||||
<div><h2>Новый город</h2><p>После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.</p></div>
|
<div><h2>{isEditing ? `Редактирование: ${editingCity.name}` : 'Новый город'}</h2><p>{isEditing ? 'Измените данные города и сохраните их. Данные его транспортной сети останутся без изменений.' : 'После сохранения он станет активным. Добавьте для него маршруты, остановки и расписания отдельно.'}</p></div>
|
||||||
{error && <p className="city-form-error" role="alert">{error}</p>}
|
{error && <p className="city-form-error" role="alert">{error}</p>}
|
||||||
<div className="city-form-grid">
|
<div className="city-form-grid">
|
||||||
<label><span className="label">Название</span><input required className="input" value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="Например, Новополоцк" /></label>
|
<label><span className="label">Название</span><input required className="input" value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="Например, Новополоцк" /></label>
|
||||||
<label><span className="label">Код города</span><input required pattern="[a-z0-9]+(-[a-z0-9]+)*" className="input" value={form.slug} onChange={event => { setSlugEdited(true); updateForm('slug', event.target.value.toLowerCase()); }} placeholder="novopolotsk" /><span className="city-form-hint">Латинские буквы, цифры и дефисы.</span></label>
|
<label><span className="label">Код города</span><input required pattern="[a-z0-9]+(-[a-z0-9]+)*" className="input" value={form.slug} onChange={event => { setSlugEdited(true); updateForm('slug', event.target.value.toLowerCase()); }} placeholder="novopolotsk" /><span className="city-form-hint">Латинские буквы, цифры и дефисы.</span></label>
|
||||||
<label><span className="label">Иконка без картинки</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /><span className="city-form-hint">Показывается, если кастомное изображение не задано.</span></label>
|
<label><span className="label">Иконка без картинки</span><input maxLength="64" className="input" value={form.emblem} onChange={event => updateForm('emblem', event.target.value)} placeholder="🏙️" /><span className="city-form-hint">Показывается, если кастомное изображение не задано.</span></label>
|
||||||
<label className="city-avatar-field"><span className="label">Картинка для герба</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
|
<label className="city-avatar-field"><span className="label">{isEditing ? 'Новая картинка для герба' : 'Картинка для герба'}</span><input type="file" accept="image/jpeg,image/png,image/webp" onChange={selectNewAvatar} /><span className="city-form-hint">JPG, PNG или WEBP, до 5 МБ.</span>{avatarPreview && <img className="city-avatar-preview" src={avatarPreview} alt="Предпросмотр герба города" />}</label>
|
||||||
<CityCenterPicker position={selectedPosition} fallbackCenter={fallbackCenter} onSelect={selectCenter} />
|
<CityCenterPicker position={selectedPosition} fallbackCenter={fallbackCenter} onSelect={selectCenter} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={() => { clearForm(); setShowForm(false); setError(''); }}>Отмена</button></div>
|
<div className="flex flex-wrap gap-3"><button disabled={saving} className="btn-primary" type="submit">{saving ? 'Сохраняем…' : isEditing ? 'Сохранить изменения' : 'Создать город'}</button><button disabled={saving} className="btn-secondary" type="button" onClick={closeEditor}>Отмена</button></div>
|
||||||
</form>}
|
</form>}
|
||||||
|
|
||||||
<section className="cities-grid" aria-label="Доступные города">
|
<section className="cities-grid" aria-label="Доступные города">
|
||||||
{cities.map(city => <article key={city.id} className={`card city-network-card ${city.id === activeCity?.id ? 'is-active' : ''}`}>
|
{cities.map(city => <article key={city.id} className={`card city-network-card ${city.id === activeCity?.id ? 'is-active' : ''}`}>
|
||||||
<CityEmblem city={city} className="is-large" />
|
<div className="city-card-summary">
|
||||||
<div className="min-w-0 flex-1"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
|
<CityEmblem city={city} className="is-large" />
|
||||||
<CityAvatarEditor city={city} />
|
<div className="min-w-0"><h2>{city.name}</h2><p>{city.center_lat != null ? 'Центр карты настроен' : 'Центр карты ещё не указан'}</p></div>
|
||||||
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
|
</div>
|
||||||
|
<div className="city-card-actions">
|
||||||
|
{city.id === activeCity?.id && <span className="city-current"><CheckCircle2 size={16} aria-hidden="true" />Активен</span>}
|
||||||
|
<button type="button" className="btn-secondary city-edit-button" onClick={() => openEdit(city)}><Pencil size={16} aria-hidden="true" />Редактировать</button>
|
||||||
|
<CityAvatarEditor city={city} />
|
||||||
|
</div>
|
||||||
</article>)}
|
</article>)}
|
||||||
</section>
|
</section>
|
||||||
<div className="city-isolation-note"><MapPinned size={20} aria-hidden="true" /><p>Переключение города в боковой панели меняет рабочий контур целиком: данные одного города не отображаются и не редактируются в другом.</p></div>
|
<div className="city-isolation-note"><MapPinned size={20} aria-hidden="true" /><p>Переключение города в боковой панели меняет рабочий контур целиком: данные одного города не отображаются и не редактируются в другом.</p></div>
|
||||||
|
|||||||
74
frontend/src/pages/LiveMapPage.css
Normal file
74
frontend/src/pages/LiveMapPage.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
285
frontend/src/pages/LiveMapPage.jsx
Normal file
285
frontend/src/pages/LiveMapPage.jsx
Normal file
@@ -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, '"')
|
||||||
|
.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: `<div class="live-map-marker-label" style="--route-color:${markerColor}">
|
||||||
|
<span class="live-map-marker-arrow" style="transform:rotate(${heading}deg)">▲</span>
|
||||||
|
<span>${label}</span>
|
||||||
|
</div>`,
|
||||||
|
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 <PageLoader />;
|
||||||
|
|
||||||
|
return <div className="live-map-page">
|
||||||
|
<header className="live-map-heading">
|
||||||
|
<div><h1 className="page-title">Живая карта</h1><p>Положение транспорта обновляется через realtime. Если соединение временно пропало, остаётся последний доступный снимок.</p></div>
|
||||||
|
<button type="button" className="btn-secondary live-map-refresh" onClick={() => refreshSnapshot(routes, { manual: true })} disabled={refreshing || !routes.length}>
|
||||||
|
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />{refreshing ? 'Обновляем…' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="live-map-status" aria-label="Статус живой карты">
|
||||||
|
<div className={`live-map-connection is-${connectionState}`}><Signal size={18} aria-hidden="true" /><span>{realtimeStatusLabel(connectionState)}</span></div>
|
||||||
|
<span><Clock3 size={16} aria-hidden="true" />{lastUpdatedAt ? `Снимок: ${lastUpdatedAt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}` : 'Ожидаем первый снимок'}</span>
|
||||||
|
<span><Bus size={16} aria-hidden="true" />На карте: <strong>{visibleVehicles.length}</strong></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <div className="live-map-error" role="alert"><AlertCircle size={20} aria-hidden="true" /><span>{error}</span></div>}
|
||||||
|
|
||||||
|
<div className="live-map-layout">
|
||||||
|
<section className="live-map-panel" aria-labelledby="live-map-title">
|
||||||
|
<h2 id="live-map-title" className="sr-only">Карта живого транспорта</h2>
|
||||||
|
<div className="live-map-filters" role="group" aria-label="Фильтр маршрутов">
|
||||||
|
<button type="button" aria-pressed={selectedRouteId === 'all'} onClick={() => { setSelectedRouteId('all'); setSelectedVehicleId(null); }}>Все <span>{mappableVehicles.length}</span></button>
|
||||||
|
{routes.map((route) => <button type="button" key={route.id} aria-pressed={Number(selectedRouteId) === Number(route.id)} onClick={() => { setSelectedRouteId(route.id); setSelectedVehicleId(null); }}>
|
||||||
|
№{route.route_number}<span>{mappableVehicles.filter((vehicle) => Number(vehicle.route_id) === Number(route.id)).length}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>
|
||||||
|
<div className="live-map-canvas">
|
||||||
|
<MapContainer center={center} zoom={13} className="live-map-leaflet" scrollWheelZoom>
|
||||||
|
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© OpenStreetMap contributors' />
|
||||||
|
<MapViewport city={activeCity} selectedVehicle={selectedVehicle} />
|
||||||
|
{visibleVehicles.map((vehicle) => {
|
||||||
|
const route = routesById.get(Number(vehicle.route_id));
|
||||||
|
const color = vehicle.route_color || route?.color;
|
||||||
|
return <Marker key={vehicle.vehicle_id} position={[Number(vehicle.latitude), Number(vehicle.longitude)]}
|
||||||
|
icon={createVehicleIcon(vehicle, color)} eventHandlers={{ click: () => setSelectedVehicleId(vehicle.vehicle_id) }}>
|
||||||
|
<Popup><div className="live-map-popup">
|
||||||
|
<div><RouteBadge route={{ route_number: vehicle.route_number || '—', color }} /><strong>{vehicleLabel(vehicle)}</strong></div>
|
||||||
|
<p>{route?.name || vehicle.route_name || 'Маршрут'}</p>
|
||||||
|
<dl><div><dt>Источник</dt><dd>{sourceLabel(vehicle)}</dd></div><div><dt>Обновлено</dt><dd>{formatAge(vehicle, now)}</dd></div><div><dt>Скорость</dt><dd>{vehicle.speed_kmh != null ? `${Math.round(Number(vehicle.speed_kmh))} км/ч` : 'нет данных'}</dd></div></dl>
|
||||||
|
</div></Popup>
|
||||||
|
</Marker>;
|
||||||
|
})}
|
||||||
|
</MapContainer>
|
||||||
|
{!visibleVehicles.length && <div className="live-map-empty"><MapPin size={40} aria-hidden="true" /><div><h2>Пока нет живых координат</h2><p>Когда GPS или подтверждённые агрегированные данные поступят, транспорт появится здесь автоматически.</p></div></div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="live-map-sidebar" aria-label="Транспорт на карте">
|
||||||
|
<section className="live-map-metrics">
|
||||||
|
<div><span><CheckCircle2 size={18} aria-hidden="true" /></span><p>Официальный GPS<strong>{officialCount}</strong></p></div>
|
||||||
|
<div><span><Users size={18} aria-hidden="true" /></span><p>По данным пассажиров<strong>{crowdCount}</strong></p></div>
|
||||||
|
</section>
|
||||||
|
<section className="live-map-vehicles">
|
||||||
|
<div className="live-map-sidebar-heading"><div><h2>Транспорт</h2><p>{selectedRouteId === 'all' ? 'Все маршруты' : `Маршрут №${routesById.get(Number(selectedRouteId))?.route_number || '—'}`}</p></div><Radio size={19} aria-hidden="true" /></div>
|
||||||
|
{visibleVehicles.length ? <div className="live-map-vehicle-list">{visibleVehicles.map((vehicle) => {
|
||||||
|
const route = routesById.get(Number(vehicle.route_id));
|
||||||
|
const color = vehicle.route_color || route?.color;
|
||||||
|
return <button type="button" key={vehicle.vehicle_id} className={`live-map-vehicle ${String(selectedVehicleId) === String(vehicle.vehicle_id) ? 'is-selected' : ''}`} onClick={() => setSelectedVehicleId(vehicle.vehicle_id)}>
|
||||||
|
<RouteBadge route={{ route_number: vehicle.route_number || '—', color }} />
|
||||||
|
<span className="live-map-vehicle-main"><strong>{vehicleLabel(vehicle)}</strong><small>{sourceLabel(vehicle)} · {formatAge(vehicle, now)}</small></span>
|
||||||
|
<i className={vehicle.source === 'crowdsourced' ? 'is-crowd' : ''} aria-label={vehicle.is_online ? 'Онлайн' : 'Не в сети'} />
|
||||||
|
</button>;
|
||||||
|
})}</div> : <div className="live-map-sidebar-empty"><Bus size={34} aria-hidden="true" /><p>Нет транспорта по выбранному фильтру.</p></div>}
|
||||||
|
</section>
|
||||||
|
<Link className="live-map-routes-link" to="/routes">Настроить маршруты</Link>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
83
frontend/src/pages/ScheduleCenterPage.css
Normal file
83
frontend/src/pages/ScheduleCenterPage.css
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
204
frontend/src/pages/ScheduleCenterPage.jsx
Normal file
204
frontend/src/pages/ScheduleCenterPage.jsx
Normal file
@@ -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 <article className={`schedule-center-summary-card is-${tone}`}>
|
||||||
|
<span><Icon size={20} aria-hidden="true" /></span>
|
||||||
|
<div><p>{label}</p><strong>{value}</strong></div>
|
||||||
|
</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineLane({ dayType, start, end }) {
|
||||||
|
const versions = dayType.versions.filter((version) => visibleVersion(version, start, end));
|
||||||
|
|
||||||
|
return <section className="schedule-center-lane" aria-label={`${dayType.label}: ${DAY_STATUS[dayType.status]}`}>
|
||||||
|
<div className="schedule-center-lane-label">
|
||||||
|
<strong>{dayType.label}</strong><span>{dayType.shortLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-center-lane-content">
|
||||||
|
<div className="schedule-center-track" style={{ minHeight: `${Math.max(48, versions.length * 30 + 12)}px` }}>
|
||||||
|
{!versions.length && <span className="schedule-center-empty-track">Нет версии</span>}
|
||||||
|
{versions.map((version, index) => <span key={version.id}
|
||||||
|
className={`schedule-center-version is-${version.state}${version.has_conflict ? ' has-conflict' : ''}`}
|
||||||
|
style={versionStyle(version, index, start, end)}
|
||||||
|
title={`Версия №${version.id}: ${formatPeriod(version)}`}>
|
||||||
|
№{version.id}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
<div className="schedule-center-version-list">
|
||||||
|
{versions.map((version) => <span key={version.id} className={`schedule-center-version-meta is-${version.state}${version.has_conflict ? ' has-conflict' : ''}`}>
|
||||||
|
<strong>№{version.id}</strong> · {formatPeriod(version)} · {version.departure_count} рейс{version.departure_count === 1 ? '' : version.departure_count > 1 && version.departure_count < 5 ? 'а' : 'ов'}
|
||||||
|
</span>)}
|
||||||
|
{dayType.gaps.map((gap) => <span key={`${gap.starts_on}-${gap.ends_on}`} className="schedule-center-gap-meta">
|
||||||
|
Пробел: {formatDate(gap.starts_on)}{gap.ends_on !== gap.starts_on && ` — ${formatDate(gap.ends_on)}`}
|
||||||
|
</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Issue({ issue }) {
|
||||||
|
const isCritical = issue.severity === 'critical';
|
||||||
|
const Icon = isCritical ? AlertCircle : AlertTriangle;
|
||||||
|
return <article className={`schedule-center-issue is-${issue.severity}`}>
|
||||||
|
<span><Icon size={20} aria-hidden="true" /></span>
|
||||||
|
<div>
|
||||||
|
<div className="schedule-center-issue-heading"><h3>{issue.title}</h3><small>{issue.day_type_label}</small></div>
|
||||||
|
<p>{issue.route_label}: {issue.description}</p>
|
||||||
|
</div>
|
||||||
|
<Link to={`/schedules?route=${issue.route_id}`} className="schedule-center-issue-action">
|
||||||
|
Исправить<ArrowRight size={16} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="schedule-center-page">
|
||||||
|
<header className="schedule-center-heading">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Центр расписаний</h1>
|
||||||
|
<p>Показывает, какие версии действуют на выбранную дату, что вступит в силу дальше и где периоды пересекаются или остаются без покрытия.</p>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-center-actions">
|
||||||
|
{activeCity?.schedule_source_url && <Link to="/schedule-import" className="btn-secondary"><Download size={16} aria-hidden="true" />Импорт</Link>}
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => loadReport({ refresh: true })} disabled={loading || refreshing}>
|
||||||
|
<RefreshCw size={16} className={refreshing ? 'animate-spin' : ''} aria-hidden="true" />{refreshing ? 'Обновляем…' : 'Обновить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="schedule-center-controls card" aria-label="Период обзора">
|
||||||
|
<CalendarClock size={20} aria-hidden="true" />
|
||||||
|
<label>Дата проверки<input className="input" type="date" value={targetDate} onChange={(event) => setTargetDate(event.target.value)} /></label>
|
||||||
|
<p>Показан период с {formatDate(targetDate)} по {report ? formatDate(report.window_end) : '…'}.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{loading && !report ? <PageLoader /> : <>
|
||||||
|
{error && <div className="schedule-center-message" role="alert"><AlertCircle size={20} aria-hidden="true" /><span>{error}</span><button type="button" onClick={() => loadReport()}>Повторить</button></div>}
|
||||||
|
|
||||||
|
{report && <>
|
||||||
|
<section className="schedule-center-summary" aria-label="Сводка расписаний">
|
||||||
|
<SummaryCard icon={CalendarClock} label="Маршрутов в обзоре" value={summary.routes} tone="neutral" />
|
||||||
|
<SummaryCard icon={CheckCircle2} label="Действующих версий" value={summary.active} tone="active" />
|
||||||
|
<SummaryCard icon={Clock3} label="Вступят в силу позже" value={summary.upcoming} tone="future" />
|
||||||
|
<SummaryCard icon={AlertCircle} label="Пересечений периодов" value={summary.conflicts} tone="critical" />
|
||||||
|
<SummaryCard icon={AlertTriangle} label="Пробелов в покрытии" value={summary.gaps} tone="warning" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="schedule-center-calendar" aria-labelledby="schedule-center-calendar-title">
|
||||||
|
<div className="schedule-center-section-heading">
|
||||||
|
<div><h2 id="schedule-center-calendar-title">Календарь версий</h2><p>Каждая полоска — отдельная версия. Красная рамка означает пересечение с другой версией того же типа дня.</p></div>
|
||||||
|
<div className="schedule-center-legend" aria-label="Обозначения"><span className="is-active">Действует</span><span className="is-upcoming">Будущая</span><span className="has-conflict">Пересечение</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-center-route-list">
|
||||||
|
{report.routes.map((route) => <article className="schedule-center-route" key={route.route_id}>
|
||||||
|
<header>
|
||||||
|
<div className="schedule-center-route-title"><RouteBadge route={route} /><div><h3>{route.route_name}</h3><p>Версии для Пн–Пт, Сб и Вс</p></div></div>
|
||||||
|
<Link to={`/schedules?route=${route.route_id}`} className="schedule-center-route-action">Открыть редактор<ArrowRight size={16} aria-hidden="true" /></Link>
|
||||||
|
</header>
|
||||||
|
<div className="schedule-center-date-scale" aria-hidden="true"><span>{formatDate(report.target_date)}</span><span>+ 30 дней</span><span>+ 60 дней</span><span>{formatDate(report.window_end)}</span></div>
|
||||||
|
<div className="schedule-center-lanes">
|
||||||
|
{route.day_types.map((dayType) => <TimelineLane key={dayType.value} dayType={dayType} start={report.target_date} end={report.window_end} />)}
|
||||||
|
</div>
|
||||||
|
</article>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="schedule-center-issues" aria-labelledby="schedule-center-issues-title">
|
||||||
|
<div className="schedule-center-section-heading"><div><h2 id="schedule-center-issues-title">Требует внимания</h2><p>Центр ничего не исправляет сам: откройте редактор только для нужного маршрута.</p></div></div>
|
||||||
|
{!report.issues.length ? <div className="schedule-center-clear"><CheckCircle2 size={40} aria-hidden="true" /><div><h3>Периоды согласованы</h3><p>На следующие 90 дней нет пересечений и пробелов в версиях расписаний.</p></div></div>
|
||||||
|
: <div className="schedule-center-issue-list">{report.issues.map((issue) => <Issue key={issue.id} issue={issue} />)}</div>}
|
||||||
|
</section>
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@ export const routesApi = {
|
|||||||
export const citiesApi = {
|
export const citiesApi = {
|
||||||
getAll: () => api.get('/cities'),
|
getAll: () => api.get('/cities'),
|
||||||
create: (data) => api.post('/cities', data),
|
create: (data) => api.post('/cities', data),
|
||||||
|
update: (cityId, data) => api.put(`/cities/${cityId}`, data),
|
||||||
uploadAvatar: (cityId, file) => {
|
uploadAvatar: (cityId, file) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('avatar', file);
|
formData.append('avatar', file);
|
||||||
@@ -149,6 +150,10 @@ export const qualityApi = {
|
|||||||
getReport: () => api.get('/quality')
|
getReport: () => api.get('/quality')
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const scheduleCenterApi = {
|
||||||
|
getOverview: (date) => api.get('/schedule-center', { params: { date } })
|
||||||
|
};
|
||||||
|
|
||||||
// Schedules API (расписания)
|
// Schedules API (расписания)
|
||||||
export const schedulesApi = {
|
export const schedulesApi = {
|
||||||
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),
|
getRouteSchedules: (routeId) => api.get(`/routes/${routeId}/schedules`),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function buildRealtimeUrl() {
|
|||||||
export class RouteRealtimeClient {
|
export class RouteRealtimeClient {
|
||||||
constructor({
|
constructor({
|
||||||
routeId,
|
routeId,
|
||||||
|
routeIds,
|
||||||
token,
|
token,
|
||||||
apiKey = API_KEY,
|
apiKey = API_KEY,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
@@ -38,7 +39,10 @@ export class RouteRealtimeClient {
|
|||||||
onVehiclePosition,
|
onVehiclePosition,
|
||||||
onError
|
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.token = token;
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.onStatusChange = onStatusChange;
|
this.onStatusChange = onStatusChange;
|
||||||
@@ -52,7 +56,7 @@ export class RouteRealtimeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (!this.routeId || this.socket) {
|
if (!this.routeIds.length || this.socket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,15 +116,14 @@ export class RouteRealtimeClient {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeToRoute() {
|
subscribeToRoutes() {
|
||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.send(JSON.stringify({
|
this.routeIds.forEach((routeId) => {
|
||||||
type: 'subscribe:route',
|
this.socket.send(JSON.stringify({ type: 'subscribe:route', routeId }));
|
||||||
routeId: this.routeId
|
});
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(rawMessage) {
|
handleMessage(rawMessage) {
|
||||||
@@ -132,7 +135,7 @@ export class RouteRealtimeClient {
|
|||||||
break;
|
break;
|
||||||
case 'auth:ok':
|
case 'auth:ok':
|
||||||
this.updateStatus('authenticated', payload);
|
this.updateStatus('authenticated', payload);
|
||||||
this.subscribeToRoute();
|
this.subscribeToRoutes();
|
||||||
break;
|
break;
|
||||||
case 'subscription:ok':
|
case 'subscription:ok':
|
||||||
this.updateStatus('connected', payload);
|
this.updateStatus('connected', payload);
|
||||||
@@ -141,7 +144,7 @@ export class RouteRealtimeClient {
|
|||||||
this.updateStatus('disconnected', payload);
|
this.updateStatus('disconnected', payload);
|
||||||
break;
|
break;
|
||||||
case 'snapshot:route':
|
case 'snapshot:route':
|
||||||
this.onSnapshot?.(payload.vehicles || []);
|
this.onSnapshot?.(payload.vehicles || [], payload.routeId);
|
||||||
break;
|
break;
|
||||||
case 'vehicle:position':
|
case 'vehicle:position':
|
||||||
if (payload.vehicle) {
|
if (payload.vehicle) {
|
||||||
|
|||||||
Reference in New Issue
Block a user