527 lines
17 KiB
TypeScript
527 lines
17 KiB
TypeScript
import {
|
||
Button,
|
||
FormControlLabel,
|
||
Checkbox,
|
||
Paper,
|
||
TextField,
|
||
Box,
|
||
Typography,
|
||
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, useParams } from "react-router-dom";
|
||
import { toast } from "react-toastify";
|
||
import {
|
||
userStore,
|
||
languageStore,
|
||
LoadingSpinner,
|
||
mediaStore,
|
||
isMediaIdEmpty,
|
||
SelectMediaDialog,
|
||
UploadMediaDialog,
|
||
PreviewMediaDialog,
|
||
authStore,
|
||
cityStore,
|
||
MultiSelect,
|
||
type User,
|
||
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;
|
||
}
|
||
|
||
export const UserEditPage = observer(() => {
|
||
const navigate = useNavigate();
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [isLoadingData, setIsLoadingData] = useState(true);
|
||
|
||
const { id } = useParams();
|
||
const { editUserData, editUser, getUser, setEditUserData, setEditUserRoles } = userStore;
|
||
const canReadCities = authStore.canRead("cities");
|
||
|
||
const [localRoles, setLocalRoles] = useState<string[]>([]);
|
||
const [localCityIds, setLocalCityIds] = useState<number[]>([]);
|
||
const [initialUserCities, setInitialUserCities] = useState<UserCity[]>([]);
|
||
|
||
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
|
||
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
|
||
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
|
||
const [mediaId, setMediaId] = useState("");
|
||
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
|
||
const [activeMenuType, setActiveMenuType] = useState<
|
||
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
|
||
>(null);
|
||
|
||
useEffect(() => {
|
||
languageStore.setLanguage("ru");
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
(async () => {
|
||
if (id) {
|
||
setIsLoadingData(true);
|
||
try {
|
||
if (!authStore.me) {
|
||
await authStore.getMeAction().catch(() => undefined);
|
||
}
|
||
await Promise.all([
|
||
mediaStore.getMedia(),
|
||
authStore.canRead("cities")
|
||
? cityStore.getRuCities()
|
||
: authStore.fetchMeCities().catch(() => undefined),
|
||
]);
|
||
const data = (await getUser(Number(id))) as User | undefined;
|
||
|
||
if (data) {
|
||
setEditUserData(
|
||
data.name || "",
|
||
data.email || "",
|
||
data.password || "",
|
||
data.is_admin || false,
|
||
data.icon || "",
|
||
);
|
||
|
||
const roles = data.roles ?? [];
|
||
setLocalRoles(roles);
|
||
setEditUserRoles(roles);
|
||
|
||
const cityIds = (data.cities ?? []).map((c) => c.city_id);
|
||
setLocalCityIds(cityIds);
|
||
setInitialUserCities(data.cities ?? []);
|
||
}
|
||
} finally {
|
||
setIsLoadingData(false);
|
||
}
|
||
} else {
|
||
setIsLoadingData(false);
|
||
}
|
||
})();
|
||
}, [id]);
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
|
||
const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"];
|
||
const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles]));
|
||
setEditUserRoles(rolesToSave);
|
||
await editUser(Number(id));
|
||
|
||
await userStore.addUserCityAction({
|
||
id: Number(id),
|
||
city_ids: localCityIds,
|
||
});
|
||
|
||
toast.success("Пользователь успешно обновлён");
|
||
navigate("/user");
|
||
} catch {
|
||
toast.error("Ошибка при обновлении пользователя");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleMediaSelect = (media: {
|
||
id: string;
|
||
filename: string;
|
||
media_name?: string;
|
||
media_type: number;
|
||
}) => {
|
||
setEditUserData(
|
||
editUserData.name || "",
|
||
editUserData.email || "",
|
||
editUserData.password || "",
|
||
editUserData.is_admin || false,
|
||
media.id,
|
||
);
|
||
};
|
||
|
||
const selectedMedia =
|
||
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
|
||
? mediaStore.media.find((m) => m.id === editUserData.icon)
|
||
: null;
|
||
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
|
||
? null
|
||
: (selectedMedia?.id ?? editUserData.icon ?? null);
|
||
|
||
const cityOptionsMap = new Map<number, string>();
|
||
|
||
const sourceCities: UserCity[] = canReadCities
|
||
? cityStore.ruCities.data
|
||
.filter((city) => city.id !== undefined)
|
||
.map((city) => ({
|
||
city_id: city.id as number,
|
||
name: city.name,
|
||
}))
|
||
: authStore.meCities.ru;
|
||
|
||
for (const city of sourceCities) {
|
||
cityOptionsMap.set(city.city_id, city.name);
|
||
}
|
||
|
||
for (const city of initialUserCities) {
|
||
if (!cityOptionsMap.has(city.city_id)) {
|
||
cityOptionsMap.set(city.city_id, city.name);
|
||
}
|
||
}
|
||
|
||
const cityOptions = Array.from(cityOptionsMap.entries()).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}));
|
||
|
||
if (isLoadingData) {
|
||
return (
|
||
<Box
|
||
sx={{
|
||
display: "flex",
|
||
justifyContent: "center",
|
||
alignItems: "center",
|
||
minHeight: "60vh",
|
||
}}
|
||
>
|
||
<LoadingSpinner message="Загрузка данных пользователя..." />
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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>
|
||
|
||
<TextField
|
||
fullWidth
|
||
label="Имя"
|
||
value={editUserData.name || ""}
|
||
required
|
||
onChange={(e) =>
|
||
setEditUserData(
|
||
e.target.value,
|
||
editUserData.email || "",
|
||
editUserData.password || "",
|
||
editUserData.is_admin || false,
|
||
editUserData.icon,
|
||
)
|
||
}
|
||
/>
|
||
<TextField
|
||
fullWidth
|
||
label="Email"
|
||
value={editUserData.email || ""}
|
||
required
|
||
onChange={(e) =>
|
||
setEditUserData(
|
||
editUserData.name || "",
|
||
e.target.value,
|
||
editUserData.password || "",
|
||
editUserData.is_admin || false,
|
||
editUserData.icon,
|
||
)
|
||
}
|
||
/>
|
||
<TextField
|
||
fullWidth
|
||
label="Пароль"
|
||
placeholder="Оставить пустым, чтобы не менять"
|
||
value={editUserData.password || ""}
|
||
onChange={(e) =>
|
||
setEditUserData(
|
||
editUserData.name || "",
|
||
editUserData.email || "",
|
||
e.target.value,
|
||
editUserData.is_admin || false,
|
||
editUserData.icon,
|
||
)
|
||
}
|
||
/>
|
||
|
||
<div className="w-full flex flex-col gap-4 max-w-[300px]">
|
||
<ImageUploadCard
|
||
title="Аватар"
|
||
imageKey="thumbnail"
|
||
imageUrl={effectiveIconUrl}
|
||
onImageClick={() => {
|
||
setIsPreviewMediaOpen(true);
|
||
setMediaId(effectiveIconUrl ?? "");
|
||
}}
|
||
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
|
||
onSelectFileClick={() => {
|
||
setActiveMenuType("image");
|
||
setIsSelectMediaOpen(true);
|
||
}}
|
||
setUploadMediaOpen={() => {
|
||
setIsUploadMediaOpen(true);
|
||
setActiveMenuType("image");
|
||
}}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Права доступа ── */}
|
||
<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");
|
||
}
|
||
next.push("admin");
|
||
return next;
|
||
});
|
||
} else {
|
||
setLocalRoles((prev) => prev.filter((r) => r !== "admin"));
|
||
}
|
||
}}
|
||
/>
|
||
}
|
||
label="Полный доступ (admin)"
|
||
/>
|
||
|
||
<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 }}>
|
||
Создание (snapshot_create)
|
||
</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,
|
||
);
|
||
}
|
||
|
||
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;
|
||
});
|
||
};
|
||
|
||
const handleSnapshotCreateChange = (checked: boolean) => {
|
||
if (!isSnapshotResource) {
|
||
return;
|
||
}
|
||
setLocalRoles((prev) => {
|
||
const withoutSnapshotCreate = prev.filter(
|
||
(role) => role !== "snapshot_create"
|
||
);
|
||
return checked
|
||
? [...withoutSnapshotCreate, "snapshot_create"]
|
||
: withoutSnapshotCreate;
|
||
});
|
||
};
|
||
|
||
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"
|
||
/>
|
||
) : (
|
||
<Typography variant="body2" color="text.secondary">
|
||
-
|
||
</Typography>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</Box>
|
||
</section>
|
||
|
||
<Divider />
|
||
|
||
{/* ── Города ── */}
|
||
<section className="flex flex-col gap-4">
|
||
<Typography variant="h6">Города</Typography>
|
||
<MultiSelect
|
||
options={cityOptions}
|
||
value={localCityIds}
|
||
onChange={(ids) => setLocalCityIds(ids as number[])}
|
||
label="Города"
|
||
placeholder="Выберите города"
|
||
loading={canReadCities ? !cityStore.ruCities.loaded : isLoadingData}
|
||
/>
|
||
</section>
|
||
|
||
<Button
|
||
variant="contained"
|
||
className="self-end"
|
||
startIcon={isLoading ? <Loader2 size={20} className="animate-spin" /> : <Save size={20} />}
|
||
onClick={handleSave}
|
||
disabled={isLoading || !editUserData.name || !editUserData.email}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
|
||
<SelectMediaDialog
|
||
open={isSelectMediaOpen}
|
||
onClose={() => setIsSelectMediaOpen(false)}
|
||
onSelectMedia={handleMediaSelect}
|
||
mediaType={1}
|
||
/>
|
||
<UploadMediaDialog
|
||
open={isUploadMediaOpen}
|
||
onClose={() => setIsUploadMediaOpen(false)}
|
||
contextObjectName={editUserData.name || "Пользователь"}
|
||
contextType="user"
|
||
afterUpload={handleMediaSelect}
|
||
hardcodeType={activeMenuType}
|
||
/>
|
||
<PreviewMediaDialog
|
||
open={isPreviewMediaOpen}
|
||
onClose={() => setIsPreviewMediaOpen(false)}
|
||
mediaId={mediaId}
|
||
/>
|
||
<DeleteModal
|
||
open={isDeleteIconModalOpen}
|
||
onDelete={() => {
|
||
setEditUserData(
|
||
editUserData.name || "",
|
||
editUserData.email || "",
|
||
editUserData.password || "",
|
||
editUserData.is_admin || false,
|
||
"",
|
||
);
|
||
setIsDeleteIconModalOpen(false);
|
||
}}
|
||
onCancel={() => setIsDeleteIconModalOpen(false)}
|
||
edit
|
||
/>
|
||
</Paper>
|
||
);
|
||
});
|