refactor(frontend): build department management screen

This commit is contained in:
lizerk 2026-04-13 17:52:41 +03:00
parent 48e44d022f
commit 28d61b842c
7 changed files with 1612 additions and 60 deletions

View File

@ -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
Файлы:

View File

@ -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

View File

@ -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 (
<div className="app-surface-muted rounded-xl p-4">
<div className="app-text-muted mb-3 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em]">
{icon}
{label}
</div>
<p className="text-xl font-semibold text-[var(--foreground)]">{value}</p>
</div>
);
}
function PermissionBadge({
active,
children,
}: {
active: boolean;
children: React.ReactNode;
}) {
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"
}`}
>
{children}
</span>
);
}
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<void>;
onRemoveMember: (employeeId: number) => Promise<void>;
onSetHead: (employeeId: number) => Promise<void>;
}) {
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 (
<article className="app-surface-muted rounded-xl p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 space-y-3">
<DepartmentPersonChip
currentUserId={currentUserId}
person={member.employee}
subtitle={subtitle}
/>
<div className="flex flex-wrap items-center gap-2">
{isHead ? (
<span className="app-feedback-warning inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium">
<Crown size={12} />
Руководитель
</span>
) : 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 ? (
<span className="app-badge inline-flex rounded-full px-2.5 py-1 text-xs font-medium">
{member.role.name}
</span>
) : null}
</div>
</div>
<div className="flex w-full flex-col gap-3 xl:max-w-[360px] xl:items-end">
{canAssignRoles ? (
<div className="w-full xl:w-64">
<label className="app-text-muted mb-1 block text-xs font-medium">
Роль в отделе
</label>
<select
value={member.role?.id ?? ""}
onChange={(event) =>
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"
>
<option value="">Без роли</option>
{roleOptions.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{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>
</div>
</article>
);
}
function DepartmentRoleCard({
canAssignRoles,
pendingKey,
role,
usageCount,
onDelete,
onEdit,
}: {
canAssignRoles: boolean;
pendingKey: string | null;
role: DepartmentRole;
usageCount: number;
onDelete: (role: DepartmentRole) => Promise<void>;
onEdit: (role: DepartmentRole) => void;
}) {
const isDeleting = pendingKey === `role-delete-${role.id}`;
return (
<article className="app-surface-muted rounded-xl p-4">
<div className="flex items-start justify-between gap-3">
<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">
{usageCount} участников
</span>
</div>
<p className="app-text-muted mt-1 text-xs">
{role.permissions_verbose.length
? "Набор прав для участников отдела"
: "У роли пока нет назначенных прав"}
</p>
</div>
{canAssignRoles ? (
<div className="flex shrink-0 items-center 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"
onClick={() => 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"
>
<Trash2 size={14} />
Удалить
</button>
</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>
);
}
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<void>;
}) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Редактирование отдела"
size="md"
footer={
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={onClose}
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
>
Отмена
</button>
<button
type="button"
onClick={() => void onSave()}
disabled={loading}
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
Сохранить
</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={draft.name}
onChange={(event) => onChange({ 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">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Описание
</label>
<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="Чем занимается отдел, какие зоны ответственности ведёт"
/>
</section>
</div>
</Modal>
);
}
function AddMemberModal({
isOpen,
items,
loading,
onClose,
onSave,
onSelect,
selectedId,
}: {
isOpen: boolean;
items: Array<{ id: number; name: string }>;
loading: boolean;
onClose: () => void;
onSave: () => Promise<void>;
onSelect: (id: number | null) => void;
selectedId: number | null;
}) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Добавить участника"
size="md"
footer={
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={onClose}
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
>
Отмена
</button>
<button
type="button"
onClick={() => void onSave()}
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="Сотрудник *"
items={items}
selectedId={selectedId}
onSelect={onSelect}
placeholder={
items.length
? "Выберите сотрудника"
: "Свободных сотрудников для добавления нет"
}
disabled={!items.length}
/>
<p className="app-text-muted mt-3 text-sm">
В список попадают только сотрудники, которые ещё не состоят в отделе.
</p>
</section>
</Modal>
);
}
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<void>;
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={roleDraft.id ? "Редактирование роли" : "Новая роль отдела"}
size="lg"
footer={
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={onClose}
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
>
Отмена
</button>
<button
type="button"
onClick={() => void onSave()}
disabled={loading}
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
{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>
</Modal>
);
}
export default function DepartmentDetailPage() {
const params = useParams<{ id: string }>();
const departmentId = Number(params?.id);
const h = useDepartmentPage(departmentId);
const [department, setDepartment] = useState<Department | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<AppShell>
<div className="space-y-4">
<div className="space-y-6">
<Link
href="/departments"
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
@ -51,43 +544,336 @@ export default function DepartmentDetailPage() {
К списку отделов
</Link>
{loading ? (
{h.loading ? (
<div className="app-surface rounded-2xl p-8 text-center">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted text-sm">Загрузка отдела...</p>
</div>
) : error ? (
) : h.error ? (
<div className="app-feedback-danger rounded-2xl p-6 text-center">
<p className="text-sm">{error}</p>
<p className="text-sm">{h.error}</p>
</div>
) : department ? (
<section className="app-surface rounded-2xl p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="app-selected app-accent-text mb-2 inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs">
<Building2 size={12} />
Отдел
) : 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="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">
<Building2 size={12} />
Управление отделом
</span>
<PermissionBadge active={h.userPerms.is_head}>
<Crown size={12} className="mr-1" />
Вы руководитель
</PermissionBadge>
<PermissionBadge active={h.userPerms.can_manage}>
Может управлять
</PermissionBadge>
</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>
</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}
{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>
<h1 className="text-xl font-semibold text-[var(--foreground)]">{department.name}</h1>
<p className="app-text-muted mt-2 text-sm">{department.description || "Описание не заполнено"}</p>
<p className="mt-3 text-sm text-[var(--foreground)]">
Руководитель:{" "}
<span className="font-medium">
{department.head
? `${department.head.last_name} ${department.head.first_name}`.trim()
: "Не назначен"}
</span>
</p>
</div>
<div className="app-selected app-accent-text inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs">
<Users size={12} />
{department.employees_count ?? 0}
<div className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Участники"
value={String(activeMembersCount)}
icon={<Users size={14} />}
/>
<StatCard
label="Ролей"
value={String(h.roles.length)}
icon={<ShieldCheck size={14} />}
/>
<StatCard
label="Руководитель"
value={h.department.head ? "Назначен" : "Не назначен"}
icon={<Crown size={14} />}
/>
<StatCard
label="Контур"
value={h.userPerms.can_manage ? "Управление" : "Просмотр"}
icon={<BriefcaseBusiness size={14} />}
/>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.65fr)_minmax(300px,0.95fr)]">
<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-center lg:justify-between">
<div>
<h2 className="app-card-caption">Участники отдела</h2>
<p className="app-text-muted mt-2 text-sm">
Управление составом отдела и ролями участников.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<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>
<div className="relative mb-4">
<input
value={h.membersQuery}
onChange={(event) => h.setMembersQuery(event.target.value)}
placeholder="Поиск по участникам, почте, должности или роли"
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
/>
</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-center lg:justify-between">
<div>
<h2 className="app-card-caption">Роли отдела</h2>
<p className="app-text-muted mt-2 text-sm">
Наборы прав для участников отдела и их назначение в составе команды.
</p>
</div>
{h.userPerms.can_assign_roles ? (
<button
type="button"
onClick={h.openCreateRole}
className="app-action-primary 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">
<ShieldCheck 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>
</div>
<aside className="space-y-6">
<section className="app-surface rounded-2xl p-5">
<h2 className="app-card-caption">Руководитель отдела</h2>
<div className="mt-4 space-y-4">
{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-muted rounded-xl p-4 text-sm text-[var(--foreground)]">
Руководитель пока не назначен.
</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">
<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>
</section>
<section className="app-surface rounded-2xl p-5">
<h2 className="app-card-caption">Права текущего пользователя</h2>
<div className="mt-4 flex flex-wrap gap-2">
<PermissionBadge active={h.userPerms.is_head}>
Руководитель отдела
</PermissionBadge>
<PermissionBadge active={h.userPerms.can_manage}>
Управление составом
</PermissionBadge>
<PermissionBadge active={h.userPerms.can_change_head}>
Смена руководителя
</PermissionBadge>
<PermissionBadge active={h.userPerms.can_assign_roles}>
Назначение ролей
</PermissionBadge>
</div>
</section>
<section className="app-surface rounded-2xl p-5">
<h2 className="app-card-caption">Состояние отдела</h2>
<div className="mt-4 space-y-3">
<div className="app-surface-muted rounded-xl p-4">
<p className="app-text-muted text-xs uppercase tracking-[0.14em]">
Всего связей
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground)]">
{h.members.length}
</p>
</div>
<div className="app-surface-muted rounded-xl p-4">
<p className="app-text-muted text-xs uppercase tracking-[0.14em]">
Активных участников
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground)]">
{activeMembersCount}
</p>
</div>
<div className="app-surface-muted rounded-xl p-4">
<p className="app-text-muted text-xs uppercase tracking-[0.14em]">
Ролевых наборов
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground)]">
{h.roles.length}
</p>
</div>
</div>
</section>
</aside>
</div>
</section>
</>
) : null}
</div>
<DepartmentEditorModal
draft={h.departmentDraft}
isOpen={h.editDepartmentOpen}
loading={h.pendingKey === "department"}
onChange={h.updateDepartmentDraft}
onClose={h.closeDepartmentEditor}
onSave={h.saveDepartment}
/>
<AddMemberModal
isOpen={h.addMemberOpen}
items={h.selectableEmployees}
loading={h.pendingKey === "member"}
onClose={h.closeAddMember}
onSave={h.submitAddMember}
onSelect={h.setSelectedMemberId}
selectedId={h.selectedMemberId}
/>
<RoleEditorModal
isOpen={h.roleEditorOpen}
loading={h.pendingKey === "role"}
onClose={h.closeRoleEditor}
onDraftChange={h.setRoleDraft}
onSave={h.saveRole}
permissionChoices={h.permissionChoices}
roleDraft={h.roleDraft}
/>
</AppShell>
);
}

View File

@ -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 = (
<>
<RequestAvatar
alt={personName}
fallback={fallback}
size="lg"
src={person.avatar}
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-[var(--foreground)]">
{personName}
</span>
{subtitle ? (
<span className="app-text-muted block truncate text-xs">{subtitle}</span>
) : null}
</span>
</>
);
const className =
"app-badge inline-flex max-w-full items-center gap-2 rounded-full px-2.5 py-1.5";
return profileLink ? (
<Link href={profileLink} className={`${className} hover:bg-[var(--surface-tertiary)]`}>
{content}
</Link>
) : (
<span className={className}>{content}</span>
);
}

View File

@ -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<number, number>;
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<void>;
saveDepartment: () => Promise<void>;
saveRole: () => Promise<void>;
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<void>;
submitHeadChange: () => Promise<void>;
submitQuickHeadChange: (headId: number) => Promise<void>;
submitHeadRemoval: () => Promise<void>;
submitMemberRole: (employeeId: number, roleId: number | null) => Promise<void>;
submitRemoveMember: (employeeId: number) => Promise<void>;
submitRoleDelete: (role: DepartmentRole) => Promise<void>;
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<string, unknown>;
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<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);
const [error, setError] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState<SavingKey | null>(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<number | null>(null);
const [selectedHeadId, setSelectedHeadId] = useState<number | null>(null);
const [roleEditorOpen, setRoleEditorOpen] = useState(false);
const [roleDraft, setRoleDraft] = useState<DepartmentRoleDraft>({
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<User>((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<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;
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<Record<number, number>>((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,
};
}

View File

@ -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 : ''}`);
},
};
}

View File

@ -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;