feat: add passenger settings and fare configuration with validation
This commit is contained in:
11
README.md
11
README.md
@@ -404,3 +404,14 @@ MIT License - используйте свободно!
|
|||||||
Поля находятся в существующем JSONB настроек устройств; новая SQL-миграция не требуется. Старые сохранённые конфигурации нормализуются при чтении (`auto / 60`) и не изменяются в базе автоматически. Для редактирования этих полей разверните обновлённые API, frontend админки и Board вместе.
|
Поля находятся в существующем JSONB настроек устройств; новая SQL-миграция не требуется. Старые сохранённые конфигурации нормализуются при чтении (`auto / 60`) и не изменяются в базе автоматически. Для редактирования этих полей разверните обновлённые API, frontend админки и Board вместе.
|
||||||
|
|
||||||
Блок информации «Дальше по маршруту» заменяет первый и последний рейс. Его переключатель сохраняет существующий ключ `infoServiceEnabled`: ранее отключённый блок остаётся отключённым, SQL-миграция и изменение JSON устройства не нужны. Схему следующих остановок и время пути строит обновлённый Board из полного кеша расписания; в админке изменено только название переключателя.
|
Блок информации «Дальше по маршруту» заменяет первый и последний рейс. Его переключатель сохраняет существующий ключ `infoServiceEnabled`: ранее отключённый блок остаётся отключённым, SQL-миграция и изменение JSON устройства не нужны. Схему следующих остановок и время пути строит обновлённый Board из полного кеша расписания; в админке изменено только название переключателя.
|
||||||
|
|
||||||
|
|
||||||
|
### Пассажирские блоки табло
|
||||||
|
|
||||||
|
В форме устройства добавлены переключатели «До важных мест», «Оплата поездки», «Напоминание о последнем рейсе» и «Вероятность осадков». До трёх важных мест задаются конкретной остановкой назначения и коротким названием. По умолчанию места не выбраны; незаполненный блок на табло скрыт. Отправление, путь и прибытие рассчитывает Board из полного действующего расписания с порядком остановок и конечной конкретного рейса. Отмены, направление и переход через полночь учитываются.
|
||||||
|
|
||||||
|
Тарифы по типу транспорта имеют поля `cityId`, `type` (`bus` / `minibus`), `price`, `payment`, `checkedAt`. Для Полоцка (город 1) пользователь подтвердил 11 октября 2026 года: автобус 1,05 BYN, маршрутка 2,10 BYN, оплата QR-кодом или наличными. Для других городов тарифы остаются незаполненными. Допустимы до 32 исключений по `routeId`; исключение имеет приоритет над тарифом типа. Цена и способы оплаты — подтверждённый оператором текст, дата проверки сохраняется и видна пассажиру. Будущий тариф не публикуется до указанной даты. Платёжный QR-код не генерируется.
|
||||||
|
|
||||||
|
Конфигурация: `infoPlacesEnabled`, `infoFareEnabled`, `lastTripNoticeEnabled`, `weatherHintEnabled` (по умолчанию `true`); `places: [{stopId, name}]`; `fareByType: [{cityId, type, price, payment, checkedAt}]`; `fareRules: [{routeId, price, payment, checkedAt}]`. API проверяет структуру, лимиты, дубликаты, действительность даты и принадлежность городу/площадке. Явные пустые массивы сохраняются. Старые конфигурации нормализуются без записи в БД; ранее отсутствовавший тариф типа подставляется только для города 1.
|
||||||
|
|
||||||
|
Новые поля хранятся в существующем JSONB; дополнительная SQL-миграция не нужна. Разверните обновлённые backend, frontend админки и RideBus Board вместе. Переключатели и содержимое передаются через существующие версии настроек и подтверждения устройством. Проверки пассажирских настроек входят в `test/boardDevices.test.js` и `test/boardDevicesApi.test.js`; визуальный стенд `ridebus-board/test/fixtures/passenger-server.mjs` использует отдельный локальный origin и тестовое расписание.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { randomBytes, randomInt, randomUUID, createHash } = require('node:crypto');
|
const { randomBytes, randomInt, randomUUID, createHash } = require('node:crypto');
|
||||||
const { validSettings, deviceInfo, DeviceError } = require('./boardDeviceSettings');
|
const { validSettings, deviceInfo, DeviceError } = require('./boardDeviceSettings');
|
||||||
|
const { cityFareDefaults } = require('./boardPassengerSettings');
|
||||||
const tokenHash = token => createHash('sha256').update(token).digest('hex');
|
const tokenHash = token => createHash('sha256').update(token).digest('hex');
|
||||||
function credential(token) {
|
function credential(token) {
|
||||||
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new DeviceError(401, 'invalid_device_token');
|
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new DeviceError(401, 'invalid_device_token');
|
||||||
@@ -11,14 +12,20 @@ function name(value) {
|
|||||||
}
|
}
|
||||||
function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; }
|
function deviceId(value) { if (typeof value !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) throw new DeviceError(400, 'invalid_device_id'); return value; }
|
||||||
function publicDevice(row, now) {
|
function publicDevice(row, now) {
|
||||||
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings: validSettings(row.settings),
|
const settings = validSettings(row.settings);
|
||||||
|
if (!Object.hasOwn(row.settings, 'fareByType')) settings.fareByType = cityFareDefaults(row.city_id);
|
||||||
|
return { id: row.id, name: row.name, cityId: row.city_id, cityName: row.city_name, stopName: row.stop_name, stopDirection: row.stop_direction || '', settings,
|
||||||
revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at,
|
revision: row.revision, appliedRevision: row.applied_revision, lastSeenAt: row.last_seen_at, deviceInfo: row.device_info, revokedAt: row.revoked_at,
|
||||||
online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) };
|
online: !row.revoked_at && Boolean(row.last_seen_at && now - Date.parse(row.last_seen_at) < 90000) };
|
||||||
}
|
}
|
||||||
function createBoardDeviceService(repository, { now = Date.now, makeCode = () => String(randomInt(10000)).padStart(4, '0') } = {}) {
|
function createBoardDeviceService(repository, { now = Date.now, makeCode = () => String(randomInt(10000)).padStart(4, '0') } = {}) {
|
||||||
async function checked(cityId, input) {
|
async function checked(cityId, input) {
|
||||||
const settings = validSettings(input);
|
const settings = validSettings(input);
|
||||||
|
if (!Object.hasOwn(input, 'fareByType')) settings.fareByType = cityFareDefaults(cityId);
|
||||||
if (!await repository.catalog(cityId, settings)) throw new DeviceError(400, 'invalid_stop_or_route', 'Остановка или маршрут не принадлежат выбранному городу и площадке');
|
if (!await repository.catalog(cityId, settings)) throw new DeviceError(400, 'invalid_stop_or_route', 'Остановка или маршрут не принадлежат выбранному городу и площадке');
|
||||||
|
for (const place of settings.places) if (place.stopId === settings.stopId || !await repository.catalog(cityId, { stopId: place.stopId, routeFilter: '' })) throw new DeviceError(400, 'invalid_place', 'Выберите другую остановку назначения в этом городе');
|
||||||
|
for (const rule of settings.fareRules) if (!await repository.catalog(cityId, { stopId: settings.stopId, routeFilter: String(rule.routeId) })) throw new DeviceError(400, 'invalid_fare_route', 'Маршрут тарифа не проходит через эту площадку');
|
||||||
|
if (settings.fareByType.some(rule => rule.cityId !== cityId)) throw new DeviceError(400, 'invalid_fare_city', 'Тариф относится к другому городу');
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
const { passengerDefaults, checkPassengerSettings } = require('./boardPassengerSettings');
|
||||||
const defaults = {
|
const defaults = {
|
||||||
|
...passengerDefaults,
|
||||||
stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '',
|
stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '',
|
||||||
theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
|
theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue',
|
||||||
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
|
infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true,
|
||||||
|
journeyTranslitEnabled: false,
|
||||||
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
|
showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near',
|
||||||
nightMode: 'auto', nightWakeMinutes: 60,
|
nightMode: 'auto', nightWakeMinutes: 60,
|
||||||
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.',
|
adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.',
|
||||||
@@ -17,9 +20,10 @@ function validSettings(input) {
|
|||||||
for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) {
|
for (const [key, min, max] of [['stopId', 1, 1000000], ['mainSeconds', 5, 120], ['infoSeconds', 5, 60], ['rowCount', 2, 6], ['adSeconds', 5, 30]]) {
|
||||||
if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) fail(key);
|
if (!Number.isInteger(value[key]) || value[key] < min || value[key] > max) fail(key);
|
||||||
}
|
}
|
||||||
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof value[key] !== 'boolean') fail(key);
|
for (const key of ['holdNear', 'infoServiceEnabled', 'infoWeatherEnabled', 'infoQrEnabled', 'journeyTranslitEnabled', 'showNextSameRoute', 'longWaitEnabled', 'adsEnabled']) if (typeof value[key] !== 'boolean') fail(key);
|
||||||
for (const [key, choices] of Object.entries({ theme: ['dark', 'light'], colorScheme: ['lime', 'amber', 'cyan', 'mint', 'lavender', 'white'], lightColorScheme: ['blue', 'forest', 'crimson', 'violet', 'teal', 'graphite'], displayProfile: ['tv', 'street'], viewingDistance: ['near', 'medium', 'far'], nightMode: ['off', 'auto'] })) if (!choices.includes(value[key])) fail(key);
|
for (const [key, choices] of Object.entries({ theme: ['dark', 'light'], colorScheme: ['lime', 'amber', 'cyan', 'mint', 'lavender', 'white'], lightColorScheme: ['blue', 'forest', 'crimson', 'violet', 'teal', 'graphite'], displayProfile: ['tv', 'street'], viewingDistance: ['near', 'medium', 'far'], nightMode: ['off', 'auto'] })) if (!choices.includes(value[key])) fail(key);
|
||||||
if (![45, 60].includes(value.nightWakeMinutes)) fail('nightWakeMinutes');
|
if (![45, 60].includes(value.nightWakeMinutes)) fail('nightWakeMinutes');
|
||||||
|
checkPassengerSettings(value, fail);
|
||||||
if (typeof value.routeFilter !== 'string' || !/^(|[1-9]\d{0,8})$/.test(value.routeFilter)) fail('routeFilter');
|
if (typeof value.routeFilter !== 'string' || !/^(|[1-9]\d{0,8})$/.test(value.routeFilter)) fail('routeFilter');
|
||||||
for (const [key, max] of [['adTitle', 80], ['adText', 200]]) if (typeof value[key] !== 'string' || value[key].length > max) fail(key);
|
for (const [key, max] of [['adTitle', 80], ['adText', 200]]) if (typeof value[key] !== 'string' || value[key].length > max) fail(key);
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
21
backend/src/services/boardPassengerSettings.js
Normal file
21
backend/src/services/boardPassengerSettings.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const passengerDefaults = { infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [], fareByType: [] };
|
||||||
|
const confirmedPolotskFares = [
|
||||||
|
{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
|
||||||
|
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
|
||||||
|
];
|
||||||
|
const cityFareDefaults = cityId => cityId === 1 ? confirmedPolotskFares : [];
|
||||||
|
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
const text = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
||||||
|
const id = (value, max = 1000000) => Number.isSafeInteger(value) && value > 0 && value <= max;
|
||||||
|
const date = value => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value + 'T12:00:00Z')) && new Date(value + 'T12:00:00Z').toISOString().slice(0, 10) === value;
|
||||||
|
const terms = value => text(value.price, 40) && text(value.payment, 100) && date(value.checkedAt);
|
||||||
|
function checkPassengerSettings(settings, fail) {
|
||||||
|
for (const key of ['infoPlacesEnabled', 'infoFareEnabled', 'lastTripNoticeEnabled', 'weatherHintEnabled']) if (typeof settings[key] !== 'boolean') fail(key);
|
||||||
|
if (!Array.isArray(settings.places) || settings.places.length > 3 || new Set(settings.places.map(p => p?.stopId)).size !== settings.places.length
|
||||||
|
|| !settings.places.every(p => object(p) && Object.keys(p).every(k => ['stopId', 'name'].includes(k)) && id(p.stopId) && text(p.name, 48))) fail('places');
|
||||||
|
if (!Array.isArray(settings.fareRules) || settings.fareRules.length > 32 || new Set(settings.fareRules.map(p => p?.routeId)).size !== settings.fareRules.length
|
||||||
|
|| !settings.fareRules.every(p => object(p) && Object.keys(p).every(k => ['routeId', 'price', 'payment', 'checkedAt'].includes(k)) && id(p.routeId, 999999999) && terms(p))) fail('fareRules');
|
||||||
|
if (!Array.isArray(settings.fareByType) || settings.fareByType.length > 2 || new Set(settings.fareByType.map(p => p?.type)).size !== settings.fareByType.length
|
||||||
|
|| !settings.fareByType.every(p => object(p) && Object.keys(p).every(k => ['cityId', 'type', 'price', 'payment', 'checkedAt'].includes(k)) && id(p.cityId) && ['bus', 'minibus'].includes(p.type) && terms(p))) fail('fareByType');
|
||||||
|
}
|
||||||
|
module.exports = { passengerDefaults, cityFareDefaults, checkPassengerSettings };
|
||||||
@@ -59,3 +59,46 @@ test('night preferences validate, survive updates and migrate old stored device
|
|||||||
await service.update(d.id,1,{name:'Night',revision:1,settings:{...defaults,nightMode:'off',nightWakeMinutes:45}},1);
|
await service.update(d.id,1,{name:'Night',revision:1,settings:{...defaults,nightMode:'off',nightWakeMinutes:45}},1);
|
||||||
const saved=(await service.status(p.token)).device;assert.equal(saved.settings.nightMode,'off');assert.equal(saved.settings.nightWakeMinutes,45);assert.equal(saved.revision,2);
|
const saved=(await service.status(p.token)).device;assert.equal(saved.settings.nightMode,'off');assert.equal(saved.settings.nightWakeMinutes,45);assert.equal(saved.revision,2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('transliteration migrates old devices, validates booleans and survives a remote update', async () => {
|
||||||
|
const repo = memoryRepository(), service = createBoardDeviceService(repo);
|
||||||
|
const pairing = await service.start({});
|
||||||
|
const device = await service.claim(1, { code: pairing.code, name: 'Latin', settings: defaults }, 1);
|
||||||
|
delete repo.devices.get(device.id).settings.journeyTranslitEnabled;
|
||||||
|
assert.equal((await service.status(pairing.token)).device.settings.journeyTranslitEnabled, false);
|
||||||
|
assert.equal((await service.list(1))[0].settings.journeyTranslitEnabled, false);
|
||||||
|
for (const flag of ['true', 1, null]) assert.throws(() => validSettings({ ...defaults, journeyTranslitEnabled: flag }), e => e.status === 400);
|
||||||
|
await service.update(device.id, 1, { name: 'Latin', revision: 1, settings: { ...defaults, journeyTranslitEnabled: true } }, 1);
|
||||||
|
const saved = (await service.status(pairing.token)).device;
|
||||||
|
assert.equal(saved.settings.journeyTranslitEnabled, true);
|
||||||
|
assert.equal(saved.revision, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passenger configuration validates shape and city/stop/route identity before consuming a code', async () => {
|
||||||
|
const service = createBoardDeviceService(memoryRepository()), p = await service.start({});
|
||||||
|
const rule = { routeId: 1, price: 'Особый тариф', payment: 'Подтверждённый способ', checkedAt: '2026-10-11' };
|
||||||
|
for (const patch of [{ places: [{ stopId: 3, name: 'Другой город' }] }, { places: [{ stopId: 1, name: 'Та же остановка' }] },
|
||||||
|
{ fareRules: [{ ...rule, routeId: 2 }] }, { fareByType: [{ cityId: 2, type: 'bus', ...rule }] }, { places: null }, { infoFareEnabled: 'true' }]) {
|
||||||
|
await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, ...patch } }, 1), e => e.status === 400);
|
||||||
|
}
|
||||||
|
await assert.rejects(service.claim(1, { code: p.code, name: 'Guide', settings: null }, 1), e => e.status === 400);
|
||||||
|
await assert.rejects(service.claim(1, { code:p.code, name:'Guide', settings:{...defaults, fareByType:[{cityId:2,type:'bus',price:'1 BYN',payment:'Наличные',checkedAt:'2026-10-11'}]} }, 1), e => e.code === 'invalid_fare_city');
|
||||||
|
assert.equal((await service.status(p.token)).state, 'pending');
|
||||||
|
const d = await service.claim(1, { code: p.code, name: 'Guide', settings: { ...defaults, places: [{ stopId: 2, name: 'Вокзал' }], fareRules: [rule] } }, 1);
|
||||||
|
const saved = (await service.status(p.token)).device.settings;
|
||||||
|
assert.equal(saved.places[0].name, 'Вокзал');assert.equal(saved.fareRules[0].price, 'Особый тариф');
|
||||||
|
await service.update(d.id,1,{name:'Guide',revision:1,settings:{...saved,weatherHintEnabled:false,lastTripNoticeEnabled:false}},1);
|
||||||
|
assert.equal((await service.status(p.token)).device.settings.weatherHintEnabled,false);
|
||||||
|
});
|
||||||
|
test('legacy Polotsk tariffs migrate using the operator confirmation; other cities stay unconfigured', async () => {
|
||||||
|
for(const cityId of [1,2]) {
|
||||||
|
const repo=memoryRepository(),service=createBoardDeviceService(repo),p=await service.start({});
|
||||||
|
const d=await service.claim(cityId,{code:p.code,name:'Legacy',settings:{...defaults,stopId:cityId===1?1:3}},1);
|
||||||
|
for(const key of ['infoPlacesEnabled','infoFareEnabled','lastTripNoticeEnabled','weatherHintEnabled','places','fareRules','fareByType']) delete repo.devices.get(d.id).settings[key];
|
||||||
|
const saved=(await service.status(p.token)).device.settings;
|
||||||
|
assert.equal(saved.lastTripNoticeEnabled,true);assert.deepEqual(saved.places,[]);
|
||||||
|
assert.equal(saved.fareByType.length,cityId===1?2:0);
|
||||||
|
if(cityId===1) {assert.equal(saved.fareByType[0].price,'1,05 BYN');assert.equal(saved.fareByType[1].price,'2,10 BYN');assert.equal(saved.fareByType[0].checkedAt,'2026-10-11');}
|
||||||
|
assert.equal(repo.devices.get(d.id).settings.fareByType,undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
40
frontend/src/components/BoardPassengerSettings.jsx
Normal file
40
frontend/src/components/BoardPassengerSettings.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export const passengerDefaults = { infoPlacesEnabled: true, infoFareEnabled: true, lastTripNoticeEnabled: true, weatherHintEnabled: true, places: [], fareRules: [], fareByType: [] };
|
||||||
|
export const cityFareDefaults = cityId => cityId === 1 ? [
|
||||||
|
{ cityId: 1, type: 'bus', price: '1,05 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
|
||||||
|
{ cityId: 1, type: 'minibus', price: '2,10 BYN', payment: 'QR-кодом или наличными', checkedAt: '2026-10-11' },
|
||||||
|
] : [];
|
||||||
|
const today = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Minsk' }).format(Date.now());
|
||||||
|
export function BoardPassengerSettings({ settings, stops, routes, cityId, set }) {
|
||||||
|
const editPlace = (index, patch) => set('places', settings.places.map((value, i) => i === index ? { ...value, ...patch } : value));
|
||||||
|
const editRule = (index, patch) => set('fareRules', settings.fareRules.map((value, i) => i === index ? { ...value, ...patch } : value));
|
||||||
|
return <div className="device-passenger-settings">
|
||||||
|
<h3>Важные места</h3><p className="device-night-hint">До трёх остановок назначения. Укажите короткое название для пассажиров; табло найдёт ближайший прямой рейс с учётом его конечной и направления.</p>
|
||||||
|
{settings.places.map((place, index) => <fieldset className="device-passenger-editor" key={index}><legend>Место {index + 1}</legend>
|
||||||
|
<label>Остановка назначения<select aria-label={`Остановка места ${index + 1}`} required value={place.stopId || ''} onChange={e => { const stop = stops.find(s => s.id === Number(e.target.value)); editPlace(index, { stopId: Number(e.target.value), name: stop?.name.slice(0, 48) || '' }); }}>
|
||||||
|
<option value="">Выберите остановку</option>{stops.filter(s => s.id !== settings.stopId).map(stop => <option key={stop.id} value={stop.id} disabled={settings.places.some((p, i) => i !== index && p.stopId === stop.id)}>{stop.name}{stop.description ? ` · ${stop.description}` : ''}</option>)}
|
||||||
|
</select></label><label>Название на табло<input aria-label={`Название места ${index + 1}`} required maxLength={48} value={place.name} onChange={e => editPlace(index, { name: e.target.value })} /></label>
|
||||||
|
<button type="button" onClick={() => set('places', settings.places.filter((_, i) => i !== index))}>Удалить место {index + 1}</button>
|
||||||
|
</fieldset>)}
|
||||||
|
<button type="button" disabled={settings.places.length >= 3 || !stops.length} onClick={() => set('places', [...settings.places, { stopId: 0, name: '' }])}>Добавить место</button>
|
||||||
|
<h3>Оплата поездки</h3><p className="device-night-hint">Основные тарифы по типу транспорта. Только подтверждённые цена с валютой, способы оплаты и дата проверки. Без подходящего тарифа карточка скрыта.</p>
|
||||||
|
{['bus', 'minibus'].map(type => {
|
||||||
|
const rule = settings.fareByType.find(r => r.type === type && r.cityId === cityId), label = type === 'bus' ? 'автобуса' : 'маршрутки';
|
||||||
|
const edit = patch => set('fareByType', settings.fareByType.map(r => r.type === type && r.cityId === cityId ? { ...r, ...patch } : r));
|
||||||
|
return <fieldset className="device-passenger-editor" key={type}><legend>Тариф {label}</legend>
|
||||||
|
<label className="device-check"><input type="checkbox" checked={!!rule} onChange={e => set('fareByType', [...settings.fareByType.filter(r => r.type !== type), ...(e.target.checked ? [{ cityId, type, price: '', payment: '', checkedAt: today() }] : [])])} />Использовать тариф {label}</label>
|
||||||
|
{rule && <><div className="device-form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость ${label}`} required maxLength={40} value={rule.price} onChange={e => edit({ price: e.target.value })} /></label><label>Проверено<input aria-label={`Дата проверки ${label}`} required type="date" max={today()} value={rule.checkedAt} onChange={e => edit({ checkedAt: e.target.value })} /></label></div>
|
||||||
|
<label>Способы оплаты<input aria-label={`Способы оплаты ${label}`} required maxLength={100} value={rule.payment} onChange={e => edit({ payment: e.target.value })} /></label></>}
|
||||||
|
</fieldset>;
|
||||||
|
})}
|
||||||
|
<h3>Исключения для маршрутов</h3><p className="device-night-hint">Индивидуальный тариф заменяет основной тариф типа транспорта.</p>
|
||||||
|
{settings.fareRules.map((rule, index) => <fieldset className="device-passenger-editor" key={index}><legend>Тариф {index + 1}</legend>
|
||||||
|
<label>Маршрут<select aria-label={`Маршрут тарифа ${index + 1}`} required value={rule.routeId || ''} onChange={e => editRule(index, { routeId: Number(e.target.value) })}>
|
||||||
|
<option value="">Выберите маршрут</option>{!routes.some(r => r.id === rule.routeId) && rule.routeId > 0 && <option value={rule.routeId}>Маршрут {rule.routeId} · сохранённый тариф</option>}
|
||||||
|
{routes.map(route => <option key={route.id} value={route.id} disabled={settings.fareRules.some((r, i) => i !== index && r.routeId === route.id)}>№{route.route_number} · {route.name}</option>)}
|
||||||
|
</select></label><div className="device-form-grid"><label>Стоимость с валютой<input aria-label={`Стоимость тарифа ${index + 1}`} required maxLength={40} value={rule.price} onChange={e => editRule(index, { price: e.target.value })} /></label><label>Проверено<input aria-label={`Дата проверки тарифа ${index + 1}`} required type="date" max={today()} value={rule.checkedAt} onChange={e => editRule(index, { checkedAt: e.target.value })} /></label></div>
|
||||||
|
<label>Способы оплаты<input aria-label={`Способы оплаты тарифа ${index + 1}`} required maxLength={100} value={rule.payment} onChange={e => editRule(index, { payment: e.target.value })} /></label>
|
||||||
|
<button type="button" onClick={() => set('fareRules', settings.fareRules.filter((_, i) => i !== index))}>Удалить тариф {index + 1}</button>
|
||||||
|
</fieldset>)}
|
||||||
|
<button type="button" disabled={settings.fareRules.length >= 32 || !routes.length} onClick={() => set('fareRules', [...settings.fareRules, { routeId: 0, price: '', payment: '', checkedAt: today() }])}>Добавить тариф</button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
.devices-page{max-width:1200px;margin:auto;color:#17251d}.devices-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.devices-heading h1{font-size:32px;font-weight:700;margin:4px 0 8px}.devices-heading p{color:#657068}.devices-heading>div:last-child{display:flex;gap:10px}.devices-page button,.device-dialog button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid #d8dfda;border-radius:12px;background:white;padding:10px 16px;font-weight:600;min-height:44px}.devices-page button:disabled,.device-dialog button:disabled{opacity:.45;cursor:not-allowed}.devices-page .device-primary,.device-dialog .device-primary{background:#1c6c46;color:white;border-color:#1c6c46}.device-kicker{font-size:11px;font-weight:700;letter-spacing:.14em;color:#6a7c70}.devices-empty{text-align:center;background:white;border:1px dashed #c6d3ca;border-radius:24px;padding:64px 24px}.devices-empty svg{margin:auto;color:#478a62}.devices-empty h2{font-weight:650;font-size:22px;margin:16px}.devices-empty p{color:#657068;margin:0 0 24px}.device-list{display:grid;gap:14px}.device-card{display:flex;align-items:center;gap:20px;background:white;border:1px solid #dfe7e1;border-radius:20px;padding:24px}.device-card-icon{background:#edf5ef;border-radius:14px;padding:16px;color:#276347}.device-card-body{flex:1;min-width:0}.device-card-title{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.device-card h2{font-weight:650;font-size:20px;overflow-wrap:anywhere}.device-card p{color:#58675d;margin:6px 0 12px}.device-state{background:#f1f3f1;color:#6d766f;font-size:12px;border-radius:999px;padding:5px 10px}.device-state.online{background:#e0f3e5;color:#176439}.device-card-meta{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:#69766d}.device-card-meta>span,.device-notice{display:flex;align-items:center;gap:6px}.device-card[data-revoked=true]{opacity:.65}.device-notice,.device-error{border-radius:12px;padding:14px 18px;margin:16px 0;background:#e8f4eb;color:#22633e}.device-error{background:#fff0ec;color:#973b22}.device-dialog{padding:0;border:1px solid #dde5df;border-radius:24px;width:min(660px,calc(100% - 24px));max-height:calc(100dvh - 32px);color:#17251d;box-shadow:0 24px 100px #17251d33}.device-dialog::backdrop{background:#11211880;backdrop-filter:blur(4px)}.device-dialog form{display:flex;flex-direction:column;max-height:calc(100dvh - 34px)}.device-dialog header,.device-dialog footer{padding:22px 26px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-shrink:0}.device-dialog header{border-bottom:1px solid #e4ebe6}.device-dialog h2{font-size:23px;font-weight:700;margin-top:4px}.device-form-content{padding:24px 26px;overflow:auto}.device-form-content>label{margin-bottom:16px}.device-dialog label{display:flex;flex-direction:column;gap:8px;font-size:13px;font-weight:600}.device-dialog input,.device-dialog select,.device-dialog textarea{border:1px solid #d0dbd3;border-radius:10px;padding:11px 12px;font-size:15px;background:#fafcfb;min-width:0;width:100%}.device-dialog input:focus,.device-dialog select:focus,.device-dialog textarea:focus{outline:2px solid #478a62;outline-offset:2px}.device-dialog .device-code-input{font-size:34px;letter-spacing:.35em;text-align:center;font-variant-numeric:tabular-nums}.device-dialog small{color:#718076;font-weight:400}.device-dialog h3{font-size:16px;font-weight:700;margin:24px 0 14px}.device-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px}.device-dialog .device-check{flex-direction:row;align-items:center;padding:12px 0;font-weight:500}.device-check input{width:18px;height:18px;accent-color:#1c6c46}.device-dialog footer{border-top:1px solid #e4ebe6;justify-content:flex-end}.device-dialog .device-revoke{margin-right:auto;color:#963e2d;background:#fff8f5}.device-revoke-prompt{border:1px solid #eccabd;border-radius:12px;padding:16px;background:#fff8f5}.device-revoke-prompt p{font-size:14px;margin-bottom:12px}.device-revoke-prompt button{margin-right:8px}@media(max-width:700px){.devices-heading{align-items:flex-start;flex-direction:column}.device-card{flex-wrap:wrap;padding:18px;gap:12px}.device-card-icon{padding:12px}.device-card-body{flex-basis:calc(100% - 65px)}.device-card>button{width:100%}.device-form-grid{grid-template-columns:1fr}.device-dialog header,.device-dialog footer,.device-form-content{padding:18px}.device-card-meta{gap:8px}}
|
.devices-page{max-width:1200px;margin:auto;color:#17251d}.devices-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:28px}.devices-heading h1{font-size:32px;font-weight:700;margin:4px 0 8px}.devices-heading p{color:#657068}.devices-heading>div:last-child{display:flex;gap:10px}.devices-page button,.device-dialog button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid #d8dfda;border-radius:12px;background:white;padding:10px 16px;font-weight:600;min-height:44px}.devices-page button:disabled,.device-dialog button:disabled{opacity:.45;cursor:not-allowed}.devices-page .device-primary,.device-dialog .device-primary{background:#1c6c46;color:white;border-color:#1c6c46}.device-kicker{font-size:11px;font-weight:700;letter-spacing:.14em;color:#6a7c70}.devices-empty{text-align:center;background:white;border:1px dashed #c6d3ca;border-radius:24px;padding:64px 24px}.devices-empty svg{margin:auto;color:#478a62}.devices-empty h2{font-weight:650;font-size:22px;margin:16px}.devices-empty p{color:#657068;margin:0 0 24px}.device-list{display:grid;gap:14px}.device-card{display:flex;align-items:center;gap:20px;background:white;border:1px solid #dfe7e1;border-radius:20px;padding:24px}.device-card-icon{background:#edf5ef;border-radius:14px;padding:16px;color:#276347}.device-card-body{flex:1;min-width:0}.device-card-title{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.device-card h2{font-weight:650;font-size:20px;overflow-wrap:anywhere}.device-card p{color:#58675d;margin:6px 0 12px}.device-state{background:#f1f3f1;color:#6d766f;font-size:12px;border-radius:999px;padding:5px 10px}.device-state.online{background:#e0f3e5;color:#176439}.device-card-meta{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:#69766d}.device-card-meta>span,.device-notice{display:flex;align-items:center;gap:6px}.device-card[data-revoked=true]{opacity:.65}.device-notice,.device-error{border-radius:12px;padding:14px 18px;margin:16px 0;background:#e8f4eb;color:#22633e}.device-error{background:#fff0ec;color:#973b22}.device-dialog{padding:0;border:1px solid #dde5df;border-radius:24px;width:min(660px,calc(100% - 24px));max-height:calc(100dvh - 32px);color:#17251d;box-shadow:0 24px 100px #17251d33}.device-dialog::backdrop{background:#11211880;backdrop-filter:blur(4px)}.device-dialog form{display:flex;flex-direction:column;max-height:calc(100dvh - 34px)}.device-dialog header,.device-dialog footer{padding:22px 26px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-shrink:0}.device-dialog header{border-bottom:1px solid #e4ebe6}.device-dialog h2{font-size:23px;font-weight:700;margin-top:4px}.device-form-content{padding:24px 26px;overflow:auto}.device-form-content>label{margin-bottom:16px}.device-dialog label{display:flex;flex-direction:column;gap:8px;font-size:13px;font-weight:600}.device-dialog input,.device-dialog select,.device-dialog textarea{border:1px solid #d0dbd3;border-radius:10px;padding:11px 12px;font-size:15px;background:#fafcfb;min-width:0;width:100%}.device-dialog input:focus,.device-dialog select:focus,.device-dialog textarea:focus{outline:2px solid #478a62;outline-offset:2px}.device-dialog .device-code-input{font-size:34px;letter-spacing:.35em;text-align:center;font-variant-numeric:tabular-nums}.device-dialog small{color:#718076;font-weight:400}.device-dialog h3{font-size:16px;font-weight:700;margin:24px 0 14px}.device-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px}.device-dialog .device-check{flex-direction:row;align-items:center;padding:12px 0;font-weight:500}.device-check input{width:18px;height:18px;accent-color:#1c6c46}.device-dialog footer{border-top:1px solid #e4ebe6;justify-content:flex-end}.device-dialog .device-revoke{margin-right:auto;color:#963e2d;background:#fff8f5}.device-revoke-prompt{border:1px solid #eccabd;border-radius:12px;padding:16px;background:#fff8f5}.device-revoke-prompt p{font-size:14px;margin-bottom:12px}.device-revoke-prompt button{margin-right:8px}@media(max-width:700px){.devices-heading{align-items:flex-start;flex-direction:column}.device-card{flex-wrap:wrap;padding:18px;gap:12px}.device-card-icon{padding:12px}.device-card-body{flex-basis:calc(100% - 65px)}.device-card>button{width:100%}.device-form-grid{grid-template-columns:1fr}.device-dialog header,.device-dialog footer,.device-form-content{padding:18px}.device-card-meta{gap:8px}}
|
||||||
|
|
||||||
.device-night-hint{font-size:13px;line-height:1.5;color:#69766d;margin:0 0 20px}
|
.device-night-hint{font-size:13px;line-height:1.5;color:#69766d;margin:0 0 20px}
|
||||||
|
|
||||||
|
.device-passenger-editor{min-width:0;border:1px solid #d0dbd3;border-radius:14px;padding:16px;margin:14px 0}.device-passenger-editor legend{font-size:13px;font-weight:600;color:#657068;padding:0 6px}.device-passenger-editor>label{margin-bottom:14px}.device-passenger-editor>button{font-size:13px;margin-top:6px}.device-passenger-settings>button{margin-bottom:8px}
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import { Monitor, Plus, RefreshCw, X, Link2, Save, Unplug, CheckCircle2, Clock3
|
|||||||
import { boardDevicesApi, stopsApi } from '../services/api';
|
import { boardDevicesApi, stopsApi } from '../services/api';
|
||||||
import { useCity } from '../contexts/CityContext';
|
import { useCity } from '../contexts/CityContext';
|
||||||
import './DevicesPage.css';
|
import './DevicesPage.css';
|
||||||
|
import { BoardPassengerSettings, passengerDefaults, cityFareDefaults } from '../components/BoardPassengerSettings';
|
||||||
|
|
||||||
const defaults = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', nightMode: 'auto', nightWakeMinutes: 60, adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
const defaults = { stopId: 1, demo: false, mainSeconds: 25, infoSeconds: 8, holdNear: true, rowCount: 4, routeFilter: '', theme: 'dark', colorScheme: 'lime', lightColorScheme: 'blue', infoServiceEnabled: true, infoWeatherEnabled: true, infoQrEnabled: true, journeyTranslitEnabled: false, showNextSameRoute: true, longWaitEnabled: true, displayProfile: 'tv', viewingDistance: 'near', nightMode: 'auto', nightWakeMinutes: 60, adsEnabled: false, adSeconds: 7, adTitle: 'Ваше место в городе', adText: 'Короткая история. Большая аудитория.' };
|
||||||
const darkColors = [['lime', 'Лайм'], ['amber', 'Янтарь'], ['cyan', 'Голубой'], ['mint', 'Мята'], ['lavender', 'Лаванда'], ['white', 'Белый']];
|
const darkColors = [['lime', 'Лайм'], ['amber', 'Янтарь'], ['cyan', 'Голубой'], ['mint', 'Мята'], ['lavender', 'Лаванда'], ['white', 'Белый']];
|
||||||
const lightColors = [['blue', 'Синий'], ['forest', 'Зелёный'], ['crimson', 'Кармин'], ['violet', 'Фиолетовый'], ['teal', 'Бирюзовый'], ['graphite', 'Графит']];
|
const lightColors = [['blue', 'Синий'], ['forest', 'Зелёный'], ['crimson', 'Кармин'], ['violet', 'Фиолетовый'], ['teal', 'Бирюзовый'], ['graphite', 'Графит']];
|
||||||
const date = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : 'Ещё не выходило на связь';
|
const date = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : 'Ещё не выходило на связь';
|
||||||
const message = error => error.response?.data?.message || 'Не удалось выполнить запрос. Попробуйте ещё раз';
|
const message = error => error.response?.data?.message || 'Не удалось выполнить запрос. Попробуйте ещё раз';
|
||||||
|
|
||||||
function DeviceForm({ device, stops, onClose, onSaved }) {
|
function DeviceForm({ device, stops, cityId, onClose, onSaved }) {
|
||||||
const dialog = useRef(null);
|
const dialog = useRef(null);
|
||||||
const [code, setCode] = useState(''), [name, setName] = useState(device?.name || '');
|
const [code, setCode] = useState(''), [name, setName] = useState(device?.name || '');
|
||||||
const [settings, setSettings] = useState({ ...defaults, stopId: stops[0]?.id || 1, ...device?.settings });
|
const [settings, setSettings] = useState({ ...defaults, ...passengerDefaults, fareByType: cityFareDefaults(cityId), stopId: stops[0]?.id || 1, ...device?.settings });
|
||||||
const [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
const [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||||
const [detached, setDetached] = useState(false), [routes, setRoutes] = useState([]);
|
const [detached, setDetached] = useState(false), [routes, setRoutes] = useState([]);
|
||||||
useEffect(() => { let alive = true; setRoutes([]); boardDevicesApi.routes(settings.stopId).then(({ data }) => { if (alive) setRoutes(data); }).catch(() => { if (alive) setError('Не удалось загрузить маршруты площадки'); }); return () => { alive = false; }; }, [settings.stopId]);
|
useEffect(() => { let alive = true; setRoutes([]); boardDevicesApi.routes(settings.stopId).then(({ data }) => { if (alive) setRoutes(data); }).catch(() => { if (alive) setError('Не удалось загрузить маршруты площадки'); }); return () => { alive = false; }; }, [settings.stopId]);
|
||||||
@@ -55,7 +56,9 @@ function DeviceForm({ device, stops, onClose, onSaved }) {
|
|||||||
{select('nightMode', 'Ночной режим', [['off', 'Выключен'], ['auto', 'Автоматически']])}
|
{select('nightMode', 'Ночной режим', [['off', 'Выключен'], ['auto', 'Автоматически']])}
|
||||||
{settings.nightMode === 'auto' && select('nightWakeMinutes', 'Вернуть обычное табло', [[45, 'За 45 минут до рейса'], [60, 'За 60 минут до рейса']], true)}
|
{settings.nightMode === 'auto' && select('nightWakeMinutes', 'Вернуть обычное табло', [[45, 'За 45 минут до рейса'], [60, 'За 60 минут до рейса']], true)}
|
||||||
</div><p className="device-night-hint">Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. По полному расписанию остановки.</p>
|
</div><p className="device-night-hint">Во время ночного перерыва длиннее двух часов. Обычный экран вернётся за выбранное время до первого рейса. По полному расписанию остановки.</p>
|
||||||
<h3>Блоки информации</h3>{check('infoServiceEnabled', 'Дальше по маршруту')}{check('infoWeatherEnabled', 'Погода')}{check('infoQrEnabled', 'QR-код расписания')}
|
<h3>Блоки информации</h3>{check('infoServiceEnabled', 'Дальше по ближайшему рейсу')}{check('infoWeatherEnabled', 'Погода')}{check('infoQrEnabled', 'QR-код расписания')}{check('journeyTranslitEnabled', 'Транслит названий остановок')}
|
||||||
|
{check('lastTripNoticeEnabled', 'Напоминание о последнем рейсе')}{check('weatherHintEnabled', 'Вероятность осадков')}{check('infoPlacesEnabled', 'До важных мест')}{check('infoFareEnabled', 'Оплата поездки')}
|
||||||
|
<BoardPassengerSettings settings={settings} stops={stops} routes={routes} cityId={cityId} set={set} />
|
||||||
<h3>Реклама · предпросмотр</h3>{check('adsEnabled', 'Добавить рекламу в цикл')}
|
<h3>Реклама · предпросмотр</h3>{check('adsEnabled', 'Добавить рекламу в цикл')}
|
||||||
{settings.adsEnabled && <><label>Длительность, сек<input type="number" min={5} max={30} required value={settings.adSeconds} onChange={e => set('adSeconds', Number(e.target.value))} /></label><label>Заголовок<input maxLength={80} value={settings.adTitle} onChange={e => set('adTitle', e.target.value)} /></label><label>Текст<textarea maxLength={200} value={settings.adText} onChange={e => set('adText', e.target.value)} /></label></>}
|
{settings.adsEnabled && <><label>Длительность, сек<input type="number" min={5} max={30} required value={settings.adSeconds} onChange={e => set('adSeconds', Number(e.target.value))} /></label><label>Заголовок<input maxLength={80} value={settings.adTitle} onChange={e => set('adTitle', e.target.value)} /></label><label>Текст<textarea maxLength={200} value={settings.adText} onChange={e => set('adText', e.target.value)} /></label></>}
|
||||||
{error && <p className="device-error" role="alert">{error}</p>}
|
{error && <p className="device-error" role="alert">{error}</p>}
|
||||||
@@ -85,6 +88,6 @@ export default function DevicesPage() {
|
|||||||
{!loading && !devices.length && <div className="devices-empty"><Monitor size={42} /><h2>Подключите первое табло</h2><p>Откройте RideBus Board на телевизоре и введите появившийся код здесь.</p><button className="device-primary" disabled={!stops.length} onClick={() => setEditor({})}>Подключить по коду</button></div>}
|
{!loading && !devices.length && <div className="devices-empty"><Monitor size={42} /><h2>Подключите первое табло</h2><p>Откройте RideBus Board на телевизоре и введите появившийся код здесь.</p><button className="device-primary" disabled={!stops.length} onClick={() => setEditor({})}>Подключить по коду</button></div>}
|
||||||
{loading && <p>Загружаем устройства…</p>}
|
{loading && <p>Загружаем устройства…</p>}
|
||||||
<div className="device-list">{devices.map(device => <article key={device.id} className="device-card" data-revoked={Boolean(device.revokedAt)}><div className="device-card-icon"><Monitor size={26} /></div><div className="device-card-body"><div className="device-card-title"><h2>{device.name}</h2><span className={`device-state ${device.online ? 'online' : ''}`}>{device.revokedAt ? 'Отвязано' : device.online ? 'В сети' : 'Нет связи'}</span></div><p>{device.stopName}{device.stopDirection ? ` · ${device.stopDirection}` : ''}</p><div className="device-card-meta"><span><Clock3 size={15} />{date(device.lastSeenAt)}</span><span>{device.deviceInfo.width && device.deviceInfo.height ? `${device.deviceInfo.width}×${device.deviceInfo.height}` : 'Размер экрана неизвестен'}</span><span>{device.revokedAt ? 'Доступ отозван' : device.appliedRevision === device.revision ? 'Настройки применены' : 'Ожидают применения'} · v{device.revision}</span></div></div><button disabled={Boolean(device.revokedAt) || !stops.length} onClick={() => { setNotice(''); setEditor(device); }}>Настроить</button></article>)}</div>
|
<div className="device-list">{devices.map(device => <article key={device.id} className="device-card" data-revoked={Boolean(device.revokedAt)}><div className="device-card-icon"><Monitor size={26} /></div><div className="device-card-body"><div className="device-card-title"><h2>{device.name}</h2><span className={`device-state ${device.online ? 'online' : ''}`}>{device.revokedAt ? 'Отвязано' : device.online ? 'В сети' : 'Нет связи'}</span></div><p>{device.stopName}{device.stopDirection ? ` · ${device.stopDirection}` : ''}</p><div className="device-card-meta"><span><Clock3 size={15} />{date(device.lastSeenAt)}</span><span>{device.deviceInfo.width && device.deviceInfo.height ? `${device.deviceInfo.width}×${device.deviceInfo.height}` : 'Размер экрана неизвестен'}</span><span>{device.revokedAt ? 'Доступ отозван' : device.appliedRevision === device.revision ? 'Настройки применены' : 'Ожидают применения'} · v{device.revision}</span></div></div><button disabled={Boolean(device.revokedAt) || !stops.length} onClick={() => { setNotice(''); setEditor(device); }}>Настроить</button></article>)}</div>
|
||||||
{editor && <DeviceForm device={editor.id ? editor : null} stops={stops} onClose={() => setEditor(null)} onSaved={text => { setEditor(null); setNotice(text); void load(); }} />}
|
{editor && <DeviceForm device={editor.id ? editor : null} stops={stops} cityId={activeCity.id} onClose={() => setEditor(null)} onSaved={text => { setEditor(null); setNotice(text); void load(); }} />}
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user