import { useState, useMemo } from 'react'; import { useParams } from 'wouter'; import { useQuery, useMutation } from '@tanstack/react-query'; import Layout from '@/components/Layout'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import { isAdmin } from '@/lib/permissions'; import { apiRequest, queryClient } from '@/lib/queryClient'; import { cn } from '@/lib/utils'; import { ArrowLeft, Loader2, User as UserIcon, Mail, Phone, Shield, FileText, History, Calendar, Clock, CheckCircle2, Circle, UserCheck, Plus } from 'lucide-react'; import { ListSkeleton, PageSkeleton } from '@/components/ui/skeleton'; import { Link } from 'wouter'; import { ProfileSystemTextRow, ProfileSelectRow, ProfileToggleRow, ProfilePasswordRow, ProfileExtraContactList, ProfileCustomFieldRow, } from '@/components/profile/ProfileFieldRow'; import { ProfileNotificationsTab } from '@/components/profile/ProfileNotificationsTab'; import { ProfileOfflineTab } from '@/components/profile/ProfileOfflineTab'; import { ProfileSessionsTab } from '@/components/profile/ProfileSessionsTab'; import { ProfileInterfaceTab } from '@/components/profile/ProfileInterfaceTab'; import { ProfileSecurityTab } from '@/components/profile/ProfileSecurityTab'; import { ProfileGpsTab } from '@/components/profile/ProfileGpsTab'; interface UserProfileTab { id: number; name: string; code: string; } interface UserProfileField { id: number; name: string; code: string; type: string; isRequired: boolean; placeholder?: string; options?: any; defaultValue?: string; tabId?: number | null; linkedFormId?: number | null; companyAutofill?: Record; maxFileCount?: number | null; maxFileSizeMB?: number | null; autoProlongation?: boolean; autoProlongationLabel?: string; allowMultiple?: boolean; } interface UserProfileFieldValue { id: number; userId: number; fieldId: number; value: any; } interface UserProfileData { user: { id: number; firstName: string; lastName: string; middleName?: string; email: string; position?: string; phone?: string; additionalPhones?: Array<{ value: string; tag: string }>; additionalEmails?: Array<{ value: string; tag: string }>; appRole: string; isActive: boolean; statusId?: number | null; status?: { id: number; name: string; color: string } | null; lastLogin?: string; createdAt: string; }; tabs: UserProfileTab[]; fields: UserProfileField[]; fieldValues: UserProfileFieldValue[]; canEdit: boolean; organizationalRoles: { id: number; name: string }[]; } interface LinkedTask { id: number; title: string; formId: number; formName: string; statusId?: number; statusName?: string; statusColor?: string; isCompleted: boolean; dueDate?: string; createdAt: string; updatedAt: string; fieldId: number; fieldName: string; } interface AuditLogEntry { id: number; action: string; fieldId?: number; fieldName?: string; oldValue: any; newValue: any; changedBy?: number; changedByName?: string; createdAt: string; } interface UserDelegation { id: number; userId: number; delegateUserId: number; formId: number | null; dateFrom: string; dateTo: string; isActive: boolean; delegateName: string | null; formName: string | null; } function formatDelegationDate(dateStr: string) { const d = new Date(dateStr + 'T00:00:00'); return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); } function delegationState(d: UserDelegation, today: string): 'active' | 'expired' | 'disabled' { if (!d.isActive) return 'disabled'; if (d.dateTo < today) return 'expired'; return 'active'; } function getFieldValue(fieldId: number, values: UserProfileFieldValue[]): any { return values.find((v) => v.fieldId === fieldId)?.value ?? null; } function formatAuditValue(value: any): string { if (value === null || value === undefined) return '—'; if (typeof value === 'boolean') return value ? 'Да' : 'Нет'; if (typeof value === 'object') { if (Array.isArray(value)) return value.length ? value.map(formatAuditValue).join(', ') : '—'; return JSON.stringify(value); } return String(value); } function formatAuditAction(action: string): string { switch (action) { case 'field_value.created': return 'Создано значение'; case 'field_value.updated': return 'Изменено значение'; case 'field_value.deleted': return 'Удалено значение'; case 'user.updated': return 'Изменён профиль'; case 'user.role.changed': return 'Изменена роль'; case 'user.activated': return 'Пользователь активирован'; case 'user.deactivated': return 'Пользователь деактивирован'; default: return action; } } function DossierTab({ userId, tasks }: { userId: number; tasks: LinkedTask[] }) { const grouped = useMemo(() => { const map = new Map(); for (const task of tasks) { if (!map.has(task.formName)) map.set(task.formName, []); map.get(task.formName)!.push(task); } return Array.from(map.entries()).sort((a, b) => a[0].localeCompare(b[0], 'ru')); }, [tasks]); if (tasks.length === 0) { return (
Нет связанных задач
); } return (
{grouped.map(([formName, formTasks]) => (
{formName} ({formTasks.length})
{formTasks.map((task) => (
{task.isCompleted ? ( ) : ( )} {task.title}
#{task.id} {task.dueDate ? new Date(task.dueDate).toLocaleDateString('ru-RU') : 'Без срока'} {task.fieldName}
{task.statusName && ( {task.statusName} )}
))}
))}
); } function HistoryTab({ auditLog }: { auditLog: AuditLogEntry[] }) { if (auditLog.length === 0) { return (
История изменений пуста
); } return (
{auditLog.map((entry) => (
{formatAuditAction(entry.action)} {entry.fieldName && ( «{entry.fieldName}» )}
{(entry.action === 'field_value.updated' || entry.action === 'field_value.created' || entry.action === 'field_value.deleted') && (
{entry.action !== 'field_value.created' && ( <> Было: {formatAuditValue(entry.oldValue)} )} {entry.action !== 'field_value.deleted' && ( <> Стало: {formatAuditValue(entry.newValue)} )}
)}
{new Date(entry.createdAt).toLocaleString('ru-RU')} {entry.changedByName && ( <> • {entry.changedByName} )}
))}
); } // Карточка раздела профиля (по макету v4): заголовок с разделителем + строки полей function ProfileCard({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
); } // Чип в шапке профиля const headerChipClass = 'inline-flex items-center gap-1 rounded-full border bg-muted/50 px-2 py-0.5 text-[11px] text-muted-foreground'; // Триггер вторичного раздела (компактный чип вместо растянутой вкладки) const sectionChipClass = 'h-7 px-2.5 text-[12px] rounded-md font-normal text-muted-foreground hover:bg-muted data-[state=active]:bg-primary data-[state=active]:text-primary-foreground data-[state=active]:shadow-none'; export default function UserProfilePage({ userId: userIdProp }: { userId?: number; params?: { id?: string } } = {}) { const params = useParams<{ id: string }>(); // userId приходит пропом с маршрута /profile (свой профиль) или из URL /users/:id const userId = userIdProp ?? parseInt(params.id); const { toast } = useToast(); const { user: currentUser } = useAuth(); const isSelf = currentUser?.id === userId; const isAdminView = isAdmin(currentUser); const { data, isLoading, error } = useQuery({ queryKey: ['/api/users', userId, 'profile'], queryFn: async () => { const res = await apiRequest('GET', `/api/users/${userId}/profile`); return (await res.json()) as UserProfileData; }, enabled: !isNaN(userId), }); const canEdit = data?.canEdit ?? (isAdmin(currentUser) || currentUser?.id === userId); const { data: usersData } = useQuery<{ success: boolean; users: any[] }>({ queryKey: ['/api/users/list'], enabled: !!data, }); const allUsers = usersData?.users || []; const { data: statusesData } = useQuery<{ success: boolean; statuses: Array<{ id: number; name: string; color: string }> }>({ queryKey: ['/api/user-statuses'], enabled: !!data, }); const userStatuses = statusesData?.statuses || []; // === Замещения пользователя === const { data: formsData } = useQuery<{ success: boolean; forms: Array<{ id: number; name: string }> }>({ queryKey: ['/api/forms'], enabled: !!data, }); const allForms = formsData?.forms || []; const { data: delegationsData } = useQuery<{ success: boolean; delegations: UserDelegation[] }>({ queryKey: ['/api/users', userId, 'delegations'], queryFn: async () => { const res = await apiRequest('GET', `/api/users/${userId}/delegations`); return (await res.json()) as { success: boolean; delegations: UserDelegation[] }; }, enabled: !isNaN(userId) && !!data, }); const delegations = delegationsData?.delegations || []; const todayStr = useMemo(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }, []); const currentDelegations = delegations.filter( (d) => delegationState(d, todayStr) === 'active' && d.dateFrom <= todayStr ); const [subOpen, setSubOpen] = useState(false); const [newDelegateId, setNewDelegateId] = useState(''); const [newFormId, setNewFormId] = useState('all'); const [newDateFrom, setNewDateFrom] = useState(''); const [newDateTo, setNewDateTo] = useState(''); const invalidateDelegations = () => queryClient.invalidateQueries({ queryKey: ['/api/users', userId, 'delegations'] }); const createDelegationMutation = useMutation({ mutationFn: async () => { const res = await apiRequest('POST', `/api/users/${userId}/delegations`, { delegateUserId: Number(newDelegateId), formId: newFormId === 'all' ? null : Number(newFormId), dateFrom: newDateFrom, dateTo: newDateTo, }); return res.json(); }, onSuccess: () => { toast({ title: 'Замещение добавлено' }); setNewDelegateId(''); setNewFormId('all'); setNewDateFrom(''); setNewDateTo(''); setSubOpen(false); invalidateDelegations(); }, onError: (err: any) => { const isConflict = err?.status === 409; toast({ title: 'Ошибка', description: isConflict ? 'Замещение на этот период уже существует' : err?.message || 'Не удалось добавить замещение', variant: 'destructive', }); }, }); const deactivateDelegationMutation = useMutation({ mutationFn: async (delegationId: number) => { const res = await apiRequest('PATCH', `/api/users/${userId}/delegations/${delegationId}`, { isActive: false }); return res.json(); }, onSuccess: () => { toast({ title: 'Замещение отключено' }); invalidateDelegations(); }, onError: (err: any) => { toast({ title: 'Ошибка', description: err?.message || 'Не удалось отключить замещение', variant: 'destructive' }); }, }); const deleteDelegationMutation = useMutation({ mutationFn: async (delegationId: number) => { const res = await apiRequest('DELETE', `/api/users/${userId}/delegations/${delegationId}`); return res.json(); }, onSuccess: () => { toast({ title: 'Замещение удалено' }); invalidateDelegations(); }, onError: (err: any) => { toast({ title: 'Ошибка', description: err?.message || 'Не удалось удалить замещение', variant: 'destructive' }); }, }); const { data: linkedTasksData, isLoading: linkedTasksLoading } = useQuery<{ success: boolean; tasks: LinkedTask[] }>({ queryKey: ['/api/users', userId, 'linked-tasks'], queryFn: async () => { const res = await apiRequest('GET', `/api/users/${userId}/linked-tasks`); return (await res.json()) as { success: boolean; tasks: LinkedTask[] }; }, enabled: !isNaN(userId) && !!data, }); const { data: auditLogData, isLoading: auditLogLoading } = useQuery<{ success: boolean; auditLog: AuditLogEntry[] }>({ queryKey: ['/api/users', userId, 'profile', 'audit-log'], queryFn: async () => { const res = await apiRequest('GET', `/api/users/${userId}/profile/audit-log`); return (await res.json()) as { success: boolean; auditLog: AuditLogEntry[] }; }, enabled: !isNaN(userId) && !!data, }); if (isLoading) { return ( ); } if (error || !data?.user) { return (

