feat: объединение страниц профиля — /profile рендерит карточку пользователя

- /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 для не-админа (сервер их и раньше игнорировал)
- пункт меню «Настройки профиля» переименован в «Мой профиль»
This commit is contained in:
2026-08-05 11:48:07 +03:00
parent 667bddeac9
commit 47869971e6
13 changed files with 1089 additions and 971 deletions

View File

@@ -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 (
<Route path="/projects" component={Projects} />
<Route path="/reports" component={Reports} />
<Route path="/settings" component={Settings} />
<Route path="/profile" component={ProfileSettings} />
<Route path="/profile" component={MyProfilePage} />
<Route path="/bots" component={Bots} />
<Route path="/field-templates" component={FieldTemplates} />
<Route path="/global-fields">

View File

@@ -1619,7 +1619,7 @@ export function Sidebar({ children }: SidebarProps) {
<Link href="/profile">
<DropdownMenuItem className="text-xs" data-testid="item-profile-settings">
<User className="w-3.5 h-3.5 mr-2" />
Настройки профиля
Мой профиль
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />

View File

@@ -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 (
<div className="space-y-4">
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
{theme === 'dark' ? (
<Moon className="w-4 h-4 text-muted-foreground" />
) : (
<Sun className="w-4 h-4 text-muted-foreground" />
)}
<span className="font-medium">Тема</span>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Скин интерфейса</span>
</div>
<Select value={skinId} onValueChange={setSkinId}>
<SelectTrigger className="w-[180px]" data-testid="select-skin">
<SelectValue placeholder="Выберите скин" />
</SelectTrigger>
<SelectContent>
{availableSkins.map(skin => (
<SelectItem key={skin.id} value={skin.id}>
{skin.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{isPwa && (
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Шрифт</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={decreaseFontSize}
disabled={fontSize <= 12}
>
−
</Button>
<span className="text-sm w-8 text-center tabular-nums">{fontSize}</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={increaseFontSize}
disabled={fontSize >= 24}
>
+
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -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<string>('');
const [selectedTargetType, setSelectedTargetType] = useState<string>('all');
const [selectedTargetId, setSelectedTargetId] = useState<string>('');
const [selectedChannels, setSelectedChannels] = useState<string[]>(['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<string, NotificationEventType[]>);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Добавить подписку
</CardTitle>
<CardDescription>
Выберите события, о которых хотите получать уведомления
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Тип события</Label>
<Select value={selectedEventType} onValueChange={setSelectedEventType}>
<SelectTrigger data-testid="select-event-type">
<SelectValue placeholder="Выберите событие" />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{getCategoryLabel(category)}
</div>
{types.map(et => (
<SelectItem key={et.code} value={et.code}>
{et.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Область</Label>
<Select value={selectedTargetType} onValueChange={setSelectedTargetType}>
<SelectTrigger data-testid="select-target-type">
<SelectValue placeholder="Выберите область" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все события</SelectItem>
<SelectItem value="form">Конкретная форма</SelectItem>
</SelectContent>
</Select>
</div>
{selectedTargetType === 'form' && (
<div className="space-y-2">
<Label>Форма</Label>
<Select value={selectedTargetId} onValueChange={setSelectedTargetId}>
<SelectTrigger data-testid="select-target-form">
<SelectValue placeholder="Выберите форму" />
</SelectTrigger>
<SelectContent>
{forms.map(form => (
<SelectItem key={form.id} value={form.id.toString()}>
{form.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Каналы</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={selectedChannels.includes('app') ? 'default' : 'outline'}
title="В приложении"
onClick={() => {
if (selectedChannels.includes('app')) {
if (selectedChannels.length > 1) {
setSelectedChannels(selectedChannels.filter(c => c !== 'app'));
}
} else {
setSelectedChannels([...selectedChannels, 'app']);
}
}}
data-testid="button-channel-app"
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('push') ? 'default' : 'outline'}
title="Push-уведомления (браузер/PWA)"
onClick={() => {
if (selectedChannels.includes('push')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'push'));
} else {
setSelectedChannels([...selectedChannels, 'push']);
}
}}
data-testid="button-channel-push"
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('email') ? 'default' : 'outline'}
title="Email"
onClick={() => {
if (selectedChannels.includes('email')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'email'));
} else {
setSelectedChannels([...selectedChannels, 'email']);
}
}}
data-testid="button-channel-email"
>
<Mail className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<Button
onClick={handleAddSubscription}
disabled={!selectedEventType || createSubscriptionMutation.isPending}
data-testid="button-add-subscription"
>
{createSubscriptionMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Добавить подписку
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Мои подписки</CardTitle>
<CardDescription>
Управляйте своими уведомлениями
</CardDescription>
</CardHeader>
<CardContent>
{subscriptions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
У вас пока нет подписок на события
</div>
) : (
<div className="space-y-3">
{subscriptions.map(sub => (
<div
key={sub.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
sub.isActive ? 'bg-background' : 'bg-muted/50 opacity-60'
}`}
data-testid={`subscription-row-${sub.id}`}
>
<div className="flex items-center gap-4">
<Switch
checked={sub.isActive}
onCheckedChange={() => handleToggleSubscription(sub)}
data-testid={`switch-subscription-${sub.id}`}
/>
<div>
<div className="font-medium">{getEventTypeName(sub.eventTypeCode)}</div>
<div className="text-sm text-muted-foreground">
{getTargetLabel(sub)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant={sub.channels.includes('app') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'app')}
disabled={updateSubscriptionMutation.isPending}
title="В приложении"
data-testid={`button-toggle-app-${sub.id}`}
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('push') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'push')}
disabled={updateSubscriptionMutation.isPending}
title="Push-уведомления (браузер/PWA)"
data-testid={`button-toggle-push-${sub.id}`}
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('email') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'email')}
disabled={updateSubscriptionMutation.isPending}
title="Email"
data-testid={`button-toggle-email-${sub.id}`}
>
<Mail className="w-4 h-4" />
</Button>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => deleteSubscriptionMutation.mutate(sub.id)}
disabled={deleteSubscriptionMutation.isPending}
data-testid={`button-delete-subscription-${sub.id}`}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Доступные типы событий</CardTitle>
<CardDescription>
События, на которые можно подписаться
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category} className="space-y-2">
<h4 className="font-medium flex items-center gap-2">
<Badge className={getCategoryColor(category)}>
{getCategoryLabel(category)}
</Badge>
</h4>
<ul className="space-y-1">
{types.map(et => (
<li key={et.code} className="text-sm text-muted-foreground pl-2">
• {et.name}
{et.description && (
<span className="text-xs ml-1">- {et.description}</span>
)}
</li>
))}
</ul>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<WifiOff className="w-5 h-5" />
Offline-кэш форм
</CardTitle>
<CardDescription>
Выберите формы, задачи которых будут сохраняться на устройстве для работы без интернета.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingSyncConfig ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (syncConfigData?.forms || []).filter(f => f.offlineCache?.enabled).length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Нет форм, разрешённых для offline-кэширования. Администратор может включить их в редакторе формы.
</div>
) : (
<div className="space-y-3">
{(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 (
<div
key={form.id}
className={`flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border gap-4 ${
isActive ? 'bg-background' : 'bg-muted/50 opacity-70'
}`}
>
<div className="flex items-center gap-4">
<Switch
checked={isActive}
onCheckedChange={(checked) =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: checked,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
/>
<div>
<div className="font-medium">{form.name}</div>
<div className="text-sm text-muted-foreground">
Макс. {form.offlineCache?.maxTasks ?? 500} задач ·{' '}
{form.offlineCache?.strategy === 'assigned'
? 'назначенные мне'
: form.offlineCache?.strategy === 'all'
? 'все задачи'
: 'последние по дате'}
</div>
</div>
</div>
{isActive && (
<div className="flex items-center gap-4 pl-12 md:pl-0">
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeRelatedTasks ?? true}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: checked === true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-related-${form.id}`}
/>
<Label htmlFor={`offline-sub-related-${form.id}`} className="text-sm cursor-pointer">
Связанные задачи
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeFiles ?? false}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: checked === true,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-files-${form.id}`}
/>
<Label htmlFor={`offline-sub-files-${form.id}`} className="text-sm cursor-pointer">
Файлы
</Label>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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<string | null>(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 (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Смена пароля</CardTitle>
<CardDescription>
Введите текущий пароль и новый пароль для вашего аккаунта
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Текущий пароль</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Введите текущий пароль"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Новый пароль</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={e => {
setNewPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Введите новый пароль"
aria-invalid={!!passwordError}
aria-describedby={passwordError ? 'new-password-error' : undefined}
/>
<p className="text-xs text-muted-foreground">
Минимум 8 символов, заглавная и строчная буква, цифра и специальный символ
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Подтвердите новый пароль</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Повторите новый пароль"
/>
{passwordError && (
<p id="new-password-error" className="text-xs text-destructive">
{passwordError}
</p>
)}
</div>
<Button
onClick={() => {
if (!currentPassword || !newPassword || !confirmPassword) {
toast({ title: 'Заполните все поля', variant: 'destructive' });
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Новые пароли не совпадают');
toast({ title: 'Новые пароли не совпадают', variant: 'destructive' });
return;
}
// Показываем конкретное невыполненное правило, а не общий текст
const failedRules: string[] = [];
if (newPassword.length < 8) failedRules.push('минимум 8 символов');
if (!/[A-Z]/.test(newPassword)) failedRules.push('заглавную букву');
if (!/[a-z]/.test(newPassword)) failedRules.push('строчную букву');
if (!/\d/.test(newPassword)) failedRules.push('цифру');
if (!/[!@#$%^&*(),.?":{}|<>]/.test(newPassword)) failedRules.push('специальный символ');
if (failedRules.length > 0) {
const message = `Пароль должен содержать: ${failedRules.join(', ')}`;
setPasswordError(message);
toast({ title: message, variant: 'destructive' });
return;
}
changePasswordMutation.mutate({ currentPassword, newPassword });
}}
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Lock className="w-4 h-4 mr-2" />
)}
Изменить пароль
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<span className="font-medium">Сессия</span>
<Button
variant="destructive"
size="sm"
onClick={async () => {
await logout();
setLocation('/login');
}}
className="flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Выйти
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</Layout>
);
}
return <UserProfilePage userId={user.id} />;
}

View File

@@ -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<string>('');
const [selectedTargetType, setSelectedTargetType] = useState<string>('all');
const [selectedTargetId, setSelectedTargetId] = useState<string>('');
const [selectedChannels, setSelectedChannels] = useState<string[]>(['app', 'push']);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// Текст ошибки валидации нового пароля — показывается рядом с полем
const [passwordError, setPasswordError] = useState<string | null>(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<string, NotificationEventType[]>);
if (isAuthLoading || !user || isLoadingEventTypes || isLoadingSubscriptions) {
return (
<Layout>
<main className="flex-1 overflow-auto p-6">
<div className="max-w-4xl mx-auto flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
</main>
</Layout>
);
}
return (
<Layout>
<main className="flex-1 overflow-auto p-4">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border">
<Link href="/home">
<button className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" data-testid="button-back">
<ArrowLeft className="w-3.5 h-3.5" />
</button>
</Link>
<User className="w-4 h-4 text-muted-foreground" />
<div>
<h1 className="text-sm font-medium" data-testid="text-profile-title">Настройки профиля</h1>
<p className="text-[10px] text-muted-foreground">Управление аккаунтом и уведомлениями</p>
</div>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList>
<TabsTrigger value="profile" className="flex items-center gap-2">
<User className="w-4 h-4" />
Профиль
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Lock className="w-4 h-4" />
Безопасность
</TabsTrigger>
<TabsTrigger value="notifications" className="flex items-center gap-2">
<Bell className="w-4 h-4" />
Уведомления
</TabsTrigger>
<TabsTrigger value="offline" className="flex items-center gap-2">
<WifiOff className="w-4 h-4" />
Offline
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Личные данные</CardTitle>
<CardDescription>
Информация о вашем аккаунте
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Имя</Label>
<Input value={user.firstName || ''} disabled />
</div>
<div className="space-y-2">
<Label>Отчество</Label>
<Input value={user.middleName || ''} disabled />
</div>
<div className="space-y-2">
<Label>Фамилия</Label>
<Input value={user.lastName || ''} disabled />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={user.email || ''} disabled />
</div>
<div className="space-y-2">
<Label>Роль</Label>
<Input value={isAdmin(user) ? 'Администратор' : 'Пользователь'} disabled />
</div>
</div>
<p className="text-sm text-muted-foreground">
Для изменения личных данных обратитесь к администратору
</p>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<span className="font-medium">Сессия</span>
<Button
variant="destructive"
size="sm"
onClick={async () => {
await logout();
setLocation('/login');
}}
className="flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Выйти
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
{theme === 'dark' ? (
<Moon className="w-4 h-4 text-muted-foreground" />
) : (
<Sun className="w-4 h-4 text-muted-foreground" />
)}
<span className="font-medium">Тема</span>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Скин интерфейса</span>
</div>
<Select value={skinId} onValueChange={setSkinId}>
<SelectTrigger className="w-[180px]" data-testid="select-skin">
<SelectValue placeholder="Выберите скин" />
</SelectTrigger>
<SelectContent>
{availableSkins.map(skin => (
<SelectItem key={skin.id} value={skin.id}>
{skin.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{isPwa && (
<Card>
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-muted-foreground" />
<span className="font-medium">Шрифт</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={decreaseFontSize}
disabled={fontSize <= 12}
>
−
</Button>
<span className="text-sm w-8 text-center tabular-nums">{fontSize}</span>
<Button
variant="outline"
size="icon"
className="h-7 w-7 text-xs"
onClick={increaseFontSize}
disabled={fontSize >= 24}
>
+
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="notifications" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="w-5 h-5" />
Добавить подписку
</CardTitle>
<CardDescription>
Выберите события, о которых хотите получать уведомления
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Тип события</Label>
<Select value={selectedEventType} onValueChange={setSelectedEventType}>
<SelectTrigger data-testid="select-event-type">
<SelectValue placeholder="Выберите событие" />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{getCategoryLabel(category)}
</div>
{types.map(et => (
<SelectItem key={et.code} value={et.code}>
{et.name}
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Область</Label>
<Select value={selectedTargetType} onValueChange={setSelectedTargetType}>
<SelectTrigger data-testid="select-target-type">
<SelectValue placeholder="Выберите область" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все события</SelectItem>
<SelectItem value="form">Конкретная форма</SelectItem>
</SelectContent>
</Select>
</div>
{selectedTargetType === 'form' && (
<div className="space-y-2">
<Label>Форма</Label>
<Select value={selectedTargetId} onValueChange={setSelectedTargetId}>
<SelectTrigger data-testid="select-target-form">
<SelectValue placeholder="Выберите форму" />
</SelectTrigger>
<SelectContent>
{forms.map(form => (
<SelectItem key={form.id} value={form.id.toString()}>
{form.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>Каналы</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={selectedChannels.includes('app') ? 'default' : 'outline'}
title="В приложении"
onClick={() => {
if (selectedChannels.includes('app')) {
if (selectedChannels.length > 1) {
setSelectedChannels(selectedChannels.filter(c => c !== 'app'));
}
} else {
setSelectedChannels([...selectedChannels, 'app']);
}
}}
data-testid="button-channel-app"
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('push') ? 'default' : 'outline'}
title="Push-уведомления (браузер/PWA)"
onClick={() => {
if (selectedChannels.includes('push')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'push'));
} else {
setSelectedChannels([...selectedChannels, 'push']);
}
}}
data-testid="button-channel-push"
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={selectedChannels.includes('email') ? 'default' : 'outline'}
title="Email"
onClick={() => {
if (selectedChannels.includes('email')) {
setSelectedChannels(selectedChannels.filter(c => c !== 'email'));
} else {
setSelectedChannels([...selectedChannels, 'email']);
}
}}
data-testid="button-channel-email"
>
<Mail className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<Button
onClick={handleAddSubscription}
disabled={!selectedEventType || createSubscriptionMutation.isPending}
data-testid="button-add-subscription"
>
{createSubscriptionMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Добавить подписку
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Мои подписки</CardTitle>
<CardDescription>
Управляйте своими уведомлениями
</CardDescription>
</CardHeader>
<CardContent>
{subscriptions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
У вас пока нет подписок на события
</div>
) : (
<div className="space-y-3">
{subscriptions.map(sub => (
<div
key={sub.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
sub.isActive ? 'bg-background' : 'bg-muted/50 opacity-60'
}`}
data-testid={`subscription-row-${sub.id}`}
>
<div className="flex items-center gap-4">
<Switch
checked={sub.isActive}
onCheckedChange={() => handleToggleSubscription(sub)}
data-testid={`switch-subscription-${sub.id}`}
/>
<div>
<div className="font-medium">{getEventTypeName(sub.eventTypeCode)}</div>
<div className="text-sm text-muted-foreground">
{getTargetLabel(sub)}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-1">
<Button
type="button"
size="sm"
variant={sub.channels.includes('app') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'app')}
disabled={updateSubscriptionMutation.isPending}
title="В приложении"
data-testid={`button-toggle-app-${sub.id}`}
>
<Bell className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('push') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'push')}
disabled={updateSubscriptionMutation.isPending}
title="Push-уведомления (браузер/PWA)"
data-testid={`button-toggle-push-${sub.id}`}
>
<Smartphone className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant={sub.channels.includes('email') ? 'default' : 'outline'}
onClick={() => handleToggleChannel(sub, 'email')}
disabled={updateSubscriptionMutation.isPending}
title="Email"
data-testid={`button-toggle-email-${sub.id}`}
>
<Mail className="w-4 h-4" />
</Button>
</div>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => deleteSubscriptionMutation.mutate(sub.id)}
disabled={deleteSubscriptionMutation.isPending}
data-testid={`button-delete-subscription-${sub.id}`}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Доступные типы событий</CardTitle>
<CardDescription>
События, на которые можно подписаться
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(groupedEventTypes).map(([category, types]) => (
<div key={category} className="space-y-2">
<h4 className="font-medium flex items-center gap-2">
<Badge className={getCategoryColor(category)}>
{getCategoryLabel(category)}
</Badge>
</h4>
<ul className="space-y-1">
{types.map(et => (
<li key={et.code} className="text-sm text-muted-foreground pl-2">
• {et.name}
{et.description && (
<span className="text-xs ml-1">- {et.description}</span>
)}
</li>
))}
</ul>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="offline" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<WifiOff className="w-5 h-5" />
Offline-кэш форм
</CardTitle>
<CardDescription>
Выберите формы, задачи которых будут сохраняться на устройстве для работы без интернета.
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingSyncConfig ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (syncConfigData?.forms || []).filter(f => f.offlineCache?.enabled).length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Нет форм, разрешённых для offline-кэширования. Администратор может включить их в редакторе формы.
</div>
) : (
<div className="space-y-3">
{(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 (
<div
key={form.id}
className={`flex flex-col md:flex-row md:items-center justify-between p-4 rounded-lg border gap-4 ${
isActive ? 'bg-background' : 'bg-muted/50 opacity-70'
}`}
>
<div className="flex items-center gap-4">
<Switch
checked={isActive}
onCheckedChange={(checked) =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: checked,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
/>
<div>
<div className="font-medium">{form.name}</div>
<div className="text-sm text-muted-foreground">
Макс. {form.offlineCache?.maxTasks ?? 500} задач ·{' '}
{form.offlineCache?.strategy === 'assigned'
? 'назначенные мне'
: form.offlineCache?.strategy === 'all'
? 'все задачи'
: 'последние по дате'}
</div>
</div>
</div>
{isActive && (
<div className="flex items-center gap-4 pl-12 md:pl-0">
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeRelatedTasks ?? true}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: checked === true,
includeFiles: sub?.includeFiles ?? false,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-related-${form.id}`}
/>
<Label htmlFor={`offline-sub-related-${form.id}`} className="text-sm cursor-pointer">
Связанные задачи
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={sub?.includeFiles ?? false}
onCheckedChange={(checked: boolean | 'indeterminate') =>
saveOfflineSubscriptionMutation.mutate({
formId: form.id,
isActive: true,
includeRelatedTasks: sub?.includeRelatedTasks ?? true,
includeFiles: checked === true,
})
}
disabled={saveOfflineSubscriptionMutation.isPending}
id={`offline-sub-files-${form.id}`}
/>
<Label htmlFor={`offline-sub-files-${form.id}`} className="text-sm cursor-pointer">
Файлы
</Label>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Смена пароля</CardTitle>
<CardDescription>
Введите текущий пароль и новый пароль для вашего аккаунта
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Текущий пароль</Label>
<Input
id="current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
placeholder="Введите текущий пароль"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Новый пароль</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={e => {
setNewPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Введите новый пароль"
aria-invalid={!!passwordError}
aria-describedby={passwordError ? 'new-password-error' : undefined}
/>
<p className="text-xs text-muted-foreground">
Минимум 8 символов, заглавная и строчная буква, цифра и специальный символ
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Подтвердите новый пароль</Label>
<Input
id="confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
setPasswordError(null);
}}
placeholder="Повторите новый пароль"
/>
{passwordError && (
<p id="new-password-error" className="text-xs text-destructive">
{passwordError}
</p>
)}
</div>
<Button
onClick={() => {
if (!currentPassword || !newPassword || !confirmPassword) {
toast({ title: 'Заполните все поля', variant: 'destructive' });
return;
}
if (newPassword !== confirmPassword) {
setPasswordError('Новые пароли не совпадают');
toast({ title: 'Новые пароли не совпадают', variant: 'destructive' });
return;
}
// Показываем конкретное невыполненное правило, а не общий текст
const failedRules: string[] = [];
if (newPassword.length < 8) failedRules.push('минимум 8 символов');
if (!/[A-Z]/.test(newPassword)) failedRules.push('заглавную букву');
if (!/[a-z]/.test(newPassword)) failedRules.push('строчную букву');
if (!/\d/.test(newPassword)) failedRules.push('цифру');
if (!/[!@#$%^&*(),.?":{}|<>]/.test(newPassword)) failedRules.push('специальный символ');
if (failedRules.length > 0) {
const message = `Пароль должен содержать: ${failedRules.join(', ')}`;
setPasswordError(message);
toast({ title: message, variant: 'destructive' });
return;
}
changePasswordMutation.mutate({ currentPassword, newPassword });
}}
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Lock className="w-4 h-4 mr-2" />
)}
Изменить пароль
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</main>
</Layout>
);
};
export default ProfileSettings;

View File

@@ -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<UserProfileData>({
queryKey: ['/api/users', userId, 'profile'],
queryFn: async () => {
@@ -909,7 +917,7 @@ export default function UserProfilePage() {
</div>
)}
</div>
<Link href="/users">
<Link href={isSelf ? '/' : '/users'}>
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-1" />
Назад
@@ -934,6 +942,33 @@ export default function UserProfilePage() {
<History className="w-3.5 h-3.5" />
История
</TabsTrigger>
{/* Уведомления и Offline — для себя и для админа на чужой карточке */}
{(isSelf || isAdminView) && (
<>
<TabsTrigger value="notifications" className="flex items-center gap-1">
<Bell className="w-3.5 h-3.5" />
Уведомления
</TabsTrigger>
<TabsTrigger value="offline" className="flex items-center gap-1">
<WifiOff className="w-3.5 h-3.5" />
Offline
</TabsTrigger>
</>
)}
{/* Интерфейс и Безопасность — только на своём профиле:
тема/скин локальны для устройства, смена пароля другому есть в «Основное» */}
{isSelf && (
<>
<TabsTrigger value="interface" className="flex items-center gap-1">
<Palette className="w-3.5 h-3.5" />
Интерфейс
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-1">
<Lock className="w-3.5 h-3.5" />
Безопасность
</TabsTrigger>
</>
)}
</TabsList>
{/* System tab */}
@@ -990,7 +1025,7 @@ export default function UserProfilePage() {
<Select
value={systemForm.watch('appRole')}
onValueChange={(v) => systemForm.setValue('appRole', v)}
disabled={!canEdit}
disabled={!isAdminView}
>
<SelectTrigger>
<SelectValue />
@@ -1006,7 +1041,7 @@ export default function UserProfilePage() {
id="isActive"
checked={systemForm.watch('isActive')}
onCheckedChange={(c) => systemForm.setValue('isActive', !!c)}
disabled={!canEdit}
disabled={!isAdminView}
/>
<Label htmlFor="isActive">Активен</Label>
</div>
@@ -1015,7 +1050,7 @@ export default function UserProfilePage() {
<Select
value={systemForm.watch('statusId') ? String(systemForm.watch('statusId')) : 'none'}
onValueChange={(v) => systemForm.setValue('statusId', v === 'none' ? null : Number(v))}
disabled={!canEdit}
disabled={!isAdminView}
>
<SelectTrigger>
<SelectValue placeholder="Выберите статус" />
@@ -1253,6 +1288,29 @@ export default function UserProfilePage() {
<HistoryTab auditLog={auditLogData?.auditLog || []} />
)}
</TabsContent>
{/* Уведомления и Offline — свой профиль или админский просмотр чужого */}
{(isSelf || isAdminView) && (
<>
<TabsContent value="notifications" className="space-y-4">
<ProfileNotificationsTab userId={userId} />
</TabsContent>
<TabsContent value="offline" className="space-y-4">
<ProfileOfflineTab userId={userId} isSelf={isSelf} />
</TabsContent>
</>
)}
{/* Интерфейс и Безопасность — только на своём профиле */}
{isSelf && (
<>
<TabsContent value="interface" className="space-y-4">
<ProfileInterfaceTab userId={userId} />
</TabsContent>
<TabsContent value="security" className="space-y-4">
<ProfileSecurityTab />
</TabsContent>
</>
)}
</Tabs>
</div>
</Layout>