From 47869971e663cc15f659d307b31f5e1a1fb4c270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 5 Aug 2026 11:48:07 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=20?= =?UTF-8?q?=E2=80=94=20/profile=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D1=83?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /profile теперь обёртка MyProfilePage над UserProfilePage (один экран для своего и чужого профиля) - self-вкладки из ProfileSettings перенесены: Уведомления, Offline, Интерфейс, Безопасность (components/profile/*) - ProfileSettings.tsx удалён - админ видит вкладки Уведомления и Offline на чужой карточке с данными целевого пользователя (userId-параметр в GET/POST /api/subscriptions, PATCH/DELETE по владельцу, GET /api/sync/config?userId=; проверка canManageUser) - Роль/Активен/Статус disabled для не-админа (сервер их и раньше игнорировал) - пункт меню «Настройки профиля» переименован в «Мой профиль» --- client/src/App.tsx | 4 +- client/src/components/Sidebar.tsx | 2 +- .../profile/ProfileInterfaceTab.tsx | 92 ++ .../profile/ProfileNotificationsTab.tsx | 474 +++++++++ .../components/profile/ProfileOfflineTab.tsx | 182 ++++ .../components/profile/ProfileSecurityTab.tsx | 163 +++ client/src/pages/MyProfilePage.tsx | 34 + client/src/pages/ProfileSettings.tsx | 956 ------------------ client/src/pages/UserProfilePage.tsx | 72 +- server/routes/chat.notifications.routes.ts | 49 +- server/routes/sync.routes.ts | 17 +- server/services/notification.service.ts | 13 + shared/schema.ts | 2 + 13 files changed, 1089 insertions(+), 971 deletions(-) create mode 100644 client/src/components/profile/ProfileInterfaceTab.tsx create mode 100644 client/src/components/profile/ProfileNotificationsTab.tsx create mode 100644 client/src/components/profile/ProfileOfflineTab.tsx create mode 100644 client/src/components/profile/ProfileSecurityTab.tsx create mode 100644 client/src/pages/MyProfilePage.tsx delete mode 100644 client/src/pages/ProfileSettings.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index dcd0581..091b0c5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -42,7 +42,7 @@ const TableEditor = safeLazy(() => import("@/pages/TableEditor")); const TaskView = safeLazy(() => import("@/pages/TaskView")); const TaskDetail = safeLazy(() => import("@/pages/TaskDetail")); const Settings = safeLazy(() => import("@/pages/Settings")); -const ProfileSettings = safeLazy(() => import("@/pages/ProfileSettings")); +const MyProfilePage = safeLazy(() => import("@/pages/MyProfilePage")); const Reports = safeLazy(() => import("@/pages/Reports")); const Projects = safeLazy(() => import("@/pages/Projects")); const Bots = safeLazy(() => import("@/pages/Bots")); @@ -159,7 +159,7 @@ function Router() { return ( - + diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 52da7db..3a410c5 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -1619,7 +1619,7 @@ export function Sidebar({ children }: SidebarProps) { - Настройки профиля + Мой профиль diff --git a/client/src/components/profile/ProfileInterfaceTab.tsx b/client/src/components/profile/ProfileInterfaceTab.tsx new file mode 100644 index 0000000..689f610 --- /dev/null +++ b/client/src/components/profile/ProfileInterfaceTab.tsx @@ -0,0 +1,92 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useTheme } from '@/hooks/use-theme'; +import { useSkin } from '@/themes/ThemeProvider'; +import { usePwaFontSize } from '@/hooks/usePwaFontSize'; +import { Moon, Sun, Type, Palette } from 'lucide-react'; + +/** + * Вкладка «Интерфейс» единого экрана профиля: тема, скин, размер шрифта в PWA. + * Настройки персональные, поэтому вкладка рендерится только для своего профиля. + */ +export function ProfileInterfaceTab({ userId }: { userId: number }) { + const { theme, toggleTheme } = useTheme(); + const { skinId, setSkinId, availableSkins } = useSkin(); + const { isPwa, fontSize, increase: increaseFontSize, decrease: decreaseFontSize } = usePwaFontSize(userId); + + return ( +
+ + +
+ {theme === 'dark' ? ( + + ) : ( + + )} + Тема +
+ +
+
+ + + +
+ + Скин интерфейса +
+ +
+
+ + {isPwa && ( + + +
+ + Шрифт +
+
+ + {fontSize} + +
+
+
+ )} +
+ ); +} diff --git a/client/src/components/profile/ProfileNotificationsTab.tsx b/client/src/components/profile/ProfileNotificationsTab.tsx new file mode 100644 index 0000000..5f40d70 --- /dev/null +++ b/client/src/components/profile/ProfileNotificationsTab.tsx @@ -0,0 +1,474 @@ +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { queryClient, apiRequest } from '@/lib/queryClient'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useToast } from '@/hooks/use-toast'; +import { useAuth } from '@/hooks/useAuth'; +import { Bell, Mail, Plus, Trash2, Loader2, Smartphone } from 'lucide-react'; + +type NotificationEventType = { + id: number; + code: string; + name: string; + description: string | null; + category: string; + isActive: boolean; +}; + +type NotificationSubscription = { + id: number; + userId: number; + organizationId: number; + eventTypeCode: string; + targetType: string | null; + targetId: number | null; + channels: string[]; + isActive: boolean; +}; + +type Form = { + id: number; + name: string; +}; + +/** + * Вкладка «Уведомления» единого экрана профиля. + * При передаче userId чужого пользователя (админский просмотр) запросы идут с ?userId= — + * бэкенд проверяет canManageUser. Без userId — свои подписки. + */ +export function ProfileNotificationsTab({ userId }: { userId?: number }) { + const { toast } = useToast(); + const { user: currentUser } = useAuth(); + const [selectedEventType, setSelectedEventType] = useState(''); + const [selectedTargetType, setSelectedTargetType] = useState('all'); + const [selectedTargetId, setSelectedTargetId] = useState(''); + const [selectedChannels, setSelectedChannels] = useState(['app', 'push']); + + // Целевой пользователь: отличается от текущего только при админском просмотре чужой карточки + const targetUserId = userId && currentUser && userId !== currentUser.id ? userId : undefined; + + const { data: eventTypesData } = useQuery<{ success: boolean; eventTypes: NotificationEventType[] }>({ + queryKey: ['/api/subscriptions/event-types'], + staleTime: 5 * 60 * 1000, + }); + + const { data: subscriptionsData } = useQuery<{ success: boolean; subscriptions: NotificationSubscription[] }>({ + queryKey: targetUserId ? ['/api/subscriptions', 'user', targetUserId] : ['/api/subscriptions'], + queryFn: targetUserId + ? async () => { + const res = await apiRequest('GET', `/api/subscriptions?userId=${targetUserId}`); + return res.json(); + } + : undefined, + }); + + const { data: formsData } = useQuery<{ success: boolean; forms: Form[] }>({ + queryKey: ['/api/forms'], + staleTime: 5 * 60 * 1000, + }); + + const createSubscriptionMutation = useMutation({ + mutationFn: async (data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] }) => { + return apiRequest('POST', '/api/subscriptions', targetUserId ? { ...data, userId: targetUserId } : data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] }); + toast({ title: 'Подписка создана' }); + setSelectedEventType(''); + setSelectedTargetType('all'); + setSelectedTargetId(''); + }, + onError: () => { + toast({ title: 'Ошибка создания подписки', variant: 'destructive' }); + }, + }); + + const updateSubscriptionMutation = useMutation({ + mutationFn: async ({ id, data }: { id: number; data: { channels?: string[]; isActive?: boolean } }) => { + return apiRequest('PATCH', `/api/subscriptions/${id}`, data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] }); + }, + onError: () => { + toast({ title: 'Ошибка обновления подписки', variant: 'destructive' }); + }, + }); + + const deleteSubscriptionMutation = useMutation({ + mutationFn: async (id: number) => { + return apiRequest('DELETE', `/api/subscriptions/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] }); + toast({ title: 'Подписка удалена' }); + }, + onError: () => { + toast({ title: 'Ошибка удаления подписки', variant: 'destructive' }); + }, + }); + + const eventTypes = eventTypesData?.eventTypes || []; + const subscriptions = subscriptionsData?.subscriptions || []; + const forms = formsData?.forms || []; + + const getEventTypeName = (code: string) => { + const eventType = eventTypes.find(et => et.code === code); + return eventType?.name || code; + }; + + const getCategoryColor = (category: string) => { + switch (category) { + case 'task': return 'bg-info text-info-foreground'; + case 'chat': return 'bg-success text-success-foreground'; + case 'form': return 'bg-warning text-warning-foreground'; + case 'system': return 'bg-muted text-muted-foreground'; + default: return 'bg-muted text-muted-foreground'; + } + }; + + const getCategoryLabel = (category: string) => { + switch (category) { + case 'task': return 'Задачи'; + case 'chat': return 'Чат'; + case 'form': return 'Формы'; + case 'system': return 'Система'; + default: return category; + } + }; + + const getTargetLabel = (sub: NotificationSubscription) => { + if (!sub.targetType || sub.targetType === 'all') { + return 'Все'; + } + if (sub.targetType === 'form' && sub.targetId) { + const form = forms.find(f => f.id === sub.targetId); + return form ? `Форма: ${form.name}` : `Форма #${sub.targetId}`; + } + if (sub.targetType === 'task' && sub.targetId) { + return `Задача #${sub.targetId}`; + } + return sub.targetType; + }; + + const handleAddSubscription = () => { + if (!selectedEventType) { + toast({ title: 'Выберите тип события', variant: 'destructive' }); + return; + } + + const data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] } = { + eventTypeCode: selectedEventType, + channels: selectedChannels, + }; + + if (selectedTargetType && selectedTargetType !== 'all') { + data.targetType = selectedTargetType; + if (selectedTargetId) { + data.targetId = parseInt(selectedTargetId); + } + } + + createSubscriptionMutation.mutate(data); + }; + + const handleToggleSubscription = (sub: NotificationSubscription) => { + updateSubscriptionMutation.mutate({ + id: sub.id, + data: { isActive: !sub.isActive }, + }); + }; + + const handleToggleChannel = (sub: NotificationSubscription, channel: string) => { + const channels = sub.channels.includes(channel) + ? sub.channels.filter(c => c !== channel) + : [...sub.channels, channel]; + + if (channels.length === 0) { + channels.push('app'); + } + + updateSubscriptionMutation.mutate({ + id: sub.id, + data: { channels }, + }); + }; + + const groupedEventTypes = eventTypes.reduce((acc, et) => { + if (!acc[et.category]) { + acc[et.category] = []; + } + acc[et.category].push(et); + return acc; + }, {} as Record); + + return ( +
+ + + + + Добавить подписку + + + Выберите события, о которых хотите получать уведомления + + + +
+
+ + +
+ +
+ + +
+ + {selectedTargetType === 'form' && ( +
+ + +
+ )} + +
+ +
+ + + +
+
+
+ + +
+
+ + + + Мои подписки + + Управляйте своими уведомлениями + + + + {subscriptions.length === 0 ? ( +
+ У вас пока нет подписок на события +
+ ) : ( +
+ {subscriptions.map(sub => ( +
+
+ handleToggleSubscription(sub)} + data-testid={`switch-subscription-${sub.id}`} + /> +
+
{getEventTypeName(sub.eventTypeCode)}
+
+ {getTargetLabel(sub)} +
+
+
+ +
+
+ + + +
+ + +
+
+ ))} +
+ )} +
+
+ + + + Доступные типы событий + + События, на которые можно подписаться + + + +
+ {Object.entries(groupedEventTypes).map(([category, types]) => ( +
+

+ + {getCategoryLabel(category)} + +

+
    + {types.map(et => ( +
  • + • {et.name} + {et.description && ( + - {et.description} + )} +
  • + ))} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/client/src/components/profile/ProfileOfflineTab.tsx b/client/src/components/profile/ProfileOfflineTab.tsx new file mode 100644 index 0000000..c86600c --- /dev/null +++ b/client/src/components/profile/ProfileOfflineTab.tsx @@ -0,0 +1,182 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import { queryClient, apiRequest } from '@/lib/queryClient'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { Loader2, WifiOff } from 'lucide-react'; + +type OfflineForm = { + id: number; + name: string; + offlineCache?: { + enabled: boolean; + maxTasks?: number; + strategy?: 'recent' | 'assigned' | 'all'; + includeFields?: string[]; + } | null; +}; + +type OfflineSubscription = { + id: number; + userId: number; + formId: number; + isActive: boolean; + includeRelatedTasks: boolean; + includeFiles: boolean; +}; + +/** + * Вкладка «Offline» единого экрана профиля. + * isSelf=false — админский просмотр чужой карточки: конфиг запрашивается с ?userId= + * (бэкенд проверяет canManageUser), локальный performInitialSync() не вызывается. + */ +export function ProfileOfflineTab({ userId, isSelf = true }: { userId: number; isSelf?: boolean }) { + const { toast } = useToast(); + + const { data: syncConfigData, isLoading: isLoadingSyncConfig } = useQuery<{ + success: boolean; + forms: OfflineForm[]; + subscriptions: OfflineSubscription[]; + }>({ + queryKey: isSelf ? ['/api/sync/config'] : ['/api/sync/config', 'user', userId], + queryFn: isSelf + ? undefined + : async () => { + const res = await apiRequest('GET', `/api/sync/config?userId=${userId}`); + return res.json(); + }, + staleTime: 0, + }); + + const saveOfflineSubscriptionMutation = useMutation({ + mutationFn: async (data: { formId: number; isActive: boolean; includeRelatedTasks: boolean; includeFiles: boolean }) => { + const res = await apiRequest('POST', `/api/users/${userId}/offline-subscriptions`, data); + return res.json() as Promise<{ success: boolean; subscription: OfflineSubscription }>; + }, + onSuccess: async () => { + queryClient.invalidateQueries({ queryKey: ['/api/sync/config'] }); + toast({ title: 'Подписка сохранена' }); + // Локальная синхронизация устройства имеет смысл только для своих подписок + if (!isSelf) return; + try { + const { performInitialSync } = await import('@/lib/syncEngine'); + await performInitialSync(); + } catch (e) { + console.error('Initial sync failed after subscription change:', e); + } + }, + onError: () => { + toast({ title: 'Ошибка сохранения подписки', variant: 'destructive' }); + }, + }); + + return ( + + + + + Offline-кэш форм + + + Выберите формы, задачи которых будут сохраняться на устройстве для работы без интернета. + + + + {isLoadingSyncConfig ? ( +
+ +
+ ) : (syncConfigData?.forms || []).filter(f => f.offlineCache?.enabled).length === 0 ? ( +
+ Нет форм, разрешённых для offline-кэширования. Администратор может включить их в редакторе формы. +
+ ) : ( +
+ {(syncConfigData?.forms || []) + .filter(f => f.offlineCache?.enabled) + .map(form => { + const sub = (syncConfigData?.subscriptions || []).find(s => s.formId === form.id); + const isActive = sub?.isActive ?? false; + return ( +
+
+ + saveOfflineSubscriptionMutation.mutate({ + formId: form.id, + isActive: checked, + includeRelatedTasks: sub?.includeRelatedTasks ?? true, + includeFiles: sub?.includeFiles ?? false, + }) + } + disabled={saveOfflineSubscriptionMutation.isPending} + /> +
+
{form.name}
+
+ Макс. {form.offlineCache?.maxTasks ?? 500} задач ·{' '} + {form.offlineCache?.strategy === 'assigned' + ? 'назначенные мне' + : form.offlineCache?.strategy === 'all' + ? 'все задачи' + : 'последние по дате'} +
+
+
+ {isActive && ( +
+
+ + saveOfflineSubscriptionMutation.mutate({ + formId: form.id, + isActive: true, + includeRelatedTasks: checked === true, + includeFiles: sub?.includeFiles ?? false, + }) + } + disabled={saveOfflineSubscriptionMutation.isPending} + id={`offline-sub-related-${form.id}`} + /> + +
+
+ + saveOfflineSubscriptionMutation.mutate({ + formId: form.id, + isActive: true, + includeRelatedTasks: sub?.includeRelatedTasks ?? true, + includeFiles: checked === true, + }) + } + disabled={saveOfflineSubscriptionMutation.isPending} + id={`offline-sub-files-${form.id}`} + /> + +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/client/src/components/profile/ProfileSecurityTab.tsx b/client/src/components/profile/ProfileSecurityTab.tsx new file mode 100644 index 0000000..5bfd7e0 --- /dev/null +++ b/client/src/components/profile/ProfileSecurityTab.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { useLocation } from 'wouter'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useToast } from '@/hooks/use-toast'; +import { useAuth } from '@/hooks/useAuth'; +import { authService } from '@/services/auth.service'; +import { Loader2, Lock, LogOut } from 'lucide-react'; + +/** + * Вкладка «Безопасность» единого экрана профиля: смена своего пароля и выход из аккаунта. + * Смена пароля self-only, поэтому вкладка рендерится только для своего профиля. + */ +export function ProfileSecurityTab() { + const { toast } = useToast(); + const { logout } = useAuth(); + const [, setLocation] = useLocation(); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + // Текст ошибки валидации нового пароля — показывается рядом с полем + const [passwordError, setPasswordError] = useState(null); + + const changePasswordMutation = useMutation({ + mutationFn: async (data: { currentPassword: string; newPassword: string }) => { + return authService.changePassword(data.currentPassword, data.newPassword); + }, + onSuccess: (data) => { + if (data.success) { + toast({ title: data.message || 'Пароль успешно изменен' }); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setPasswordError(null); + } else { + toast({ title: data.error || 'Ошибка при изменении пароля', variant: 'destructive' }); + } + }, + onError: () => { + toast({ title: 'Ошибка при изменении пароля', variant: 'destructive' }); + }, + }); + + return ( +
+ + + Смена пароля + + Введите текущий пароль и новый пароль для вашего аккаунта + + + +
+ + setCurrentPassword(e.target.value)} + placeholder="Введите текущий пароль" + /> +
+
+ + { + setNewPassword(e.target.value); + setPasswordError(null); + }} + placeholder="Введите новый пароль" + aria-invalid={!!passwordError} + aria-describedby={passwordError ? 'new-password-error' : undefined} + /> +

+ Минимум 8 символов, заглавная и строчная буква, цифра и специальный символ +

+
+
+ + { + setConfirmPassword(e.target.value); + setPasswordError(null); + }} + placeholder="Повторите новый пароль" + /> + {passwordError && ( +

+ {passwordError} +

+ )} +
+ +
+
+ + + + Сессия + + + +
+ ); +} diff --git a/client/src/pages/MyProfilePage.tsx b/client/src/pages/MyProfilePage.tsx new file mode 100644 index 0000000..284c456 --- /dev/null +++ b/client/src/pages/MyProfilePage.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import { useLocation } from 'wouter'; +import { Loader2 } from 'lucide-react'; +import Layout from '@/components/Layout'; +import { useAuth } from '@/hooks/useAuth'; +import UserProfilePage from '@/pages/UserProfilePage'; + +/** + * Обёртка маршрута /profile: рендерит единый экран профиля (UserProfilePage) + * для текущего пользователя. URL остаётся /profile. + * Не залогинен — редирект на /login (как было в ProfileSettings). + */ +export default function MyProfilePage() { + const { user, isLoading } = useAuth(); + const [location, setLocation] = useLocation(); + + useEffect(() => { + if (!isLoading && !user) { + setLocation(`/login?returnTo=${encodeURIComponent(location)}`); + } + }, [user, isLoading, location, setLocation]); + + if (isLoading || !user) { + return ( + +
+ +
+
+ ); + } + + return ; +} diff --git a/client/src/pages/ProfileSettings.tsx b/client/src/pages/ProfileSettings.tsx deleted file mode 100644 index 26b7455..0000000 --- a/client/src/pages/ProfileSettings.tsx +++ /dev/null @@ -1,956 +0,0 @@ -import { useState, useEffect } from 'react'; -import Layout from '@/components/Layout'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import { queryClient, apiRequest } from '@/lib/queryClient'; -import { useAuth } from '@/hooks/useAuth'; -import { authService } from '@/services/auth.service'; -import { isAdmin } from '@/lib/permissions'; -import { useTheme } from '@/hooks/use-theme'; -import { useSkin } from '@/themes/ThemeProvider'; -import { useLocation } from 'wouter'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Switch } from '@/components/ui/switch'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Input } from '@/components/ui/input'; -import { useToast } from '@/hooks/use-toast'; -import { Bell, Mail, User, Plus, Trash2, Loader2, ArrowLeft, Smartphone, LogOut, Moon, Sun, Lock, Type, WifiOff, Palette } from 'lucide-react'; -import { usePwaFontSize } from '@/hooks/usePwaFontSize'; -import { Link } from 'wouter'; - -type NotificationEventType = { - id: number; - code: string; - name: string; - description: string | null; - category: string; - isActive: boolean; -}; - -type NotificationSubscription = { - id: number; - userId: number; - organizationId: number; - eventTypeCode: string; - targetType: string | null; - targetId: number | null; - channels: string[]; - isActive: boolean; -}; - -type Form = { - id: number; - name: string; -}; - -type OfflineForm = Form & { - offlineCache?: { - enabled: boolean; - maxTasks?: number; - strategy?: 'recent' | 'assigned' | 'all'; - includeFields?: string[]; - } | null; -}; - -type OfflineSubscription = { - id: number; - userId: number; - formId: number; - isActive: boolean; - includeRelatedTasks: boolean; - includeFiles: boolean; -}; - -const ProfileSettings = () => { - const { toast } = useToast(); - const { user, isLoading: isAuthLoading, logout } = useAuth(); - const { theme, toggleTheme } = useTheme(); - const { skinId, setSkinId, availableSkins } = useSkin(); - const { isPwa, fontSize, increase: increaseFontSize, decrease: decreaseFontSize } = usePwaFontSize(user?.id); - const [location, setLocation] = useLocation(); - const [selectedEventType, setSelectedEventType] = useState(''); - const [selectedTargetType, setSelectedTargetType] = useState('all'); - const [selectedTargetId, setSelectedTargetId] = useState(''); - const [selectedChannels, setSelectedChannels] = useState(['app', 'push']); - const [currentPassword, setCurrentPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - // Текст ошибки валидации нового пароля — показывается рядом с полем - const [passwordError, setPasswordError] = useState(null); - - useEffect(() => { - if (!isAuthLoading && !user) { - setLocation(`/login?returnTo=${encodeURIComponent(location)}`); - } - }, [user, isAuthLoading, location, setLocation]); - - const { data: eventTypesData, isLoading: isLoadingEventTypes } = useQuery<{ success: boolean; eventTypes: NotificationEventType[] }>({ - queryKey: ['/api/subscriptions/event-types'], - staleTime: 5 * 60 * 1000, - enabled: !!user, - }); - - const { data: subscriptionsData, isLoading: isLoadingSubscriptions } = useQuery<{ success: boolean; subscriptions: NotificationSubscription[] }>({ - queryKey: ['/api/subscriptions'], - enabled: !!user, - }); - - const { data: formsData } = useQuery<{ success: boolean; forms: Form[] }>({ - queryKey: ['/api/forms'], - staleTime: 5 * 60 * 1000, - enabled: !!user, - }); - - const createSubscriptionMutation = useMutation({ - mutationFn: async (data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] }) => { - return apiRequest('POST', '/api/subscriptions', data); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] }); - toast({ title: 'Подписка создана' }); - setSelectedEventType(''); - setSelectedTargetType('all'); - setSelectedTargetId(''); - }, - onError: () => { - toast({ title: 'Ошибка создания подписки', variant: 'destructive' }); - }, - }); - - const updateSubscriptionMutation = useMutation({ - mutationFn: async ({ id, data }: { id: number; data: { channels?: string[]; isActive?: boolean } }) => { - return apiRequest('PATCH', `/api/subscriptions/${id}`, data); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] }); - }, - onError: () => { - toast({ title: 'Ошибка обновления подписки', variant: 'destructive' }); - }, - }); - - const deleteSubscriptionMutation = useMutation({ - mutationFn: async (id: number) => { - return apiRequest('DELETE', `/api/subscriptions/${id}`); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/api/subscriptions'] }); - toast({ title: 'Подписка удалена' }); - }, - onError: () => { - toast({ title: 'Ошибка удаления подписки', variant: 'destructive' }); - }, - }); - - const changePasswordMutation = useMutation({ - mutationFn: async (data: { currentPassword: string; newPassword: string }) => { - return authService.changePassword(data.currentPassword, data.newPassword); - }, - onSuccess: (data) => { - if (data.success) { - toast({ title: data.message || 'Пароль успешно изменен' }); - setCurrentPassword(''); - setNewPassword(''); - setConfirmPassword(''); - setPasswordError(null); - } else { - toast({ title: data.error || 'Ошибка при изменении пароля', variant: 'destructive' }); - } - }, - onError: () => { - toast({ title: 'Ошибка при изменении пароля', variant: 'destructive' }); - }, - }); - - const { data: syncConfigData, isLoading: isLoadingSyncConfig } = useQuery<{ - success: boolean; - forms: OfflineForm[]; - subscriptions: OfflineSubscription[]; - }>({ - queryKey: ['/api/sync/config'], - enabled: !!user, - staleTime: 0, - }); - - const saveOfflineSubscriptionMutation = useMutation({ - mutationFn: async (data: { formId: number; isActive: boolean; includeRelatedTasks: boolean; includeFiles: boolean }) => { - const res = await apiRequest('POST', `/api/users/${user!.id}/offline-subscriptions`, data); - return res.json() as Promise<{ success: boolean; subscription: OfflineSubscription }>; - }, - onSuccess: async () => { - queryClient.invalidateQueries({ queryKey: ['/api/sync/config'] }); - toast({ title: 'Подписка сохранена' }); - try { - const { performInitialSync } = await import('@/lib/syncEngine'); - await performInitialSync(); - } catch (e) { - console.error('Initial sync failed after subscription change:', e); - } - }, - onError: () => { - toast({ title: 'Ошибка сохранения подписки', variant: 'destructive' }); - }, - }); - - const deleteOfflineSubscriptionMutation = useMutation({ - mutationFn: async (formId: number) => { - const res = await apiRequest('DELETE', `/api/users/${user!.id}/offline-subscriptions/${formId}`); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/api/sync/config'] }); - toast({ title: 'Подписка удалена' }); - }, - onError: () => { - toast({ title: 'Ошибка удаления подписки', variant: 'destructive' }); - }, - }); - - const eventTypes = eventTypesData?.eventTypes || []; - const subscriptions = subscriptionsData?.subscriptions || []; - const forms = formsData?.forms || []; - - const getEventTypeName = (code: string) => { - const eventType = eventTypes.find(et => et.code === code); - return eventType?.name || code; - }; - - const getCategoryColor = (category: string) => { - switch (category) { - case 'task': return 'bg-info text-info-foreground'; - case 'chat': return 'bg-success text-success-foreground'; - case 'form': return 'bg-warning text-warning-foreground'; - case 'system': return 'bg-muted text-muted-foreground'; - default: return 'bg-muted text-muted-foreground'; - } - }; - - const getCategoryLabel = (category: string) => { - switch (category) { - case 'task': return 'Задачи'; - case 'chat': return 'Чат'; - case 'form': return 'Формы'; - case 'system': return 'Система'; - default: return category; - } - }; - - const getTargetLabel = (sub: NotificationSubscription) => { - if (!sub.targetType || sub.targetType === 'all') { - return 'Все'; - } - if (sub.targetType === 'form' && sub.targetId) { - const form = forms.find(f => f.id === sub.targetId); - return form ? `Форма: ${form.name}` : `Форма #${sub.targetId}`; - } - if (sub.targetType === 'task' && sub.targetId) { - return `Задача #${sub.targetId}`; - } - return sub.targetType; - }; - - const handleAddSubscription = () => { - if (!selectedEventType) { - toast({ title: 'Выберите тип события', variant: 'destructive' }); - return; - } - - const data: { eventTypeCode: string; targetType?: string; targetId?: number; channels: string[] } = { - eventTypeCode: selectedEventType, - channels: selectedChannels, - }; - - if (selectedTargetType && selectedTargetType !== 'all') { - data.targetType = selectedTargetType; - if (selectedTargetId) { - data.targetId = parseInt(selectedTargetId); - } - } - - createSubscriptionMutation.mutate(data); - }; - - const handleToggleSubscription = (sub: NotificationSubscription) => { - updateSubscriptionMutation.mutate({ - id: sub.id, - data: { isActive: !sub.isActive }, - }); - }; - - const handleToggleChannel = (sub: NotificationSubscription, channel: string) => { - const channels = sub.channels.includes(channel) - ? sub.channels.filter(c => c !== channel) - : [...sub.channels, channel]; - - if (channels.length === 0) { - channels.push('app'); - } - - updateSubscriptionMutation.mutate({ - id: sub.id, - data: { channels }, - }); - }; - - const groupedEventTypes = eventTypes.reduce((acc, et) => { - if (!acc[et.category]) { - acc[et.category] = []; - } - acc[et.category].push(et); - return acc; - }, {} as Record); - - if (isAuthLoading || !user || isLoadingEventTypes || isLoadingSubscriptions) { - return ( - -
-
- -
-
-
- ); - } - - return ( - -
-
-
- - - - -
-

Настройки профиля

-

Управление аккаунтом и уведомлениями

-
-
- - - - - - Профиль - - - - Безопасность - - - - Уведомления - - - - Offline - - - - - - - Личные данные - - Информация о вашем аккаунте - - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-

- Для изменения личных данных обратитесь к администратору -

-
-
- - - - Сессия - - - - - - -
- {theme === 'dark' ? ( - - ) : ( - - )} - Тема -
- -
-
- - - -
- - Скин интерфейса -
- -
-
- - {isPwa && ( - - -
- - Шрифт -
-
- - {fontSize} - -
-
-
- )} -
- - - - - - - Добавить подписку - - - Выберите события, о которых хотите получать уведомления - - - -
-
- - -
- -
- - -
- - {selectedTargetType === 'form' && ( -
- - -
- )} - -
- -
- - - -
-
-
- - -
-
- - - - Мои подписки - - Управляйте своими уведомлениями - - - - {subscriptions.length === 0 ? ( -
- У вас пока нет подписок на события -
- ) : ( -
- {subscriptions.map(sub => ( -
-
- handleToggleSubscription(sub)} - data-testid={`switch-subscription-${sub.id}`} - /> -
-
{getEventTypeName(sub.eventTypeCode)}
-
- {getTargetLabel(sub)} -
-
-
- -
-
- - - -
- - -
-
- ))} -
- )} -
-
- - - - Доступные типы событий - - События, на которые можно подписаться - - - -
- {Object.entries(groupedEventTypes).map(([category, types]) => ( -
-

- - {getCategoryLabel(category)} - -

-
    - {types.map(et => ( -
  • - • {et.name} - {et.description && ( - - {et.description} - )} -
  • - ))} -
-
- ))} -
-
-
-
- - - - - - - Offline-кэш форм - - - Выберите формы, задачи которых будут сохраняться на устройстве для работы без интернета. - - - - {isLoadingSyncConfig ? ( -
- -
- ) : (syncConfigData?.forms || []).filter(f => f.offlineCache?.enabled).length === 0 ? ( -
- Нет форм, разрешённых для offline-кэширования. Администратор может включить их в редакторе формы. -
- ) : ( -
- {(syncConfigData?.forms || []) - .filter(f => f.offlineCache?.enabled) - .map(form => { - const sub = (syncConfigData?.subscriptions || []).find(s => s.formId === form.id); - const isActive = sub?.isActive ?? false; - return ( -
-
- - saveOfflineSubscriptionMutation.mutate({ - formId: form.id, - isActive: checked, - includeRelatedTasks: sub?.includeRelatedTasks ?? true, - includeFiles: sub?.includeFiles ?? false, - }) - } - disabled={saveOfflineSubscriptionMutation.isPending} - /> -
-
{form.name}
-
- Макс. {form.offlineCache?.maxTasks ?? 500} задач ·{' '} - {form.offlineCache?.strategy === 'assigned' - ? 'назначенные мне' - : form.offlineCache?.strategy === 'all' - ? 'все задачи' - : 'последние по дате'} -
-
-
- {isActive && ( -
-
- - saveOfflineSubscriptionMutation.mutate({ - formId: form.id, - isActive: true, - includeRelatedTasks: checked === true, - includeFiles: sub?.includeFiles ?? false, - }) - } - disabled={saveOfflineSubscriptionMutation.isPending} - id={`offline-sub-related-${form.id}`} - /> - -
-
- - saveOfflineSubscriptionMutation.mutate({ - formId: form.id, - isActive: true, - includeRelatedTasks: sub?.includeRelatedTasks ?? true, - includeFiles: checked === true, - }) - } - disabled={saveOfflineSubscriptionMutation.isPending} - id={`offline-sub-files-${form.id}`} - /> - -
-
- )} -
- ); - })} -
- )} -
-
-
- - - - - Смена пароля - - Введите текущий пароль и новый пароль для вашего аккаунта - - - -
- - setCurrentPassword(e.target.value)} - placeholder="Введите текущий пароль" - /> -
-
- - { - setNewPassword(e.target.value); - setPasswordError(null); - }} - placeholder="Введите новый пароль" - aria-invalid={!!passwordError} - aria-describedby={passwordError ? 'new-password-error' : undefined} - /> -

