refactor(frontend): split department showcase and management
This commit is contained in:
parent
b73336744a
commit
3b855e2ef1
@ -74,6 +74,19 @@
|
||||
|
||||
- `frontend/src/components/requests/RequestUserBadge.tsx`
|
||||
|
||||
### Object detail screens
|
||||
|
||||
Если один detail-route обслуживает и обычный просмотр, и управление объектом:
|
||||
|
||||
- базовый режим должен быть `showcase-first`, а не выглядеть как обрубленная админка
|
||||
- management controls встраиваются в ту же anatomy, а не создают второй отдельный layout
|
||||
- главный акцент у сильного header и одного основного рабочего блока
|
||||
- secondary-настройки должны быть заметно спокойнее главного контента
|
||||
|
||||
Референс по духу:
|
||||
|
||||
- `frontend/src/app/departments/[id]/page.tsx`
|
||||
|
||||
### Compose forms
|
||||
|
||||
Compose-паттерн должен восприниматься как рабочее пространство, а не как список независимых инпутов.
|
||||
|
||||
@ -231,10 +231,11 @@
|
||||
|
||||
Использовать как референс для:
|
||||
|
||||
- detail screen, который должен быть полноценным management workspace, а не бедной карточкой-описанием
|
||||
- detail screen, который должен уметь переключаться между `showcase` и `management` без смены route и без ощущения двух разных страниц
|
||||
- page-level controller для domain actions вокруг одного объекта: overview, members, head management, roles
|
||||
- richer detail layout, где есть сильный header, один основной roster-блок и один secondary-блок настроек, без раздувания служебных summary-panels
|
||||
- richer detail layout, где есть сильный header, один основной roster-блок и один secondary-блок ролей, без раздувания служебных summary-panels
|
||||
- member roster с role assignment и person-chip подачей вместо сырых строк участников
|
||||
- role management на уровне названий ролей и назначений участникам, без фронтового permission-editor
|
||||
|
||||
С оговоркой:
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
| `users/directory` | `mixed` | `users/page.tsx` и `employees/page.tsx` уже живут в app-surface language, сценарии user/employee navigation выровнены и читаются как один cluster | список людей всё ещё проще и беднее по interaction density, чем основные рабочие модули, поэтому это не полноценный stylistic reference | считать baseline-pass завершённым и трогать дальше только при появлении richer directory requirements |
|
||||
| `home/feed` | `mixed` | сильный app-shell fit, хорошая operational density, уже много app primitives | page очень большой, комментарии и post-compose logic перегружены, встречаются legacy `rounded-md` fragments | использовать как reference по feed density, но разрезать modal/comments/post flows и дочистить geometry |
|
||||
| `notifications` | `mixed` | близок к operational app-style, фильтры и list hierarchy уже читаются системно | page-level logic пока монолитная, нет выделенного feature controller, есть место для уплотнения и симметрии с messages/requests | после people cluster сделать короткий structural pass без крупного редизайна |
|
||||
| `departments` | `mixed` | detail screen уже поднят до полноценного management workspace: сильный header, команда как основной блок, роли как secondary-блок, page-level controller вынесен в hook | список отделов всё ещё проще detail screen, а сам модуль ещё не проверен длинным visual-pass и живым backend-сценарием | считать `departments/[id]` baseline reference для object-management pages и затем при желании подтянуть list screen до той же richness |
|
||||
| `departments` | `mixed` | detail screen уже работает как dual-mode page: showcase-first для обычного просмотра и встроенный management-layer для управленцев, page-level controller вынесен в hook | список отделов всё ещё проще detail screen, а сам модуль ещё не проверен длинным visual-pass и живым backend-сценарием | считать `departments/[id]` baseline reference для object-detail pages с mixed audience и затем при желании подтянуть list screen до той же richness |
|
||||
| `auth/legal` | `mixed` | изолированные flows уже отделены от app-shell, общая подача аккуратная | `rounded-[2rem]` и локальная hero-card геометрия образуют мини-систему отдельно от app shell | не смешивать с operational refactor; сделать отдельный compact pass по auth/legal geometry |
|
||||
| `documents/dashboard` | `legacy-heavy` | есть готовые data blocks | заметны старые rounded patterns и мелкие utility-решения | включить в documents refactor pass |
|
||||
|
||||
@ -177,8 +177,8 @@
|
||||
Что делать:
|
||||
|
||||
- у `settings` после controller extraction резать section-level UI и только затем geometry
|
||||
- у `departments` поднять богатство patterns до общего уровня app
|
||||
- detail screen `departments/[id]` уже не считать пустой зоной; дальнейшие работы здесь должны быть про polishing, а не про изобретение management UI с нуля
|
||||
- у `departments` держать detail screen в логике `showcase + management layer`, а не возвращать его в mini-admin panel
|
||||
- detail screen `departments/[id]` уже не считать пустой зоной; дальнейшие работы здесь должны быть про polishing, а не про возврат permission-editor и служебных панелей
|
||||
- у `auth/legal` выровнять isolated geometry отдельно от app-shell жанра
|
||||
|
||||
### Волна 7. Calendar special audit
|
||||
|
||||
@ -15,31 +15,30 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { DepartmentPersonChip } from "@/components/departments/DepartmentPersonChip";
|
||||
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useDepartmentPage } from "@/hooks/useDepartmentPage";
|
||||
import type {
|
||||
DepartmentMemberLink,
|
||||
DepartmentPermissionChoice,
|
||||
DepartmentRole,
|
||||
} from "@/types/api";
|
||||
import type { DepartmentMemberLink, DepartmentRole } from "@/types/api";
|
||||
|
||||
function PermissionBadge({
|
||||
active,
|
||||
function MetaChip({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "accent" | "warning";
|
||||
}) {
|
||||
const className =
|
||||
tone === "accent"
|
||||
? "app-selected app-accent-text"
|
||||
: tone === "warning"
|
||||
? "app-feedback-warning"
|
||||
: "app-badge";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
active
|
||||
? "app-selected app-accent-text"
|
||||
: "app-surface-muted app-text-muted"
|
||||
}`}
|
||||
className={`${className} inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
@ -72,98 +71,104 @@ function DepartmentMemberRow({
|
||||
onSetHead: (employeeId: number) => Promise<void>;
|
||||
}) {
|
||||
const isHead = currentHeadId === member.employee.id;
|
||||
const isBusy = pendingKey === `member-remove-${member.employee.id}`;
|
||||
const isRemoving = pendingKey === `member-remove-${member.employee.id}`;
|
||||
const isRoleBusy = pendingKey === `member-role-${member.employee.id}`;
|
||||
const subtitle = member.employee.position?.name || member.employee.email || null;
|
||||
const roleItems = roleOptions.map((role) => ({ id: role.id, name: role.name }));
|
||||
const subtitle =
|
||||
member.employee.position?.name || member.employee.email || null;
|
||||
const managementMode = canAssignRoles || canChangeHead || canManage;
|
||||
|
||||
return (
|
||||
<article className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<DepartmentPersonChip
|
||||
currentUserId={currentUserId}
|
||||
person={member.employee}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DepartmentPersonChip
|
||||
currentUserId={currentUserId}
|
||||
person={member.employee}
|
||||
subtitle={subtitle}
|
||||
/>
|
||||
{isHead ? (
|
||||
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium">
|
||||
<MetaChip tone="warning">
|
||||
<Crown size={12} />
|
||||
Руководитель
|
||||
</span>
|
||||
</MetaChip>
|
||||
) : null}
|
||||
{!member.is_active ? (
|
||||
<span className="app-feedback-danger inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
|
||||
Неактивное участие
|
||||
</span>
|
||||
) : null}
|
||||
{member.role && !canAssignRoles ? (
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{member.role ? (
|
||||
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
|
||||
{member.role.name}
|
||||
</span>
|
||||
) : null}
|
||||
{!member.role && !canAssignRoles ? (
|
||||
) : (
|
||||
<span className="app-surface rounded-full px-2.5 py-1 text-xs text-[var(--muted-foreground)]">
|
||||
Без роли
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-3 lg:max-w-[420px] lg:items-end">
|
||||
{canAssignRoles ? (
|
||||
<div className="w-full lg:w-64">
|
||||
<SearchableSelectSingle
|
||||
label="Роль"
|
||||
items={roleItems}
|
||||
selectedId={member.role?.id ?? null}
|
||||
onSelect={(roleId) => void onAssignRole(member.employee.id, roleId)}
|
||||
placeholder="Без роли"
|
||||
disabled={isRoleBusy}
|
||||
/>
|
||||
{managementMode ? (
|
||||
<div className="flex w-full flex-col gap-3 lg:w-auto lg:min-w-[260px] lg:items-end">
|
||||
{canAssignRoles ? (
|
||||
<div className="w-full lg:w-64">
|
||||
<SearchableSelectSingle
|
||||
label="Роль"
|
||||
items={roleOptions}
|
||||
selectedId={member.role?.id ?? null}
|
||||
onSelect={(roleId) =>
|
||||
void onAssignRole(member.employee.id, roleId)
|
||||
}
|
||||
placeholder="Без роли"
|
||||
disabled={isRoleBusy}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||
{canChangeHead && !isHead ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onSetHead(member.employee.id)}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<Crown size={14} />
|
||||
Руководитель
|
||||
</button>
|
||||
) : null}
|
||||
{canManage && !isHead ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRemoveMember(member.employee.id)}
|
||||
disabled={isRemoving}
|
||||
className="app-action-danger inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Убрать
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
|
||||
{canChangeHead && !isHead ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onSetHead(member.employee.id)}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<Crown size={14} />
|
||||
Сделать руководителем
|
||||
</button>
|
||||
) : null}
|
||||
{canManage && !isHead ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRemoveMember(member.employee.id)}
|
||||
disabled={isBusy}
|
||||
className="app-action-danger inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Убрать
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function DepartmentRoleCard({
|
||||
canAssignRoles,
|
||||
canManageRoles,
|
||||
pendingKey,
|
||||
role,
|
||||
usageCount,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
canAssignRoles: boolean;
|
||||
canManageRoles: boolean;
|
||||
pendingKey: string | null;
|
||||
role: DepartmentRole;
|
||||
usageCount: number;
|
||||
@ -174,32 +179,32 @@ function DepartmentRoleCard({
|
||||
|
||||
return (
|
||||
<article className="app-surface-muted rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">
|
||||
{role.name}
|
||||
</h3>
|
||||
<span className="app-selected app-accent-text inline-flex rounded-full px-2.5 py-1 text-xs">
|
||||
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
|
||||
{usageCount} участников
|
||||
</span>
|
||||
</div>
|
||||
<p className="app-text-muted mt-1 text-xs">
|
||||
{role.permissions_verbose.length
|
||||
? "Набор прав для участников отдела"
|
||||
: "У роли пока нет назначенных прав"}
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
{usageCount
|
||||
? "Используется в составе отдела."
|
||||
: "Пока не назначена участникам отдела."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canAssignRoles ? (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canManageRoles ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(role)}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<PencilLine size={14} />
|
||||
Изменить
|
||||
Переименовать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -213,21 +218,6 @@ function DepartmentRoleCard({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{role.permissions_verbose.length ? (
|
||||
role.permissions_verbose.map((permission) => (
|
||||
<span
|
||||
key={permission.id}
|
||||
className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
>
|
||||
{permission.name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="app-text-muted text-sm">Права ещё не выбраны</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@ -282,7 +272,7 @@ function DepartmentEditorModal({
|
||||
value={draft.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Например, Финансовый отдел"
|
||||
placeholder="Например, Отдел закупок"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@ -293,9 +283,9 @@ function DepartmentEditorModal({
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => onChange({ description: event.target.value })}
|
||||
rows={5}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm resize-none"
|
||||
placeholder="Чем занимается отдел, какие зоны ответственности ведёт"
|
||||
rows={4}
|
||||
className="app-input w-full rounded-xl px-3 py-2 text-sm"
|
||||
placeholder="Коротко опиши задачи и зону ответственности отдела."
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@ -341,27 +331,25 @@ function AddMemberModal({
|
||||
disabled={loading || !selectedId}
|
||||
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
Добавить в отдел
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<SearchableSelectSingle
|
||||
label="Сотрудник *"
|
||||
label="Сотрудник"
|
||||
items={items}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
placeholder={
|
||||
items.length
|
||||
? "Выберите сотрудника"
|
||||
: "Свободных сотрудников для добавления нет"
|
||||
}
|
||||
placeholder="Выберите сотрудника"
|
||||
disabled={!items.length}
|
||||
/>
|
||||
<p className="app-text-muted mt-3 text-sm">
|
||||
В список попадают только сотрудники, которые ещё не состоят в отделе.
|
||||
</p>
|
||||
{!items.length ? (
|
||||
<p className="app-text-muted mt-3 text-sm">
|
||||
В директории нет доступных сотрудников для добавления.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</Modal>
|
||||
);
|
||||
@ -373,7 +361,6 @@ function RoleEditorModal({
|
||||
onClose,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
permissionChoices,
|
||||
roleDraft,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
@ -381,36 +368,21 @@ function RoleEditorModal({
|
||||
onClose: () => void;
|
||||
onDraftChange: (
|
||||
next:
|
||||
| { id: number | null; name: string; permissionCodes: string[] }
|
||||
| ((current: {
|
||||
| { id: number | null; name: string }
|
||||
| ((current: { id: number | null; name: string }) => {
|
||||
id: number | null;
|
||||
name: string;
|
||||
permissionCodes: string[];
|
||||
}) => {
|
||||
id: number | null;
|
||||
name: string;
|
||||
permissionCodes: string[];
|
||||
}),
|
||||
) => void;
|
||||
onSave: () => Promise<void>;
|
||||
permissionChoices: DepartmentPermissionChoice[];
|
||||
roleDraft: { id: number | null; name: string; permissionCodes: string[] };
|
||||
roleDraft: { id: number | null; name: string };
|
||||
}) {
|
||||
const togglePermission = (code: string) => {
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
permissionCodes: current.permissionCodes.includes(code)
|
||||
? current.permissionCodes.filter((item) => item !== code)
|
||||
: [...current.permissionCodes, code],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={roleDraft.id ? "Редактирование роли" : "Новая роль отдела"}
|
||||
size="lg"
|
||||
title={roleDraft.id ? "Переименование роли" : "Новая роль отдела"}
|
||||
size="md"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
@ -426,73 +398,30 @@ function RoleEditorModal({
|
||||
disabled={loading}
|
||||
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
{roleDraft.id ? "Сохранить роль" : "Создать роль"}
|
||||
{roleDraft.id ? "Сохранить" : "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
Название роли *
|
||||
</label>
|
||||
<input
|
||||
value={roleDraft.name}
|
||||
onChange={(event) =>
|
||||
onDraftChange((current) => ({ ...current, name: event.target.value }))
|
||||
}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Например, Координатор отдела"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-[var(--foreground)]">
|
||||
Права роли
|
||||
</h4>
|
||||
<p className="app-text-muted mt-1 text-sm">
|
||||
Выберите, какими действиями сможет пользоваться участник с этой ролью.
|
||||
</p>
|
||||
</div>
|
||||
<span className="app-selected app-accent-text inline-flex rounded-full px-2.5 py-1 text-xs">
|
||||
{roleDraft.permissionCodes.length} прав
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{permissionChoices.map((permission) => {
|
||||
const checked = roleDraft.permissionCodes.includes(permission.code);
|
||||
return (
|
||||
<label
|
||||
key={permission.id}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-xl border px-3 py-3 text-sm transition ${
|
||||
checked
|
||||
? "app-selected border-[var(--accent-primary)]"
|
||||
: "border-[var(--border-subtle)] hover:bg-[var(--surface-elevated)]"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => togglePermission(permission.code)}
|
||||
className="mt-0.5 rounded border-[var(--border-strong)]"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-[var(--foreground)]">
|
||||
{permission.name}
|
||||
</span>
|
||||
<span className="app-text-muted mt-1 block text-xs">
|
||||
{permission.code}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
Название роли *
|
||||
</label>
|
||||
<input
|
||||
value={roleDraft.name}
|
||||
onChange={(event) =>
|
||||
onDraftChange((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="app-input w-full rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="Например, Координатор отдела"
|
||||
/>
|
||||
<p className="app-text-muted mt-3 text-sm">
|
||||
Роль нужна для аккуратного описания состава отдела и назначения участникам.
|
||||
</p>
|
||||
</section>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -503,13 +432,12 @@ export default function DepartmentDetailPage() {
|
||||
const h = useDepartmentPage(departmentId);
|
||||
|
||||
const activeMembersCount = h.members.filter((member) => member.is_active).length;
|
||||
const inactiveMembersCount = h.members.length - activeMembersCount;
|
||||
const roleOptions = h.roles.map((role) => ({ id: role.id, name: role.name }));
|
||||
const permissionSummary = [
|
||||
h.userPerms.is_head ? "Руководитель отдела" : null,
|
||||
h.userPerms.can_manage ? "Управление составом" : null,
|
||||
h.userPerms.can_change_head ? "Смена руководителя" : null,
|
||||
h.userPerms.can_assign_roles ? "Назначение ролей" : null,
|
||||
].filter(Boolean) as string[];
|
||||
const isManagementMode =
|
||||
h.userPerms.can_manage ||
|
||||
h.userPerms.can_change_head ||
|
||||
h.userPerms.can_assign_roles;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@ -534,232 +462,250 @@ export default function DepartmentDetailPage() {
|
||||
) : h.department ? (
|
||||
<>
|
||||
<section className="app-surface rounded-2xl p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.8fr)_minmax(240px,0.9fr)]">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="app-selected app-accent-text inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<MetaChip tone="accent">
|
||||
<Building2 size={12} />
|
||||
Отдел
|
||||
</span>
|
||||
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs">
|
||||
</MetaChip>
|
||||
<MetaChip>
|
||||
<Users size={12} />
|
||||
{activeMembersCount} участников
|
||||
</span>
|
||||
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs">
|
||||
{h.roles.length} ролей
|
||||
</span>
|
||||
</MetaChip>
|
||||
<MetaChip>{h.roles.length} ролей</MetaChip>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-[var(--foreground)] sm:text-3xl">
|
||||
{h.department.name}
|
||||
</h1>
|
||||
<p className="app-text-muted mt-3 max-w-3xl text-sm leading-relaxed sm:text-base">
|
||||
{h.department.description || "Описание отдела ещё не заполнено."}
|
||||
</p>
|
||||
|
||||
{permissionSummary.length ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{permissionSummary.map((label) => (
|
||||
<PermissionBadge key={label} active>
|
||||
{label}
|
||||
</PermissionBadge>
|
||||
))}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-[var(--foreground)] sm:text-3xl">
|
||||
{h.department.name}
|
||||
</h1>
|
||||
<p className="app-text-muted mt-3 max-w-3xl text-sm leading-relaxed sm:text-base">
|
||||
{h.department.description ||
|
||||
"В этом разделе собран обзор отдела, состав команды и рабочие роли."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{h.userPerms.can_manage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={h.openDepartmentEditor}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
<PencilLine size={14} />
|
||||
Редактировать отдел
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(280px,360px)]">
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="app-card-caption">Руководитель</p>
|
||||
<div className="mt-3">
|
||||
{h.department.head ? (
|
||||
<DepartmentPersonChip
|
||||
currentUserId={h.currentUserId}
|
||||
person={h.department.head}
|
||||
subtitle={h.department.head.position?.name || h.department.head.email}
|
||||
/>
|
||||
) : (
|
||||
<div className="app-surface rounded-xl px-4 py-3 text-sm text-[var(--foreground)]">
|
||||
Руководитель пока не назначен.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{h.userPerms.can_change_head ? (
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<SearchableSelectSingle
|
||||
label="Назначить руководителя"
|
||||
items={h.headCandidates}
|
||||
selectedId={h.selectedHeadId}
|
||||
onSelect={h.setSelectedHeadId}
|
||||
placeholder="Выберите сотрудника"
|
||||
disabled={!h.headCandidates.length}
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{h.userPerms.can_manage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void h.submitHeadChange()}
|
||||
disabled={h.pendingKey === "head"}
|
||||
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
onClick={h.openDepartmentEditor}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
Сохранить
|
||||
<PencilLine size={14} />
|
||||
Редактировать отдел
|
||||
</button>
|
||||
{h.department.head ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void h.submitHeadRemoval()}
|
||||
disabled={h.pendingKey === "head"}
|
||||
className="app-action-secondary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
Снять
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="app-card-caption">Руководитель отдела</p>
|
||||
<div className="mt-3">
|
||||
{h.department.head ? (
|
||||
<DepartmentPersonChip
|
||||
currentUserId={h.currentUserId}
|
||||
person={h.department.head}
|
||||
subtitle={
|
||||
h.department.head.position?.name ||
|
||||
h.department.head.email
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="app-surface rounded-xl px-4 py-3 text-sm text-[var(--foreground)]">
|
||||
Руководитель пока не назначен.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{h.userPerms.can_change_head ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<SearchableSelectSingle
|
||||
label="Назначить руководителя"
|
||||
items={h.headCandidates}
|
||||
selectedId={h.selectedHeadId}
|
||||
onSelect={h.setSelectedHeadId}
|
||||
placeholder="Выберите сотрудника"
|
||||
disabled={!h.headCandidates.length}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void h.submitHeadChange()}
|
||||
disabled={h.pendingKey === "head"}
|
||||
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
{h.department.head ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void h.submitHeadRemoval()}
|
||||
disabled={h.pendingKey === "head"}
|
||||
className="app-action-secondary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
>
|
||||
Снять
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted rounded-xl p-4">
|
||||
<p className="app-card-caption">О составе</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<MetaChip>{activeMembersCount} активных</MetaChip>
|
||||
{inactiveMembersCount ? (
|
||||
<MetaChip>{inactiveMembersCount} неактивных</MetaChip>
|
||||
) : null}
|
||||
{isManagementMode ? (
|
||||
<MetaChip tone="warning">Режим управления</MetaChip>
|
||||
) : (
|
||||
<MetaChip>Режим просмотра</MetaChip>
|
||||
)}
|
||||
</div>
|
||||
<p className="app-text-muted mt-3 text-sm">
|
||||
{isManagementMode
|
||||
? "Ты видишь рабочий экран отдела и можешь управлять составом и ролями."
|
||||
: "Здесь можно посмотреть состав отдела и его внутренние роли без лишнего admin-шумa."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section className="app-surface rounded-2xl p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="app-card-caption">Команда отдела</h2>
|
||||
<section className="app-surface rounded-2xl p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="app-card-caption">Команда отдела</h2>
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
{isManagementMode
|
||||
? "Основной рабочий блок: состав команды, роли участников и быстрые действия."
|
||||
: "Состав отдела, роли участников и текущая структура команды."}
|
||||
</p>
|
||||
</div>
|
||||
{h.userPerms.can_manage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={h.openAddMember}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
Добавить участника
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="app-surface-muted mb-4 flex flex-col gap-3 rounded-xl p-3 md:flex-row md:items-center">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search
|
||||
size={16}
|
||||
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<input
|
||||
value={h.membersQuery}
|
||||
onChange={(event) => h.setMembersQuery(event.target.value)}
|
||||
placeholder="Поиск по участникам, почте, должности или роли"
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<label className="app-text-muted inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={h.showInactiveMembers}
|
||||
onChange={(event) =>
|
||||
h.setShowInactiveMembers(event.target.checked)
|
||||
}
|
||||
className="rounded border-[var(--border-strong)]"
|
||||
/>
|
||||
Показывать неактивных
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{h.filteredMembers.length ? (
|
||||
h.filteredMembers.map((member) => (
|
||||
<DepartmentMemberRow
|
||||
key={`${member.employee.id}-${member.is_active ? "active" : "inactive"}`}
|
||||
canAssignRoles={h.userPerms.can_assign_roles}
|
||||
canChangeHead={h.userPerms.can_change_head}
|
||||
canManage={h.userPerms.can_manage}
|
||||
currentHeadId={h.department?.head?.id}
|
||||
currentUserId={h.currentUserId}
|
||||
member={member}
|
||||
pendingKey={h.pendingKey}
|
||||
roleOptions={roleOptions}
|
||||
onAssignRole={h.submitMemberRole}
|
||||
onRemoveMember={h.submitRemoveMember}
|
||||
onSetHead={h.submitQuickHeadChange}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<Users size={24} className="app-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
Участники не найдены
|
||||
</p>
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
Основной рабочий блок отдела: состав команды, роли участников и быстрые действия.
|
||||
Измени поисковый запрос или включи показ неактивных связей.
|
||||
</p>
|
||||
</div>
|
||||
{h.userPerms.can_manage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={h.openAddMember}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
Добавить участника
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="app-surface-muted mb-4 flex flex-col gap-3 rounded-xl p-3 md:flex-row md:items-center">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search
|
||||
size={16}
|
||||
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<input
|
||||
value={h.membersQuery}
|
||||
onChange={(event) => h.setMembersQuery(event.target.value)}
|
||||
placeholder="Поиск по участникам, почте, должности или роли"
|
||||
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<label className="app-text-muted inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={h.showInactiveMembers}
|
||||
onChange={(event) =>
|
||||
h.setShowInactiveMembers(event.target.checked)
|
||||
}
|
||||
className="rounded border-[var(--border-strong)]"
|
||||
/>
|
||||
Показывать неактивных
|
||||
</label>
|
||||
<section className="app-surface rounded-2xl p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="app-card-caption">Роли отдела</h2>
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
{isManagementMode
|
||||
? "Именованные роли внутри отдела: их можно создавать, переименовывать и назначать участникам."
|
||||
: "Справочный список ролей, которые используются внутри отдела."}
|
||||
</p>
|
||||
</div>
|
||||
{h.userPerms.can_assign_roles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={h.openCreateRole}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Создать роль
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{h.filteredMembers.length ? (
|
||||
h.filteredMembers.map((member) => (
|
||||
<DepartmentMemberRow
|
||||
key={`${member.employee.id}-${member.is_active ? "active" : "inactive"}`}
|
||||
canAssignRoles={h.userPerms.can_assign_roles}
|
||||
canChangeHead={h.userPerms.can_change_head}
|
||||
canManage={h.userPerms.can_manage}
|
||||
currentHeadId={h.department?.head?.id}
|
||||
currentUserId={h.currentUserId}
|
||||
member={member}
|
||||
pendingKey={h.pendingKey}
|
||||
roleOptions={roleOptions}
|
||||
onAssignRole={h.submitMemberRole}
|
||||
onRemoveMember={h.submitRemoveMember}
|
||||
onSetHead={h.submitQuickHeadChange}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<Users size={24} className="app-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
Участники не найдены
|
||||
</p>
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
Измени поисковый запрос или включи показ неактивных связей.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="app-surface rounded-2xl p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="app-card-caption">Роли отдела</h2>
|
||||
<div className="space-y-3">
|
||||
{h.roles.length ? (
|
||||
h.roles.map((role) => (
|
||||
<DepartmentRoleCard
|
||||
key={role.id}
|
||||
canManageRoles={h.userPerms.can_assign_roles}
|
||||
pendingKey={h.pendingKey}
|
||||
role={role}
|
||||
usageCount={h.roleUsage[role.id] || 0}
|
||||
onDelete={h.submitRoleDelete}
|
||||
onEdit={h.openEditRole}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
Роли ещё не созданы
|
||||
</p>
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
Наборы прав для участников. Это secondary-настройка состава, а не основной центр страницы.
|
||||
{isManagementMode
|
||||
? "Создай первую роль, если внутри отдела нужно разделить обязанности."
|
||||
: "В этом отделе пока не заведены отдельные внутренние роли."}
|
||||
</p>
|
||||
</div>
|
||||
{h.userPerms.can_assign_roles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={h.openCreateRole}
|
||||
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Создать роль
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{h.roles.length ? (
|
||||
h.roles.map((role) => (
|
||||
<DepartmentRoleCard
|
||||
key={role.id}
|
||||
canAssignRoles={h.userPerms.can_assign_roles}
|
||||
pendingKey={h.pendingKey}
|
||||
role={role}
|
||||
usageCount={h.roleUsage[role.id] || 0}
|
||||
onDelete={h.submitRoleDelete}
|
||||
onEdit={h.openEditRole}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="app-surface-muted rounded-xl p-8 text-center">
|
||||
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||
Роли ещё не созданы
|
||||
</p>
|
||||
<p className="app-text-muted mt-2 text-sm">
|
||||
Создай роли, если внутри отдела нужно разделить управленческие права.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@ -789,7 +735,6 @@ export default function DepartmentDetailPage() {
|
||||
onClose={h.closeRoleEditor}
|
||||
onDraftChange={h.setRoleDraft}
|
||||
onSave={h.saveRole}
|
||||
permissionChoices={h.permissionChoices}
|
||||
roleDraft={h.roleDraft}
|
||||
/>
|
||||
</AppShell>
|
||||
|
||||
@ -9,7 +9,6 @@ import { loadAllPages } from "@/lib/shared";
|
||||
import type {
|
||||
Department,
|
||||
DepartmentMemberLink,
|
||||
DepartmentPermissionChoice,
|
||||
DepartmentRole,
|
||||
DepartmentUserPermissions,
|
||||
PaginatedResponse,
|
||||
@ -19,7 +18,6 @@ import type {
|
||||
type DepartmentRoleDraft = {
|
||||
id: number | null;
|
||||
name: string;
|
||||
permissionCodes: string[];
|
||||
};
|
||||
|
||||
type SavingKey =
|
||||
@ -46,7 +44,6 @@ export type DepartmentPageController = {
|
||||
members: DepartmentMemberLink[];
|
||||
membersQuery: string;
|
||||
pendingKey: SavingKey | null;
|
||||
permissionChoices: DepartmentPermissionChoice[];
|
||||
roleDraft: DepartmentRoleDraft;
|
||||
roleEditorOpen: boolean;
|
||||
roleUsage: Record<number, number>;
|
||||
@ -129,7 +126,6 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
const [department, setDepartment] = useState<Department | null>(null);
|
||||
const [members, setMembers] = useState<DepartmentMemberLink[]>([]);
|
||||
const [roles, setRoles] = useState<DepartmentRole[]>([]);
|
||||
const [permissionChoices, setPermissionChoices] = useState<DepartmentPermissionChoice[]>([]);
|
||||
const [userPerms, setUserPerms] = useState<DepartmentUserPermissions | null>(null);
|
||||
const [allEmployees, setAllEmployees] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -150,7 +146,6 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
const [roleDraft, setRoleDraft] = useState<DepartmentRoleDraft>({
|
||||
id: null,
|
||||
name: "",
|
||||
permissionCodes: [],
|
||||
});
|
||||
|
||||
const hydrateDepartmentDraft = useCallback((nextDepartment: Department | null) => {
|
||||
@ -171,7 +166,6 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
setDepartment(null);
|
||||
setMembers([]);
|
||||
setRoles([]);
|
||||
setPermissionChoices([]);
|
||||
setUserPerms(null);
|
||||
setAllEmployees([]);
|
||||
setError("Некорректный идентификатор отдела");
|
||||
@ -188,13 +182,11 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
membersResponse,
|
||||
userPermsResponse,
|
||||
rolesResponse,
|
||||
permChoicesResponse,
|
||||
] = await Promise.all([
|
||||
apiClient.getDepartment(departmentId) as Promise<Department>,
|
||||
apiClient.getDepartmentMembers(departmentId) as Promise<PaginatedResponse<DepartmentMemberLink>>,
|
||||
apiClient.getDepartmentUserPerms(departmentId) as Promise<DepartmentUserPermissions>,
|
||||
apiClient.getDepartmentRoles({ department: departmentId, limit: 200 }) as Promise<PaginatedResponse<DepartmentRole> | DepartmentRole[]>,
|
||||
apiClient.getDepartmentRolePermChoices() as Promise<{ results?: DepartmentPermissionChoice[] } | DepartmentPermissionChoice[]>,
|
||||
]);
|
||||
|
||||
const nextDepartment = departmentResponse;
|
||||
@ -209,14 +201,10 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
const roleResults = Array.isArray(rolesResponse)
|
||||
? rolesResponse
|
||||
: (rolesResponse.results || []);
|
||||
const permResults = Array.isArray(permChoicesResponse)
|
||||
? permChoicesResponse
|
||||
: (permChoicesResponse.results || []);
|
||||
|
||||
setDepartment(nextDepartment);
|
||||
setMembers(nextMembers);
|
||||
setRoles([...roleResults].sort((a, b) => a.name.localeCompare(b.name, "ru")));
|
||||
setPermissionChoices(permResults);
|
||||
setUserPerms(userPermsResponse);
|
||||
hydrateDepartmentDraft(nextDepartment);
|
||||
} catch (loadError) {
|
||||
@ -468,7 +456,7 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
}, [department, loadCore]);
|
||||
|
||||
const openCreateRole = useCallback(() => {
|
||||
setRoleDraft({ id: null, name: "", permissionCodes: [] });
|
||||
setRoleDraft({ id: null, name: "" });
|
||||
setRoleEditorOpen(true);
|
||||
}, []);
|
||||
|
||||
@ -476,14 +464,13 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
setRoleDraft({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
permissionCodes: role.permissions_verbose.map((permission) => permission.code),
|
||||
});
|
||||
setRoleEditorOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRoleEditor = useCallback(() => {
|
||||
setRoleEditorOpen(false);
|
||||
setRoleDraft({ id: null, name: "", permissionCodes: [] });
|
||||
setRoleDraft({ id: null, name: "" });
|
||||
}, []);
|
||||
|
||||
const saveRole = useCallback(async () => {
|
||||
@ -500,14 +487,12 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
if (roleDraft.id) {
|
||||
await apiClient.updateDepartmentRole(roleDraft.id, {
|
||||
name,
|
||||
scoped_permission_codes: roleDraft.permissionCodes,
|
||||
});
|
||||
toast.success("Роль обновлена");
|
||||
} else {
|
||||
await apiClient.createDepartmentRole({
|
||||
department: department.id,
|
||||
name,
|
||||
scoped_permission_codes: roleDraft.permissionCodes,
|
||||
});
|
||||
toast.success("Роль создана");
|
||||
}
|
||||
@ -518,7 +503,7 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
} finally {
|
||||
setPendingKey(null);
|
||||
}
|
||||
}, [closeRoleEditor, department, loadCore, roleDraft.id, roleDraft.name, roleDraft.permissionCodes]);
|
||||
}, [closeRoleEditor, department, loadCore, roleDraft.id, roleDraft.name]);
|
||||
|
||||
const submitRoleDelete = useCallback(async (role: DepartmentRole) => {
|
||||
if (!window.confirm(`Удалить роль «${role.name}»?`)) return;
|
||||
@ -550,7 +535,6 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
|
||||
members,
|
||||
membersQuery,
|
||||
pendingKey,
|
||||
permissionChoices,
|
||||
roleDraft,
|
||||
roleEditorOpen,
|
||||
roleUsage,
|
||||
|
||||
@ -92,12 +92,12 @@ export function createEmployeesApi(request: RequestFn) {
|
||||
const qs = qp.toString();
|
||||
return request(`/api/v1/department-roles/${qs ? '?' + qs : ''}`);
|
||||
},
|
||||
createDepartmentRole: (data: { department: number; name: string; scoped_permission_codes?: string[] }) =>
|
||||
createDepartmentRole: (data: { department: number; name: string }) =>
|
||||
request('/api/v1/department-roles/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateDepartmentRole: (id: number | string, data: { name?: string; scoped_permission_codes?: string[] }) =>
|
||||
updateDepartmentRole: (id: number | string, data: { name?: string }) =>
|
||||
request(`/api/v1/department-roles/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user