diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md
index da5823e0..5d2869f6 100644
--- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md
+++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md
@@ -221,6 +221,25 @@
- action timeline
- page-level profile controllers, которые держат loading/derived state/API orchestration вне page JSX
+### Departments
+
+Файлы:
+
+- `departments/DepartmentPersonChip.tsx`
+- `app/departments/[id]/page.tsx`
+- `hooks/useDepartmentPage.ts`
+
+Использовать как референс для:
+
+- detail screen, который должен быть полноценным management workspace, а не бедной карточкой-описанием
+- page-level controller для domain actions вокруг одного объекта: overview, members, head management, roles, permissions
+- richer detail layout, где основной управленческий список сочетается с правой summary/actions колонкой
+- member roster с role assignment и person-chip подачей вместо сырых строк участников
+
+С оговоркой:
+
+- `departments/[id]` должен оставаться привязанным к реальным backend-capabilities; не наращивать UI, если под него нет соответствующих endpoint/actions
+
### Messages
Файлы:
diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
index cb614112..f5776c46 100644
--- a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
+++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md
@@ -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` | page/detail screens небольшие и уже живут в app-shell language | не хватает единых richer patterns между list/detail, зона выглядит недоразвитой по сравнению с остальными модулями | low-cost pass после крупных зон: выровнять richness и reuse |
+| `departments` | `mixed` | detail screen уже поднят до полноценного management workspace: overview, members, head management, roles, user-permissions; page-level controller вынесен в hook | список отделов всё ещё проще detail screen, а сам модуль ещё не проверен длинным visual-pass и живым backend-сценарием | считать `departments/[id]` baseline reference для object-management pages и затем при желании подтянуть 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 |
@@ -50,7 +50,7 @@
6. `messages`
7. `settings`
8. отдельный audit по `calendar`
-9. `departments`
+9. `departments` list-screen polishing
10. `auth/legal`
Логика порядка:
@@ -178,6 +178,7 @@
- у `settings` после controller extraction резать section-level UI и только затем geometry
- у `departments` поднять богатство patterns до общего уровня app
+- detail screen `departments/[id]` уже не считать пустой зоной; дальнейшие работы здесь должны быть про polishing, а не про изобретение management UI с нуля
- у `auth/legal` выровнять isolated geometry отдельно от app-shell жанра
### Волна 7. Calendar special audit
diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx
index 2fecf090..ed6c7f0b 100644
--- a/frontend/src/app/departments/[id]/page.tsx
+++ b/frontend/src/app/departments/[id]/page.tsx
@@ -1,48 +1,541 @@
"use client";
-import { useEffect, useState } from "react";
-import { useParams } from "next/navigation";
import Link from "next/link";
-import { AppShell } from "../../../components/AppShell";
-import { apiClient } from "@/lib/api";
-import type { Department } from "@/types/api";
-import { ArrowLeft, Building2, Users } from "lucide-react";
+import { useParams } from "next/navigation";
+import type { ReactNode } from "react";
+import {
+ ArrowLeft,
+ BriefcaseBusiness,
+ Building2,
+ Crown,
+ PencilLine,
+ Plus,
+ ShieldCheck,
+ Trash2,
+ UserPlus,
+ Users,
+} from "lucide-react";
+
+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";
+
+function StatCard({
+ label,
+ value,
+ icon,
+}: {
+ label: string;
+ value: string;
+ icon: ReactNode;
+}) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ );
+}
+
+function PermissionBadge({
+ active,
+ children,
+}: {
+ active: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function DepartmentMemberRow({
+ canAssignRoles,
+ canChangeHead,
+ canManage,
+ currentHeadId,
+ currentUserId,
+ member,
+ pendingKey,
+ roleOptions,
+ onAssignRole,
+ onRemoveMember,
+ onSetHead,
+}: {
+ canAssignRoles: boolean;
+ canChangeHead: boolean;
+ canManage: boolean;
+ currentHeadId?: number | null;
+ currentUserId?: number | null;
+ member: DepartmentMemberLink;
+ pendingKey: string | null;
+ roleOptions: Array<{ id: number; name: string }>;
+ onAssignRole: (employeeId: number, roleId: number | null) => Promise;
+ onRemoveMember: (employeeId: number) => Promise;
+ onSetHead: (employeeId: number) => Promise;
+}) {
+ const isHead = currentHeadId === member.employee.id;
+ const isBusy = pendingKey === `member-remove-${member.employee.id}`;
+ const isRoleBusy = pendingKey === `member-role-${member.employee.id}`;
+ const subtitle = member.employee.position?.name || member.employee.email || null;
+
+ return (
+
+
+
+
+
+
+ {isHead ? (
+
+
+ Руководитель
+
+ ) : null}
+ {!member.is_active ? (
+
+ Неактивное участие
+
+ ) : null}
+ {member.role && !canAssignRoles ? (
+
+ {member.role.name}
+
+ ) : null}
+
+
+
+
+ {canAssignRoles ? (
+
+
+ Роль в отделе
+
+
+ void onAssignRole(
+ member.employee.id,
+ event.target.value ? Number(event.target.value) : null,
+ )
+ }
+ disabled={isRoleBusy}
+ className="app-select w-full rounded-lg px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-60"
+ >
+ Без роли
+ {roleOptions.map((role) => (
+
+ {role.name}
+
+ ))}
+
+
+ ) : null}
+
+
+ {canChangeHead && !isHead ? (
+ void onSetHead(member.employee.id)}
+ className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
+ >
+
+ Сделать руководителем
+
+ ) : null}
+ {canManage && !isHead ? (
+ 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"
+ >
+
+ Убрать
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function DepartmentRoleCard({
+ canAssignRoles,
+ pendingKey,
+ role,
+ usageCount,
+ onDelete,
+ onEdit,
+}: {
+ canAssignRoles: boolean;
+ pendingKey: string | null;
+ role: DepartmentRole;
+ usageCount: number;
+ onDelete: (role: DepartmentRole) => Promise;
+ onEdit: (role: DepartmentRole) => void;
+}) {
+ const isDeleting = pendingKey === `role-delete-${role.id}`;
+
+ return (
+
+
+
+
+
+ {role.name}
+
+
+ {usageCount} участников
+
+
+
+ {role.permissions_verbose.length
+ ? "Набор прав для участников отдела"
+ : "У роли пока нет назначенных прав"}
+
+
+
+ {canAssignRoles ? (
+
+
onEdit(role)}
+ className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
+ >
+
+ Изменить
+
+
void onDelete(role)}
+ disabled={isDeleting}
+ className="app-action-danger inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm disabled:opacity-50"
+ >
+
+ Удалить
+
+
+ ) : null}
+
+
+
+ {role.permissions_verbose.length ? (
+ role.permissions_verbose.map((permission) => (
+
+ {permission.name}
+
+ ))
+ ) : (
+ Права ещё не выбраны
+ )}
+
+
+ );
+}
+
+function DepartmentEditorModal({
+ draft,
+ isOpen,
+ loading,
+ onChange,
+ onClose,
+ onSave,
+}: {
+ draft: { name: string; description: string };
+ isOpen: boolean;
+ loading: boolean;
+ onChange: (patch: Partial<{ name: string; description: string }>) => void;
+ onClose: () => void;
+ onSave: () => Promise;
+}) {
+ return (
+
+
+ Отмена
+
+ void onSave()}
+ disabled={loading}
+ className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
+ >
+ Сохранить
+
+
+ }
+ >
+
+
+
+
+
+
+ );
+}
+
+function AddMemberModal({
+ isOpen,
+ items,
+ loading,
+ onClose,
+ onSave,
+ onSelect,
+ selectedId,
+}: {
+ isOpen: boolean;
+ items: Array<{ id: number; name: string }>;
+ loading: boolean;
+ onClose: () => void;
+ onSave: () => Promise;
+ onSelect: (id: number | null) => void;
+ selectedId: number | null;
+}) {
+ return (
+
+
+ Отмена
+
+ void onSave()}
+ disabled={loading || !selectedId}
+ className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
+ >
+ Добавить в отдел
+
+
+ }
+ >
+
+
+
+ В список попадают только сотрудники, которые ещё не состоят в отделе.
+
+
+
+ );
+}
+
+function RoleEditorModal({
+ isOpen,
+ loading,
+ onClose,
+ onDraftChange,
+ onSave,
+ permissionChoices,
+ roleDraft,
+}: {
+ isOpen: boolean;
+ loading: boolean;
+ onClose: () => void;
+ onDraftChange: (
+ next:
+ | { id: number | null; name: string; permissionCodes: string[] }
+ | ((current: {
+ id: number | null;
+ name: string;
+ permissionCodes: string[];
+ }) => {
+ id: number | null;
+ name: string;
+ permissionCodes: string[];
+ }),
+ ) => void;
+ onSave: () => Promise;
+ permissionChoices: DepartmentPermissionChoice[];
+ roleDraft: { id: number | null; name: string; permissionCodes: string[] };
+}) {
+ const togglePermission = (code: string) => {
+ onDraftChange((current) => ({
+ ...current,
+ permissionCodes: current.permissionCodes.includes(code)
+ ? current.permissionCodes.filter((item) => item !== code)
+ : [...current.permissionCodes, code],
+ }));
+ };
+
+ return (
+
+
+ Отмена
+
+ void onSave()}
+ disabled={loading}
+ className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
+ >
+ {roleDraft.id ? "Сохранить роль" : "Создать роль"}
+
+
+ }
+ >
+
+
+
+
+
+
+
+ Права роли
+
+
+ Выберите, какими действиями сможет пользоваться участник с этой ролью.
+
+
+
+ {roleDraft.permissionCodes.length} прав
+
+
+
+
+ {permissionChoices.map((permission) => {
+ const checked = roleDraft.permissionCodes.includes(permission.code);
+ return (
+
+ togglePermission(permission.code)}
+ className="mt-0.5 rounded border-[var(--border-strong)]"
+ />
+
+
+ {permission.name}
+
+
+ {permission.code}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
export default function DepartmentDetailPage() {
const params = useParams<{ id: string }>();
const departmentId = Number(params?.id);
+ const h = useDepartmentPage(departmentId);
- const [department, setDepartment] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- async function loadDepartment() {
- if (!departmentId || Number.isNaN(departmentId)) {
- setError("Некорректный идентификатор отдела");
- setLoading(false);
- return;
- }
-
- try {
- setLoading(true);
- setError(null);
- const response = await apiClient.getDepartment(departmentId);
- setDepartment(response);
- } catch (e) {
- console.error("Ошибка загрузки отдела:", e);
- setError("Не удалось загрузить отдел");
- } finally {
- setLoading(false);
- }
- }
-
- loadDepartment();
- }, [departmentId]);
+ const activeMembersCount = h.members.filter((member) => member.is_active).length;
+ const roleOptions = h.roles.map((role) => ({ id: role.id, name: role.name }));
return (
-
+
- {loading ? (
+ {h.loading ? (
- ) : error ? (
+ ) : h.error ? (
- ) : department ? (
-
-
-
-
-
- Отдел
+ ) : h.department ? (
+ <>
+
+
+
+
+
+
+ Управление отделом
+
+
+
+ Вы руководитель
+
+
+ Может управлять
+
+
+
+
+ {h.department.name}
+
+
+ {h.department.description || "Описание отдела ещё не заполнено."}
+
+
+
+
+ {h.userPerms.can_manage ? (
+
+
+ Редактировать отдел
+
+ ) : null}
+ {h.userPerms.can_manage ? (
+
+
+ Добавить участника
+
+ ) : null}
-
{department.name}
-
{department.description || "Описание не заполнено"}
-
- Руководитель:{" "}
-
- {department.head
- ? `${department.head.last_name} ${department.head.first_name}`.trim()
- : "Не назначен"}
-
-
-
-
- {department.employees_count ?? 0}
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+
+
+
Участники отдела
+
+ Управление составом отдела и ролями участников.
+
+
+
+
+
+ h.setShowInactiveMembers(event.target.checked)
+ }
+ className="rounded border-[var(--border-strong)]"
+ />
+ Показывать неактивных
+
+
+
+
+
+ h.setMembersQuery(event.target.value)}
+ placeholder="Поиск по участникам, почте, должности или роли"
+ className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
+ />
+
+
+
+ {h.filteredMembers.length ? (
+ h.filteredMembers.map((member) => (
+
+ ))
+ ) : (
+
+
+
+ Участники не найдены
+
+
+ Измени поисковый запрос или включи показ неактивных связей.
+
+
+ )}
+
+
+
+
+
+
+
Роли отдела
+
+ Наборы прав для участников отдела и их назначение в составе команды.
+
+
+ {h.userPerms.can_assign_roles ? (
+
+
+ Создать роль
+
+ ) : null}
+
+
+
+ {h.roles.length ? (
+ h.roles.map((role) => (
+
+ ))
+ ) : (
+
+
+
+ Роли ещё не созданы
+
+
+ Создай роли, чтобы распределять управленческие права внутри отдела.
+
+
+ )}
+
+
+
+
+
+
+ Руководитель отдела
+
+ {h.department.head ? (
+
+ ) : (
+
+ Руководитель пока не назначен.
+
+ )}
+
+ {h.userPerms.can_change_head ? (
+
+
+
+ void h.submitHeadChange()}
+ disabled={h.pendingKey === "head"}
+ className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
+ >
+ Сохранить руководителя
+
+ {h.department.head ? (
+ void h.submitHeadRemoval()}
+ disabled={h.pendingKey === "head"}
+ className="app-action-secondary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
+ >
+ Снять руководителя
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+
+ Права текущего пользователя
+
+
+ Руководитель отдела
+
+
+ Управление составом
+
+
+ Смена руководителя
+
+
+ Назначение ролей
+
+
+
+
+
+ Состояние отдела
+
+
+
+ Всего связей
+
+
+ {h.members.length}
+
+
+
+
+ Активных участников
+
+
+ {activeMembersCount}
+
+
+
+
+ Ролевых наборов
+
+
+ {h.roles.length}
+
+
+
+
+
-
+ >
) : null}
+
+
+
+
+
+
);
}
diff --git a/frontend/src/components/departments/DepartmentPersonChip.tsx b/frontend/src/components/departments/DepartmentPersonChip.tsx
new file mode 100644
index 00000000..ead7a196
--- /dev/null
+++ b/frontend/src/components/departments/DepartmentPersonChip.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import Link from "next/link";
+
+import { RequestAvatar } from "@/components/requests/RequestAvatar";
+import { displayUserName, userProfileLink } from "@/lib/shared";
+import type { User } from "@/types/api";
+
+type DepartmentPersonChipProps = {
+ currentUserId?: number | null;
+ person: User;
+ subtitle?: string | null;
+};
+
+export function DepartmentPersonChip({
+ currentUserId,
+ person,
+ subtitle,
+}: DepartmentPersonChipProps) {
+ const personName = displayUserName(person);
+ const profileLink = userProfileLink(person, currentUserId);
+ const fallback = (
+ person.first_name?.[0] ||
+ person.last_name?.[0] ||
+ person.email?.[0] ||
+ "?"
+ ).toUpperCase();
+
+ const content = (
+ <>
+
+
+
+ {personName}
+
+ {subtitle ? (
+ {subtitle}
+ ) : null}
+
+ >
+ );
+
+ const className =
+ "app-badge inline-flex max-w-full items-center gap-2 rounded-full px-2.5 py-1.5";
+
+ return profileLink ? (
+
+ {content}
+
+ ) : (
+ {content}
+ );
+}
diff --git a/frontend/src/hooks/useDepartmentPage.ts b/frontend/src/hooks/useDepartmentPage.ts
new file mode 100644
index 00000000..067091b8
--- /dev/null
+++ b/frontend/src/hooks/useDepartmentPage.ts
@@ -0,0 +1,587 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+
+import { useUser } from "@/contexts/UserContext";
+import { apiClient } from "@/lib/api";
+import { loadAllPages } from "@/lib/shared";
+import type {
+ Department,
+ DepartmentMemberLink,
+ DepartmentPermissionChoice,
+ DepartmentRole,
+ DepartmentUserPermissions,
+ PaginatedResponse,
+ User,
+} from "@/types/api";
+
+type DepartmentRoleDraft = {
+ id: number | null;
+ name: string;
+ permissionCodes: string[];
+};
+
+type SavingKey =
+ | "department"
+ | "head"
+ | "member"
+ | "role"
+ | `member-role-${number}`
+ | `member-remove-${number}`
+ | `role-delete-${number}`;
+
+export type DepartmentPageController = {
+ addMemberOpen: boolean;
+ allEmployees: User[];
+ currentUserId: number | null;
+ department: Department | null;
+ departmentDraft: { name: string; description: string };
+ departmentId: number;
+ editDepartmentOpen: boolean;
+ error: string | null;
+ filteredMembers: DepartmentMemberLink[];
+ headCandidates: Array<{ id: number; name: string }>;
+ loading: boolean;
+ members: DepartmentMemberLink[];
+ membersQuery: string;
+ pendingKey: SavingKey | null;
+ permissionChoices: DepartmentPermissionChoice[];
+ roleDraft: DepartmentRoleDraft;
+ roleEditorOpen: boolean;
+ roleUsage: Record;
+ roles: DepartmentRole[];
+ selectableEmployees: Array<{ id: number; name: string }>;
+ selectedHeadId: number | null;
+ selectedMemberId: number | null;
+ showInactiveMembers: boolean;
+ userPerms: DepartmentUserPermissions;
+ closeAddMember: () => void;
+ closeDepartmentEditor: () => void;
+ closeRoleEditor: () => void;
+ openAddMember: () => void;
+ openCreateRole: () => void;
+ openDepartmentEditor: () => void;
+ openEditRole: (role: DepartmentRole) => void;
+ refreshPage: () => Promise;
+ saveDepartment: () => Promise;
+ saveRole: () => Promise;
+ setMembersQuery: (value: string) => void;
+ setRoleDraft: (next: DepartmentRoleDraft | ((current: DepartmentRoleDraft) => DepartmentRoleDraft)) => void;
+ setSelectedHeadId: (id: number | null) => void;
+ setSelectedMemberId: (id: number | null) => void;
+ setShowInactiveMembers: (value: boolean) => void;
+ submitAddMember: () => Promise;
+ submitHeadChange: () => Promise;
+ submitQuickHeadChange: (headId: number) => Promise;
+ submitHeadRemoval: () => Promise;
+ submitMemberRole: (employeeId: number, roleId: number | null) => Promise;
+ submitRemoveMember: (employeeId: number) => Promise;
+ submitRoleDelete: (role: DepartmentRole) => Promise;
+ updateDepartmentDraft: (patch: Partial<{ name: string; description: string }>) => void;
+};
+
+const EMPTY_USER_PERMS: DepartmentUserPermissions = {
+ is_head: false,
+ can_manage: false,
+ can_change_head: false,
+ can_assign_roles: false,
+};
+
+function getErrorMessage(error: unknown, fallback: string) {
+ const raw = String((error as Error)?.message || fallback).trim();
+ const prefix = "API Error:";
+ if (!raw.startsWith(prefix)) return raw || fallback;
+
+ const payload = raw.slice(prefix.length).trim();
+ const jsonStart = payload.indexOf("{");
+ if (jsonStart >= 0) {
+ try {
+ const parsed = JSON.parse(payload.slice(jsonStart)) as Record;
+ const detail = parsed.detail;
+ if (typeof detail === "string" && detail.trim()) return detail;
+ const firstEntry = Object.entries(parsed)[0];
+ if (firstEntry) {
+ const value = firstEntry[1];
+ if (Array.isArray(value) && value[0]) return String(value[0]);
+ if (typeof value === "string" && value.trim()) return value;
+ }
+ } catch {
+ return fallback;
+ }
+ }
+
+ return fallback;
+}
+
+function sortEmployeesByName(items: User[]) {
+ return [...items].sort((a, b) => {
+ const aName = `${a.last_name || ""} ${a.first_name || ""}`.trim();
+ const bName = `${b.last_name || ""} ${b.first_name || ""}`.trim();
+ return aName.localeCompare(bName, "ru");
+ });
+}
+
+export function useDepartmentPage(departmentId: number): DepartmentPageController {
+ const { user } = useUser();
+ const currentUserId = user?.id ?? null;
+
+ const [department, setDepartment] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [roles, setRoles] = useState([]);
+ const [permissionChoices, setPermissionChoices] = useState([]);
+ const [userPerms, setUserPerms] = useState(null);
+ const [allEmployees, setAllEmployees] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [pendingKey, setPendingKey] = useState(null);
+ const [membersQuery, setMembersQuery] = useState("");
+ const [showInactiveMembers, setShowInactiveMembers] = useState(false);
+
+ const [editDepartmentOpen, setEditDepartmentOpen] = useState(false);
+ const [departmentDraft, setDepartmentDraft] = useState({ name: "", description: "" });
+
+ const [addMemberOpen, setAddMemberOpen] = useState(false);
+ const [selectedMemberId, setSelectedMemberId] = useState(null);
+
+ const [selectedHeadId, setSelectedHeadId] = useState(null);
+
+ const [roleEditorOpen, setRoleEditorOpen] = useState(false);
+ const [roleDraft, setRoleDraft] = useState({
+ id: null,
+ name: "",
+ permissionCodes: [],
+ });
+
+ const hydrateDepartmentDraft = useCallback((nextDepartment: Department | null) => {
+ setDepartmentDraft({
+ name: nextDepartment?.name || "",
+ description: nextDepartment?.description || "",
+ });
+ setSelectedHeadId(nextDepartment?.head?.id ?? null);
+ }, []);
+
+ const loadEmployeesDirectory = useCallback(async () => {
+ const employees = await loadAllPages((params) => apiClient.getEmployees(params));
+ setAllEmployees(sortEmployeesByName(employees.filter((employee) => employee.is_active)));
+ }, []);
+
+ const loadCore = useCallback(async () => {
+ if (!departmentId || Number.isNaN(departmentId)) {
+ setDepartment(null);
+ setMembers([]);
+ setRoles([]);
+ setPermissionChoices([]);
+ setUserPerms(null);
+ setAllEmployees([]);
+ setError("Некорректный идентификатор отдела");
+ setLoading(false);
+ return;
+ }
+
+ try {
+ setLoading(true);
+ setError(null);
+
+ const [
+ departmentResponse,
+ membersResponse,
+ userPermsResponse,
+ rolesResponse,
+ permChoicesResponse,
+ ] = await Promise.all([
+ apiClient.getDepartment(departmentId) as Promise,
+ apiClient.getDepartmentMembers(departmentId) as Promise>,
+ apiClient.getDepartmentUserPerms(departmentId) as Promise,
+ apiClient.getDepartmentRoles({ department: departmentId, limit: 200 }) as Promise | DepartmentRole[]>,
+ apiClient.getDepartmentRolePermChoices() as Promise<{ results?: DepartmentPermissionChoice[] } | DepartmentPermissionChoice[]>,
+ ]);
+
+ const nextDepartment = departmentResponse;
+ const nextMembers = (membersResponse.results || []).sort((a, b) => {
+ const aIsHead = nextDepartment.head?.id === a.employee.id ? 1 : 0;
+ const bIsHead = nextDepartment.head?.id === b.employee.id ? 1 : 0;
+ if (aIsHead !== bIsHead) return bIsHead - aIsHead;
+ const aName = `${a.employee.last_name || ""} ${a.employee.first_name || ""}`.trim();
+ const bName = `${b.employee.last_name || ""} ${b.employee.first_name || ""}`.trim();
+ return aName.localeCompare(bName, "ru");
+ });
+ 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) {
+ setError(getErrorMessage(loadError, "Не удалось загрузить отдел"));
+ } finally {
+ setLoading(false);
+ }
+ }, [departmentId, hydrateDepartmentDraft]);
+
+ useEffect(() => {
+ void loadCore();
+ }, [loadCore]);
+
+ useEffect(() => {
+ if (!userPerms || (!userPerms.can_manage && !userPerms.can_change_head)) {
+ return;
+ }
+ if (allEmployees.length > 0) {
+ return;
+ }
+ void loadEmployeesDirectory().catch((loadError) => {
+ toast.error(getErrorMessage(loadError, "Не удалось загрузить сотрудников"));
+ });
+ }, [allEmployees.length, loadEmployeesDirectory, userPerms]);
+
+ const filteredMembers = useMemo(() => {
+ const q = membersQuery.trim().toLowerCase();
+
+ return members.filter((item) => {
+ if (!showInactiveMembers && !item.is_active) {
+ return false;
+ }
+
+ if (!q) {
+ return true;
+ }
+
+ const employeeName = `${item.employee.last_name || ""} ${item.employee.first_name || ""} ${item.employee.patronymic || ""}`
+ .trim()
+ .toLowerCase();
+ const email = item.employee.email?.toLowerCase() || "";
+ const position = item.employee.position?.name?.toLowerCase() || "";
+ const roleName = item.role?.name?.toLowerCase() || "";
+
+ return (
+ employeeName.includes(q) ||
+ email.includes(q) ||
+ position.includes(q) ||
+ roleName.includes(q)
+ );
+ });
+ }, [members, membersQuery, showInactiveMembers]);
+
+ const selectableEmployees = useMemo(() => {
+ const activeIds = new Set(
+ members.filter((member) => member.is_active).map((member) => member.employee.id),
+ );
+
+ return allEmployees
+ .filter((employee) => !activeIds.has(employee.id))
+ .map((employee) => ({
+ id: employee.id,
+ name: `${employee.last_name || ""} ${employee.first_name || ""}`.trim() || employee.email,
+ }));
+ }, [allEmployees, members]);
+
+ const headCandidates = useMemo(() => {
+ return allEmployees.map((employee) => ({
+ id: employee.id,
+ name: `${employee.last_name || ""} ${employee.first_name || ""}`.trim() || employee.email,
+ }));
+ }, [allEmployees]);
+
+ const roleUsage = useMemo(() => {
+ return members.reduce>((acc, member) => {
+ const roleId = member.role?.id;
+ if (!roleId || !member.is_active) return acc;
+ acc[roleId] = (acc[roleId] || 0) + 1;
+ return acc;
+ }, {});
+ }, [members]);
+
+ const refreshPage = useCallback(async () => {
+ await loadCore();
+ }, [loadCore]);
+
+ const updateDepartmentDraft = useCallback((patch: Partial<{ name: string; description: string }>) => {
+ setDepartmentDraft((current) => ({ ...current, ...patch }));
+ }, []);
+
+ const openDepartmentEditor = useCallback(() => {
+ hydrateDepartmentDraft(department);
+ setEditDepartmentOpen(true);
+ }, [department, hydrateDepartmentDraft]);
+
+ const closeDepartmentEditor = useCallback(() => {
+ hydrateDepartmentDraft(department);
+ setEditDepartmentOpen(false);
+ }, [department, hydrateDepartmentDraft]);
+
+ const saveDepartment = useCallback(async () => {
+ if (!department) return;
+
+ const name = departmentDraft.name.trim();
+ if (!name) {
+ toast.error("Укажите название отдела");
+ return;
+ }
+
+ try {
+ setPendingKey("department");
+ const response = await apiClient.updateDepartment(department.id, {
+ name,
+ description: departmentDraft.description.trim(),
+ }) as Department;
+ setDepartment(response);
+ hydrateDepartmentDraft(response);
+ setEditDepartmentOpen(false);
+ toast.success("Отдел обновлён");
+ } catch (saveError) {
+ toast.error(getErrorMessage(saveError, "Не удалось сохранить отдел"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [department, departmentDraft.description, departmentDraft.name, hydrateDepartmentDraft]);
+
+ const openAddMember = useCallback(() => {
+ setSelectedMemberId(null);
+ setAddMemberOpen(true);
+ }, []);
+
+ const closeAddMember = useCallback(() => {
+ setSelectedMemberId(null);
+ setAddMemberOpen(false);
+ }, []);
+
+ const submitAddMember = useCallback(async () => {
+ if (!department || !selectedMemberId) {
+ toast.error("Выберите сотрудника");
+ return;
+ }
+
+ try {
+ setPendingKey("member");
+ await apiClient.addDepartmentMember(department.id, selectedMemberId);
+ await loadCore();
+ setAddMemberOpen(false);
+ setSelectedMemberId(null);
+ toast.success("Сотрудник добавлен в отдел");
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось добавить сотрудника"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [department, loadCore, selectedMemberId]);
+
+ const submitHeadChange = useCallback(async () => {
+ if (!department) return;
+
+ try {
+ setPendingKey("head");
+ const response = await apiClient.setDepartmentHead(department.id, selectedHeadId) as Department;
+ setDepartment(response);
+ hydrateDepartmentDraft(response);
+ await loadCore();
+ toast.success(selectedHeadId ? "Руководитель назначен" : "Руководитель снят");
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось изменить руководителя"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [department, hydrateDepartmentDraft, loadCore, selectedHeadId]);
+
+ const submitQuickHeadChange = useCallback(async (headId: number) => {
+ if (!department) return;
+
+ try {
+ setPendingKey("head");
+ const response = await apiClient.setDepartmentHead(department.id, headId) as Department;
+ setDepartment(response);
+ hydrateDepartmentDraft(response);
+ setSelectedHeadId(headId);
+ await loadCore();
+ toast.success("Руководитель назначен");
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось изменить руководителя"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [department, hydrateDepartmentDraft, loadCore]);
+
+ const submitHeadRemoval = useCallback(async () => {
+ if (!department) return;
+
+ try {
+ setPendingKey("head");
+ const response = await apiClient.setDepartmentHead(department.id, null) as Department;
+ setDepartment(response);
+ hydrateDepartmentDraft(response);
+ setSelectedHeadId(null);
+ await loadCore();
+ toast.success("Руководитель снят");
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось снять руководителя"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [department, hydrateDepartmentDraft, loadCore]);
+
+ const submitMemberRole = useCallback(async (employeeId: number, roleId: number | null) => {
+ if (!department) return;
+
+ try {
+ setPendingKey(`member-role-${employeeId}`);
+ await apiClient.setDepartmentMemberRole(department.id, {
+ employee_id: employeeId,
+ role_id: roleId,
+ });
+ const nextRole = roles.find((role) => role.id === roleId) || null;
+ setMembers((current) =>
+ current.map((member) =>
+ member.employee.id === employeeId
+ ? { ...member, role: nextRole ? { id: nextRole.id, name: nextRole.name } : null }
+ : member,
+ ),
+ );
+ toast.success(roleId ? "Роль назначена" : "Роль снята");
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось обновить роль участника"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [department, roles]);
+
+ const submitRemoveMember = useCallback(async (employeeId: number) => {
+ if (!department) return;
+ if (!window.confirm("Убрать сотрудника из отдела?")) return;
+
+ try {
+ setPendingKey(`member-remove-${employeeId}`);
+ await apiClient.removeDepartmentMember(department.id, employeeId);
+ await loadCore();
+ toast.success("Сотрудник убран из отдела");
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось убрать сотрудника"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [department, loadCore]);
+
+ const openCreateRole = useCallback(() => {
+ setRoleDraft({ id: null, name: "", permissionCodes: [] });
+ setRoleEditorOpen(true);
+ }, []);
+
+ const openEditRole = useCallback((role: DepartmentRole) => {
+ 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: [] });
+ }, []);
+
+ const saveRole = useCallback(async () => {
+ if (!department) return;
+
+ const name = roleDraft.name.trim();
+ if (!name) {
+ toast.error("Укажите название роли");
+ return;
+ }
+
+ try {
+ setPendingKey("role");
+ 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("Роль создана");
+ }
+ await loadCore();
+ closeRoleEditor();
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось сохранить роль"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [closeRoleEditor, department, loadCore, roleDraft.id, roleDraft.name, roleDraft.permissionCodes]);
+
+ const submitRoleDelete = useCallback(async (role: DepartmentRole) => {
+ if (!window.confirm(`Удалить роль «${role.name}»?`)) return;
+
+ try {
+ setPendingKey(`role-delete-${role.id}`);
+ await apiClient.deleteDepartmentRole(role.id);
+ await loadCore();
+ toast.success("Роль удалена");
+ } catch (submitError) {
+ toast.error(getErrorMessage(submitError, "Не удалось удалить роль"));
+ } finally {
+ setPendingKey(null);
+ }
+ }, [loadCore]);
+
+ return {
+ addMemberOpen,
+ allEmployees,
+ currentUserId,
+ department,
+ departmentDraft,
+ departmentId,
+ editDepartmentOpen,
+ error,
+ filteredMembers,
+ headCandidates,
+ loading,
+ members,
+ membersQuery,
+ pendingKey,
+ permissionChoices,
+ roleDraft,
+ roleEditorOpen,
+ roleUsage,
+ roles,
+ selectableEmployees,
+ selectedHeadId,
+ selectedMemberId,
+ showInactiveMembers,
+ userPerms: userPerms || EMPTY_USER_PERMS,
+ closeAddMember,
+ closeDepartmentEditor,
+ closeRoleEditor,
+ openAddMember,
+ openCreateRole,
+ openDepartmentEditor,
+ openEditRole,
+ refreshPage,
+ saveDepartment,
+ saveRole,
+ setMembersQuery,
+ setRoleDraft,
+ setSelectedHeadId,
+ setSelectedMemberId,
+ setShowInactiveMembers,
+ submitAddMember,
+ submitHeadChange,
+ submitQuickHeadChange,
+ submitHeadRemoval,
+ submitMemberRole,
+ submitRemoveMember,
+ submitRoleDelete,
+ updateDepartmentDraft,
+ };
+}
diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts
index 82c0cd70..aa788dfc 100644
--- a/frontend/src/lib/api/employees.ts
+++ b/frontend/src/lib/api/employees.ts
@@ -53,5 +53,64 @@ export function createEmployeesApi(request: RequestFn) {
return request(`/api/v1/departments/${qs ? '?' + qs : ''}`);
},
getDepartment: (id: number | string) => request(`/api/v1/departments/${id}/`),
+ updateDepartment: (id: number | string, data: { name?: string; description?: string }) =>
+ request(`/api/v1/departments/${id}/`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ }),
+ getDepartmentMembers: (id: number | string) =>
+ request(`/api/v1/departments/${id}/members/`),
+ getDepartmentUserPerms: (id: number | string) =>
+ request(`/api/v1/departments/${id}/user-perms/`),
+ setDepartmentHead: (id: number | string, head_id: number | null) =>
+ request(`/api/v1/departments/${id}/set_head/`, {
+ method: 'POST',
+ body: JSON.stringify({ head_id }),
+ }),
+ addDepartmentMember: (id: number | string, employee_id: number) =>
+ request(`/api/v1/departments/${id}/add_member/`, {
+ method: 'POST',
+ body: JSON.stringify({ employee_id }),
+ }),
+ removeDepartmentMember: (id: number | string, employee_id: number) =>
+ request(`/api/v1/departments/${id}/remove_member/`, {
+ method: 'POST',
+ body: JSON.stringify({ employee_id }),
+ }),
+ setDepartmentMemberRole: (id: number | string, data: { employee_id: number; role_id: number | null }) =>
+ request(`/api/v1/departments/${id}/set_member_role/`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ getMyDepartments: () => request('/api/v1/departments/my-departments/'),
+ getDepartmentRoles: (params?: { department?: number | string; page?: number; limit?: number; ordering?: string }) => {
+ const qp = new URLSearchParams();
+ if (params?.department) qp.append('department', String(params.department));
+ if (params?.page) qp.append('page', params.page.toString());
+ if (params?.limit) qp.append('limit', params.limit.toString());
+ if (params?.ordering) qp.append('ordering', params.ordering);
+ const qs = qp.toString();
+ return request(`/api/v1/department-roles/${qs ? '?' + qs : ''}`);
+ },
+ createDepartmentRole: (data: { department: number; name: string; scoped_permission_codes?: string[] }) =>
+ request('/api/v1/department-roles/', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ updateDepartmentRole: (id: number | string, data: { name?: string; scoped_permission_codes?: string[] }) =>
+ request(`/api/v1/department-roles/${id}/`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ }),
+ deleteDepartmentRole: (id: number | string) =>
+ request(`/api/v1/department-roles/${id}/`, { method: 'DELETE' }),
+ getDepartmentRolePermChoices: () =>
+ request('/api/v1/department-roles/perm_choices/'),
+ getDepartmentRoleAssignments: (id: number | string, params?: { active?: boolean }) => {
+ const qp = new URLSearchParams();
+ if (params?.active !== undefined) qp.append('active', String(params.active));
+ const qs = qp.toString();
+ return request(`/api/v1/department-roles/${id}/assignments/${qs ? '?' + qs : ''}`);
+ },
};
}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 5b648946..1a278e78 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -122,6 +122,48 @@ export interface Department {
employees_count?: number;
}
+export interface DepartmentMemberRole {
+ id: number;
+ name: string;
+}
+
+export interface DepartmentMemberLink {
+ employee: User;
+ role?: DepartmentMemberRole | null;
+ is_active: boolean;
+}
+
+export interface DepartmentUserPermissions {
+ is_head: boolean;
+ can_manage: boolean;
+ can_change_head: boolean;
+ can_assign_roles: boolean;
+}
+
+export interface DepartmentPermissionChoice {
+ id: number;
+ code: string;
+ name: string;
+}
+
+export interface DepartmentRole {
+ id: number;
+ department: number;
+ name: string;
+ permissions: number[];
+ permissions_verbose: DepartmentPermissionChoice[];
+}
+
+export interface DepartmentRoleAssignment {
+ id: number;
+ employee_id: number;
+ employee_name?: string | null;
+ assigned_at?: string | null;
+ assigned_by_id?: number | null;
+ assigned_by_name?: string | null;
+ is_active: boolean;
+}
+
export interface Position {
id: number;
name: string;