- Минимум 8 символов, заглавная и строчная буква, цифра и специальный символ -

-
-
- - { - setConfirmPassword(e.target.value); - setPasswordError(null); - }} - placeholder="Повторите новый пароль" - /> - {passwordError && ( -

- {passwordError} -

- )} -
- -
-
-
-
-
-
-
- ); -}; - -export default ProfileSettings; diff --git a/client/src/pages/UserProfilePage.tsx b/client/src/pages/UserProfilePage.tsx index ab2ee4c..2a614fc 100644 --- a/client/src/pages/UserProfilePage.tsx +++ b/client/src/pages/UserProfilePage.tsx @@ -20,7 +20,7 @@ import { apiRequest, queryClient } from '@/lib/queryClient'; import { authService } from '@/services/auth.service'; import { normalizeSelectOptions } from '@/lib/fieldOptions'; import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; -import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck } from 'lucide-react'; +import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck, Bell, WifiOff, Palette, Lock } from 'lucide-react'; import { Link } from 'wouter'; import CompanyFieldInput from '@/components/CompanyFieldInput'; import GeoField from '@/components/GeoField'; @@ -28,6 +28,10 @@ import TableFieldSelect from '@/components/TableFieldSelect'; import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; import { ContactListEditor } from '@/components/user/ContactListEditor'; import ContactField from '@/components/ContactField'; +import { ProfileNotificationsTab } from '@/components/profile/ProfileNotificationsTab'; +import { ProfileOfflineTab } from '@/components/profile/ProfileOfflineTab'; +import { ProfileInterfaceTab } from '@/components/profile/ProfileInterfaceTab'; +import { ProfileSecurityTab } from '@/components/profile/ProfileSecurityTab'; interface UserProfileTab { id: number; @@ -600,12 +604,16 @@ function UserProfileFieldInput({ } } -export default function UserProfilePage() { +export default function UserProfilePage({ userId: userIdProp }: { userId?: number; params?: { id?: string } } = {}) { const params = useParams<{ id: string }>(); - const userId = parseInt(params.id); + // userId приходит пропом с маршрута /profile (свой профиль) или из URL /users/:id + const userId = userIdProp ?? parseInt(params.id); const { toast } = useToast(); const { user: currentUser } = useAuth(); + const isSelf = currentUser?.id === userId; + const isAdminView = isAdmin(currentUser); + const { data, isLoading, error } = useQuery({ queryKey: ['/api/users', userId, 'profile'], queryFn: async () => { @@ -909,7 +917,7 @@ export default function UserProfilePage() { )} - +