feat: big update 07.05.26

This commit is contained in:
2026-05-07 13:08:33 +03:00
parent 6af95bb449
commit d758dbffa6
39 changed files with 1233 additions and 814 deletions

View File

@@ -6,8 +6,6 @@ import {
MenuItem,
FormControl,
InputLabel,
ToggleButtonGroup,
ToggleButton,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
@@ -29,13 +27,12 @@ import {
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
type ColorFields = { main_color: string; left_color: string; right_color: string; rgb_color: string };
type ColorFields = { main_color: string; left_color: string; right_color: string };
const colorFields = (data: ColorFields) => ({
main_color: data.main_color,
left_color: data.left_color,
right_color: data.right_color,
rgb_color: data.rgb_color,
});
const ColorPickerField = ({
@@ -81,7 +78,6 @@ const ColorPickerField = ({
);
export const CarrierCreatePage = observer(() => {
const [colorMode, setColorMode] = useState<"rgb" | "three">("three");
const navigate = useNavigate();
const { createCarrierData, setCreateCarrierData } = carrierStore;
const { language } = languageStore;
@@ -274,51 +270,11 @@ export const CarrierCreatePage = observer(() => {
}
/>
<div className="w-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Режим цвета:</span>
<ToggleButtonGroup
size="small"
exclusive
value={colorMode}
onChange={(_, val) => {
if (!val) return;
setColorMode(val);
if (val === "rgb") {
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ main_color: "", left_color: "", right_color: "", rgb_color: createCarrierData.rgb_color }
);
} else {
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ main_color: createCarrierData.main_color, left_color: createCarrierData.left_color, right_color: createCarrierData.right_color, rgb_color: "" }
);
}
}}
>
<ToggleButton value="rgb">Один цвет</ToggleButton>
<ToggleButton value="three">Три цвета</ToggleButton>
</ToggleButtonGroup>
</div>
<span className="text-xs text-gray-400">* при переключении цвет сбрасывается</span>
</div>
{colorMode === "rgb" ? (
<div className="w-full flex flex-col gap-6">
<div className="flex flex-col gap-1">
<ColorPickerField
label="Один цвет"
value={createCarrierData.rgb_color}
label="Основной цвет"
value={createCarrierData.main_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
@@ -327,59 +283,54 @@ export const CarrierCreatePage = observer(() => {
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), rgb_color: val }
{ ...colorFields(createCarrierData), main_color: val }
)
}
/>
) : (
<>
<ColorPickerField
label="Основной цвет"
value={createCarrierData.main_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), main_color: val }
)
}
/>
<ColorPickerField
label="Левый цвет"
value={createCarrierData.left_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), left_color: val }
)
}
/>
<ColorPickerField
label="Правый цвет"
value={createCarrierData.right_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), right_color: val }
)
}
/>
</>
)}
<p className="text-xs text-gray-500 pl-1">
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Левый цвет"
value={createCarrierData.left_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), left_color: val }
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: боковое меню, левый виджет достопримечательности
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Правый цвет"
value={createCarrierData.right_color}
onChange={(val) =>
setCreateCarrierData(
createCarrierData[language].full_name,
createCarrierData[language].short_name,
createCarrierData.city_id,
createCarrierData[language].slogan,
selectedMediaId || "",
language,
{ ...colorFields(createCarrierData), right_color: val }
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: список достопримечательностей, страница достопримечательности
</p>
</div>
</div>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">

View File

@@ -7,8 +7,6 @@ import {
FormControl,
InputLabel,
Box,
ToggleButtonGroup,
ToggleButton,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react";
@@ -32,13 +30,12 @@ import {
UploadMediaDialog,
} from "@shared";
type ColorFields = { main_color: string; left_color: string; right_color: string; rgb_color: string };
type ColorFields = { main_color: string; left_color: string; right_color: string };
const colorFields = (data: ColorFields) => ({
main_color: data.main_color,
left_color: data.left_color,
right_color: data.right_color,
rgb_color: data.rgb_color,
});
const ColorPickerField = ({
@@ -90,7 +87,6 @@ export const CarrierEditPage = observer(() => {
const { language } = languageStore;
const canReadCities = authStore.canRead("cities");
const [colorMode, setColorMode] = useState<"rgb" | "three">("rgb");
const [isLoading, setIsLoading] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(true);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
@@ -126,13 +122,7 @@ export const CarrierEditPage = observer(() => {
main_color: carrierData.ru?.main_color || "",
left_color: carrierData.ru?.left_color || "",
right_color: carrierData.ru?.right_color || "",
rgb_color: carrierData.ru?.rgb_color || "",
};
if (colors.rgb_color) {
setColorMode("rgb");
} else {
setColorMode("three");
}
setEditCarrierData(
carrierData.ru?.full_name || "",
carrierData.ru?.short_name || "",
@@ -339,51 +329,11 @@ export const CarrierEditPage = observer(() => {
}
/>
<div className="w-full flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Режим цвета:</span>
<ToggleButtonGroup
size="small"
exclusive
value={colorMode}
onChange={(_, val) => {
if (!val) return;
setColorMode(val);
if (val === "rgb") {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ main_color: "", left_color: "", right_color: "", rgb_color: editCarrierData.rgb_color }
);
} else {
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ main_color: editCarrierData.main_color, left_color: editCarrierData.left_color, right_color: editCarrierData.right_color, rgb_color: "" }
);
}
}}
>
<ToggleButton value="rgb">Один цвет</ToggleButton>
<ToggleButton value="three">Три цвета</ToggleButton>
</ToggleButtonGroup>
</div>
<span className="text-xs text-gray-400">* при переключении цвет сбрасывается</span>
</div>
{colorMode === "rgb" ? (
<div className="w-full flex flex-col gap-6">
<div className="flex flex-col gap-1">
<ColorPickerField
label="Один цвет"
value={editCarrierData.rgb_color}
label="Основной цвет"
value={editCarrierData.main_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
@@ -392,59 +342,54 @@ export const CarrierEditPage = observer(() => {
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), rgb_color: val }
{ ...colorFields(editCarrierData), main_color: val }
)
}
/>
) : (
<>
<ColorPickerField
label="Основной цвет"
value={editCarrierData.main_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), main_color: val }
)
}
/>
<ColorPickerField
label="Левый цвет"
value={editCarrierData.left_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), left_color: val }
)
}
/>
<ColorPickerField
label="Правый цвет"
value={editCarrierData.right_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), right_color: val }
)
}
/>
</>
)}
<p className="text-xs text-gray-500 pl-1">
Используется в: виджет маршрута, виджет обращений, значки на карте, скопление достопримечательностей на карте, информационный виджет
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Левый цвет"
value={editCarrierData.left_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), left_color: val }
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: боковое меню, левый виджет достопримечательности
</p>
</div>
<div className="flex flex-col gap-1">
<ColorPickerField
label="Правый цвет"
value={editCarrierData.right_color}
onChange={(val) =>
setEditCarrierData(
editCarrierData[language].full_name,
editCarrierData[language].short_name,
editCarrierData.city_id,
editCarrierData[language].slogan,
editCarrierData.logo,
language,
{ ...colorFields(editCarrierData), right_color: val }
)
}
/>
<p className="text-xs text-gray-500 pl-1">
Используется в: список достопримечательностей, страница достопримечательности
</p>
</div>
</div>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">

