refactor(frontend): extend department member modal

This commit is contained in:
lizerk 2026-04-14 00:10:15 +03:00
parent 852ac367c0
commit d1a7eedcee
9 changed files with 344 additions and 101 deletions

View File

@ -18,15 +18,19 @@ import {
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import {
getAddMemberHelperText,
getAddMemberPlaceholder,
isAddMemberSelectDisabled,
} from "@/components/departments/add-member-modal-state";
import {
DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME,
getDepartmentMembersListClassName,
} from "@/components/departments/layout";
import {
getDepartmentMemberModalEmployeeLabel,
getDepartmentMemberModalHelperText,
getDepartmentMemberModalItems,
getDepartmentMemberModalPlaceholder,
getDepartmentMemberModalSubmitLabel,
getDepartmentMemberModalTitle,
isDepartmentMemberModalSubmitDisabled,
} from "@/components/departments/member-modal-state";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import { Modal } from "@/components/ui/Modal";
@ -34,6 +38,8 @@ import { useDepartmentPage } from "@/hooks/useDepartmentPage";
import { displayUserName, userProfileLink } from "@/lib/shared";
import type { DepartmentMemberLink } from "@/types/api";
type DepartmentMemberModalMode = "add" | "assignRole";
function MetaChip({
children,
tone = "neutral",
@ -391,29 +397,54 @@ function DepartmentMembersEmptyState() {
}
function AddMemberModal({
canAssignRoles,
isOpen,
items,
loading,
mode,
optionsLoading,
onClose,
onCreateRole,
onSave,
onSelect,
onSelectRole,
roleOptions,
selectedId,
selectedRoleId,
}: {
canAssignRoles: boolean;
isOpen: boolean;
items: Array<{ id: number; name: string }>;
loading: boolean;
mode: DepartmentMemberModalMode;
optionsLoading: boolean;
onClose: () => void;
onCreateRole: () => void;
onSave: () => Promise<void>;
onSelect: (id: number | null) => void;
onSelectRole: (id: number | null) => void;
roleOptions: Array<{ id: number; name: string }>;
selectedId: number | null;
selectedRoleId: number | null;
}) {
const helperText = getDepartmentMemberModalHelperText(
mode,
optionsLoading,
items.length,
);
const selectDisabled = optionsLoading || items.length === 0;
const submitDisabled = isDepartmentMemberModalSubmitDisabled({
loading,
mode,
selectedEmployeeId: selectedId,
selectedRoleId,
});
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Добавить участника"
title={getDepartmentMemberModalTitle(mode)}
size="md"
className="overflow-visible"
footer={
@ -428,26 +459,59 @@ function AddMemberModal({
<button
type="button"
onClick={() => void onSave()}
disabled={loading || !selectedId}
disabled={submitDisabled}
className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50"
>
Добавить
{getDepartmentMemberModalSubmitLabel(mode)}
</button>
</div>
}
>
<section className="app-surface-muted rounded-xl p-4">
<section className="app-surface-muted space-y-4 rounded-xl p-4">
<SearchableSelectSingle
label="Сотрудник"
label={getDepartmentMemberModalEmployeeLabel(mode)}
items={items}
selectedId={selectedId}
onSelect={onSelect}
placeholder={getAddMemberPlaceholder(optionsLoading)}
disabled={isAddMemberSelectDisabled(optionsLoading, items.length)}
placeholder={getDepartmentMemberModalPlaceholder(mode, optionsLoading)}
disabled={selectDisabled}
/>
{getAddMemberHelperText(optionsLoading, items.length) ? (
{canAssignRoles ? (
<div className="space-y-3">
<SearchableSelectSingle
label={mode === "assignRole" ? "Роль *" : "Роль"}
items={roleOptions}
selectedId={selectedRoleId}
onSelect={onSelectRole}
placeholder={
roleOptions.length
? "Выберите роль"
: "Сначала создайте роль отдела"
}
disabled={loading || roleOptions.length === 0}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm">
{mode === "add"
? "Роль можно назначить сразу при добавлении, но это необязательно."
: "Роль можно выдать любому активному сотруднику, даже вне состава отдела."}
</p>
<button
type="button"
onClick={onCreateRole}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
>
<Plus size={14} />
Создать роль
</button>
</div>
</div>
) : null}
{helperText ? (
<p className="app-text-muted mt-3 text-sm">
{getAddMemberHelperText(optionsLoading, items.length)}
{helperText}
</p>
) : null}
</section>
@ -658,17 +722,32 @@ export default function DepartmentDetailPage() {
{departmentMenuOpen ? (
<div className="app-menu absolute right-0 top-full z-20 mt-2 w-56 rounded-xl py-1.5">
{h.userPerms.can_manage ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
setManagementMode(true);
h.openAddMember();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<UserPlus size={14} className="app-text-muted" />
Добавить участников
</button>
) : null}
{h.userPerms.can_assign_roles ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
setManagementMode(true);
h.openAddMember();
h.openAssignRoleModal();
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<UserPlus size={14} className="app-text-muted" />
Позвать сотрудников
<Plus size={14} className="app-text-muted" />
Выдать роль
</button>
) : null}
@ -775,14 +854,27 @@ export default function DepartmentDetailPage() {
/>
<AddMemberModal
canAssignRoles={h.userPerms.can_assign_roles}
isOpen={h.addMemberOpen}
items={h.selectableEmployees}
items={getDepartmentMemberModalItems(
h.memberModalMode,
h.selectableEmployees,
h.assignableEmployees,
)}
loading={h.pendingKey === "member"}
mode={h.memberModalMode}
optionsLoading={h.employeesDirectoryLoading}
onClose={h.closeAddMember}
onCreateRole={() => {
h.closeAddMember();
h.openCreateRole();
}}
onSave={h.submitAddMember}
onSelect={h.setSelectedMemberId}
onSelectRole={h.setSelectedRoleId}
roleOptions={roleOptions}
selectedId={h.selectedMemberId}
selectedRoleId={h.selectedRoleId}
/>
<RoleEditorModal

View File

@ -1,31 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getAddMemberHelperText,
getAddMemberPlaceholder,
isAddMemberSelectDisabled,
} from "./add-member-modal-state.ts";
test("add member modal shows loading placeholder while directory loads", () => {
assert.equal(getAddMemberPlaceholder(true), "Загружаем сотрудников...");
assert.equal(isAddMemberSelectDisabled(true, 3), true);
assert.equal(
getAddMemberHelperText(true, 3),
"Загружаем доступных сотрудников...",
);
});
test("add member modal shows empty state when there are no selectable employees", () => {
assert.equal(getAddMemberPlaceholder(false), "Выберите сотрудника");
assert.equal(isAddMemberSelectDisabled(false, 0), true);
assert.equal(
getAddMemberHelperText(false, 0),
"В директории нет доступных сотрудников для добавления.",
);
});
test("add member modal stays enabled when options are ready", () => {
assert.equal(isAddMemberSelectDisabled(false, 2), false);
assert.equal(getAddMemberHelperText(false, 2), null);
});

View File

@ -1,23 +0,0 @@
export function getAddMemberPlaceholder(optionsLoading: boolean) {
return optionsLoading ? "Загружаем сотрудников..." : "Выберите сотрудника";
}
export function isAddMemberSelectDisabled(
optionsLoading: boolean,
itemsCount: number,
) {
return optionsLoading || itemsCount === 0;
}
export function getAddMemberHelperText(
optionsLoading: boolean,
itemsCount: number,
) {
if (optionsLoading) {
return "Загружаем доступных сотрудников...";
}
if (itemsCount === 0) {
return "В директории нет доступных сотрудников для добавления.";
}
return null;
}

View File

@ -1,4 +1,4 @@
export function getDepartmentMembersListClassName(isManagementMode: boolean) {
export function getDepartmentMembersListClassName(isManagementMode) {
return isManagementMode
? "space-y-3"
: "flex flex-wrap items-center gap-2";

View File

@ -4,7 +4,7 @@ import assert from "node:assert/strict";
import {
DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME,
getDepartmentMembersListClassName,
} from "./layout.ts";
} from "./layout.js";
test("showcase members layout keeps bubble flow", () => {
assert.equal(

View File

@ -1,23 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const test = require("node:test");
const assert = require("node:assert/strict");
const {
DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME,
getDepartmentMembersListClassName,
} = require("./layout.ts");
test("showcase members layout keeps bubble flow", () => {
assert.equal(
getDepartmentMembersListClassName(false),
"flex flex-wrap items-center gap-2",
);
});
test("management members layout keeps stacked editing rows", () => {
assert.equal(getDepartmentMembersListClassName(true), "space-y-3");
});
test("department empty state spans full section width", () => {
assert.match(DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME, /\bw-full\b/);
});

View File

@ -0,0 +1,57 @@
export function getDepartmentMemberModalItems(
mode,
selectableEmployees,
assignableEmployees,
) {
return mode === "add" ? selectableEmployees : assignableEmployees;
}
export function getDepartmentMemberModalSubmitLabel(mode) {
return mode === "add" ? "Добавить" : "Выдать роль";
}
export function getDepartmentMemberModalTitle(mode) {
return mode === "add" ? "Добавить участника" : "Выдать роль сотруднику";
}
export function getDepartmentMemberModalEmployeeLabel(mode) {
return mode === "add" ? "Сотрудник" : "Сотрудник *";
}
export function getDepartmentMemberModalPlaceholder(mode, optionsLoading) {
if (optionsLoading) return "Загружаем сотрудников...";
return mode === "add" ? "Выберите сотрудника" : "Выберите сотрудника для роли";
}
export function getDepartmentMemberModalHelperText(
mode,
optionsLoading,
itemsCount,
) {
if (optionsLoading) {
return mode === "add"
? "Загружаем доступных сотрудников..."
: "Загружаем сотрудников, которым можно выдать роль...";
}
if (itemsCount === 0) {
return mode === "add"
? "В директории нет доступных сотрудников для добавления."
: "Нет активных сотрудников, которым можно выдать роль.";
}
return mode === "add"
? "При добавлении можно сразу назначить роль, если она нужна."
: "Роль в отделе можно выдать любому активному сотруднику, даже если он ещё не состоит в отделе.";
}
export function isDepartmentMemberModalSubmitDisabled({
loading,
mode,
selectedEmployeeId,
selectedRoleId,
}) {
if (loading || !selectedEmployeeId) return true;
if (mode === "assignRole" && !selectedRoleId) return true;
return false;
}

View File

@ -0,0 +1,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getDepartmentMemberModalEmployeeLabel,
getDepartmentMemberModalHelperText,
getDepartmentMemberModalItems,
getDepartmentMemberModalPlaceholder,
getDepartmentMemberModalSubmitLabel,
getDepartmentMemberModalTitle,
isDepartmentMemberModalSubmitDisabled,
} from "./member-modal-state.js";
test("member modal uses selectable employees in add mode", () => {
const selectable = [{ id: 1, name: "A" }];
const assignable = [{ id: 2, name: "B" }];
assert.deepEqual(
getDepartmentMemberModalItems("add", selectable, assignable),
selectable,
);
assert.equal(getDepartmentMemberModalSubmitLabel("add"), "Добавить");
assert.equal(getDepartmentMemberModalTitle("add"), "Добавить участника");
assert.equal(getDepartmentMemberModalEmployeeLabel("add"), "Сотрудник");
});
test("member modal uses full assignable directory in role mode", () => {
const selectable = [{ id: 1, name: "A" }];
const assignable = [{ id: 2, name: "B" }];
assert.deepEqual(
getDepartmentMemberModalItems("assignRole", selectable, assignable),
assignable,
);
assert.equal(
getDepartmentMemberModalSubmitLabel("assignRole"),
"Выдать роль",
);
assert.equal(
getDepartmentMemberModalTitle("assignRole"),
"Выдать роль сотруднику",
);
assert.equal(
getDepartmentMemberModalEmployeeLabel("assignRole"),
"Сотрудник *",
);
});
test("member modal submit stays disabled until required fields are selected", () => {
assert.equal(
isDepartmentMemberModalSubmitDisabled({
loading: false,
mode: "add",
selectedEmployeeId: null,
selectedRoleId: null,
}),
true,
);
assert.equal(
isDepartmentMemberModalSubmitDisabled({
loading: false,
mode: "assignRole",
selectedEmployeeId: 1,
selectedRoleId: null,
}),
true,
);
assert.equal(
isDepartmentMemberModalSubmitDisabled({
loading: false,
mode: "assignRole",
selectedEmployeeId: 1,
selectedRoleId: 2,
}),
false,
);
});
test("member modal helper texts reflect mode and loading state", () => {
assert.equal(
getDepartmentMemberModalPlaceholder("add", true),
"Загружаем сотрудников...",
);
assert.equal(
getDepartmentMemberModalPlaceholder("assignRole", false),
"Выберите сотрудника для роли",
);
assert.match(
getDepartmentMemberModalHelperText("assignRole", false, 1) || "",
/любому активному сотруднику/i,
);
});

View File

@ -20,6 +20,8 @@ type DepartmentRoleDraft = {
name: string;
};
type DepartmentMemberModalMode = "add" | "assignRole";
type SavingKey =
| "department"
| "department-delete"
@ -33,6 +35,7 @@ type SavingKey =
export type DepartmentPageController = {
addMemberOpen: boolean;
allEmployees: User[];
assignableEmployees: Array<{ id: number; name: string }>;
currentUserId: number | null;
department: Department | null;
departmentDraft: { name: string; description: string };
@ -43,6 +46,7 @@ export type DepartmentPageController = {
headCandidates: Array<{ id: number; name: string }>;
employeesDirectoryLoading: boolean;
loading: boolean;
memberModalMode: DepartmentMemberModalMode;
members: DepartmentMemberLink[];
pendingKey: SavingKey | null;
roleDraft: DepartmentRoleDraft;
@ -52,11 +56,13 @@ export type DepartmentPageController = {
selectableEmployees: Array<{ id: number; name: string }>;
selectedHeadId: number | null;
selectedMemberId: number | null;
selectedRoleId: number | null;
userPerms: DepartmentUserPermissions;
closeAddMember: () => void;
closeDepartmentEditor: () => void;
closeRoleEditor: () => void;
openAddMember: () => void;
openAssignRoleModal: () => void;
openCreateRole: () => void;
openDepartmentEditor: () => void;
openEditRole: (role: DepartmentRole) => void;
@ -64,8 +70,10 @@ export type DepartmentPageController = {
saveDepartment: () => Promise<void>;
saveRole: () => Promise<void>;
setRoleDraft: (next: DepartmentRoleDraft | ((current: DepartmentRoleDraft) => DepartmentRoleDraft)) => void;
setMemberModalMode: (mode: DepartmentMemberModalMode) => void;
setSelectedHeadId: (id: number | null) => void;
setSelectedMemberId: (id: number | null) => void;
setSelectedRoleId: (id: number | null) => void;
submitAddMember: () => Promise<void>;
submitDeleteDepartment: () => Promise<boolean>;
submitHeadChange: () => Promise<void>;
@ -136,7 +144,9 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
const [departmentDraft, setDepartmentDraft] = useState({ name: "", description: "" });
const [addMemberOpen, setAddMemberOpen] = useState(false);
const [memberModalMode, setMemberModalMode] = useState<DepartmentMemberModalMode>("add");
const [selectedMemberId, setSelectedMemberId] = useState<number | null>(null);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [selectedHeadId, setSelectedHeadId] = useState<number | null>(null);
@ -255,6 +265,13 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
}));
}, [allEmployees, members]);
const assignableEmployees = useMemo(() => {
return allEmployees.map((employee) => ({
id: employee.id,
name: `${employee.last_name || ""} ${employee.first_name || ""}`.trim() || employee.email,
}));
}, [allEmployees]);
const headCandidates = useMemo(() => {
return allEmployees.map((employee) => ({
id: employee.id,
@ -316,7 +333,21 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
}, [department, departmentDraft.description, departmentDraft.name, hydrateDepartmentDraft]);
const openAddMember = useCallback(() => {
setMemberModalMode("add");
setSelectedMemberId(null);
setSelectedRoleId(null);
setAddMemberOpen(true);
if (allEmployees.length === 0) {
void loadEmployeesDirectory().catch((loadError) => {
toast.error(getErrorMessage(loadError, "Не удалось загрузить сотрудников"));
});
}
}, [allEmployees.length, loadEmployeesDirectory]);
const openAssignRoleModal = useCallback(() => {
setMemberModalMode("assignRole");
setSelectedMemberId(null);
setSelectedRoleId(null);
setAddMemberOpen(true);
if (allEmployees.length === 0) {
void loadEmployeesDirectory().catch((loadError) => {
@ -327,6 +358,8 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
const closeAddMember = useCallback(() => {
setSelectedMemberId(null);
setSelectedRoleId(null);
setMemberModalMode("add");
setAddMemberOpen(false);
}, []);
@ -336,19 +369,57 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
return;
}
if (memberModalMode === "assignRole" && !selectedRoleId) {
toast.error("Выберите роль");
return;
}
try {
setPendingKey("member");
await apiClient.addDepartmentMember(department.id, selectedMemberId);
if (memberModalMode === "add") {
await apiClient.addDepartmentMember(department.id, selectedMemberId);
if (selectedRoleId && userPerms?.can_assign_roles) {
await apiClient.setDepartmentMemberRole(department.id, {
employee_id: selectedMemberId,
role_id: selectedRoleId,
});
}
} else {
await apiClient.setDepartmentMemberRole(department.id, {
employee_id: selectedMemberId,
role_id: selectedRoleId,
});
}
await loadCore();
setAddMemberOpen(false);
setSelectedMemberId(null);
toast.success("Сотрудник добавлен в отдел");
setSelectedRoleId(null);
setMemberModalMode("add");
toast.success(
memberModalMode === "add"
? "Сотрудник добавлен в отдел"
: "Роль назначена сотруднику",
);
} catch (submitError) {
toast.error(getErrorMessage(submitError, "Не удалось добавить сотрудника"));
toast.error(
getErrorMessage(
submitError,
memberModalMode === "add"
? "Не удалось добавить сотрудника"
: "Не удалось назначить роль сотруднику",
),
);
} finally {
setPendingKey(null);
}
}, [department, loadCore, selectedMemberId]);
}, [
department,
loadCore,
memberModalMode,
selectedMemberId,
selectedRoleId,
userPerms,
]);
const submitDeleteDepartment = useCallback(async () => {
if (!department) return false;
@ -529,6 +600,7 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
return {
addMemberOpen,
allEmployees,
assignableEmployees,
currentUserId,
department,
departmentDraft,
@ -539,6 +611,7 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
filteredMembers,
headCandidates,
loading,
memberModalMode,
members,
pendingKey,
roleDraft,
@ -548,11 +621,13 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
selectableEmployees,
selectedHeadId,
selectedMemberId,
selectedRoleId,
userPerms: userPerms || EMPTY_USER_PERMS,
closeAddMember,
closeDepartmentEditor,
closeRoleEditor,
openAddMember,
openAssignRoleModal,
openCreateRole,
openDepartmentEditor,
openEditRole,
@ -560,8 +635,10 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
saveDepartment,
saveRole,
setRoleDraft,
setMemberModalMode,
setSelectedHeadId,
setSelectedMemberId,
setSelectedRoleId,
submitAddMember,
submitDeleteDepartment,
submitHeadChange,