Files
WhiteNightsAdminPanel/src/pages/User/UserEditPage/index.tsx

552 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Button,
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,
selectedCityStore,
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(() => {
selectedCityStore.setIsLocked(true);
return () => selectedCityStore.setIsLocked(false);
}, []);
useEffect(() => {
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) {
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>
<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">
<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>
<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>
);
});