feat: webgl preview improvements, permissions refactor and snapshot safeguards

This commit is contained in:
2026-06-14 23:13:51 +03:00
parent d4c5db61ea
commit cc38f2e66c
22 changed files with 558 additions and 810 deletions

View File

@@ -19,6 +19,8 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
height: 60,
top: 0,
hasScroll: false,
isAtTop: true,
isAtBottom: false,
});
const [visible, setVisible] = useState(false);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -33,8 +35,11 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const st = el.scrollTop;
const th = ch;
const isAtTop = st <= 0;
const isAtBottom = st + ch >= sh - 1;
if (sh <= ch) {
setState((prev) => ({ ...prev, hasScroll: false }));
setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true }));
return;
}
@@ -43,7 +48,7 @@ function useThumbSync(scrollableRef: React.RefObject<HTMLDivElement | null>) {
const scrollRange = sh - ch;
const top = range <= 0 ? 0 : (st / scrollRange) * range;
setState({ height: thumbHeight, top, hasScroll: true });
setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom });
}, []);
useEffect(() => {
@@ -253,9 +258,12 @@ export const TouchableLayout = forwardRef<HTMLDivElement, TouchableLayoutProps>(
};
}, [thumb.hasScroll]);
const containerClassName = className
? `scrollable-container ${className}`
: "scrollable-container";
const containerClassName = [
"scrollable-container",
className,
thumb.isAtTop ? "is-at-top" : "",
thumb.isAtBottom ? "is-at-bottom" : "",
].filter(Boolean).join(" ");
const viewportStyle: React.CSSProperties = maxHeight
? {

View File

@@ -155,6 +155,19 @@ const useSightClustering = (
continue;
}
const hasCustomIcon =
sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null);
if (hasCustomIcon) {
sight.visited = true;
clusteredResult.push({
type: "point",
id: String(sight.id),
data: sight,
});
continue;
}
const clusterSights: SightData[] = [];
const queue = [sight];
sight.visited = true;
@@ -164,8 +177,12 @@ const useSightClustering = (
clusterSights.push(current);
for (const potentialNeighbor of unclusteredSights) {
const neighborHasCustomIcon =
potentialNeighbor.is_default_icon === false &&
!isMediaIdEmpty(potentialNeighbor.icon ?? null);
if (
!potentialNeighbor.visited &&
!neighborHasCustomIcon &&
clusterSights.length < 4 &&
getDistance(current, potentialNeighbor) < distanceThreshold
) {
@@ -175,6 +192,10 @@ const useSightClustering = (
}
}
for (const leftover of queue) {
leftover.visited = false;
}
if (clusterSights.length > 1) {
let furthestSight: SightData | null = null;
let maxDistanceToPath = -1;
@@ -381,12 +402,14 @@ export const WebGLMap = observer(() => {
return livePercent;
}
if (
sight != null &&
typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size)
) {
return sight.icon_size;
if (sight?.is_default_icon === false) {
if (
typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size)
) {
return sight.icon_size;
}
return 100;
}
if (

View File

@@ -111,7 +111,13 @@ const StationItem = ({
};
return (
<div>
<div
className={
selectedStationId === station.id
? "side-menu-sight-selected-wrapper"
: ""
}
>
<div
ref={containerRef}
className="side-menu-sight"

View File

@@ -1,3 +1,15 @@
@property --fade-top {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --fade-bottom {
syntax: "<length>";
inherits: false;
initial-value: 45px;
}
@keyframes pulse-chevron {
0% {
transform: rotate(var(--r, 0deg)) translateY(0px) scale(1);
@@ -124,6 +136,27 @@
backface-visibility: hidden;
}
.list-of-sights-content .scrollable {
--fade-top: 0px;
--fade-bottom: 45px;
mask-image: linear-gradient(
to bottom,
transparent 0px,
black var(--fade-top),
black calc(100% - var(--fade-bottom)),
transparent 100%
);
transition: --fade-top 0.5s ease, --fade-bottom 0.5s ease;
}
.list-of-sights-content:not(.is-at-top) .scrollable {
--fade-top: 15px;
}
.list-of-sights-content.is-at-bottom .scrollable {
--fade-bottom: 0px;
}
.list-of-sights-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -137,6 +170,11 @@
pointer-events: auto;
}
.list-of-sights-content .custom-scrollbar-track {
margin-bottom: 10px;
overflow: hidden;
}
.sight-component {
display: flex;
flex-direction: column;
@@ -411,6 +449,7 @@
position: relative;
padding: 7px 60px;
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: space-evenly;

View File

@@ -63,7 +63,7 @@
}
.side-menu-sights-block .scrollable-viewport {
height: calc(92%);
height: calc(98%);
}
.side-menu-sights-block .scrollable {

View File

@@ -53,7 +53,14 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
}}
>
{/* Кнопка назад — вне основного меню */}
<div style={{ padding: "12px 12px 0" }}>
<div
style={{
padding: "12px 12px 0",
opacity: open ? 1 : 0,
pointerEvents: open ? "auto" : "none",
transition: "opacity 0.25s ease",
}}
>
<Button
onClick={handleBack}
variant="contained"
@@ -212,7 +219,13 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
</div>
</div>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<div
className="absolute bottom-[20px] z-10"
style={{
right: open ? -520 : -312,
transition: "right 0.3s ease",
}}
>
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</div>

View File

@@ -54,6 +54,7 @@ export const RoutePreview = () => {
<Box
sx={{
position: "relative",
zIndex: 20,
width: isLeftSidebarOpen ? 288 : 0,
transition: "width 0.3s ease",
overflow: "visible",

View File

@@ -2327,9 +2327,6 @@ export const WebGLRouteMapPrototype = observer(() => {
const stationScreenY =
rotatedY * camera.scale + camera.translation.y;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY;
const backendAlign = station.align;
const anchor = getAnchorFromOffset(backendAlign ?? 2);
@@ -2339,8 +2336,6 @@ export const WebGLRouteMapPrototype = observer(() => {
const dpr = Math.max(1, window.devicePixelRatio || 1);
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const rotationCss = `${rotationAngle}rad`;
const counterRotationCss = `${-rotationAngle}rad`;
@@ -2359,6 +2354,13 @@ export const WebGLRouteMapPrototype = observer(() => {
const scaleFactor = 1 + (zoomClampedScale - 1) * 0.4;
const primaryFontSize = 16 * fontScale * scaleFactor;
const mainLabelHeight = primaryFontSize * 1.2;
const labelX = stationScreenX + offsetX;
const labelY = stationScreenY + offsetY + mainLabelHeight / 2;
const cssX = labelX / dpr;
const cssY = labelY / dpr;
const secondaryFontSize = 13 * fontScale * scaleFactor;
const secondaryMarginTop = 5 * fontScale * scaleFactor;
@@ -2404,7 +2406,7 @@ export const WebGLRouteMapPrototype = observer(() => {
hoveredStationIconId === station.id ||
resizingStationIconId === station.id;
const secondaryLineHeight = 1.2;
const secondaryLineHeight = 1.2 * scaleFactor;
return (
<div key={station.id}>
@@ -2438,7 +2440,6 @@ export const WebGLRouteMapPrototype = observer(() => {
cursor: "grab",
userSelect: "none",
touchAction: "none",
lineHeight: 1,
}}
>
<div
@@ -2549,7 +2550,6 @@ export const WebGLRouteMapPrototype = observer(() => {
position: "relative",
fontWeight: 700,
fontSize: primaryFontSize,
lineHeight: 1,
textShadow: "0 0 4px rgba(0,0,0,0.6)",
pointerEvents: "none",
whiteSpace: "nowrap",

View File

@@ -6,7 +6,7 @@ import {
DialogContent,
DialogActions,
} from "@mui/material";
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore } from "@shared";
import { snapshotStore, authStore, routeStore, selectedCityStore, cityStore, carrierStore } from "@shared";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useState, useEffect, useMemo } from "react";
@@ -93,22 +93,55 @@ export const SnapshotCreatePage = observer(() => {
routeStore.routes.loaded = false;
});
await routeStore.getRoutes();
await carrierStore.getCarriers("ru");
const routes = routeStore.routes.data;
const numberCount = new Map<string, number>();
const carriers = carrierStore.carriers.ru.data;
const carrierCityMap = new Map<number, number>();
for (const c of carriers) {
carrierCityMap.set(c.id, c.city_id);
}
const duplicateMessages: string[] = [];
const directionKey = new Map<string, number>();
for (const route of routes) {
const num = (route.route_sys_number ?? "").trim();
if (num) {
numberCount.set(num, (numberCount.get(num) ?? 0) + 1);
if (!num) continue;
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
const key = `${num}|${route.route_direction}|${cityId}`;
directionKey.set(key, (directionKey.get(key) ?? 0) + 1);
}
for (const [key, count] of directionKey) {
if (count > 1) {
const [num, dir] = key.split("|");
const dirLabel = dir === "true" ? "прямой" : "обратный";
duplicateMessages.push(
`Дублируется маршрут №${num} (${dirLabel})`
);
}
}
const duplicates = Array.from(numberCount.entries())
.filter(([, count]) => count > 1)
.map(([num]) => num);
const cityPerNumber = new Map<string, Set<number>>();
for (const route of routes) {
const num = (route.route_sys_number ?? "").trim();
if (!num) continue;
const cityId = carrierCityMap.get(route.carrier_id) ?? 0;
if (!cityPerNumber.has(num)) {
cityPerNumber.set(num, new Set());
}
cityPerNumber.get(num)!.add(cityId);
}
for (const [num, cities] of cityPerNumber) {
if (cities.size > 1) {
duplicateMessages.push(
`Маршрут №${num} присутствует в нескольких городах`
);
}
}
if (duplicates.length > 0) {
setDuplicateRouteNumbers(duplicates);
if (duplicateMessages.length > 0) {
setDuplicateRouteNumbers(duplicateMessages);
setDuplicateWarningOpen(true);
} else {
await startExport();
@@ -190,10 +223,8 @@ export const SnapshotCreatePage = observer(() => {
некорректным данным в экспорте.
</p>
<ul className="list-disc pl-5">
{duplicateRouteNumbers.map((num) => (
<li key={num}>
Найдены повторяющиеся маршруты с номером трассы {num}
</li>
{duplicateRouteNumbers.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</DialogContent>

View File

@@ -1,11 +1,11 @@
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { ruRU } from "@mui/x-data-grid/locales";
import { authStore, languageStore, snapshotStore, cityStore, SearchInput } from "@shared";
import { authStore, languageStore, snapshotStore, cityStore, vehicleStore, SearchInput } from "@shared";
import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography } from "@mui/material";
const LOW_STORAGE_THRESHOLD_GB = 10;
@@ -43,11 +43,14 @@ export const SnapshotListPage = observer(() => {
createEmptySnapshot,
} = snapshotStore;
const canWriteDevices = authStore.canWrite("devices");
const canReadDevices = authStore.canRead("devices");
const canCreateSnapshot =
authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isSnapshotOnDeviceWarning, setIsSnapshotOnDeviceWarning] = useState(false);
const [devicesWithSnapshot, setDevicesWithSnapshot] = useState<string[]>([]);
const [rowId, setRowId] = useState<string | null>(null);
const { language } = languageStore;
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
@@ -77,7 +80,11 @@ export const SnapshotListPage = observer(() => {
useEffect(() => {
const fetchSnapshots = async () => {
setIsLoading(true);
await Promise.all([getSnapshots(), getStorageInfo()]);
const promises: Promise<void>[] = [getSnapshots(), getStorageInfo()];
if (canReadDevices && !vehicleStore.vehicles.loaded) {
promises.push(vehicleStore.getVehicles());
}
await Promise.all(promises);
setIsLoading(false);
};
fetchSnapshots();
@@ -164,8 +171,20 @@ export const SnapshotListPage = observer(() => {
<button
title="Удалить"
onClick={() => {
const snapshotId = params.row.id;
if (canReadDevices) {
const devicesUsing = vehicleStore.vehicles.data
.filter(v => v.vehicle.current_snapshot_uuid === snapshotId)
.map(v => v.vehicle.tail_number || v.vehicle.uuid || `ID ${v.vehicle.id}`);
if (devicesUsing.length > 0) {
setDevicesWithSnapshot(devicesUsing);
setIsSnapshotOnDeviceWarning(true);
setRowId(snapshotId);
return;
}
}
setIsDeleteModalOpen(true);
setRowId(params.row.id);
setRowId(snapshotId);
}}
>
<Trash2 size={20} className="text-red-500" />
@@ -409,6 +428,36 @@ export const SnapshotListPage = observer(() => {
</DialogActions>
</Dialog>
<Dialog
open={isSnapshotOnDeviceWarning}
onClose={() => setIsSnapshotOnDeviceWarning(false)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Удаление невозможно</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mt: 1 }}>
Этот экспорт загружен на устройства. Удалите или замените экспорт на
устройствах перед удалением.
</Alert>
<Box sx={{ mt: 2 }}>
<Typography variant="body2" fontWeight={600} gutterBottom>
Устройства:
</Typography>
{devicesWithSnapshot.map((name, i) => (
<Typography key={i} variant="body2">
{name}
</Typography>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsSnapshotOnDeviceWarning(false)}>
Закрыть
</Button>
</DialogActions>
</Dialog>
<SnapshotRestore
open={isRestoreModalOpen}
loading={isLoading}

View File

@@ -2,16 +2,8 @@ import {
Button,
Paper,
TextField,
Checkbox,
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite";
@@ -28,41 +20,7 @@ import {
selectedCityStore,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
export const UserCreatePage = observer(() => {
const navigate = useNavigate();
@@ -276,133 +234,8 @@ export const UserCreatePage = observer(() => {
</Button>
</Box>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>
Доп. права
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(
updated,
"vehicles",
val as PermissionLevel,
);
}
return updated;
});
};
const isDevicesResource = key === "devices";
const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) {
return;
}
setLocalRoles((prev) => {
const withoutSnapshotCreate = prev.filter(
(role) => role !== "snapshot_create"
);
return checked
? [...withoutSnapshotCreate, "snapshot_create"]
: withoutSnapshotCreate;
});
};
const handleMaintenanceChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((r) => r !== "devices_maintenance_rw");
return checked ? [...without, "devices_maintenance_rw"] : without;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">
-
</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) =>
handleSnapshotCreateChange(e.target.checked)
}
size="small"
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
<RolesHintTable />
</section>
<Button

View File

@@ -1,17 +1,9 @@
import {
Button,
Checkbox,
Paper,
TextField,
Box,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite";
@@ -35,41 +27,7 @@ import {
type UserCity,
} from "@shared";
import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets";
const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
type PermissionLevel = "none" | "ro" | "rw";
function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}
import { ImageUploadCard, DeleteModal, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets";
export const UserEditPage = observer(() => {
const navigate = useNavigate();
@@ -358,133 +316,8 @@ export const UserEditPage = observer(() => {
</Button>
</Box>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>
Доп. права
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(
updated,
"vehicles",
val as PermissionLevel,
);
}
return updated;
});
};
const isDevicesResource = key === "devices";
const handleSnapshotCreateChange = (checked: boolean) => {
if (!isSnapshotResource) {
return;
}
setLocalRoles((prev) => {
const withoutSnapshotCreate = prev.filter(
(role) => role !== "snapshot_create"
);
return checked
? [...withoutSnapshotCreate, "snapshot_create"]
: withoutSnapshotCreate;
});
};
const handleMaintenanceChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((r) => r !== "devices_maintenance_rw");
return checked ? [...without, "devices_maintenance_rw"] : without;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">
-
</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) =>
handleSnapshotCreateChange(e.target.checked)
}
size="small"
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
<PermissionsTable localRoles={localRoles} setLocalRoles={setLocalRoles} />
<RolesHintTable />
</section>
<Divider />

View File

@@ -24,30 +24,51 @@ const shiftYYYYMMDD = (value: string, days: number) => {
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
const LOG_LEVEL_STYLES: Record<
LogLevel,
{ badge: string; text: string; bg: string; color: string; borderColor: string }
> = {
info: {
badge: "bg-blue-100 text-blue-700",
text: "text-[#000000BF]",
bg: "#DBEAFE",
color: "#1D4ED8",
borderColor: "#93C5FD",
},
debug: {
badge: "bg-gray-100 text-gray-600",
text: "text-gray-600",
bg: "#F3F4F6",
color: "#4B5563",
borderColor: "#D1D5DB",
},
warn: {
badge: "bg-amber-100 text-amber-700",
text: "text-amber-800",
bg: "#FEF3C7",
color: "#B45309",
borderColor: "#FCD34D",
},
error: {
badge: "bg-red-100 text-red-700",
text: "text-red-700",
bg: "#FEE2E2",
color: "#B91C1C",
borderColor: "#FCA5A5",
},
fatal: {
badge: "bg-red-200 text-red-900",
text: "text-red-900 font-semibold",
bg: "#FECACA",
color: "#7F1D1D",
borderColor: "#F87171",
},
unknown: {
badge: "bg-gray-100 text-gray-500",
text: "text-[#000000BF]",
bg: "#F3F4F6",
color: "#6B7280",
borderColor: "#D1D5DB",
},
};
@@ -139,6 +160,23 @@ export const DeviceLogsModal = ({
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
const ALL_LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "fatal"];
const [activeLevels, setActiveLevels] = useState<Set<LogLevel>>(
new Set(ALL_LEVELS)
);
const toggleLevel = (level: LogLevel) => {
setActiveLevels((prev) => {
const next = new Set(prev);
if (next.has(level)) {
next.delete(level);
} else {
next.add(level);
}
return next;
});
};
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
@@ -205,16 +243,21 @@ export const DeviceLogsModal = ({
return parsed;
}, [chunks]);
const filteredLogs = useMemo(
() => logs.filter((log) => activeLevels.has(log.level)),
[logs, activeLevels]
);
const logsText = useMemo(
() =>
logs
filteredLogs
.map((log) => {
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
const time = log.time ? `[${log.time}] ` : "";
return `${time}${level}: ${log.text}`;
})
.join("\n"),
[logs]
[filteredLogs]
);
const handleDownloadLogs = () => {
@@ -253,6 +296,28 @@ export const DeviceLogsModal = ({
<div className="flex flex-col gap-6 h-[85vh]">
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
<div className="flex gap-1.5 items-center">
{ALL_LEVELS.map((level) => {
const active = activeLevels.has(level);
const s = LOG_LEVEL_STYLES[level];
return (
<button
key={level}
type="button"
onClick={() => toggleLevel(level)}
className="cursor-pointer select-none rounded-md px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide transition-all duration-150"
style={{
backgroundColor: active ? s.bg : "transparent",
color: active ? s.color : "#9CA3AF",
border: `1.5px solid ${active ? s.borderColor : "#E5E7EB"}`,
opacity: active ? 1 : 0.55,
}}
>
{level}
</button>
);
})}
</div>
<div className="flex gap-4 items-center">
<TextField
type="date"
@@ -280,7 +345,7 @@ export const DeviceLogsModal = ({
variant="outlined"
size="small"
onClick={handleDownloadLogs}
disabled={isLoading || Boolean(error) || logs.length === 0}
disabled={isLoading || Boolean(error) || filteredLogs.length === 0}
>
Скачать .txt
</Button>
@@ -303,8 +368,8 @@ export const DeviceLogsModal = ({
{!isLoading && !error && (
<div className="w-full h-full overflow-y-auto rounded-xl">
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{logs.length > 0 ? (
logs.map((log) => {
{filteredLogs.length > 0 ? (
filteredLogs.map((log) => {
const style = LOG_LEVEL_STYLES[log.level];
return (
<div

View File

@@ -0,0 +1,128 @@
import {
Checkbox,
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
} from "@mui/material";
import { ROLE_RESOURCES, getPermissionLevel, applyPermissionChange, type PermissionLevel } from "./constants";
interface PermissionsTableProps {
localRoles: string[];
setLocalRoles: React.Dispatch<React.SetStateAction<string[]>>;
}
export function PermissionsTable({ localRoles, setLocalRoles }: PermissionsTableProps) {
return (
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: "action.hover" }}>
<TableCell sx={{ fontWeight: 600, width: 220 }}>Ресурс</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Нет доступа</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
<TableCell align="center" sx={{ fontWeight: 600 }}>Доп. права</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_RESOURCES.map(({ key, label }) => {
const level = getPermissionLevel(localRoles, key);
const isSnapshotResource = key === "snapshot";
const isDevicesResource = key === "devices";
const handleChange = (val: string) => {
setLocalRoles((prev) => {
let updated = applyPermissionChange(prev, key, val as PermissionLevel);
if (key === "devices") {
updated = applyPermissionChange(updated, "vehicles", val as PermissionLevel);
}
return updated;
});
};
const handleSnapshotCreateChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((role) => role !== "snapshot_create");
return checked ? [...without, "snapshot_create"] : without;
});
};
const handleMaintenanceChange = (checked: boolean) => {
setLocalRoles((prev) => {
const without = prev.filter((r) => r !== "devices_maintenance_rw");
return checked ? [...without, "devices_maintenance_rw"] : without;
});
};
return (
<TableRow key={key} hover>
<TableCell>{label}</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="none" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Typography variant="body2" color="text.secondary">-</Typography>
) : (
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="ro" size="small" />
</RadioGroup>
)}
</TableCell>
<TableCell align="center" padding="checkbox">
<RadioGroup
row
value={level}
onChange={(e) => handleChange(e.target.value)}
sx={{ justifyContent: "center", flexWrap: "nowrap" }}
>
<Radio value="rw" size="small" />
</RadioGroup>
</TableCell>
<TableCell align="center" padding="checkbox">
{isSnapshotResource ? (
<Checkbox
checked={localRoles.includes("snapshot_create")}
onChange={(e) => handleSnapshotCreateChange(e.target.checked)}
size="small"
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "center" }}>
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Техническое обслуживание (ТО)"
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">-</Typography>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -0,0 +1,49 @@
import {
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@mui/material";
const ROLE_HINTS = [
{ tab: "Экспорт", roles: "Экспорт (Ч/З)" },
{ tab: "Создание экспорта", roles: "Экспорт (доп. права) + Устройства (Ч/З)" },
{ tab: "Устройства", roles: "Устройства + Транспорт + Маршруты + Перевозчики + Экспорт (Ч/З)" },
{ tab: "Карта", roles: "Маршруты (Ч/З) или Остановки (Ч/З) или Достопримечательности (Ч/З)" },
{ tab: "Пользователи", roles: "Пользователи" },
{ tab: "Достопримечательности", roles: "Достопримечательности" },
{ tab: "Остановки", roles: "Остановки" },
{ tab: "Маршруты", roles: "Маршруты + Перевозчики" },
{ tab: "Страны", roles: "Страны" },
{ tab: "Города", roles: "Города + Страны" },
{ tab: "Перевозчики", roles: "Перевозчики" },
];
export function RolesHintTable() {
return (
<Box sx={{ mt: 2, p: 2, bgcolor: "grey.50", borderRadius: 1, border: "1px solid", borderColor: "divider" }}>
<Typography variant="subtitle2" gutterBottom>
Какие роли нужны для вкладок
</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Вкладка</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Необходимые роли</TableCell>
</TableRow>
</TableHead>
<TableBody>
{ROLE_HINTS.map(({ tab, roles }) => (
<TableRow key={tab}>
<TableCell sx={{ py: 0.5 }}>{tab}</TableCell>
<TableCell sx={{ py: 0.5 }}>{roles}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -0,0 +1,33 @@
export const ROLE_RESOURCES = [
{ key: "snapshot", label: "Экспорт" },
{ key: "devices", label: "Устройства" },
{ key: "vehicles", label: "Транспорт" },
{ key: "users", label: "Пользователи" },
{ key: "sights", label: "Достопримечательности" },
{ key: "stations", label: "Остановки" },
{ key: "routes", label: "Маршруты" },
{ key: "countries", label: "Страны" },
{ key: "cities", label: "Города" },
{ key: "carriers", label: "Перевозчики" },
] as const;
export type PermissionLevel = "none" | "ro" | "rw";
export function getPermissionLevel(roles: string[], resource: string): PermissionLevel {
if (roles.includes(`${resource}_rw`)) return "rw";
if (roles.includes(`${resource}_ro`)) return "ro";
return "none";
}
export function applyPermissionChange(
roles: string[],
resource: string,
level: PermissionLevel,
): string[] {
const filtered = roles.filter(
(r) => r !== `${resource}_ro` && r !== `${resource}_rw`,
);
if (level === "ro") return [...filtered, `${resource}_ro`];
if (level === "rw") return [...filtered, `${resource}_rw`];
return filtered;
}

View File

@@ -0,0 +1,3 @@
export { PermissionsTable } from "./PermissionsTable";
export { RolesHintTable } from "./RolesHintTable";
export { ROLE_RESOURCES, type PermissionLevel, getPermissionLevel, applyPermissionChange } from "./constants";

View File

@@ -164,6 +164,7 @@
position: relative;
padding: 7px;
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: space-around;

View File

@@ -21,3 +21,4 @@ export * from "./SaveWithoutCityAgree";
export * from "./CitySelector";
export * from "./modals";
export * from "./TestingModeBanner";
export * from "./PermissionsTable";