Пользователь не найден или произошла ошибка

); } const { user, tabs, fields } = data; // Кастомные поля, сгруппированные по вкладкам профиля (user_profile_tabs) const customGroups: { key: string; title: string | null; fields: UserProfileField[] }[] = []; const noTabFields = fields.filter((f) => !f.tabId); if (noTabFields.length > 0) customGroups.push({ key: 'none', title: null, fields: noTabFields }); for (const tab of tabs) { const tabFields = fields.filter((f) => f.tabId === tab.id); if (tabFields.length > 0) customGroups.push({ key: String(tab.id), title: tab.name, fields: tabFields }); } // Подзаголовки групп — только если вкладок с полями больше одной const tabsWithFields = customGroups.filter((g) => g.title !== null).length; const showGroupTitles = tabsWithFields > 1; const appRoleLabel = user.appRole === 'admin' ? 'Администратор' : user.appRole === 'accountant' ? 'Бухгалтер' : 'Пользователь'; // Сводка по замещению: первая активная запись или «не настроено» const delegationSummary = currentDelegations.length > 0 ? `${currentDelegations[0].delegateName || '—'} · ${ currentDelegations[0].formId ? currentDelegations[0].formName || `Форма #${currentDelegations[0].formId}` : 'все формы' } · до ${formatDelegationDate(currentDelegations[0].dateTo)}` : 'не настроено'; // Блок замещений: строка со сводкой + «+» (Popover с формой), список под строкой. // Без прав canEdit — только просмотр списка. const delegationBlock = (withDivider: boolean) => (
Замещение 0 ? 'font-medium' : 'text-muted-foreground' )} > {delegationSummary} {canEdit && (
Заместитель
Форма
С setNewDateFrom(e.target.value)} />
По setNewDateTo(e.target.value)} />
)}
{delegations.length > 0 && (
{delegations.map((d) => { const state = delegationState(d, todayStr); return (
{d.delegateName || '—'} {d.formId ? (d.formName || `Форма #${d.formId}`) : 'Все формы (по умолчанию)'} с {formatDelegationDate(d.dateFrom)} по {formatDelegationDate(d.dateTo)} {state === 'active' && Активно} {state === 'expired' && Истекло} {state === 'disabled' && Отключено} {canEdit && (
{state === 'active' && ( )}
)}
); })}
)}
); return (
{/* Шапка: аватар-инициалы, ФИО, бейджи, read-only чипы */}
{user.firstName?.[0]}{user.middleName?.[0] || ''}{user.lastName?.[0]}

{user.firstName} {user.middleName} {user.lastName}

{user.isActive ? 'Активен' : 'Неактивен'} {user.status && ( {user.status.name} )}
{user.position && (

{user.position}

)}
{user.email} {user.phone && ( {user.phone} )} {appRoleLabel} {(data?.organizationalRoles || []).map((role) => ( {role.name} ))}
{currentDelegations.length > 0 && (
Замещает: {currentDelegations.map((d) => `${d.delegateName || '—'} (до ${formatDelegationDate(d.dateTo)})`).join(', ')}
)}
{/* Основное */} {/* Контакты */} (/^\S+@\S+\.\S+$/.test(v) ? null : 'Неверный формат email')} />
{/* Роль и доступ */} ({ value: String(s.id), label: s.name }))} serialize={(v) => (v === '' ? null : Number(v))} displayValue={ user.status ? ( {user.status.name} ) : undefined } userId={userId} canEdit={isAdminView} /> {/* Смена пароля другому пользователю — только админу; свой пароль — вкладка «Безопасность» */} {isAdminView && !isSelf && ( )} {/* Поля + Замещение: если кастомных полей нет — замещение отдельной карточкой */} {customGroups.length > 0 ? ( {customGroups.map((group) => (
{showGroupTitles && (
{group.title ?? 'Основное'}
)} {group.fields.map((field) => ( ))}
))} {delegationBlock(true)}
) : ( {delegationBlock(false)} )}
{/* Вторичные разделы компактными чипами */}
Досье История {/* Уведомления, Offline и Сессии — для себя и для админа на чужой карточке */} {(isSelf || isAdminView) && ( <> Уведомления Offline Сессии )} {/* Интерфейс, Безопасность и GPS — только на своём профиле: тема/скин локальны для устройства, смена пароля другому — в карточке «Роль и доступ» */} {isSelf && ( <> Интерфейс Безопасность GPS )} {/* Dossier tab */} {linkedTasksLoading ? ( ) : ( )} {/* History tab */} {auditLogLoading ? ( ) : ( )} {/* Уведомления, Offline и Сессии — свой профиль или админский просмотр чужого */} {(isSelf || isAdminView) && ( <> )} {/* Интерфейс, Безопасность и GPS — только на своём профиле */} {isSelf && ( <> )}
); }