From d437f63d022645e1e6d5b9617bfdb664479a9b12 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 14:25:00 +0300 Subject: [PATCH] refactor(frontend): finish people cluster alignment --- docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md | 13 +- frontend/src/app/employees/page.tsx | 252 ++++++++++-------- frontend/src/app/users/[id]/page.tsx | 6 +- 3 files changed, 156 insertions(+), 115 deletions(-) diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md index 87cbedee..ea5f4ae9 100644 --- a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md +++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md @@ -30,8 +30,8 @@ | `procurement` | `needs refactor` | крупные панели и часть detail blocks уже читаются неплохо, есть shared-like detail content | большой page-file, gray/white legacy в stats/suppliers/forms, смешение operational page и старых local panels | нормализовать page panels, suppliers/stats/forms и только потом решать дальнейшую декомпозицию | | `calendar` | `legacy-heavy` | богатая функциональность, много reusable доменных компонентов | historical layering, legacy-термины и неоднородные модалки | отдельный audit до системного refactor | | `messages` | `mixed` | зрелая interaction model, сильный composer и message item, app-style в основных экранах в целом выдержан | очень доменно-специфичный UX, крупные page-files, часть settings/create/chat-experiment flows ещё шумят по state/legacy dialogs | использовать выборочно как reference, без механического копирования в другие зоны; дальше резать state-heavy pages | -| `users/profile` | `mixed` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components, `/profile` и `/users/[id]` теперь оба опираются на page-level controllers, modals подтянуты к app-style | profile cluster уже выровнен структурно, но hero/section character всё ещё требует финальной product-tuning и часть user detail blocks остаётся локально особенной | держать как reference по profile anatomy и завершить alignment-pass точечно, без превращения его в request-like layout | -| `users/directory` | `mixed` | `users/page.tsx` уже живёт в app-surface language, сценарии user/employee navigation простые и понятные | `employees/page.tsx` и часть directory flows ещё не доведены до того же visual contract, cluster всё ещё не полностью симметричен | после profile cluster пройтись по employee directory и довести весь people-list слой до одного app-shell contract | +| `users/profile` | `mixed` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components, `/profile` и `/users/[id]` опираются на page-level controllers, modals подтянуты к app-style, локальные detail blocks выровнены по геометрии | profile cluster уже собран в одну систему, но hero/section character всё ещё требует ручного visual review перед тем, как считать его зрелым reference | держать как reference по profile anatomy и возвращаться только на точечный visual-tuning без новой structural волны | +| `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 | @@ -51,7 +51,6 @@ 7. отдельный audit по `calendar` 8. `departments` 9. `auth/legal` -10. завершение `users/profile` + `users/directory` residual pass Логика порядка: @@ -71,20 +70,20 @@ Статус: -- базовый structural + modal + directory-shell pass уже сделан +- wave 1 baseline pass завершён Цель: - подтянуть people/profile cluster к общему product feel без потери profile anatomy -Что осталось: +Что зафиксировано: - `/profile` уже переведён на page-level controller по аналогии с `useUserDetailPage` - `EditUserProfileModal` и `EmployeeActionModal` уже подтянуты к app-style primitives - `ProfileSections` уже нормализованы по базовой геометрии без слома anatomy - `users/page.tsx` уже переведён на app-surface language -- отдельно добить `employees/page.tsx` и остаточные локальные blocks в `/users/[id]` -- при необходимости сделать финальный visual-tuning hero density после ручного review +- `employees/page.tsx` и остаточные локальные blocks в `/users/[id]` уже приведены к тому же cluster contract +- дальнейший проход нужен только если после ручного review выяснится, что hero density всё ещё визуально выбивается ### Волна 2. Operational pages with high leverage diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 2ea81e2f..d3aaad4d 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -1,14 +1,26 @@ "use client"; -import { AppShell } from "../../components/AppShell"; -import { apiClient } from "@/lib/api"; -import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState, type MouseEvent } from "react"; import { MessageCircle } from "lucide-react"; + +import { AppShell } from "../../components/AppShell"; import { useUser } from "@/contexts/UserContext"; +import { apiClient } from "@/lib/api"; +import { resolveMediaUrl } from "@/lib/url"; import type { Chat, User } from "@/types/api"; +type PrivateChat = Chat & { member_ids?: number[] }; + +const getErrorMessage = (error: unknown, fallback: string) => + String((error as Error)?.message || fallback); + +function getInitials(employee: User) { + return `${employee.last_name?.[0] || ""}${employee.first_name?.[0] || ""}`; +} + export default function EmployeesPage() { const router = useRouter(); const { user: currentUser } = useUser(); @@ -22,7 +34,7 @@ export default function EmployeesPage() { const loaderRef = useRef(null); const isFetchingRef = useRef(false); - const loadEmployees = async (pageToLoad: number, append: boolean) => { + async function loadEmployees(pageToLoad: number, append: boolean) { if (isFetchingRef.current) return; try { @@ -34,69 +46,73 @@ export default function EmployeesPage() { } setError(null); - const response = await apiClient.getEmployees({ - page: pageToLoad, + const response = await apiClient.getEmployees({ + page: pageToLoad, limit: 20, - is_active: true + is_active: true, }); const nextChunk = response.results || []; - setEmployees((prev) => (append ? [...prev, ...nextChunk] : nextChunk)); + setEmployees((current) => + append ? [...current, ...nextChunk] : nextChunk, + ); setHasMore(Boolean(response.next)); setPage(pageToLoad); - } catch (err: unknown) { - console.error("Ошибка загрузки сотрудников:", err); - setError("Не удалось загрузить список сотрудников"); + } catch (error: unknown) { + console.error("Ошибка загрузки сотрудников:", error); + setError(getErrorMessage(error, "Не удалось загрузить список сотрудников")); } finally { isFetchingRef.current = false; setLoading(false); setLoadingMore(false); } - }; + } useEffect(() => { - loadEmployees(1, false); + void loadEmployees(1, false); }, []); - // Создание чата с сотрудником - const handleStartChat = async (employee: User, e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (!currentUser || creatingChatFor === employee.id) return; - if (currentUser.id === employee.id) return; // Не создаем чат с собой - + async function handleStartChat(employee: User, event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (!currentUser || creatingChatFor === employee.id || currentUser.id === employee.id) { + return; + } + setCreatingChatFor(employee.id); try { - // Ищем существующий приватный чат с этим пользователем - const allChats = await apiClient.getAllChats(); - - const existingChat = allChats.find((chat: Chat & { member_ids?: number[] }) => { - if (chat.type !== 'private') return false; - - const memberIds: number[] = chat.member_ids || []; - return memberIds.length === 2 && - memberIds.includes(currentUser.id) && - memberIds.includes(employee.id); + const allChats = (await apiClient.getAllChats()) as PrivateChat[]; + + const existingChat = allChats.find((chat) => { + if (chat.type !== "private") return false; + + const memberIds = Array.isArray(chat.member_ids) ? chat.member_ids : []; + return ( + memberIds.length === 2 && + memberIds.includes(currentUser.id) && + memberIds.includes(employee.id) + ); }); - + if (existingChat) { router.push(`/messages/${existingChat.id}`); - } else { - const chat = await apiClient.createChat({ - type: 'private', - name: 'Диалог', - participants: [employee.id] - }); - router.push(`/messages/${chat.id}`); + return; } - } catch (err: unknown) { - console.error("Ошибка создания чата:", err); + + const chat = await apiClient.createChat({ + type: "private", + name: "Диалог", + participants: [employee.id], + }); + router.push(`/messages/${chat.id}`); + } catch (error: unknown) { + console.error("Ошибка создания чата:", error); alert("Не удалось открыть чат"); } finally { setCreatingChatFor(null); } - }; + } useEffect(() => { if (!hasMore || loading) return; @@ -109,13 +125,13 @@ export default function EmployeesPage() { const first = entries[0]; if (!first?.isIntersecting) return; if (isFetchingRef.current || loadingMore || !hasMore) return; - loadEmployees(page + 1, true); + void loadEmployees(page + 1, true); }, { root: null, rootMargin: "300px 0px", threshold: 0.1, - } + }, ); observer.observe(target); @@ -123,17 +139,15 @@ export default function EmployeesPage() { return () => { observer.disconnect(); }; - }, [page, hasMore, loading, loadingMore]); + }, [hasMore, loading, loadingMore, page]); if (loading) { return ( -
-
-
-

Загрузка...

-
-
+
+
+

Загрузка сотрудников...

+
); } @@ -141,72 +155,100 @@ export default function EmployeesPage() { if (error) { return ( -
-

{error}

-
+
+

{error}

+
); } - const sortedEmployees = [...employees].sort((a, b) => { - const nameA = `${a.last_name} ${a.first_name}`.trim(); - const nameB = `${b.last_name} ${b.first_name}`.trim(); - return nameA.localeCompare(nameB, "ru"); + const sortedEmployees = [...employees].sort((left, right) => { + const leftName = `${left.last_name || ""} ${left.first_name || ""}`.trim(); + const rightName = `${right.last_name || ""} ${right.first_name || ""}`.trim(); + return leftName.localeCompare(rightName, "ru"); }); return ( -
- {sortedEmployees.map((employee) => { - const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim(); - const initials = `${employee.last_name?.[0] || ''}${employee.first_name?.[0] || ''}`; - const position = employee.position?.name || 'Сотрудник'; - const isCurrentUser = currentUser?.id === employee.id; +
+
+ {sortedEmployees.map((employee) => { + const fullName = + `${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim(); + const initials = getInitials(employee); + const position = employee.position?.name || "Сотрудник"; + const isCurrentUser = currentUser?.id === employee.id; - return ( -
- -
- {employee.avatar ? ( - {fullName} - ) : ( - initials - )} -
-
-

{fullName}

-

{position}

- {employee.email && ( -

{employee.email}

- )} -
- - {!isCurrentUser && ( - - )} -
- ); - })} +
+ {employee.avatar ? ( + {fullName + ) : ( + + {initials} + + )} +
+
+

+ {fullName || "Сотрудник"} +

+

{position}

+ {employee.email ? ( +

+ {employee.email} +

+ ) : null} +
+ -
- {loadingMore ? ( -

Подгружаем сотрудников...

- ) : !hasMore && employees.length > 0 ? ( -

Все сотрудники загружены

- ) : null} + {!isCurrentUser ? ( + + ) : null} +
+ ); + })} + +
+ {loadingMore ? ( +

+ Подгружаем сотрудников... +

+ ) : !hasMore && employees.length > 0 ? ( +

+ Все сотрудники загружены +

+ ) : null} +
-
+
); } diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index c091cf97..5f0e539a 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -296,12 +296,12 @@ export default function UserDetailPage() { {loading ? ( -
+

Загрузка сотрудника...

) : error ? ( -
+

{error}

) : person ? ( @@ -407,7 +407,7 @@ export default function UserDetailPage() { {person.departments?.length ? ( -
+

Отделы