refactor(frontend): build department management screen
This commit is contained in:
parent
48e44d022f
commit
28d61b842c
@ -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
|
||||
|
||||
Файлы:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
58
frontend/src/components/departments/DepartmentPersonChip.tsx
Normal file
58
frontend/src/components/departments/DepartmentPersonChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
587
frontend/src/hooks/useDepartmentPage.ts
Normal file
587
frontend/src/hooks/useDepartmentPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 : ''}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user