feat: webgl preview improvements, permissions refactor and snapshot safeguards
This commit is contained in:
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
.side-menu-sights-block .scrollable-viewport {
|
||||
height: calc(92%);
|
||||
height: calc(98%);
|
||||
}
|
||||
|
||||
.side-menu-sights-block .scrollable {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,6 +54,7 @@ export const RoutePreview = () => {
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
zIndex: 20,
|
||||
width: isLeftSidebarOpen ? 288 : 0,
|
||||
transition: "width 0.3s ease",
|
||||
overflow: "visible",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
128
src/widgets/PermissionsTable/PermissionsTable.tsx
Normal file
128
src/widgets/PermissionsTable/PermissionsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/widgets/PermissionsTable/RolesHintTable.tsx
Normal file
49
src/widgets/PermissionsTable/RolesHintTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/widgets/PermissionsTable/constants.ts
Normal file
33
src/widgets/PermissionsTable/constants.ts
Normal 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;
|
||||
}
|
||||
3
src/widgets/PermissionsTable/index.ts
Normal file
3
src/widgets/PermissionsTable/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PermissionsTable } from "./PermissionsTable";
|
||||
export { RolesHintTable } from "./RolesHintTable";
|
||||
export { ROLE_RESOURCES, type PermissionLevel, getPermissionLevel, applyPermissionChange } from "./constants";
|
||||
@@ -164,6 +164,7 @@
|
||||
position: relative;
|
||||
padding: 7px;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
@@ -21,3 +21,4 @@ export * from "./SaveWithoutCityAgree";
|
||||
export * from "./CitySelector";
|
||||
export * from "./modals";
|
||||
export * from "./TestingModeBanner";
|
||||
export * from "./PermissionsTable";
|
||||
|
||||
Reference in New Issue
Block a user