refactor(frontend): split department showcase and management

This commit is contained in:
lizerk 2026-04-13 22:25:30 +03:00
parent b73336744a
commit 3b855e2ef1
6 changed files with 366 additions and 423 deletions

View File

@ -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-паттерн должен восприниматься как рабочее пространство, а не как список независимых инпутов.

View File

@ -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
С оговоркой:

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

View File

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

View File

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

View File

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