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:
@@ -42,7 +42,7 @@ const TableEditor = safeLazy(() => import("@/pages/TableEditor"));
|
|||||||
const TaskView = safeLazy(() => import("@/pages/TaskView"));
|
const TaskView = safeLazy(() => import("@/pages/TaskView"));
|
||||||
const TaskDetail = safeLazy(() => import("@/pages/TaskDetail"));
|
const TaskDetail = safeLazy(() => import("@/pages/TaskDetail"));
|
||||||
const Settings = safeLazy(() => import("@/pages/Settings"));
|
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 Reports = safeLazy(() => import("@/pages/Reports"));
|
||||||
const Projects = safeLazy(() => import("@/pages/Projects"));
|
const Projects = safeLazy(() => import("@/pages/Projects"));
|
||||||
const Bots = safeLazy(() => import("@/pages/Bots"));
|
const Bots = safeLazy(() => import("@/pages/Bots"));
|
||||||
@@ -159,7 +159,7 @@ function Router() { return (
|
|||||||
<Route path="/projects" component={Projects} />
|
<Route path="/projects" component={Projects} />
|
||||||
<Route path="/reports" component={Reports} />
|
<Route path="/reports" component={Reports} />
|
||||||
<Route path="/settings" component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
<Route path="/profile" component={ProfileSettings} />
|
<Route path="/profile" component={MyProfilePage} />
|
||||||
<Route path="/bots" component={Bots} />
|
<Route path="/bots" component={Bots} />
|
||||||
<Route path="/field-templates" component={FieldTemplates} />
|
<Route path="/field-templates" component={FieldTemplates} />
|
||||||
<Route path="/global-fields">
|
<Route path="/global-fields">
|
||||||
|
|||||||
@@ -1619,7 +1619,7 @@ export function Sidebar({ children }: SidebarProps) {
|
|||||||
<Link href="/profile">
|
<Link href="/profile">
|
||||||
<DropdownMenuItem className="text-xs" data-testid="item-profile-settings">
|
<DropdownMenuItem className="text-xs" data-testid="item-profile-settings">
|
||||||
<User className="w-3.5 h-3.5 mr-2" />
|
<User className="w-3.5 h-3.5 mr-2" />
|
||||||
Настройки профиля
|
Мой профиль
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
92
client/src/components/profile/ProfileInterfaceTab.tsx
Normal file
92
client/src/components/profile/ProfileInterfaceTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
474
client/src/components/profile/ProfileNotificationsTab.tsx
Normal file
474
client/src/components/profile/ProfileNotificationsTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
client/src/components/profile/ProfileOfflineTab.tsx
Normal file
182
client/src/components/profile/ProfileOfflineTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
client/src/components/profile/ProfileSecurityTab.tsx
Normal file
163
client/src/components/profile/ProfileSecurityTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
client/src/pages/MyProfilePage.tsx
Normal file
34
client/src/pages/MyProfilePage.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -20,7 +20,7 @@ import { apiRequest, queryClient } from '@/lib/queryClient';
|
|||||||
import { authService } from '@/services/auth.service';
|
import { authService } from '@/services/auth.service';
|
||||||
import { normalizeSelectOptions } from '@/lib/fieldOptions';
|
import { normalizeSelectOptions } from '@/lib/fieldOptions';
|
||||||
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
|
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 { Link } from 'wouter';
|
||||||
import CompanyFieldInput from '@/components/CompanyFieldInput';
|
import CompanyFieldInput from '@/components/CompanyFieldInput';
|
||||||
import GeoField from '@/components/GeoField';
|
import GeoField from '@/components/GeoField';
|
||||||
@@ -28,6 +28,10 @@ import TableFieldSelect from '@/components/TableFieldSelect';
|
|||||||
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
|
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
|
||||||
import { ContactListEditor } from '@/components/user/ContactListEditor';
|
import { ContactListEditor } from '@/components/user/ContactListEditor';
|
||||||
import ContactField from '@/components/ContactField';
|
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 {
|
interface UserProfileTab {
|
||||||
id: number;
|
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 params = useParams<{ id: string }>();
|
||||||
const userId = parseInt(params.id);
|
// userId приходит пропом с маршрута /profile (свой профиль) или из URL /users/:id
|
||||||
|
const userId = userIdProp ?? parseInt(params.id);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
const isSelf = currentUser?.id === userId;
|
||||||
|
const isAdminView = isAdmin(currentUser);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery<UserProfileData>({
|
const { data, isLoading, error } = useQuery<UserProfileData>({
|
||||||
queryKey: ['/api/users', userId, 'profile'],
|
queryKey: ['/api/users', userId, 'profile'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -909,7 +917,7 @@ export default function UserProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link href="/users">
|
<Link href={isSelf ? '/' : '/users'}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||||
Назад
|
Назад
|
||||||
@@ -934,6 +942,33 @@ export default function UserProfilePage() {
|
|||||||
<History className="w-3.5 h-3.5" />
|
<History className="w-3.5 h-3.5" />
|
||||||
История
|
История
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
{/* System tab */}
|
{/* System tab */}
|
||||||
@@ -990,7 +1025,7 @@ export default function UserProfilePage() {
|
|||||||
<Select
|
<Select
|
||||||
value={systemForm.watch('appRole')}
|
value={systemForm.watch('appRole')}
|
||||||
onValueChange={(v) => systemForm.setValue('appRole', v)}
|
onValueChange={(v) => systemForm.setValue('appRole', v)}
|
||||||
disabled={!canEdit}
|
disabled={!isAdminView}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -1006,7 +1041,7 @@ export default function UserProfilePage() {
|
|||||||
id="isActive"
|
id="isActive"
|
||||||
checked={systemForm.watch('isActive')}
|
checked={systemForm.watch('isActive')}
|
||||||
onCheckedChange={(c) => systemForm.setValue('isActive', !!c)}
|
onCheckedChange={(c) => systemForm.setValue('isActive', !!c)}
|
||||||
disabled={!canEdit}
|
disabled={!isAdminView}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="isActive">Активен</Label>
|
<Label htmlFor="isActive">Активен</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1015,7 +1050,7 @@ export default function UserProfilePage() {
|
|||||||
<Select
|
<Select
|
||||||
value={systemForm.watch('statusId') ? String(systemForm.watch('statusId')) : 'none'}
|
value={systemForm.watch('statusId') ? String(systemForm.watch('statusId')) : 'none'}
|
||||||
onValueChange={(v) => systemForm.setValue('statusId', v === 'none' ? null : Number(v))}
|
onValueChange={(v) => systemForm.setValue('statusId', v === 'none' ? null : Number(v))}
|
||||||
disabled={!canEdit}
|
disabled={!isAdminView}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Выберите статус" />
|
<SelectValue placeholder="Выберите статус" />
|
||||||
@@ -1253,6 +1288,29 @@ export default function UserProfilePage() {
|
|||||||
<HistoryTab auditLog={auditLogData?.auditLog || []} />
|
<HistoryTab auditLog={auditLogData?.auditLog || []} />
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { tenantIsolation } from "../middleware/tenant.middleware";
|
|||||||
import { validateRequest } from "../middleware/validation.middleware";
|
import { validateRequest } from "../middleware/validation.middleware";
|
||||||
import { createSubscriptionSchema, updateSubscriptionSchema } from "@shared/schema";
|
import { createSubscriptionSchema, updateSubscriptionSchema } from "@shared/schema";
|
||||||
import { notificationService } from "../services/notification.service";
|
import { notificationService } from "../services/notification.service";
|
||||||
|
import { canManageUser } from "../utils/user-access";
|
||||||
|
|
||||||
export function registerChatNotificationRoutes(router: Router): void {
|
export function registerChatNotificationRoutes(router: Router): void {
|
||||||
// Get user notifications
|
// Get user notifications
|
||||||
@@ -97,13 +98,27 @@ export function registerChatNotificationRoutes(router: Router): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get user subscriptions
|
// Get user subscriptions
|
||||||
|
// ?userId=N — подписки другого пользователя (только при canManageUser: админ/руководитель)
|
||||||
router.get('/api/subscriptions',
|
router.get('/api/subscriptions',
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
tenantIsolation,
|
tenantIsolation,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
|
let targetUserId = req.user!.id;
|
||||||
|
if (req.query.userId !== undefined) {
|
||||||
|
const queryUserId = parseInt(String(req.query.userId));
|
||||||
|
if (isNaN(queryUserId)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Некорректный ID пользователя' });
|
||||||
|
}
|
||||||
|
if (queryUserId !== req.user!.id) {
|
||||||
|
if (!(await canManageUser(req.user, queryUserId, req.organizationId!))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Нет прав на просмотр подписок этого пользователя' });
|
||||||
|
}
|
||||||
|
targetUserId = queryUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
const subscriptions = await notificationService.getUserSubscriptions(
|
const subscriptions = await notificationService.getUserSubscriptions(
|
||||||
req.user!.id, req.organizationId!
|
targetUserId, req.organizationId!
|
||||||
);
|
);
|
||||||
res.json({ success: true, subscriptions });
|
res.json({ success: true, subscriptions });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -114,14 +129,22 @@ export function registerChatNotificationRoutes(router: Router): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create subscription
|
// Create subscription
|
||||||
|
// body.userId — создать подписку другому пользователю (только при canManageUser)
|
||||||
router.post('/api/subscriptions',
|
router.post('/api/subscriptions',
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
tenantIsolation,
|
tenantIsolation,
|
||||||
validateRequest(createSubscriptionSchema),
|
validateRequest(createSubscriptionSchema),
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
|
let targetUserId = req.user!.id;
|
||||||
|
if (req.body.userId !== undefined && req.body.userId !== req.user!.id) {
|
||||||
|
if (!(await canManageUser(req.user, req.body.userId, req.organizationId!))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Нет прав на управление подписками этого пользователя' });
|
||||||
|
}
|
||||||
|
targetUserId = req.body.userId;
|
||||||
|
}
|
||||||
const subscription = await notificationService.createSubscription({
|
const subscription = await notificationService.createSubscription({
|
||||||
userId: req.user!.id,
|
userId: targetUserId,
|
||||||
organizationId: req.organizationId!,
|
organizationId: req.organizationId!,
|
||||||
eventTypeCode: req.body.eventTypeCode,
|
eventTypeCode: req.body.eventTypeCode,
|
||||||
targetType: req.body.targetType,
|
targetType: req.body.targetType,
|
||||||
@@ -147,8 +170,17 @@ export function registerChatNotificationRoutes(router: Router): void {
|
|||||||
if (isNaN(subscriptionId)) {
|
if (isNaN(subscriptionId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Некорректный ID подписки' });
|
return res.status(400).json({ success: false, error: 'Некорректный ID подписки' });
|
||||||
}
|
}
|
||||||
|
const existing = await notificationService.getSubscriptionById(subscriptionId, req.organizationId!);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Подписка не найдена' });
|
||||||
|
}
|
||||||
|
// Владелец подписки или canManageUser над владельцем (админ/руководитель)
|
||||||
|
if (existing.userId !== req.user!.id &&
|
||||||
|
!(await canManageUser(req.user, existing.userId, req.organizationId!))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Нет прав на изменение этой подписки' });
|
||||||
|
}
|
||||||
const subscription = await notificationService.updateSubscription(
|
const subscription = await notificationService.updateSubscription(
|
||||||
subscriptionId, req.user!.id, req.organizationId!, req.body
|
subscriptionId, existing.userId, req.organizationId!, req.body
|
||||||
);
|
);
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
return res.status(404).json({ success: false, error: 'Подписка не найдена' });
|
return res.status(404).json({ success: false, error: 'Подписка не найдена' });
|
||||||
@@ -171,7 +203,16 @@ export function registerChatNotificationRoutes(router: Router): void {
|
|||||||
if (isNaN(subscriptionId)) {
|
if (isNaN(subscriptionId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Некорректный ID подписки' });
|
return res.status(400).json({ success: false, error: 'Некорректный ID подписки' });
|
||||||
}
|
}
|
||||||
await notificationService.deleteSubscription(subscriptionId, req.user!.id, req.organizationId!);
|
const existing = await notificationService.getSubscriptionById(subscriptionId, req.organizationId!);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Подписка не найдена' });
|
||||||
|
}
|
||||||
|
// Владелец подписки или canManageUser над владельцем (админ/руководитель)
|
||||||
|
if (existing.userId !== req.user!.id &&
|
||||||
|
!(await canManageUser(req.user, existing.userId, req.organizationId!))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Нет прав на удаление этой подписки' });
|
||||||
|
}
|
||||||
|
await notificationService.deleteSubscription(subscriptionId, existing.userId, req.organizationId!);
|
||||||
res.json({ success: true, message: 'Подписка удалена' });
|
res.json({ success: true, message: 'Подписка удалена' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete subscription error:', error);
|
console.error('Delete subscription error:', error);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { storage } from "../storage";
|
|||||||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||||
import { logAudit } from "../utils/audit";
|
import { logAudit } from "../utils/audit";
|
||||||
|
import { canManageUser } from "../utils/user-access";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const OFFLINE_TASK_LIMIT = 500;
|
const OFFLINE_TASK_LIMIT = 500;
|
||||||
@@ -33,12 +34,26 @@ export function registerSyncRoutes(app: import("express").Express): void {
|
|||||||
* - формы с включённым глобальным offlineCache
|
* - формы с включённым глобальным offlineCache
|
||||||
* - персональные подписки пользователя
|
* - персональные подписки пользователя
|
||||||
* - автоматизации, разрешённые для офлайн-выполнения
|
* - автоматизации, разрешённые для офлайн-выполнения
|
||||||
|
* ?userId=N — конфигурация другого пользователя (только при canManageUser: админ/руководитель)
|
||||||
*/
|
*/
|
||||||
router.get('/config', async (req: AuthenticatedRequest, res) => {
|
router.get('/config', async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.id;
|
let userId = req.user!.id;
|
||||||
const organizationId = req.organizationId!;
|
const organizationId = req.organizationId!;
|
||||||
|
|
||||||
|
if (req.query.userId !== undefined) {
|
||||||
|
const queryUserId = parseInt(String(req.query.userId));
|
||||||
|
if (isNaN(queryUserId)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Некорректный ID пользователя' });
|
||||||
|
}
|
||||||
|
if (queryUserId !== userId) {
|
||||||
|
if (!(await canManageUser(req.user, queryUserId, organizationId))) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Нет прав на просмотр конфигурации этого пользователя' });
|
||||||
|
}
|
||||||
|
userId = queryUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [globalForms, subscriptions, automations] = await Promise.all([
|
const [globalForms, subscriptions, automations] = await Promise.all([
|
||||||
storage.getFormsWithOfflineCache(organizationId),
|
storage.getFormsWithOfflineCache(organizationId),
|
||||||
storage.getUserOfflineSubscriptions(userId, organizationId),
|
storage.getUserOfflineSubscriptions(userId, organizationId),
|
||||||
|
|||||||
@@ -490,6 +490,19 @@ export class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSubscriptionById(id: number, organizationId: number): Promise<NotificationSubscription | null> {
|
||||||
|
const [subscription] = await db
|
||||||
|
.select()
|
||||||
|
.from(notificationSubscriptions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(notificationSubscriptions.id, id),
|
||||||
|
eq(notificationSubscriptions.organizationId, organizationId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return subscription || null;
|
||||||
|
}
|
||||||
|
|
||||||
async createSubscription(data: {
|
async createSubscription(data: {
|
||||||
userId: number;
|
userId: number;
|
||||||
organizationId: number;
|
organizationId: number;
|
||||||
|
|||||||
@@ -2252,6 +2252,8 @@ export const createSubscriptionSchema = z.object({
|
|||||||
targetType: z.enum(['form', 'task', 'all']).optional(),
|
targetType: z.enum(['form', 'task', 'all']).optional(),
|
||||||
targetId: z.number().positive().optional(),
|
targetId: z.number().positive().optional(),
|
||||||
channels: z.array(z.enum(['app', 'email', 'push'])).default(['app']),
|
channels: z.array(z.enum(['app', 'email', 'push'])).default(['app']),
|
||||||
|
// Целевой пользователь (только для админа/руководителя, проверка canManageUser в роуте)
|
||||||
|
userId: z.number().int().positive().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateSubscriptionSchema = z.object({
|
export const updateSubscriptionSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user