View File

@@ -57,7 +57,7 @@ export const RouteCreatePage = observer(() => {
const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState("");
const [videoTimer, setVideoTimer] = useState(60);
const [videoTimer, setVideoTimer] = useState(420);
const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);

View File

@@ -557,7 +557,7 @@ export const RouteEditPage = observer(() => {
className="w-full"
label="Таймер видео заставки (сек)"
type="number"
value={editRouteData.video_timer ?? 60}
value={editRouteData.video_timer ?? 420}
onChange={(e) => {
const val = Math.max(1, Math.round(Number(e.target.value)));
if (Number.isFinite(val)) {

View File

@@ -139,7 +139,7 @@ export const RouteListPage = observer(() => {
headerAlign: "center" as const,
sortable: true,
renderHeader: (params: any) => (
<Tooltip title="Количество привязанных достопримечательностей">
<Tooltip title="Отображает количество привязанных достопримечательностей">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
@@ -157,7 +157,7 @@ export const RouteListPage = observer(() => {
headerAlign: "center" as const,
sortable: true,
renderHeader: (params: any) => (
<Tooltip title="Количество привязанных остановок">
<Tooltip title="Отображает количество привязанных остановок">
<span>{params.colDef.headerName}</span>
</Tooltip>
),

View File

@@ -1,4 +1,4 @@
import { Box, Stack, Typography, Button } from "@mui/material";
import { Button } from "@mui/material";
import { useNavigate, useNavigationType } from "react-router";
import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext";
@@ -15,22 +15,22 @@ type LeftSidebarProps = {
export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
const navigate = useNavigate();
const navigationType = useNavigationType(); // PUSH, POP, REPLACE
const navigationType = useNavigationType();
const { routeData } = useMapData();
const [carrierThumbnail, setCarrierThumbnail] = useState<string | null>(null);
const [carrierLogo, setCarrierLogo] = useState<string | null>(null);
const [carrierSlogan, setCarrierSlogan] = useState<string | null>(null);
const [carrierShortName, setCarrierShortName] = useState<string | null>(null);
useEffect(() => {
async function fetchCarrierThumbnail() {
async function fetchCarrierData() {
if (routeData?.carrier_id) {
const { city_id, logo } = (
await authInstance.get(`/carrier/${routeData.carrier_id}`)
).data;
const { arms } = (await authInstance.get(`/city/${city_id}`)).data;
setCarrierThumbnail(arms);
setCarrierLogo(logo);
const carrier = (await authInstance.get(`/carrier/${routeData.carrier_id}`)).data;
setCarrierLogo(carrier.logo);
setCarrierSlogan(carrier.slogan ?? null);
setCarrierShortName(carrier.short_name ?? null);
}
}
fetchCarrierThumbnail();
fetchCarrierData();
}, [routeData?.carrier_id]);
const handleBack = () => {
@@ -42,131 +42,162 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
};
return (
<Box
sx={{
<div
style={{
position: "relative",
height: "100%",
color: "#fff",
transition: "padding 0.3s ease",
p: open ? 2 : 0,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
}}
>
<Stack
direction="column"
height="100%"
width="100%"
spacing={4}
alignItems="stretch"
justifyContent="space-between"
sx={{
{/* Кнопка назад — вне основного меню */}
<div style={{ padding: "12px 12px 0" }}>
<Button
onClick={handleBack}
variant="contained"
sx={{
backgroundColor: "#222",
color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
"&:hover": { backgroundColor: "#2d2d2d" },
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
Назад
</Button>
</div>
{/* Основное меню — повторяет .side-menu */}
<div
style={{
boxSizing: "border-box",
paddingTop: 46,
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "calc(100% - 56px)",
position: "relative",
opacity: open ? 1 : 0,
transition: "opacity 0.25s ease",
pointerEvents: open ? "auto" : "none",
display: open ? "flex" : "none",
}}
>
<div>
<Button
onClick={handleBack}
variant="contained"
color="primary"
sx={{
backgroundColor: "#222",
color: "#fff",
borderRadius: 1.5,
px: 2,
py: 1,
marginBottom: 10,
"&:hover": {
backgroundColor: "#2d2d2d",
},
{/* Герб — .side-menu-crest */}
<div
style={{
width: 170,
height: 170,
alignSelf: "flex-start",
marginLeft: 20,
backgroundColor: "rgba(255,255,255,0.15)",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(255,255,255,0.5)",
fontSize: 14,
fontWeight: 500,
}}
>
Герб
</div>
{/* Слоган — .side-menu-label */}
{carrierSlogan && (
<div
style={{
marginTop: 10,
textAlign: "left",
fontSize: 15,
padding: "0 20px",
alignSelf: "flex-start",
fontWeight: 400,
lineHeight: "150%",
}}
fullWidth
startIcon={<ArrowBackIcon />}
>
Назад
</Button>
{carrierSlogan}
</div>
)}
<Stack
direction="column"
alignItems="center"
justifyContent="center"
spacing={3}
{/* Кнопки — .side-menu-buttons */}
<div style={{ width: 220, marginTop: 260 }}>
<div
style={{
backgroundColor: "#fff",
color: "#000",
textAlign: "center",
padding: "8px 16px",
marginBottom: 16,
borderRadius: 10,
}}
>
<div
style={{
maxWidth: 150,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}
>
{carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
<MediaViewer
media={{
id: carrierThumbnail,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail",
}}
fullWidth
fullHeight
/>
)}
<Typography sx={{ color: "#fff" }} textAlign="center">
При поддержке Правительства
</Typography>
</div>
</Stack>
<div className="flex flex-col items-center justify-center gap-2 mt-10">
<button className="bg-[#fcd500] text-black px-4 py-2 rounded-md w-full font-medium my-10">
Обращение губернатора
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Достопримечательности
</button>
<button className="bg-white text-black px-4 py-2 rounded-md w-full font-medium mx-5">
Остановки
</button>
Достопримечательности
</div>
<div
style={{
backgroundColor: "#fff",
color: "#000",
textAlign: "center",
padding: "8px 16px",
marginBottom: 16,
borderRadius: 10,
}}
>
Остановки
</div>
</div>
<Stack
direction="column"
alignItems="center"
maxHeight={150}
justifyContent="center"
flexGrow={1}
{/* Нижняя секция — .side-menu-bottom-section */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
display: "flex",
flexDirection: "column",
}}
>
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<MediaViewer
media={{
id: carrierLogo,
media_type: 1, // Тип "Фото" для логотипа
filename: "route_thumbnail_logo",
}}
fullHeight
/>
)}
</Stack>
{/* .side-menu-carrier-block */}
<div style={{ padding: "0 20px" }}>
{carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<div style={{ width: 170 }}>
<MediaViewer
media={{ id: carrierLogo, media_type: 1, filename: "carrier_logo" }}
fullWidth
/>
</div>
)}
{carrierShortName && (
<div
style={{
marginTop: 4,
textAlign: "left",
fontSize: 16,
fontWeight: 700,
lineHeight: "150%",
color: "#fff",
}}
>
{carrierShortName}
</div>
)}
</div>
<Typography
variant="h6"
textAlign="center"
sx={{ color: "#fff", marginTop: "auto" }}
>
#ВсемПоПути
</Typography>
</Stack>
{/* .side-menu-bottom-photo */}
<img
src="/side-menu-photo.png"
alt=""
style={{ width: "100%", marginTop: 32, display: "block", pointerEvents: "none" }}
/>
</div>
</div>
<div className="absolute bottom-[20px] -right-[520px] z-10">
<LanguageSelector onBack={onToggle} isSidebarOpen={open} />
</div>
</Box>
</div>
);
});

View File

@@ -34,7 +34,7 @@
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%
),
rgba(179, 165, 152, 0.4);
rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4);
backdrop-filter: blur(10px);
pointer-events: auto;
z-index: 10000001;

View File

@@ -76,6 +76,26 @@ export const SnapshotListPage = observer(() => {
};
const columns: GridColDef[] = [
{
field: "color",
headerName: "",
width: 28,
sortable: false,
disableColumnMenu: true,
renderCell: (params: GridRenderCellParams) => (
<div className="flex items-center justify-center h-full w-full">
<span
style={{
display: "inline-block",
width: 12,
height: 12,
backgroundColor: params.value,
borderRadius: "50%",
}}
/>
</div>
),
},
{
field: "name",
headerName: "Название",
@@ -150,12 +170,13 @@ export const SnapshotListPage = observer(() => {
.toLowerCase()
.includes(query),
)
.map((snapshot) => ({
.map((snapshot, index) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime),
occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
color: SEGMENT_COLORS[index % SEGMENT_COLORS.length],
}));
}, [snapshots, searchQuery]);
@@ -181,7 +202,7 @@ export const SnapshotListPage = observer(() => {
setIsEmptySnapshotModalOpen(true);
}}
>
Создать пустой снапшот
Создать пустой экспорт
</Button>
)}
{canCreateSnapshot && (
@@ -203,7 +224,7 @@ export const SnapshotListPage = observer(() => {
</div>
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
{rows.map((row, i) => {
{rows.map((row) => {
const pct =
row.occupied_disk_space_gb != null && totalGB > 0
? (row.occupied_disk_space_gb / totalGB) * 100
@@ -214,8 +235,7 @@ export const SnapshotListPage = observer(() => {
key={row.id}
style={{
width: `${pct}%`,
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
backgroundColor: row.color,
}}
title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
/>
@@ -233,7 +253,7 @@ export const SnapshotListPage = observer(() => {
</div>
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
{rows.map((row, i) => {
{rows.map((row) => {
if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
return null;
return (
@@ -243,10 +263,7 @@ export const SnapshotListPage = observer(() => {
>
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
style={{ backgroundColor: row.color }}
/>
{row.name}
</div>
@@ -325,7 +342,7 @@ export const SnapshotListPage = observer(() => {
fullWidth
maxWidth="xs"
>
<DialogTitle>Создать пустой снапшот</DialogTitle>
<DialogTitle>Создать пустой экспорт</DialogTitle>
<DialogContent>
<TextField
autoFocus

View File

@@ -86,13 +86,13 @@ export const StationListPage = observer(() => {
},
{
field: "sightCount",
headerName: "Достопримечательности",
headerName: "Привязки",
width: 180,
align: "center" as const,
headerAlign: "center" as const,
sortable: true,
renderHeader: (params) => (
<Tooltip title="Количество привязанных достопримечательностей">
<Tooltip title="Отображает количество привязок">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
@@ -114,7 +114,7 @@ export const StationListPage = observer(() => {
headerAlign: "center" as const,
sortable: true,
renderHeader: (params) => (
<Tooltip title="Подтверждение добавленных пересадок">
<Tooltip title="Отображает подтверждение добавленных пересадок">
<span>{params.colDef.headerName}</span>
</Tooltip>
),

View File

@@ -1,4 +1,19 @@
import { Button, Paper, TextField } from "@mui/material";
import {
Button,
Paper,
TextField,
Checkbox,
Typography,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
RadioGroup,
Divider,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -14,6 +29,40 @@ import {
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;
}
export const UserCreatePage = observer(() => {
const navigate = useNavigate();
const { createUserData, setCreateUserData, createUser } = userStore;
@@ -26,13 +75,33 @@ export const UserCreatePage = observer(() => {
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [localRoles, setLocalRoles] = useState<string[]>(
createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"]
);
useEffect(() => {
mediaStore.getMedia();
}, []);
useEffect(() => {
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
if (isAdmin !== createUserData.is_admin) {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
isAdmin,
createUserData.icon
);
}
}, [localRoles]);
const handleCreate = async () => {
try {
setIsLoading(true);
// Убеждаемся, что роли в сторе обновлены перед созданием
userStore.createUserData.roles = localRoles;
await createUser();
toast.success("Пользователь успешно создан");
navigate("/user");
@@ -67,18 +136,15 @@ export const UserCreatePage = observer(() => {
: selectedMedia?.id ?? createUserData.icon ?? null;
return (
<Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2"
onClick={() => navigate(-1)}
>
<ArrowLeft size={20} />
Назад
</button>
</div>
<Paper className="w-full p-6 flex flex-col gap-8">
<button className="flex items-center gap-2 self-start" onClick={() => navigate(-1)}>
<ArrowLeft size={20} />
Назад
</button>
<section className="flex flex-col gap-6">
<Typography variant="h6">Основные данные</Typography>
<div className="flex flex-col gap-10 w-full items-end">
<TextField
fullWidth
label="Имя"
@@ -116,6 +182,7 @@ export const UserCreatePage = observer(() => {
label="Пароль"
value={createUserData.password || ""}
required
type="password"
onChange={(e) =>
setCreateUserData(
createUserData.name || "",
@@ -127,7 +194,7 @@ export const UserCreatePage = observer(() => {
}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<div className="w-full flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
@@ -156,23 +223,197 @@ export const UserCreatePage = observer(() => {
}}
/>
</div>
</section>
<Button
variant="contained"
className="w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading || !createUserData.name || !createUserData.password
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
</div>
<Divider />
<section className="flex flex-col gap-4">
<Typography variant="h6">Права доступа</Typography>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={() => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
true,
createUserData.icon
);
const next: string[] = [];
for (const { key } of ROLE_RESOURCES) {
next.push(`${key}_rw`);
}
next.push("snapshot_create");
setLocalRoles(next);
}}
>
Полный доступ (admin)
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
false,
createUserData.icon
);
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
}}
>
Администратор ТО
</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>
</section>
<Button
variant="contained"
className="self-end w-min flex gap-2 items-center"
startIcon={<Save size={20} />}
onClick={handleCreate}
disabled={
isLoading || !createUserData.name || !createUserData.password || !createUserData.email
}
>
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
"Создать"
)}
</Button>
<SelectMediaDialog
open={isSelectMediaOpen}

View File

@@ -1,6 +1,5 @@
import {
Button,
FormControlLabel,
Checkbox,
Paper,
TextField,
@@ -97,6 +96,20 @@ export const UserEditPage = observer(() => {
languageStore.setLanguage("ru");
}, []);
useEffect(() => {
const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`));
const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw");
if (isAdmin !== editUserData.is_admin) {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
isAdmin,
editUserData.icon || ""
);
}
}, [localRoles]);
useEffect(() => {
(async () => {
if (id) {
@@ -311,35 +324,33 @@ export const UserEditPage = observer(() => {
<section className="flex flex-col gap-4">
<Typography variant="h6">Права доступа</Typography>
<FormControlLabel
control={
<Checkbox
checked={localRoles.includes("admin")}
onChange={(e) => {
if (e.target.checked) {
setLocalRoles((prev) => {
let next = prev.filter((r) => r !== "admin");
for (const { key } of ROLE_RESOURCES) {
next = next.filter((r) => r !== `${key}_ro` && r !== `${key}_rw`);
next.push(`${key}_rw`);
}
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
if (!next.includes("devices_maintenance_rw")) {
next.push("devices_maintenance_rw");
}
next.push("admin");
return next;
});
} else {
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
}
}}
/>
}
label="Полный доступ (admin)"
/>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={() => {
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", true, editUserData.icon || "");
const next: string[] = [];
for (const { key } of ROLE_RESOURCES) {
next.push(`${key}_rw`);
}
next.push("snapshot_create");
setLocalRoles(next);
}}
>
Полный доступ (admin)
</Button>
<Button
variant="outlined"
size="small"
onClick={() => {
setEditUserData(editUserData.name || "", editUserData.email || "", editUserData.password || "", false, editUserData.icon || "");
setLocalRoles(["devices_ro", "vehicles_ro", "devices_maintenance_rw"]);
}}
>
Администратор ТО
</Button>
</Box>
<Box sx={{ border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
<Table size="small">
@@ -371,20 +382,6 @@ export const UserEditPage = observer(() => {
);
}
const allRw = ROLE_RESOURCES.every(({ key: k }) =>
updated.includes(`${k}_rw`),
);
if (allRw && !updated.includes("admin")) {
const next = [...updated];
if (!next.includes("snapshot_create")) {
next.push("snapshot_create");
}
next.push("admin");
return next;
}
if (!allRw) {
return updated.filter((r) => r !== "admin");
}
return updated;
});
};
@@ -462,12 +459,14 @@ export const UserEditPage = observer(() => {
title="Разрешает создавать новые снапшоты"
/>
) : isDevicesResource ? (
<Checkbox
checked={localRoles.includes("devices_maintenance_rw")}
onChange={(e) => handleMaintenanceChange(e.target.checked)}
size="small"
title="Разрешает переводить устройства в режим технического обслуживания"
/>
<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">
-