Compare commits

3 Commits

Author SHA1 Message Date
b42802aac0 feat: update route and station lists 2026-04-20 12:53:52 +03:00
938a7e6d1e feat: add sight short_name and sight features 2026-04-19 22:36:14 +03:00
beb9e932ef feat: add description in devices page 2026-04-19 21:07:14 +03:00
10 changed files with 163 additions and 29 deletions

View File

@@ -93,6 +93,23 @@ export const RouteListPage = observer(() => {
); );
}, },
}, },
{
field: "route_sys_number",
headerName: "Номер трассы маршрута",
description: "Уникальный номер трассы маршрута, в т.ч. используемый системой \"Говорящий город\"",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{ {
field: "route_direction", field: "route_direction",
headerName: "Направление", headerName: "Направление",
@@ -170,6 +187,7 @@ export const RouteListPage = observer(() => {
id: route.id, id: route.id,
carrier_id: route.carrier_id, carrier_id: route.carrier_id,
route_number: route.route_number, route_number: route.route_number,
route_sys_number: route.route_sys_number,
route_direction: route.route_direction ? "Прямой" : "Обратный", route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name, route_name: route.route_name,
})); }));
@@ -204,6 +222,7 @@ export const RouteListPage = observer(() => {
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
onRowDoubleClick={(params) => canWriteRoutes && navigate(`/route/${params.row.id}/edit`)}
checkboxSelection={canWriteRoutes} checkboxSelection={canWriteRoutes}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}

View File

@@ -51,7 +51,23 @@ export const SightListPage = observer(() => {
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "name", field: "name",
headerName: "Имя", headerName: "Название",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{
field: "short_name",
headerName: "Сокращенное название",
flex: 1, flex: 1,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
return ( return (
@@ -124,10 +140,11 @@ export const SightListPage = observer(() => {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
const rows = filteredSights const rows = filteredSights
.filter((sight: any) => !query || (sight.name ?? "").toLowerCase().includes(query)) .filter((sight: any) => !query || (sight.name ?? "").toLowerCase().includes(query) || (sight.short_name ?? "").toLowerCase().includes(query))
.map((sight) => ({ .map((sight: any) => ({
id: sight.id, id: sight.id,
name: sight.name, name: sight.name,
short_name: sight.short_name,
city_id: sight.city_id, city_id: sight.city_id,
})); }));
@@ -165,6 +182,7 @@ export const SightListPage = observer(() => {
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
onRowDoubleClick={(params) => canWriteSights && navigate(`/sight/${params.row.id}/edit`)}
checkboxSelection={canWriteSights} checkboxSelection={canWriteSights}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}

View File

@@ -149,7 +149,7 @@ export const StationListPage = observer(() => {
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Остановки</h1> <h1 className="text-2xl">Остановки</h1>
{canWriteStations && ( {canWriteStations && (
<CreateButton label="Создать остановки" path="/station/create" /> <CreateButton label="Создать остановку" path="/station/create" />
)} )}
</div> </div>
@@ -170,6 +170,7 @@ export const StationListPage = observer(() => {
<DataGrid <DataGrid
rows={rows} rows={rows}
columns={columns} columns={columns}
onRowDoubleClick={(params) => canWriteStations && navigate(`/station/${params.row.id}/edit`)}
checkboxSelection={canWriteStations} checkboxSelection={canWriteStations}
disableRowSelectionExcludeModel disableRowSelectionExcludeModel
loading={isLoading} loading={isLoading}

View File

@@ -16,6 +16,7 @@ type MediaItem = {
type SightLanguageInfo = { type SightLanguageInfo = {
name: string; name: string;
short_name: string;
address: string; address: string;
left: { left: {
heading: string; heading: string;
@@ -61,18 +62,21 @@ const initialSightState: SightBaseInfo = {
video_preview: null, video_preview: null,
ru: { ru: {
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
}, },
en: { en: {
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
}, },
zh: { zh: {
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -494,6 +498,7 @@ class CreateSightStore {
longitude: this.sight.longitude, longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon, is_default_icon: this.sight.is_default_icon,
name: (this.sight[primaryLanguage].name || "").trim(), name: (this.sight[primaryLanguage].name || "").trim(),
short_name: (this.sight[primaryLanguage].short_name || "").trim(),
address: this.sight[primaryLanguage].address, address: this.sight[primaryLanguage].address,
thumbnail: this.sight.thumbnail, thumbnail: this.sight.thumbnail,
icon: this.sight.icon, icon: this.sight.icon,
@@ -522,6 +527,7 @@ class CreateSightStore {
longitude: this.sight.longitude, longitude: this.sight.longitude,
is_default_icon: this.sight.is_default_icon, is_default_icon: this.sight.is_default_icon,
name: (this.sight[lang].name || "").trim(), name: (this.sight[lang].name || "").trim(),
short_name: (this.sight[lang].short_name || "").trim(),
address: this.sight[lang].address, address: this.sight[lang].address,
thumbnail: this.sight.thumbnail, thumbnail: this.sight.thumbnail,
icon: this.sight.icon, icon: this.sight.icon,

View File

@@ -10,6 +10,7 @@ import { makeAutoObservable, runInAction } from "mobx";
export type SightLanguageInfo = { export type SightLanguageInfo = {
id: number; id: number;
name: string; name: string;
short_name: string;
address: string; address: string;
left: { left: {
heading: string; heading: string;
@@ -68,6 +69,7 @@ class EditSightStore {
ru: { ru: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -75,6 +77,7 @@ class EditSightStore {
en: { en: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -82,6 +85,7 @@ class EditSightStore {
zh: { zh: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -204,6 +208,7 @@ class EditSightStore {
ru: { ru: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -212,6 +217,7 @@ class EditSightStore {
en: { en: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -220,6 +226,7 @@ class EditSightStore {
zh: { zh: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -304,6 +311,11 @@ class EditSightStore {
en: (this.sight.en.name || "").trim(), en: (this.sight.en.name || "").trim(),
zh: (this.sight.zh.name || "").trim(), zh: (this.sight.zh.name || "").trim(),
}, },
short_name: {
ru: (this.sight.ru.short_name || "").trim(),
en: (this.sight.en.short_name || "").trim(),
zh: (this.sight.zh.short_name || "").trim(),
},
address: { address: {
ru: this.sight.ru.address, ru: this.sight.ru.address,
en: this.sight.en.address, en: this.sight.en.address,

View File

@@ -11,6 +11,7 @@ export type Language = "ru" | "en" | "zh";
export type MultilingualContent = { export type MultilingualContent = {
[key in Language]: { [key in Language]: {
name: string; name: string;
short_name: string;
address: string; address: string;
}; };
}; };
@@ -18,6 +19,7 @@ export type MultilingualContent = {
export type Sight = { export type Sight = {
id: number; id: number;
name: string; name: string;
short_name: string | null;
city_id: number; city_id: number;
city: string; city: string;
address: string; address: string;
@@ -37,6 +39,7 @@ export type Sight = {
export type CreateSight = { export type CreateSight = {
[key in Language]: { [key in Language]: {
name: string; name: string;
short_name: string;
address: string; address: string;
}; };
}; };
@@ -45,9 +48,9 @@ class SightsStore {
sights: Sight[] = []; sights: Sight[] = [];
sight: Sight | null = null; sight: Sight | null = null;
createSight: CreateSight = { createSight: CreateSight = {
ru: { name: "", address: "" }, ru: { name: "", short_name: "", address: "" },
en: { name: "", address: "" }, en: { name: "", short_name: "", address: "" },
zh: { name: "", address: "" }, zh: { name: "", short_name: "", address: "" },
}; };
constructor() { constructor() {
@@ -67,6 +70,7 @@ class SightsStore {
) => { ) => {
const response = await authInstance.post("/sight", { const response = await authInstance.post("/sight", {
name: this.createSight[languageStore.language].name, name: this.createSight[languageStore.language].name,
short_name: this.createSight[languageStore.language].short_name,
address: this.createSight[languageStore.language].address, address: this.createSight[languageStore.language].address,
city_id: city, city_id: city,
latitude: coordinates.latitude, latitude: coordinates.latitude,
@@ -87,6 +91,7 @@ class SightsStore {
`/sight/${id}`, `/sight/${id}`,
{ {
name: this.createSight[anotherLanguages[0] as Language].name, name: this.createSight[anotherLanguages[0] as Language].name,
short_name: this.createSight[anotherLanguages[0] as Language].short_name,
address: this.createSight[anotherLanguages[0] as Language].address, address: this.createSight[anotherLanguages[0] as Language].address,
city_id: city, city_id: city,
latitude: coordinates.latitude, latitude: coordinates.latitude,
@@ -98,6 +103,7 @@ class SightsStore {
`/sight/${id}`, `/sight/${id}`,
{ {
name: this.createSight[anotherLanguages[1] as Language].name, name: this.createSight[anotherLanguages[1] as Language].name,
short_name: this.createSight[anotherLanguages[1] as Language].short_name,
address: this.createSight[anotherLanguages[1] as Language].address, address: this.createSight[anotherLanguages[1] as Language].address,
city_id: city, city_id: city,
latitude: coordinates.latitude, latitude: coordinates.latitude,
@@ -107,9 +113,9 @@ class SightsStore {
runInAction(() => { runInAction(() => {
this.createSight = { this.createSight = {
ru: { name: "", address: "" }, ru: { name: "", short_name: "", address: "" },
en: { name: "", address: "" }, en: { name: "", short_name: "", address: "" },
zh: { name: "", address: "" }, zh: { name: "", short_name: "", address: "" },
}; };
}); });
}; };
@@ -156,14 +162,17 @@ class SightsStore {
this.createSight = { this.createSight = {
ru: { ru: {
name: "", name: "",
short_name: "",
address: "", address: "",
}, },
en: { en: {
name: "", name: "",
short_name: "",
address: "", address: "",
}, },
zh: { zh: {
name: "", name: "",
short_name: "",
address: "", address: "",
}, },
}; };

View File

@@ -157,6 +157,7 @@ class SnapshotStore {
ru: { ru: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -164,6 +165,7 @@ class SnapshotStore {
en: { en: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],
@@ -171,6 +173,7 @@ class SnapshotStore {
zh: { zh: {
id: 0, id: 0,
name: "", name: "",
short_name: "",
address: "", address: "",
left: { heading: "", body: "", media: [] }, left: { heading: "", body: "", media: [] },
right: [], right: [],

View File

@@ -11,6 +11,7 @@ import {
Vehicle, Vehicle,
carrierStore, carrierStore,
selectedCityStore, selectedCityStore,
menuStore,
VEHICLE_TYPES, VEHICLE_TYPES,
} from "@shared"; } from "@shared";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -33,6 +34,7 @@ import {
CircularProgress, CircularProgress,
Typography, Typography,
Checkbox, Checkbox,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -370,17 +372,20 @@ export const DevicesTable = observer(() => {
filterable: true, filterable: true,
}, },
{ {
field: "can_send_update", field: "snapshot_update_blocked",
headerName: "Обновление", headerName: "Запрет",
width: 90, width: 90,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
sortable: false, sortable: false,
disableColumnMenu: true, disableColumnMenu: true,
renderHeader: (params) => (
<Tooltip title="При активации, на выбранные устройства не будут поступать обновления">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const rowData = params.row as RowData; const rowData = params.row as RowData;
const canSend =
!rowData.snapshot_update_blocked && rowData.device_uuid !== null;
return ( return (
<Box <Box
sx={{ sx={{
@@ -391,12 +396,9 @@ export const DevicesTable = observer(() => {
height: "100%", height: "100%",
pointerEvents: "none", pointerEvents: "none",
}} }}
title={
canSend ? "Можно отправить запрос" : "Блокировка обновления"
}
> >
<Checkbox <Checkbox
checked={canSend as unknown as boolean} checked={rowData.snapshot_update_blocked}
disabled disabled
size="small" size="small"
sx={{ p: 0 }} sx={{ p: 0 }}
@@ -408,11 +410,17 @@ export const DevicesTable = observer(() => {
{ {
field: "online", field: "online",
headerName: "Онлайн", headerName: "Онлайн",
width: 90, width: menuStore.isOpen ? 90 : undefined,
flex: menuStore.isOpen ? undefined : 1,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
type: "boolean", type: "boolean",
filterable: true, filterable: true,
renderHeader: (params) => (
<Tooltip title="Устройство в сети">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<Box <Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }} sx={{ display: "flex", justifyContent: "center", width: "100%" }}
@@ -433,6 +441,11 @@ export const DevicesTable = observer(() => {
headerAlign: "center", headerAlign: "center",
type: "boolean", type: "boolean",
filterable: true, filterable: true,
renderHeader: (params) => (
<Tooltip title="Режим технического обслуживания экрана для предотвращения возможного наложения изображения">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const rowData = params.row as RowData; const rowData = params.row as RowData;
const isMaintenanceLoading = const isMaintenanceLoading =
@@ -467,6 +480,11 @@ export const DevicesTable = observer(() => {
headerAlign: "center", headerAlign: "center",
type: "boolean", type: "boolean",
filterable: true, filterable: true,
renderHeader: (params) => (
<Tooltip title="Отправка фиктивных координат на устройство для демонстрации работы">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
const rowData = params.row as RowData; const rowData = params.row as RowData;
const isDemoLoading = const isDemoLoading =
@@ -492,19 +510,29 @@ export const DevicesTable = observer(() => {
}, },
{ {
field: "lastUpdate", field: "lastUpdate",
headerName: "Обновлено", headerName: "Дата последнего статуса",
flex: 1, flex: 1,
minWidth: 140, minWidth: 200,
filterable: true, filterable: true,
renderHeader: (params) => (
<Tooltip title="Дата получения последнего статуса от устройства">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => renderCell: (params: GridRenderCellParams) =>
formatDate(params.value as string | null), formatDate(params.value as string | null),
}, },
{ {
field: "snapshot_name", field: "snapshot_name",
headerName: "Экспорт на устройстве", headerName: "Экспорт на устройстве",
flex: 1, flex: menuStore.isOpen ? 1 : 2,
minWidth: 140, minWidth: 140,
filterable: true, filterable: true,
renderHeader: (params) => (
<Tooltip title="Название загруженного экспорта медиа">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
valueGetter: (_value, row, _apiRef) => { valueGetter: (_value, row, _apiRef) => {
const rowData = row as RowData; const rowData = row as RowData;
const uuid = rowData.current_snapshot_uuid; const uuid = rowData.current_snapshot_uuid;
@@ -519,22 +547,33 @@ export const DevicesTable = observer(() => {
flex: 1, flex: 1,
minWidth: 140, minWidth: 140,
filterable: true, filterable: true,
renderHeader: (params) => (
<Tooltip title="Номер и буквенный код отображаемого на экране маршрута">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
valueGetter: (_value, row) => { valueGetter: (_value, row) => {
const rowData = row as RowData; const rowData = row as RowData;
const routeId = rowData.current_route_id; const routeId = rowData.current_route_id;
if (!routeId) return "—"; if (!routeId) return "—";
const route = routes.data.find((r) => r.id === routeId); const route = routes.data.find((r) => r.id === routeId);
return route?.route_number || "—"; return route?.route_sys_number || "—";
}, },
}, },
{ {
field: "gps", field: "gps",
headerName: "GPS", headerName: "GPS",
width: 70, width: menuStore.isOpen ? 70 : undefined,
flex: menuStore.isOpen ? undefined : 1,
align: "center", align: "center",
headerAlign: "center", headerAlign: "center",
type: "boolean", type: "boolean",
filterable: true, filterable: true,
renderHeader: (params) => (
<Tooltip title="Получение GPS координат">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => ( renderCell: (params: GridRenderCellParams) => (
<Box <Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }} sx={{ display: "flex", justifyContent: "center", width: "100%" }}
@@ -705,7 +744,7 @@ export const DevicesTable = observer(() => {
selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length; selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length;
if (blockedCount > 0) { if (blockedCount > 0) {
toast.info( toast.info(
`Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)`, `Экспорт медиа не отправлен на ${blockedCount} устройств (блокировка)`,
); );
} }
@@ -715,10 +754,10 @@ export const DevicesTable = observer(() => {
`/devices/${deviceUuid}/force-snapshot-update`, `/devices/${deviceUuid}/force-snapshot-update`,
{ snapshot_id: snapshotId }, { snapshot_id: snapshotId },
); );
toast.success("Обновление ПО отправлено на устройство"); toast.success("Экспорт медиа отправлен на устройство");
} catch (error) { } catch (error) {
console.error(`Error sending snapshot to device ${deviceUuid}:`, error); console.error(`Error sending snapshot to device ${deviceUuid}:`, error);
toast.error("Не удалось отправить обновление ПО на устройство"); toast.error("Не удалось отправить экспорт медиа на устройство");
} }
}; };
@@ -800,7 +839,7 @@ export const DevicesTable = observer(() => {
onClick={handleOpenSendSnapshotModal} onClick={handleOpenSendSnapshotModal}
size="small" size="small"
> >
Обновление ПО ({selectedDeviceUuidsAllowed.length} Экспорт медиа ({selectedDeviceUuidsAllowed.length}
{selectedDeviceUuids.length !== {selectedDeviceUuids.length !==
selectedDeviceUuidsAllowed.length && selectedDeviceUuidsAllowed.length &&
`/${selectedDeviceUuids.length}`} `/${selectedDeviceUuids.length}`}
@@ -914,7 +953,7 @@ export const DevicesTable = observer(() => {
sx={{ width: "min(760px, 94vw)", p: 3 }} sx={{ width: "min(760px, 94vw)", p: 3 }}
> >
<Box component="h2" sx={{ mb: 1, typography: "h6" }}> <Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Обновление ПО Экспорт медиа
</Box> </Box>
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
Выбрано устройств для обновления:{" "} Выбрано устройств для обновления:{" "}

View File

@@ -237,6 +237,21 @@ export const CreateInformationTab = observer(
variant="outlined" variant="outlined"
/> />
<TextField
label={`Сокращенное название (${language.toUpperCase()})`}
value={data.short_name || ""}
onChange={(e) => {
handleChange(
{
short_name: e.target.value,
},
language
);
}}
fullWidth
variant="outlined"
/>
<TextField <TextField
label="Адрес" label="Адрес"
value={data.address} value={data.address}

View File

@@ -241,6 +241,18 @@ export const InformationTab = observer(
variant="outlined" variant="outlined"
/> />
<TextField
label={`Сокращенное название (${language.toUpperCase()})`}
value={sight[language].short_name || ""}
onChange={(e) => {
handleChange(language as Language, {
short_name: e.target.value,
});
}}
fullWidth
variant="outlined"
/>
<TextField <TextField
label="Адрес" label="Адрес"
value={sight[language].address} value={sight[language].address}