fix(frontend): stabilize department add-member modal

This commit is contained in:
lizerk 2026-04-13 23:57:55 +03:00
parent c1840fe1d1
commit 852ac367c0
6 changed files with 106 additions and 8 deletions

View File

@ -8,7 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "node --experimental-strip-types --test src/**/*.test.ts",
"test": "node --experimental-strip-types --test src/**/*.test.js",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs || true"
},
"dependencies": {

View File

@ -18,6 +18,11 @@ 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,
@ -389,6 +394,7 @@ function AddMemberModal({
isOpen,
items,
loading,
optionsLoading,
onClose,
onSave,
onSelect,
@ -397,6 +403,7 @@ function AddMemberModal({
isOpen: boolean;
items: Array<{ id: number; name: string }>;
loading: boolean;
optionsLoading: boolean;
onClose: () => void;
onSave: () => Promise<void>;
onSelect: (id: number | null) => void;
@ -408,6 +415,7 @@ function AddMemberModal({
onClose={onClose}
title="Добавить участника"
size="md"
className="overflow-visible"
footer={
<div className="flex flex-wrap justify-end gap-2">
<button
@ -434,12 +442,12 @@ function AddMemberModal({
items={items}
selectedId={selectedId}
onSelect={onSelect}
placeholder="Выберите сотрудника"
disabled={!items.length}
placeholder={getAddMemberPlaceholder(optionsLoading)}
disabled={isAddMemberSelectDisabled(optionsLoading, items.length)}
/>
{!items.length ? (
{getAddMemberHelperText(optionsLoading, items.length) ? (
<p className="app-text-muted mt-3 text-sm">
В директории нет доступных сотрудников для добавления.
{getAddMemberHelperText(optionsLoading, items.length)}
</p>
) : null}
</section>
@ -770,6 +778,7 @@ export default function DepartmentDetailPage() {
isOpen={h.addMemberOpen}
items={h.selectableEmployees}
loading={h.pendingKey === "member"}
optionsLoading={h.employeesDirectoryLoading}
onClose={h.closeAddMember}
onSave={h.submitAddMember}
onSelect={h.setSelectedMemberId}

View File

@ -0,0 +1,31 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEPARTMENT_MEMBERS_EMPTY_STATE_CLASSNAME,
getDepartmentMembersListClassName,
} from "./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

@ -41,6 +41,7 @@ export type DepartmentPageController = {
error: string | null;
filteredMembers: DepartmentMemberLink[];
headCandidates: Array<{ id: number; name: string }>;
employeesDirectoryLoading: boolean;
loading: boolean;
members: DepartmentMemberLink[];
pendingKey: SavingKey | null;
@ -126,6 +127,7 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
const [roles, setRoles] = useState<DepartmentRole[]>([]);
const [userPerms, setUserPerms] = useState<DepartmentUserPermissions | null>(null);
const [allEmployees, setAllEmployees] = useState<User[]>([]);
const [employeesDirectoryLoading, setEmployeesDirectoryLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pendingKey, setPendingKey] = useState<SavingKey | null>(null);
@ -153,8 +155,13 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
}, []);
const loadEmployeesDirectory = useCallback(async () => {
const employees = await loadAllPages<User>((params) => apiClient.getEmployees(params));
setAllEmployees(sortEmployeesByName(employees.filter((employee) => employee.is_active)));
setEmployeesDirectoryLoading(true);
try {
const employees = await loadAllPages<User>((params) => apiClient.getEmployees(params));
setAllEmployees(sortEmployeesByName(employees.filter((employee) => employee.is_active)));
} finally {
setEmployeesDirectoryLoading(false);
}
}, []);
const loadCore = useCallback(async () => {
@ -311,7 +318,12 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
const openAddMember = useCallback(() => {
setSelectedMemberId(null);
setAddMemberOpen(true);
}, []);
if (allEmployees.length === 0) {
void loadEmployeesDirectory().catch((loadError) => {
toast.error(getErrorMessage(loadError, "Не удалось загрузить сотрудников"));
});
}
}, [allEmployees.length, loadEmployeesDirectory]);
const closeAddMember = useCallback(() => {
setSelectedMemberId(null);
@ -522,6 +534,7 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
departmentDraft,
departmentId,
editDepartmentOpen,
employeesDirectoryLoading,
error,
filteredMembers,
headCandidates,