From fe040e2da9bfbcbbb7683c82b72681ae438e6e63 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 14:15:34 +0300 Subject: [PATCH] refactor(frontend): align profile page controllers --- .../guides/frontend/06-COMPONENT-INVENTORY.md | 3 + docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md | 6 +- frontend/src/app/profile/page.tsx | 450 +++--------------- frontend/src/app/users/[id]/page.tsx | 9 +- .../components/users/EditUserProfileModal.tsx | 228 ++++----- .../components/users/EmployeeActionModal.tsx | 107 +++-- frontend/src/hooks/useProfilePage.ts | 444 +++++++++++++++++ 7 files changed, 713 insertions(+), 534 deletions(-) create mode 100644 frontend/src/hooks/useProfilePage.ts diff --git a/docs/guides/frontend/06-COMPONENT-INVENTORY.md b/docs/guides/frontend/06-COMPONENT-INVENTORY.md index ff39f859..b82b7012 100644 --- a/docs/guides/frontend/06-COMPONENT-INVENTORY.md +++ b/docs/guides/frontend/06-COMPONENT-INVENTORY.md @@ -202,6 +202,8 @@ - `users/EditUserProfileModal.tsx` - `users/EmployeeActionModal.tsx` - `users/EmployeeActionsTimeline.tsx` +- `hooks/useProfilePage.ts` +- `hooks/useUserDetailPage.ts` Использовать как референс для: @@ -209,6 +211,7 @@ - profile sections - info cards - action timeline +- page-level profile controllers, которые держат loading/derived state/API orchestration вне page JSX ### Messages diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md index fb514239..b7a1e2b7 100644 --- a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md +++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md @@ -30,7 +30,7 @@ | `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` | `needs refactor` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components | профиль визуально ощущается как screen из другого проекта, `/profile` и `/users/[id]` архитектурно несимметричны, модалки и часть users-list screens живут в gray/white legacy language | держать как reference по profile anatomy, но сделать отдельный alignment-pass по people/profile cluster без превращения его в request-like layout | +| `users/profile` | `needs refactor` | хорошие profile sections, hero-like anatomy, action timeline, переиспользуемые section-level components, `/profile` и `/users/[id]` теперь оба опираются на page-level controllers | профиль всё ещё визуально ощущается как screen из другого проекта, `ProfileSections` держат custom hero geometry, users modals и часть users-list screens ещё тянут cluster назад | держать как reference по profile anatomy, но продолжить alignment-pass по people/profile cluster без превращения его в request-like layout | | `users/directory` | `needs refactor` | простые сценарии, понятные employee/user navigation flows | `users/page.tsx` и часть directory screens всё ещё в white/gray card language, визуально выпадают из app surfaces | после profile cluster пройтись по user/employee lists и привести их к общему app-shell contract | | `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 без крупного редизайна | @@ -75,8 +75,8 @@ Что делать: -- вынести controller для `/profile` по аналогии с `useUserDetailPage` -- убрать gray/white modal/forms debt из `EditUserProfileModal` и `EmployeeActionModal` +- `/profile` уже переведён на page-level controller по аналогии с `useUserDetailPage` +- `EditUserProfileModal` и `EmployeeActionModal` уже подтянуты к app-style primitives - нормализовать `ProfileSections` по геометрии без уничтожения hero/section contract - привести `users/page.tsx` к app-surface language diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index f1ebf6b8..30629b3b 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -1,13 +1,7 @@ "use client"; import Image from "next/image"; -import { useEffect, useMemo, useState } from "react"; -import { - Mail, - MessageCircle, - Phone, - RefreshCw, -} from "lucide-react"; +import { Mail, MessageCircle, Phone, RefreshCw } from "lucide-react"; import { AppShell } from "@/components/AppShell"; import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline"; @@ -18,378 +12,83 @@ import { ProfileInfoCard, ProfileSkillsCard, type ProfileContactRow, - type ProfileInfoItem, } from "@/components/users/ProfileSections"; -import { useUser } from "@/contexts/UserContext"; -import { apiClient } from "@/lib/api"; -import { resolveMediaUrl } from "@/lib/url"; import { - formatBirthdayWithYear, - formatProfileDate, - formatProfileDateTime, - getEmployeeActionTone, - getLatestEmployeeAction, - getWorkDuration, - getUserFullName, - normalizeTelegramLink, - normalizeWhatsAppLink, - sortEmployeeActions, -} from "@/lib/users/userDetailUtils"; -import type { DirectoryLoginResult } from "@/types/api"; + useProfilePage, + type ProfileContactEntry, +} from "@/hooks/useProfilePage"; +import { formatBirthdayWithYear, getEmployeeActionTone } from "@/lib/users/userDetailUtils"; +import { resolveMediaUrl } from "@/lib/url"; function initials(firstName?: string, lastName?: string) { return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П"; } +function getContactIcon(entry: ProfileContactEntry) { + switch (entry.kind) { + case "email": + return ; + case "phone": + return ; + case "directory-login": + return ( + + ); + case "telegram": + case "whatsapp": + case "wechat": + default: + return ; + } +} + export default function ProfilePage() { - const { user, loading } = useUser(); - const [copiedContactKey, setCopiedContactKey] = useState(null); - const [directoryLogin, setDirectoryLogin] = useState(null); - const [directoryLoginLoading, setDirectoryLoginLoading] = useState(false); - const [directoryLoginRefreshing, setDirectoryLoginRefreshing] = - useState(false); - const [directoryLoginError, setDirectoryLoginError] = useState( - null, - ); - const [availableSkills, setAvailableSkills] = useState< - Array<{ id: number; name: string; description?: string }> - >([]); - const [profileSkills, setProfileSkills] = useState< - Array<{ id: number; name: string; description?: string }> - >([]); - const [skillsLoading, setSkillsLoading] = useState(false); - const [skillsSaving, setSkillsSaving] = useState(false); - const [skillName, setSkillName] = useState(""); - const [skillsError, setSkillsError] = useState(null); - - const fullName = useMemo(() => { - return getUserFullName(user) || "Пользователь"; - }, [user]); - - const currentAction = useMemo(() => { - return getLatestEmployeeAction(user?.actions); - }, [user?.actions]); - - const sortedActions = useMemo(() => { - return sortEmployeeActions(user?.actions); - }, [user?.actions]); - - useEffect(() => { - setDirectoryLogin(user?.username?.trim() || null); - setDirectoryLoginError(null); - }, [user?.username]); - - useEffect(() => { - setProfileSkills(user?.skills || []); - }, [user?.skills]); - - useEffect(() => { - let mounted = true; - - async function loadDirectoryLogin() { - if (!user?.is_ldap_managed || user.username?.trim()) { - return; - } - - try { - setDirectoryLoginLoading(true); - setDirectoryLoginError(null); - const response = - (await apiClient.getDirectoryLogin()) as DirectoryLoginResult; - if (!mounted) return; - setDirectoryLogin(response.username?.trim() || null); - } catch (error: any) { - if (!mounted) return; - setDirectoryLoginError( - String(error?.message || "Не удалось получить логин из каталога"), - ); - } finally { - if (mounted) { - setDirectoryLoginLoading(false); - } - } - } - - void loadDirectoryLogin(); - - return () => { - mounted = false; - }; - }, [user?.id, user?.is_ldap_managed, user?.username]); - - useEffect(() => { - let mounted = true; - - async function loadSkills() { - try { - setSkillsLoading(true); - const response = (await apiClient.getSkills()) as Array<{ - id: number; - name: string; - description?: string; - }>; - if (!mounted) return; - setAvailableSkills(response); - } catch (error: any) { - if (!mounted) return; - setSkillsError( - String(error?.message || "Не удалось загрузить список навыков"), - ); - } finally { - if (mounted) { - setSkillsLoading(false); - } - } - } - - void loadSkills(); - - return () => { - mounted = false; - }; - }, []); - - const handleRefreshDirectoryLogin = async () => { - try { - setDirectoryLoginRefreshing(true); - setDirectoryLoginError(null); - const response = - (await apiClient.refreshDirectoryLogin()) as DirectoryLoginResult; - setDirectoryLogin(response.username?.trim() || null); - } catch (error: any) { - setDirectoryLoginError( - String(error?.message || "Не удалось обновить логин из каталога"), - ); - } finally { - setDirectoryLoginRefreshing(false); - } - }; - - const handleCopyContact = async (value: string, key: string) => { - try { - await navigator.clipboard.writeText(value); - setCopiedContactKey(key); - window.setTimeout(() => setCopiedContactKey((current) => (current === key ? null : current)), 2000); - } catch (error) { - console.error("Ошибка копирования:", error); - } - }; - - const saveSkills = async (skillIds: number[]) => { - try { - setSkillsSaving(true); - setSkillsError(null); - const response = (await apiClient.updateCurrentUserProfile({ - skills_ids: skillIds, - })) as { - skills?: Array<{ id: number; name: string; description?: string }>; - }; - setProfileSkills(response.skills || []); - } catch (error: any) { - setSkillsError( - String(error?.message || "Не удалось обновить навыки"), - ); - } finally { - setSkillsSaving(false); - } - }; - - const handleRemoveSkill = async (skillId: number) => { - if (!user) return; - const nextIds = profileSkills - .map((skill) => skill.id) - .filter((id) => id !== skillId); - await saveSkills(nextIds); - }; - - const handleAddSkill = async (forcedSkill?: { id: number; name: string }) => { - if (!user) return; - const rawName = forcedSkill?.name || skillName; - const trimmedName = rawName.trim(); - if (!trimmedName || skillsSaving) return; - - try { - setSkillsSaving(true); - setSkillsError(null); - - const existingSkill = - forcedSkill || - availableSkills.find( - (skill) => skill.name.trim().toLowerCase() === trimmedName.toLowerCase(), - ); - - let skillId = existingSkill?.id; - - if (!skillId) { - const createdSkill = (await apiClient.createSkill({ - name: trimmedName, - })) as { id: number; name: string; description?: string }; - skillId = createdSkill.id; - setAvailableSkills((current) => - [...current, createdSkill].sort((left, right) => - left.name.localeCompare(right.name, "ru"), - ), - ); - } - - const nextIds = Array.from( - new Set([...profileSkills.map((skill) => skill.id), skillId]), - ); - - const response = (await apiClient.updateCurrentUserProfile({ - skills_ids: nextIds, - })) as { - skills?: Array<{ id: number; name: string; description?: string }>; - }; - setProfileSkills(response.skills || []); - setSkillName(""); - } catch (error: any) { - setSkillsError( - String(error?.message || "Не удалось добавить навык"), - ); - } finally { - setSkillsSaving(false); - } - }; - - const departmentBadge = useMemo(() => { - if (!user?.departments?.length) return null; - const label = user.departments.length === 1 - ? user.departments[0].name - : `${user.departments.length} отделов`; - - return ( - - ); - }, [user?.departments]); - - const contactRows = useMemo(() => { - if (!user) return []; - - const rows: ProfileContactRow[] = []; - - const email = user.email; - if (email) { - rows.push({ - key: "email", - label: "Почта", - value: email, - icon: , - onClick: () => void handleCopyContact(email, "email"), - copied: copiedContactKey === "email", - }); - } - - const phone = user.phone_number; - if (phone) { - rows.push({ - key: "phone", - label: "Телефон", - value: phone, - icon: , - onClick: () => void handleCopyContact(phone, "phone"), - copied: copiedContactKey === "phone", - }); - } - - const telegram = user.telegram; - if (telegram) { - rows.push({ - key: "telegram", - label: "Telegram", - value: telegram, - icon: , - onClick: () => void handleCopyContact(normalizeTelegramLink(telegram), "telegram"), - copied: copiedContactKey === "telegram", - }); - } - - const whatsapp = user.whatsapp; - if (whatsapp) { - rows.push({ - key: "whatsapp", - label: "WhatsApp", - value: whatsapp, - icon: , - onClick: () => void handleCopyContact(normalizeWhatsAppLink(whatsapp), "whatsapp"), - copied: copiedContactKey === "whatsapp", - }); - } - - const wechat = user.wechat; - if (wechat) { - rows.push({ - key: "wechat", - label: "WeChat", - value: wechat, - icon: , - onClick: () => void handleCopyContact(wechat, "wechat"), - copied: copiedContactKey === "wechat", - }); - } - - if (user.is_ldap_managed || directoryLoginLoading || directoryLoginError || directoryLogin) { - const directoryLoginValue = directoryLoginLoading - ? "Загружаем..." - : directoryLogin || "Не найден"; - rows.push({ - key: "directory-login", - label: "Логин в каталоге", - value: directoryLoginValue, - icon: ( - - ), - onClick: directoryLogin && !directoryLoginLoading - ? () => void handleCopyContact(directoryLogin, "directory-login") - : undefined, - copied: copiedContactKey === "directory-login", - action: ( - - ), - }); - } - - return rows; - }, [ - copiedContactKey, - directoryLogin, - directoryLoginLoading, - directoryLoginRefreshing, - directoryLoginError, + const { + availableSkills, + contactEntries, + currentAction, + departmentSummary, + fullName, + infoItems, + loading, + profileSkills, + skillName, + skillsError, + skillsLoading, + skillsSaving, + sortedActions, user, - ]); + handleAddSkill, + handleCopyContact, + handleRefreshDirectoryLogin, + handleRemoveSkill, + onInputSkillName, + } = useProfilePage(); - const infoItems = useMemo(() => ([ - { - label: "В компании", - value: getWorkDuration(user?.date_joined) || "—", - }, - { - label: "Дата найма", - value: formatProfileDate(user?.date_joined), - }, - { - label: "Профиль создан", - value: formatProfileDate(user?.created_at), - }, - { - label: "Последний вход", - value: formatProfileDateTime(user?.last_login), - }, - ]), [user?.created_at, user?.date_joined, user?.last_login]); + const contactRows: ProfileContactRow[] = contactEntries.map((entry) => ({ + key: entry.key, + label: entry.label, + value: entry.value, + icon: getContactIcon(entry), + onClick: + entry.canCopy && entry.copyValue + ? () => void handleCopyContact(entry.copyValue!, entry.key) + : undefined, + copied: entry.copied, + action: entry.canRefresh ? ( + + ) : undefined, + })); if (loading) { return ( @@ -423,7 +122,7 @@ export default function ProfilePage() { ) : null } avatar={ -
+
{user.avatar ? ( + ) : undefined + } bottomPanel={} /> void handleAddSkill()} submitDisabled={!skillName.trim() || skillsSaving} inputDisabled={skillsSaving} diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index 928b65a5..c091cf97 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -27,7 +27,6 @@ import { import { useUser } from "@/contexts/UserContext"; import { useUserDetailPage } from "@/hooks/useUserDetailPage"; import { apiClient } from "@/lib/api"; -import { resolveMediaUrl } from "@/lib/url"; import { formatBirthdayWithYear, formatProfileDate, @@ -120,10 +119,10 @@ export default function UserDetailPage() { }>; if (!mounted) return; setAvailableSkills(response); - } catch (error: any) { + } catch (error: unknown) { if (!mounted) return; setSkillsError( - String(error?.message || "Не удалось загрузить список навыков"), + String((error as Error)?.message || "Не удалось загрузить список навыков"), ); } finally { if (mounted) { @@ -265,9 +264,9 @@ export default function UserDetailPage() { } setSkillName(""); - } catch (error: any) { + } catch (error: unknown) { setSkillsError( - String(error?.message || "Не удалось добавить навык"), + String((error as Error)?.message || "Не удалось добавить навык"), ); } finally { setSkillsSaving(false); diff --git a/frontend/src/components/users/EditUserProfileModal.tsx b/frontend/src/components/users/EditUserProfileModal.tsx index 4271ab79..bc9c1e2a 100644 --- a/frontend/src/components/users/EditUserProfileModal.tsx +++ b/frontend/src/components/users/EditUserProfileModal.tsx @@ -1,9 +1,14 @@ "use client"; +import Image from "next/image"; import type { ChangeEvent } from "react"; + import { Modal } from "@/components/ui"; +import type { + UserProfileEditForm, + UserProfileTextField, +} from "@/lib/users/userDetailUtils"; import type { User } from "@/types/api"; -import type { UserProfileEditForm, UserProfileTextField } from "@/lib/users/userDetailUtils"; type EditUserProfileModalProps = { actionLoading: string | null; @@ -19,6 +24,44 @@ type EditUserProfileModalProps = { person: User | null; }; +type TextFieldProps = { + id: string; + label: string; + onChange: (value: string) => void; + placeholder?: string; + required?: boolean; + type?: "email" | "tel" | "text"; + value: string; +}; + +function TextField({ + id, + label, + onChange, + placeholder, + required = false, + type = "text", + value, +}: TextFieldProps) { + return ( + + ); +} + export default function EditUserProfileModal({ actionLoading, avatarFailed, @@ -32,23 +75,30 @@ export default function EditUserProfileModal({ onTextFieldChange, person, }: EditUserProfileModalProps) { + const isSaving = actionLoading === "edit"; + const footerContent = (
); @@ -59,36 +109,49 @@ export default function EditUserProfileModal({ onClose={onClose} title="Редактировать профиль" size="lg" - closeOnEsc={actionLoading !== "edit"} + closeOnEsc={!isSaving} footer={footerContent} > - {person && ( + {person ? (
-
- -
-
+
+
+
{form.avatarPreview ? ( - // eslint-disable-next-line @next/next/no-img-element - Preview ) : avatarUrl && !avatarFailed ? ( - // eslint-disable-next-line @next/next/no-img-element - Avatar ) : ( - {initials} + + {initials} + )}
-
-

- Максимальный размер: 5MB. Форматы: JPG, PNG, GIF -

-
+ -
-
- - +
+ onTextFieldChange("lastName", event.target.value)} + onChange={(value) => onTextFieldChange("lastName", value)} placeholder="Иванов" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" - maxLength={150} /> -
- -
- - onTextFieldChange("firstName", event.target.value)} + onChange={(value) => onTextFieldChange("firstName", value)} placeholder="Иван" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" - maxLength={150} /> -
- -
- - onTextFieldChange("patronymic", event.target.value)} + onChange={(value) => onTextFieldChange("patronymic", value)} placeholder="Иванович" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" - maxLength={150} />
-
+ -
-
- - +
+ onTextFieldChange("email", event.target.value)} + onChange={(value) => onTextFieldChange("email", value)} placeholder="ivan@example.com" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" /> -
- -
- - onTextFieldChange("phone", event.target.value)} + onChange={(value) => onTextFieldChange("phone", value)} placeholder="+7 999 123-45-67" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" />
-
+ -
-
- - +
+ onTextFieldChange("telegram", event.target.value)} + onChange={(value) => onTextFieldChange("telegram", value)} placeholder="@username" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" /> -
- -
- - onTextFieldChange("whatsapp", event.target.value)} + onChange={(value) => onTextFieldChange("whatsapp", value)} placeholder="+7 999 123-45-67" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" /> -
- -
- - onTextFieldChange("wechat", event.target.value)} + onChange={(value) => onTextFieldChange("wechat", value)} placeholder="username" - className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20" />
-
+
- )} + ) : null} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/users/EmployeeActionModal.tsx b/frontend/src/components/users/EmployeeActionModal.tsx index 1a3a3cc3..2dba57a1 100644 --- a/frontend/src/components/users/EmployeeActionModal.tsx +++ b/frontend/src/components/users/EmployeeActionModal.tsx @@ -1,7 +1,10 @@ "use client"; import { Modal } from "@/components/ui"; -import type { EmployeeActionField, EmployeeActionForm } from "@/lib/users/userDetailUtils"; +import type { + EmployeeActionField, + EmployeeActionForm, +} from "@/lib/users/userDetailUtils"; type EmployeeActionOption = { value: string; @@ -27,23 +30,25 @@ export default function EmployeeActionModal({ onFieldChange, onSave, }: EmployeeActionModalProps) { + const isSaving = actionLoading === "action"; + const footerContent = (
); @@ -54,56 +59,62 @@ export default function EmployeeActionModal({ onClose={onClose} title={form.editingId ? "Редактировать событие" : "Кадровое событие"} size="sm" - closeOnEsc={actionLoading !== "action"} + closeOnEsc={!isSaving} footer={footerContent} >
-
-
+ -
-
+ -
-