From d1a7eedcee9dd7f9c15854940894c1916faabe4e Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 14 Apr 2026 00:10:15 +0300 Subject: [PATCH] refactor(frontend): extend department member modal --- frontend/src/app/departments/[id]/page.tsx | 128 +++++++++++++++--- .../add-member-modal-state.test.js | 31 ----- .../departments/add-member-modal-state.ts | 23 ---- .../departments/{layout.ts => layout.js} | 2 +- .../src/components/departments/layout.test.js | 2 +- .../src/components/departments/layout.test.ts | 23 ---- .../departments/member-modal-state.js | 57 ++++++++ .../departments/member-modal-state.test.js | 94 +++++++++++++ frontend/src/hooks/useDepartmentPage.ts | 85 +++++++++++- 9 files changed, 344 insertions(+), 101 deletions(-) delete mode 100644 frontend/src/components/departments/add-member-modal-state.test.js delete mode 100644 frontend/src/components/departments/add-member-modal-state.ts rename frontend/src/components/departments/{layout.ts => layout.js} (94%) delete mode 100644 frontend/src/components/departments/layout.test.ts create mode 100644 frontend/src/components/departments/member-modal-state.js create mode 100644 frontend/src/components/departments/member-modal-state.test.js diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index 6be20421..b21e6551 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -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; 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 ( void onSave()} - disabled={loading || !selectedId} + disabled={submitDisabled} className="app-action-primary rounded-lg px-4 py-2 text-sm disabled:opacity-50" > - Добавить + {getDepartmentMemberModalSubmitLabel(mode)} } > -
+
- {getAddMemberHelperText(optionsLoading, items.length) ? ( + + {canAssignRoles ? ( +
+ +
+

+ {mode === "add" + ? "Роль можно назначить сразу при добавлении, но это необязательно." + : "Роль можно выдать любому активному сотруднику, даже вне состава отдела."} +

+ +
+
+ ) : null} + + {helperText ? (

- {getAddMemberHelperText(optionsLoading, items.length)} + {helperText}

) : null}
@@ -658,17 +722,32 @@ export default function DepartmentDetailPage() { {departmentMenuOpen ? (
{h.userPerms.can_manage ? ( + + ) : null} + + {h.userPerms.can_assign_roles ? ( ) : null} @@ -775,14 +854,27 @@ export default function DepartmentDetailPage() { /> { + h.closeAddMember(); + h.openCreateRole(); + }} onSave={h.submitAddMember} onSelect={h.setSelectedMemberId} + onSelectRole={h.setSelectedRoleId} + roleOptions={roleOptions} selectedId={h.selectedMemberId} + selectedRoleId={h.selectedRoleId} /> { - 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); -}); diff --git a/frontend/src/components/departments/add-member-modal-state.ts b/frontend/src/components/departments/add-member-modal-state.ts deleted file mode 100644 index d3efb64c..00000000 --- a/frontend/src/components/departments/add-member-modal-state.ts +++ /dev/null @@ -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; -} diff --git a/frontend/src/components/departments/layout.ts b/frontend/src/components/departments/layout.js similarity index 94% rename from frontend/src/components/departments/layout.ts rename to frontend/src/components/departments/layout.js index 20751e0b..d58cc622 100644 --- a/frontend/src/components/departments/layout.ts +++ b/frontend/src/components/departments/layout.js @@ -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"; diff --git a/frontend/src/components/departments/layout.test.js b/frontend/src/components/departments/layout.test.js index 3bc3e84f..9dbdd29d 100644 --- a/frontend/src/components/departments/layout.test.js +++ b/frontend/src/components/departments/layout.test.js @@ -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( diff --git a/frontend/src/components/departments/layout.test.ts b/frontend/src/components/departments/layout.test.ts deleted file mode 100644 index b108058c..00000000 --- a/frontend/src/components/departments/layout.test.ts +++ /dev/null @@ -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/); -}); diff --git a/frontend/src/components/departments/member-modal-state.js b/frontend/src/components/departments/member-modal-state.js new file mode 100644 index 00000000..30b7e272 --- /dev/null +++ b/frontend/src/components/departments/member-modal-state.js @@ -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; +} diff --git a/frontend/src/components/departments/member-modal-state.test.js b/frontend/src/components/departments/member-modal-state.test.js new file mode 100644 index 00000000..62eb69c5 --- /dev/null +++ b/frontend/src/components/departments/member-modal-state.test.js @@ -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, + ); +}); diff --git a/frontend/src/hooks/useDepartmentPage.ts b/frontend/src/hooks/useDepartmentPage.ts index ac6be4e8..182a660f 100644 --- a/frontend/src/hooks/useDepartmentPage.ts +++ b/frontend/src/hooks/useDepartmentPage.ts @@ -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; saveRole: () => Promise; 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; submitDeleteDepartment: () => Promise; submitHeadChange: () => Promise; @@ -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("add"); const [selectedMemberId, setSelectedMemberId] = useState(null); + const [selectedRoleId, setSelectedRoleId] = useState(null); const [selectedHeadId, setSelectedHeadId] = useState(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,