From 07ded50432564432a3f4fda68669d57b141b8d25 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 13 Apr 2026 14:44:38 +0300 Subject: [PATCH] refactor(frontend): extract settings page controller --- docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md | 4 +- frontend/src/app/settings/page.tsx | 584 ++--------------- frontend/src/hooks/useSettingsPage.ts | 604 ++++++++++++++++++ 3 files changed, 672 insertions(+), 520 deletions(-) create mode 100644 frontend/src/hooks/useSettingsPage.ts diff --git a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md index 4c7ab9c5..cb614112 100644 --- a/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md +++ b/docs/guides/frontend/09-FRONTEND-AUDIT-MAP.md @@ -24,7 +24,7 @@ | Зона | Статус | Что уже хорошо | Основные проблемы | Рекомендуемый следующий шаг | | --- | --- | --- | --- | --- | | `requests` | `mixed` | визуально удачный экран, compose-настроение, participant-only логика, UI вокруг `can_decide`, compose/list/detail/controls/preview разведены по feature components, screen orchestration вынесена в hook, list/swipe flow собраны в feature containers, avatar/preview media contract выровнен через local wrapper | зона полезна как проверка guide, но не должна автоматически становиться полным visual-reference; здесь важнее бережный structural refactor без насильственного редизайна | использовать как ориентир по декомпозиции насыщенного экрана и осторожной нормализации уже хорошего UI, а не как шаблон для жёсткой пересборки dense list-паттернов | -| `settings` | `mixed` | сильная surface hierarchy, хорошие primary sections, в целом совпадает с app-shell language | встречаются custom radii, page-level файл очень большой, часть hero/media решений выбивается по геометрии | резать controller/view-model слои и точечно сокращать custom geometry без смены общего layout | +| `settings` | `mixed` | сильная surface hierarchy, хорошие primary sections, в целом совпадает с app-shell language, page-level controller уже вынесен в `useSettingsPage` | встречаются custom radii, view-layer секции всё ещё собраны в очень крупный page-file, часть hero/media решений выбивается по геометрии | продолжать резать settings на feature sections и точечно сокращать custom geometry без смены общего layout | | `documents` | `needs refactor` | богатый набор сценариев, detail/upload/comment patterns уже выделены в компоненты | смешение `rounded-md`, `rounded-lg`, `rounded-xl`, заметный gray-legacy в editor/viewer/tags, слишком много modal-heavy page-logic | сделать отдельный pass по карточкам, metadata/upload flows и documents subcomponents, не ограничиваться только page-level | | `equipment` | `needs refactor` | понятные формы, hooks уже вынесены, основные списки и модалки покрывают богатый сценарий | большой page-file, смешение app-style и gray/white fragments, detail/comments/forms ещё не сведены к одному contract | после documents пройтись по secondary cards, detail blocks и action forms | | `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 и только потом решать дальнейшую декомпозицию | @@ -176,7 +176,7 @@ Что делать: -- у `settings` сначала резать size/structure page, затем geometry +- у `settings` после controller extraction резать section-level UI и только затем geometry - у `departments` поднять богатство patterns до общего уровня app - у `auth/legal` выровнять isolated geometry отдельно от app-shell жанра diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index ecf4cf69..4f225756 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -1,7 +1,6 @@ "use client"; import Image from "next/image"; -import { useEffect, useMemo, useRef, useState } from "react"; import { Bell, Check, @@ -18,113 +17,25 @@ import { Sun, UserRound, } from "lucide-react"; -import { toast } from "sonner"; -import { AppShell, PageHeader } from "@/components/AppShell"; -import { useTheme } from "@/contexts/ThemeContext"; -import { useUser } from "@/contexts/UserContext"; -import { useWebPush } from "@/hooks/useWebPush"; -import { apiClient } from "@/lib/api"; -import type { ThemePreference } from "@/lib/theme"; -import { resolveMediaUrl } from "@/lib/url"; -import type { AuthSession } from "@/types/api"; +import { AppShell } from "@/components/AppShell"; +import { + formatSessionDateTime, + getSessionDeviceKind, + getSessionDeviceName, + type NotificationPreferences, + useSettingsPage, +} from "@/hooks/useSettingsPage"; import { getVerbName } from "@/lib/verbTranslations"; -type NotificationPreferences = { - web_enabled: boolean; - email_enabled: boolean; - email_frequency: "instant" | "daily" | "weekly" | "never"; - push_enabled: boolean; - dnd_enabled: boolean; - dnd_start_time: string | null; - dnd_end_time: string | null; - disabled_verbs: string[]; -}; - -type VerbType = { - verb: string; - name: string; - total: number; - unread: number; -}; - -const themeCards: Array<{ - value: ThemePreference; - title: string; - description: string; - icon: typeof Sun; -}> = [ - { - value: "light", - title: "Светлая", - description: "Светлые поверхности и нейтральный фон.", - icon: Sun, - }, - { - value: "dark", - title: "Темная", - description: "Темные поверхности без browser force-dark.", - icon: Moon, - }, - { - value: "auto", - title: "Авто", - description: "Следует системной теме устройства.", - icon: Monitor, - }, -]; - -const defaultPreferences: NotificationPreferences = { - web_enabled: true, - email_enabled: false, - email_frequency: "instant", - push_enabled: false, - dnd_enabled: false, - dnd_start_time: null, - dnd_end_time: null, - disabled_verbs: [], -}; - -function preferencesSignature(value: NotificationPreferences | null) { - if (!value) return ""; - return JSON.stringify({ - ...value, - disabled_verbs: [...value.disabled_verbs].sort(), - dnd_start_time: value.dnd_start_time || null, - dnd_end_time: value.dnd_end_time || null, - }); -} - -function initials(firstName?: string, lastName?: string) { - return `${lastName?.[0] || ""}${firstName?.[0] || ""}`.trim() || "П"; -} - -function formatSessionDateTime(value?: string | null) { - if (!value) return "—"; - - try { - return new Intl.DateTimeFormat("ru-RU", { - day: "2-digit", - month: "short", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(value)); - } catch { - return value; - } -} +const themeIcons = { + Sun, + Moon, + Monitor, +} as const; function getSessionDeviceIcon(deviceName?: string | null) { - const normalized = (deviceName || "").toLowerCase(); - return /iphone|android|mobile|ipad|phone/.test(normalized) ? Smartphone : Monitor; -} - -function getSessionDeviceName(session: AuthSession) { - return session.device_name?.trim() || "Неизвестное устройство"; -} - -function isApiNotFoundError(error: unknown) { - return error instanceof Error && error.message.includes("API Error: 404"); + return getSessionDeviceKind(deviceName) === "mobile" ? Smartphone : Monitor; } function Toggle({ @@ -196,422 +107,58 @@ function SectionCard({ } export default function SettingsPage() { - const { user, loading, refreshUser, logout } = useUser(); - const { theme, resolvedTheme, setTheme } = useTheme(); - const avatarInputRef = useRef(null); - - const [profileForm, setProfileForm] = useState({ - first_name: "", - last_name: "", - patronymic: "", - birth_date: "", - }); - const [contactsForm, setContactsForm] = useState({ - email: "", - phone_number: "", - telegram: "", - whatsapp: "", - wechat: "", - }); - const [avatarFile, setAvatarFile] = useState(null); - const [avatarPreview, setAvatarPreview] = useState(null); - const [savingProfile, setSavingProfile] = useState(false); - const [savingContacts, setSavingContacts] = useState(false); - - const [preferences, setPreferences] = useState(defaultPreferences); - const [savedPreferences, setSavedPreferences] = useState(null); - const [verbTypes, setVerbTypes] = useState([]); - const [preferencesLoading, setPreferencesLoading] = useState(true); - const [savingPreferences, setSavingPreferences] = useState(false); - const [sessions, setSessions] = useState([]); - const [sessionsLoading, setSessionsLoading] = useState(true); - const [sessionsUnavailable, setSessionsUnavailable] = useState(false); - const [sessionActionKey, setSessionActionKey] = useState(null); - const [passwordForm, setPasswordForm] = useState({ - current_password: "", - new_password: "", - new_password_confirm: "", - }); - const [changingPassword, setChangingPassword] = useState(false); - const [showPasswordFields, setShowPasswordFields] = useState({ - current: false, - next: false, - confirm: false, - }); const { - isSupported, + activeVerbCount, + avatarInputRef, + changingPassword, + contactsDirty, + contactsForm, + currentSession, + fullName, isSubscribed, + isSupported, + loading, + notificationDirty, + otherSessions, + passwordForm, permission, - isLoading: pushLoading, - subscribe, - unsubscribe, - } = useWebPush(); - - useEffect(() => { - if (!user) return; - setProfileForm({ - first_name: user.first_name || "", - last_name: user.last_name || "", - patronymic: user.patronymic || "", - birth_date: user.birth_date || "", - }); - setContactsForm({ - email: user.email || "", - phone_number: user.phone_number || "", - telegram: user.telegram || "", - whatsapp: user.whatsapp || "", - wechat: user.wechat || "", - }); - }, [user]); - - useEffect(() => { - const section = new URLSearchParams(window.location.search).get("section"); - if (!section) return; - const element = document.getElementById(section); - if (element) { - requestAnimationFrame(() => { - element.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - } - }, []); - - useEffect(() => { - let mounted = true; - - async function loadNotificationSettings() { - try { - setPreferencesLoading(true); - const [prefsResponse, verbsResponse] = await Promise.all([ - apiClient.getNotificationPreferences(), - apiClient.getVerbTypes(), - ]); - - if (!mounted) return; - - const normalized: NotificationPreferences = { - ...defaultPreferences, - ...prefsResponse, - email_frequency: - prefsResponse.email_frequency === "disabled" - ? "never" - : prefsResponse.email_frequency, - }; - - setPreferences(normalized); - setSavedPreferences(normalized); - setVerbTypes(verbsResponse.verb_types || []); - } catch (error) { - console.error("Failed to load notification settings", error); - toast.error("Не удалось загрузить настройки уведомлений"); - } finally { - if (mounted) { - setPreferencesLoading(false); - } - } - } - - void loadNotificationSettings(); - - return () => { - mounted = false; - }; - }, []); - - useEffect(() => { - if (!savedPreferences || pushLoading) return; - if (!isSupported) return; - - setPreferences((prev) => - prev.push_enabled === isSubscribed ? prev : { ...prev, push_enabled: isSubscribed }, - ); - setSavedPreferences((prev) => - prev && prev.push_enabled === isSubscribed ? prev : prev ? { ...prev, push_enabled: isSubscribed } : prev, - ); - }, [isSubscribed, isSupported, pushLoading, savedPreferences]); - - useEffect(() => { - let mounted = true; - - async function loadSessions() { - try { - setSessionsLoading(true); - setSessionsUnavailable(false); - const response = await apiClient.getAuthSessions(); - if (!mounted) return; - setSessions(response); - } catch (error) { - if (isApiNotFoundError(error)) { - console.warn("Auth sessions endpoint is unavailable in the running backend process"); - if (mounted) { - setSessionsUnavailable(true); - setSessions([]); - } - return; - } - - console.error("Failed to load sessions", error); - if (mounted) { - toast.error("Не удалось загрузить активные сессии"); - } - } finally { - if (mounted) { - setSessionsLoading(false); - } - } - } - - void loadSessions(); - - return () => { - mounted = false; - }; - }, []); - - const fullName = useMemo(() => { - if (!user) return "Пользователь"; - return `${user.last_name || ""} ${user.first_name || ""} ${user.patronymic || ""}`.trim() || "Пользователь"; - }, [user]); - - const profileDirty = useMemo(() => { - if (!user) return false; - return ( - profileForm.first_name !== (user.first_name || "") || - profileForm.last_name !== (user.last_name || "") || - profileForm.patronymic !== (user.patronymic || "") || - profileForm.birth_date !== (user.birth_date || "") || - Boolean(avatarFile) - ); - }, [avatarFile, profileForm, user]); - - const contactsDirty = useMemo(() => { - if (!user) return false; - return ( - contactsForm.email !== (user.email || "") || - contactsForm.phone_number !== (user.phone_number || "") || - contactsForm.telegram !== (user.telegram || "") || - contactsForm.whatsapp !== (user.whatsapp || "") || - contactsForm.wechat !== (user.wechat || "") - ); - }, [contactsForm, user]); - - const notificationDirty = useMemo(() => { - return preferencesSignature(preferences) !== preferencesSignature(savedPreferences); - }, [preferences, savedPreferences]); - - const unreadVerbCount = useMemo( - () => verbTypes.reduce((sum, item) => sum + item.unread, 0), - [verbTypes], - ); - - const activeVerbCount = useMemo( - () => verbTypes.filter((item) => !preferences.disabled_verbs.includes(item.verb)).length, - [preferences.disabled_verbs, verbTypes], - ); - - const summaryAvatar = avatarPreview || (user?.avatar ? resolveMediaUrl(user.avatar) : null); - const currentSession = useMemo( - () => sessions.find((session) => session.is_current) || null, - [sessions], - ); - const otherSessions = useMemo( - () => sessions.filter((session) => !session.is_current), - [sessions], - ); - - const handleAvatarChange = (file: File | null) => { - if (!file) return; - if (!file.type.startsWith("image/")) { - toast.error("Можно загрузить только изображение"); - return; - } - if (file.size > 5 * 1024 * 1024) { - toast.error("Максимальный размер файла 5 МБ"); - return; - } - - setAvatarFile(file); - const reader = new FileReader(); - reader.onload = () => { - setAvatarPreview(typeof reader.result === "string" ? reader.result : null); - }; - reader.readAsDataURL(file); - }; - - const handlePushToggle = async (enabled: boolean) => { - setPreferences((prev) => ({ ...prev, push_enabled: enabled })); - - if (enabled && !isSubscribed) { - await subscribe(); - } else if (!enabled && isSubscribed) { - await unsubscribe(); - } - }; - - const handleDndToggle = (enabled: boolean) => { - if (enabled) { - setPreferences((prev) => ({ - ...prev, - dnd_enabled: true, - dnd_start_time: prev.dnd_start_time || "00:00", - dnd_end_time: prev.dnd_end_time || "23:59", - })); - return; - } - - setPreferences((prev) => ({ ...prev, dnd_enabled: false })); - }; - - const saveProfile = async () => { - if (!profileDirty) return; - try { - setSavingProfile(true); - await apiClient.updateCurrentUserProfile({ - first_name: profileForm.first_name.trim(), - last_name: profileForm.last_name.trim(), - patronymic: profileForm.patronymic.trim(), - birth_date: profileForm.birth_date.trim() || null, - avatar: avatarFile || undefined, - }); - await refreshUser(); - setAvatarFile(null); - setAvatarPreview(null); - toast.success("Профиль обновлен"); - } catch (error) { - console.error(error); - toast.error("Не удалось сохранить профиль"); - } finally { - setSavingProfile(false); - } - }; - - const saveContacts = async () => { - if (!contactsDirty) return; - try { - setSavingContacts(true); - await apiClient.updateCurrentUserProfile({ - email: contactsForm.email.trim(), - phone_number: contactsForm.phone_number.trim(), - telegram: contactsForm.telegram.trim(), - whatsapp: contactsForm.whatsapp.trim(), - wechat: contactsForm.wechat.trim(), - }); - await refreshUser(); - toast.success("Контакты обновлены"); - } catch (error) { - console.error(error); - toast.error("Не удалось сохранить контакты"); - } finally { - setSavingContacts(false); - } - }; - - const savePreferences = async () => { - if (!notificationDirty) return; - try { - setSavingPreferences(true); - - if (preferences.push_enabled !== isSubscribed) { - if (preferences.push_enabled) { - const success = await subscribe(); - if (!success) { - throw new Error("Push subscribe failed"); - } - } else { - const success = await unsubscribe(); - if (!success && isSubscribed) { - throw new Error("Push unsubscribe failed"); - } - } - } - - await apiClient.updateNotificationPreferences({ - ...preferences, - dnd_start_time: preferences.dnd_start_time || undefined, - dnd_end_time: preferences.dnd_end_time || undefined, - }); - - setSavedPreferences(preferences); - toast.success("Настройки уведомлений сохранены"); - } catch (error) { - console.error(error); - toast.error("Не удалось сохранить настройки уведомлений"); - } finally { - setSavingPreferences(false); - } - }; - - const handleSessionLogout = async (session: AuthSession) => { - const isCurrent = session.is_current; - const busyKey = `delete:${session.session_id}`; - - try { - setSessionActionKey(busyKey); - await apiClient.deleteAuthSession(session.session_id); - - if (isCurrent) { - toast.success("Текущая сессия завершена"); - logout(); - return; - } - - setSessions((prev) => prev.filter((item) => item.session_id !== session.session_id)); - toast.success("Сессия завершена"); - } catch (error) { - console.error(error); - toast.error("Не удалось завершить сессию"); - } finally { - setSessionActionKey(null); - } - }; - - const handleLogoutOthers = async () => { - try { - setSessionActionKey("logout-others"); - const response = await apiClient.logoutOtherSessions(); - setSessions((prev) => prev.filter((session) => session.is_current)); - toast.success( - response.revoked > 0 - ? `Завершено сессий: ${response.revoked}` - : "Других активных сессий нет", - ); - } catch (error) { - console.error(error); - toast.error("Не удалось завершить остальные сессии"); - } finally { - setSessionActionKey(null); - } - }; - - const handleChangePassword = async () => { - if (!passwordForm.current_password || !passwordForm.new_password || !passwordForm.new_password_confirm) { - toast.error("Заполните все поля пароля"); - return; - } - if (passwordForm.new_password !== passwordForm.new_password_confirm) { - toast.error("Подтверждение пароля не совпадает"); - return; - } - - try { - setChangingPassword(true); - await apiClient.changePassword(passwordForm); - setPasswordForm({ - current_password: "", - new_password: "", - new_password_confirm: "", - }); - toast.success("Пароль обновлен"); - } catch (error) { - console.error(error); - toast.error( - error instanceof Error && error.message.includes("current_password") - ? "Текущий пароль указан неверно" - : "Не удалось изменить пароль", - ); - } finally { - setChangingPassword(false); - } - }; + preferences, + preferencesLoading, + profileDirty, + profileForm, + pushLoading, + resolvedTheme, + savingContacts, + savingPreferences, + savingProfile, + sessionActionKey, + sessionsLoading, + sessionsUnavailable, + showPasswordFields, + summaryAvatar, + theme, + unreadVerbCount, + user, + verbTypes, + handleAvatarChange, + handleChangePassword, + handleDndToggle, + handleLogoutOthers, + handlePushToggle, + handleSessionLogout, + logout, + saveContacts, + savePreferences, + saveProfile, + setContactsForm, + setPasswordForm, + setPreferences, + setProfileForm, + setShowPasswordFields, + setTheme, + settingsInitials, + settingsThemeCards, + } = useSettingsPage(); if (loading || !user) { return ( @@ -680,7 +227,7 @@ export default function SettingsPage() { {fullName} ) : (
- {initials(user.first_name, user.last_name)} + {settingsInitials(user.first_name, user.last_name)}
)} @@ -750,8 +297,9 @@ export default function SettingsPage() { description="Тема текущего фронта и примененный visual mode." >
- {themeCards.map(({ value, title, description, icon: Icon }) => { + {settingsThemeCards.map(({ value, title, description, iconName }) => { const active = theme === value; + const Icon = themeIcons[iconName]; return (