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 TaskDetail = safeLazy(() => import("@/pages/TaskDetail"));
|
||||
const Settings = safeLazy(() => import("@/pages/Settings"));
|
||||
const ProfileSettings = safeLazy(() => import("@/pages/ProfileSettings"));
|
||||
const MyProfilePage = safeLazy(() => import("@/pages/MyProfilePage"));
|
||||
const Reports = safeLazy(() => import("@/pages/Reports"));
|
||||
const Projects = safeLazy(() => import("@/pages/Projects"));
|
||||
const Bots = safeLazy(() => import("@/pages/Bots"));
|
||||
@@ -159,7 +159,7 @@ function Router() { return (
|
||||
<Route path="/projects" component={Projects} />
|
||||
<Route path="/reports" component={Reports} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/profile" component={ProfileSettings} />
|
||||
<Route path="/profile" component={MyProfilePage} />
|
||||
<Route path="/bots" component={Bots} />
|
||||
<Route path="/field-templates" component={FieldTemplates} />
|
||||
<Route path="/global-fields">
|
||||
|
||||
@@ -1619,7 +1619,7 @@ export function Sidebar({ children }: SidebarProps) {
|
||||
<Link href="/profile">
|
||||
<DropdownMenuItem className="text-xs" data-testid="item-profile-settings">
|
||||
<User className="w-3.5 h-3.5 mr-2" />
|
||||
Настройки профиля
|
||||
Мой профиль
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
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 { normalizeSelectOptions } from '@/lib/fieldOptions';
|
||||
import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect';
|
||||
import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck } from 'lucide-react';
|
||||
import { ArrowLeft, Save, Loader2, User as UserIcon, Mail, Shield, Briefcase, FileText, History, Calendar, Clock, CheckCircle2, Circle, AlertCircle, UserCheck, Bell, WifiOff, Palette, Lock } from 'lucide-react';
|
||||
import { Link } from 'wouter';
|
||||
import CompanyFieldInput from '@/components/CompanyFieldInput';
|
||||
import GeoField from '@/components/GeoField';
|
||||
@@ -28,6 +28,10 @@ import TableFieldSelect from '@/components/TableFieldSelect';
|
||||
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
|
||||
import { ContactListEditor } from '@/components/user/ContactListEditor';
|
||||
import ContactField from '@/components/ContactField';
|
||||
import { ProfileNotificationsTab } from '@/components/profile/ProfileNotificationsTab';
|
||||
import { ProfileOfflineTab } from '@/components/profile/ProfileOfflineTab';
|
||||
import { ProfileInterfaceTab } from '@/components/profile/ProfileInterfaceTab';
|
||||
import { ProfileSecurityTab } from '@/components/profile/ProfileSecurityTab';
|
||||
|
||||
interface UserProfileTab {
|
||||
id: number;
|
||||
@@ -600,12 +604,16 @@ function UserProfileFieldInput({
|
||||
}
|
||||
}
|
||||
|
||||
export default function UserProfilePage() {
|
||||
export default function UserProfilePage({ userId: userIdProp }: { userId?: number; params?: { id?: string } } = {}) {
|
||||
const params = useParams<{ id: string }>();
|
||||
const userId = parseInt(params.id);
|
||||
// userId приходит пропом с маршрута /profile (свой профиль) или из URL /users/:id
|
||||
const userId = userIdProp ?? parseInt(params.id);
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
const isSelf = currentUser?.id === userId;
|
||||
const isAdminView = isAdmin(currentUser);
|
||||
|
||||
const { data, isLoading, error } = useQuery<UserProfileData>({
|
||||
queryKey: ['/api/users', userId, 'profile'],
|
||||
queryFn: async () => {
|
||||
@@ -909,7 +917,7 @@ export default function UserProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/users">
|
||||
<Link href={isSelf ? '/' : '/users'}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
Назад
|
||||
@@ -934,6 +942,33 @@ export default function UserProfilePage() {
|
||||
<History className="w-3.5 h-3.5" />
|
||||
История
|
||||
</TabsTrigger>
|
||||
{/* Уведомления и Offline — для себя и для админа на чужой карточке */}
|
||||
{(isSelf || isAdminView) && (
|
||||
<>
|
||||
<TabsTrigger value="notifications" className="flex items-center gap-1">
|
||||
<Bell className="w-3.5 h-3.5" />
|
||||
Уведомления
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="offline" className="flex items-center gap-1">
|
||||
<WifiOff className="w-3.5 h-3.5" />
|
||||
Offline
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
{/* Интерфейс и Безопасность — только на своём профиле:
|
||||
тема/скин локальны для устройства, смена пароля другому есть в «Основное» */}
|
||||
{isSelf && (
|
||||
<>
|
||||
<TabsTrigger value="interface" className="flex items-center gap-1">
|
||||
<Palette className="w-3.5 h-3.5" />
|
||||
Интерфейс
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="flex items-center gap-1">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
Безопасность
|
||||
</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* System tab */}
|
||||
@@ -990,7 +1025,7 @@ export default function UserProfilePage() {
|
||||
<Select
|
||||
value={systemForm.watch('appRole')}
|
||||
onValueChange={(v) => systemForm.setValue('appRole', v)}
|
||||
disabled={!canEdit}
|
||||
disabled={!isAdminView}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -1006,7 +1041,7 @@ export default function UserProfilePage() {
|
||||
id="isActive"
|
||||
checked={systemForm.watch('isActive')}
|
||||
onCheckedChange={(c) => systemForm.setValue('isActive', !!c)}
|
||||
disabled={!canEdit}
|
||||
disabled={!isAdminView}
|
||||
/>
|
||||
<Label htmlFor="isActive">Активен</Label>
|
||||
</div>
|
||||
@@ -1015,7 +1050,7 @@ export default function UserProfilePage() {
|
||||
<Select
|
||||
value={systemForm.watch('statusId') ? String(systemForm.watch('statusId')) : 'none'}
|
||||
onValueChange={(v) => systemForm.setValue('statusId', v === 'none' ? null : Number(v))}
|
||||
disabled={!canEdit}
|
||||
disabled={!isAdminView}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите статус" />
|
||||
@@ -1253,6 +1288,29 @@ export default function UserProfilePage() {
|
||||
<HistoryTab auditLog={auditLogData?.auditLog || []} />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Уведомления и Offline — свой профиль или админский просмотр чужого */}
|
||||
{(isSelf || isAdminView) && (
|
||||
<>
|
||||
<TabsContent value="notifications" className="space-y-4">
|
||||
<ProfileNotificationsTab userId={userId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="offline" className="space-y-4">
|
||||
<ProfileOfflineTab userId={userId} isSelf={isSelf} />
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
{/* Интерфейс и Безопасность — только на своём профиле */}
|
||||
{isSelf && (
|
||||
<>
|
||||
<TabsContent value="interface" className="space-y-4">
|
||||
<ProfileInterfaceTab userId={userId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<ProfileSecurityTab />
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { validateRequest } from "../middleware/validation.middleware";
|
||||
import { createSubscriptionSchema, updateSubscriptionSchema } from "@shared/schema";
|
||||
import { notificationService } from "../services/notification.service";
|
||||
import { canManageUser } from "../utils/user-access";
|
||||
|
||||
export function registerChatNotificationRoutes(router: Router): void {
|
||||
// Get user notifications
|
||||
@@ -97,13 +98,27 @@ export function registerChatNotificationRoutes(router: Router): void {
|
||||
);
|
||||
|
||||
// Get user subscriptions
|
||||
// ?userId=N — подписки другого пользователя (только при canManageUser: админ/руководитель)
|
||||
router.get('/api/subscriptions',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
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(
|
||||
req.user!.id, req.organizationId!
|
||||
targetUserId, req.organizationId!
|
||||
);
|
||||
res.json({ success: true, subscriptions });
|
||||
} catch (error) {
|
||||
@@ -114,14 +129,22 @@ export function registerChatNotificationRoutes(router: Router): void {
|
||||
);
|
||||
|
||||
// Create subscription
|
||||
// body.userId — создать подписку другому пользователю (только при canManageUser)
|
||||
router.post('/api/subscriptions',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
validateRequest(createSubscriptionSchema),
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
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({
|
||||
userId: req.user!.id,
|
||||
userId: targetUserId,
|
||||
organizationId: req.organizationId!,
|
||||
eventTypeCode: req.body.eventTypeCode,
|
||||
targetType: req.body.targetType,
|
||||
@@ -147,8 +170,17 @@ export function registerChatNotificationRoutes(router: Router): void {
|
||||
if (isNaN(subscriptionId)) {
|
||||
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(
|
||||
subscriptionId, req.user!.id, req.organizationId!, req.body
|
||||
subscriptionId, existing.userId, req.organizationId!, req.body
|
||||
);
|
||||
if (!subscription) {
|
||||
return res.status(404).json({ success: false, error: 'Подписка не найдена' });
|
||||
@@ -171,7 +203,16 @@ export function registerChatNotificationRoutes(router: Router): void {
|
||||
if (isNaN(subscriptionId)) {
|
||||
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: 'Подписка удалена' });
|
||||
} catch (error) {
|
||||
console.error('Delete subscription error:', error);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { storage } from "../storage";
|
||||
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { logAudit } from "../utils/audit";
|
||||
import { canManageUser } from "../utils/user-access";
|
||||
import { z } from "zod";
|
||||
|
||||
const OFFLINE_TASK_LIMIT = 500;
|
||||
@@ -33,12 +34,26 @@ export function registerSyncRoutes(app: import("express").Express): void {
|
||||
* - формы с включённым глобальным offlineCache
|
||||
* - персональные подписки пользователя
|
||||
* - автоматизации, разрешённые для офлайн-выполнения
|
||||
* ?userId=N — конфигурация другого пользователя (только при canManageUser: админ/руководитель)
|
||||
*/
|
||||
router.get('/config', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
let userId = req.user!.id;
|
||||
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([
|
||||
storage.getFormsWithOfflineCache(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: {
|
||||
userId: number;
|
||||
organizationId: number;
|
||||
|
||||
@@ -2252,6 +2252,8 @@ export const createSubscriptionSchema = z.object({
|
||||
targetType: z.enum(['form', 'task', 'all']).optional(),
|
||||
targetId: z.number().positive().optional(),
|
||||
channels: z.array(z.enum(['app', 'email', 'push'])).default(['app']),
|
||||
// Целевой пользователь (только для админа/руководителя, проверка canManageUser в роуте)
|
||||
userId: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const updateSubscriptionSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user