Files
iistwin/client/src/pages/UserProfilePage.tsx

896 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string>;
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<string, LinkedTask[]>();
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 (
<div className="text-center py-8 text-muted-foreground text-sm">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-30" />
Нет связанных задач
</div>
);
}
return (
<div className="space-y-6">
{grouped.map(([formName, formTasks]) => (
<div key={formName} className="border border-border rounded-lg overflow-hidden">
<div className="bg-muted/40 px-4 py-2 text-sm font-medium border-b border-border">
{formName}
<span className="text-muted-foreground ml-2 text-xs">({formTasks.length})</span>
</div>
<div className="divide-y divide-border">
{formTasks.map((task) => (
<Link key={task.id} href={`/forms/${task.formId}/tasks/${task.id}`}>
<div className="p-3 hover:bg-muted/30 cursor-pointer transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{task.isCompleted ? (
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
) : (
<Circle className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<span className="text-sm font-medium truncate">{task.title}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground flex-wrap">
<span>#{task.id}</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{task.dueDate
? new Date(task.dueDate).toLocaleDateString('ru-RU')
: 'Без срока'}
</span>
<span className="flex items-center gap-1">
<UserIcon className="w-3 h-3" />
{task.fieldName}
</span>
</div>
</div>
{task.statusName && (
<Badge style={{ backgroundColor: task.statusColor || '#e5e7eb', color: '#fff' }} className="text-[10px] shrink-0">
{task.statusName}
</Badge>
)}
</div>
</div>
</Link>
))}
</div>
</div>
))}
</div>
);
}
function HistoryTab({ auditLog }: { auditLog: AuditLogEntry[] }) {
if (auditLog.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground text-sm">
<History className="w-8 h-8 mx-auto mb-2 opacity-30" />
История изменений пуста
</div>
);
}
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="divide-y divide-border">
{auditLog.map((entry) => (
<div key={entry.id} className="p-3 hover:bg-muted/20">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{formatAuditAction(entry.action)}
{entry.fieldName && (
<span className="text-muted-foreground font-normal ml-1">«{entry.fieldName}»</span>
)}
</div>
{(entry.action === 'field_value.updated' || entry.action === 'field_value.created' || entry.action === 'field_value.deleted') && (
<div className="mt-1 text-xs text-muted-foreground grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
{entry.action !== 'field_value.created' && (
<>
<span>Было:</span>
<span className="truncate">{formatAuditValue(entry.oldValue)}</span>
</>
)}
{entry.action !== 'field_value.deleted' && (
<>
<span>Стало:</span>
<span className="truncate">{formatAuditValue(entry.newValue)}</span>
</>
)}
</div>
)}
<div className="flex items-center gap-2 mt-1 text-[10px] text-muted-foreground">
<Clock className="w-3 h-3" />
{new Date(entry.createdAt).toLocaleString('ru-RU')}
{entry.changedByName && (
<>
<span>•</span>
<span>{entry.changedByName}</span>
</>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}
// Карточка раздела профиля (по макету v4): заголовок с разделителем + строки полей
function ProfileCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border bg-card text-card-foreground shadow-sm py-3 px-4">
<div className="text-[13px] font-semibold px-2 pb-1.5 mb-1 border-b border-border">{title}</div>
{children}
</div>
);
}
// Чип в шапке профиля
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<UserProfileData>({
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 (
<Layout>
<PageSkeleton />
</Layout>
);
}
if (error || !data?.user) {
return (
<Layout>
<div className="p-8 text-center">
<p className="text-destructive">Пользователь не найден или произошла ошибка</p>
<Link href="/users">
<Button variant="outline" className="mt-4">
<ArrowLeft className="w-4 h-4 mr-2" />
Назад к списку
</Button>
</Link>
</div>
</Layout>
);
}
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) => (
<div className={cn(withDivider && 'border-t border-border mt-2 pt-1')}>
<div className="flex items-center gap-2 px-2 py-1 -mx-2 min-h-[28px]">
<span className="w-40 shrink-0 text-[13px] leading-5 font-semibold text-foreground">Замещение</span>
<span
className={cn(
'flex-1 min-w-0 text-[13px] leading-5 truncate',
currentDelegations.length > 0 ? 'font-medium' : 'text-muted-foreground'
)}
>
{delegationSummary}
</span>
{canEdit && (
<Popover open={subOpen} onOpenChange={setSubOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="w-6 h-6 rounded-full border border-input text-muted-foreground hover:border-primary hover:text-primary inline-flex items-center justify-center shrink-0 transition-colors"
title="Добавить замещение"
>
<Plus className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[calc(100vw-3rem)] sm:w-80">
<div className="space-y-2.5">
<div>
<span className="block text-[12px] text-muted-foreground mb-1">Заместитель</span>
<Select value={newDelegateId} onValueChange={setNewDelegateId}>
<SelectTrigger className="h-8 text-[13px]">
<SelectValue placeholder="Выберите пользователя" />
</SelectTrigger>
<SelectContent>
{allUsers
.filter((u: any) => u.id !== userId)
.map((u: any) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.fullName || `${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<span className="block text-[12px] text-muted-foreground mb-1">Форма</span>
<Select value={newFormId} onValueChange={setNewFormId}>
<SelectTrigger className="h-8 text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">По умолчанию (все формы)</SelectItem>
{allForms.map((f) => (
<SelectItem key={f.id} value={String(f.id)}>{f.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<div className="flex-1">
<span className="block text-[12px] text-muted-foreground mb-1">С</span>
<Input
type="date"
className="h-8 text-[13px]"
value={newDateFrom}
onChange={(e) => setNewDateFrom(e.target.value)}
/>
</div>
<div className="flex-1">
<span className="block text-[12px] text-muted-foreground mb-1">По</span>
<Input
type="date"
className="h-8 text-[13px]"
value={newDateTo}
onChange={(e) => setNewDateTo(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="ghost" size="sm" className="h-7 text-[12px]" onClick={() => setSubOpen(false)}>
Отмена
</Button>
<Button
size="sm"
className="h-7 text-[12px]"
disabled={
createDelegationMutation.isPending || !newDelegateId || !newDateFrom || !newDateTo
}
onClick={() => createDelegationMutation.mutate()}
>
{createDelegationMutation.isPending && <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />}
Добавить
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
{delegations.length > 0 && (
<div className="space-y-1 mt-1">
{delegations.map((d) => {
const state = delegationState(d, todayStr);
return (
<div
key={d.id}
className="flex items-center gap-2 flex-wrap rounded px-2 py-1 -mx-2 text-xs hover:bg-muted/50"
data-testid={`delegation-row-${d.id}`}
>
<span className="font-medium text-[13px]">{d.delegateName || '—'}</span>
<span className="text-muted-foreground">
{d.formId ? (d.formName || `Форма #${d.formId}`) : 'Все формы (по умолчанию)'}
</span>
<span className="text-muted-foreground">
с {formatDelegationDate(d.dateFrom)} по {formatDelegationDate(d.dateTo)}
</span>
{state === 'active' && <Badge variant="default" className="text-[10px] h-4 px-1.5">Активно</Badge>}
{state === 'expired' && <Badge variant="secondary" className="text-[10px] h-4 px-1.5">Истекло</Badge>}
{state === 'disabled' && <Badge variant="outline" className="text-[10px] h-4 px-1.5">Отключено</Badge>}
{canEdit && (
<div className="ml-auto flex items-center gap-1">
{state === 'active' && (
<Button
variant="outline"
size="sm"
className="h-6 text-[11px] px-2"
disabled={deactivateDelegationMutation.isPending}
onClick={() => deactivateDelegationMutation.mutate(d.id)}
>
Отключить
</Button>
)}
<Button
variant="outline"
size="sm"
className="h-6 text-[11px] px-2 text-destructive hover:text-destructive"
disabled={deleteDelegationMutation.isPending}
onClick={() => {
if (window.confirm(`Удалить замещение «${d.delegateName || '—'}»?`)) {
deleteDelegationMutation.mutate(d.id);
}
}}
>
Удалить
</Button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
return (
<Layout>
<div className="max-w-4xl mx-auto px-4 py-6 space-y-3">
{/* Шапка: аватар-инициалы, ФИО, бейджи, read-only чипы */}
<div className="rounded-lg border bg-card text-card-foreground shadow-sm px-5 py-4 flex items-center gap-4 flex-wrap">
<div className="w-14 h-14 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-lg font-semibold shrink-0">
{user.firstName?.[0]}{user.middleName?.[0] || ''}{user.lastName?.[0]}
</div>
<div className="flex-1 min-w-[220px]">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-lg font-bold leading-6">
{user.firstName} {user.middleName} {user.lastName}
</h1>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? 'Активен' : 'Неактивен'}
</Badge>
{user.status && (
<Badge style={{ backgroundColor: user.status.color, color: '#000' }} className="text-xs">
{user.status.name}
</Badge>
)}
</div>
{user.position && (
<p className="text-[13px] text-muted-foreground mt-0.5">{user.position}</p>
)}
<div className="flex gap-1.5 mt-1.5 flex-wrap">
<span className={headerChipClass}>
<Mail className="w-3 h-3" /> {user.email}
</span>
{user.phone && (
<span className={headerChipClass}>
<Phone className="w-3 h-3" /> {user.phone}
</span>
)}
<span className={headerChipClass}>
<Shield className="w-3 h-3" /> {appRoleLabel}
</span>
{(data?.organizationalRoles || []).map((role) => (
<span key={role.id} className={headerChipClass}>{role.name}</span>
))}
</div>
{currentDelegations.length > 0 && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<UserCheck className="w-3.5 h-3.5 shrink-0" />
<span>
Замещает: {currentDelegations.map((d) => `${d.delegateName || '—'} (до ${formatDelegationDate(d.dateTo)})`).join(', ')}
</span>
</div>
)}
</div>
<Link href={isSelf ? '/' : '/users'}>
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-1" />
Назад
</Button>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Основное */}
<ProfileCard title="Основное">
<ProfileSystemTextRow label="Имя" fieldKey="firstName" value={user.firstName} required userId={userId} canEdit={canEdit} />
<ProfileSystemTextRow label="Отчество" fieldKey="middleName" value={user.middleName} userId={userId} canEdit={canEdit} />
<ProfileSystemTextRow label="Фамилия" fieldKey="lastName" value={user.lastName} required userId={userId} canEdit={canEdit} />
<ProfileSystemTextRow label="Должность" fieldKey="position" value={user.position} userId={userId} canEdit={canEdit} />
</ProfileCard>
{/* Контакты */}
<ProfileCard title="Контакты">
<ProfileSystemTextRow
label="Email"
fieldKey="email"
value={user.email}
required
userId={userId}
canEdit={canEdit}
validate={(v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Неверный формат email')}
/>
<ProfileSystemTextRow label="Телефон" fieldKey="phone" value={user.phone} userId={userId} canEdit={canEdit} />
<ProfileExtraContactList kind="phone" items={user.additionalPhones || []} userId={userId} canEdit={canEdit} />
<ProfileExtraContactList kind="email" items={user.additionalEmails || []} userId={userId} canEdit={canEdit} />
</ProfileCard>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Роль и доступ */}
<ProfileCard title="Роль и доступ">
<ProfileSelectRow
label="Роль"
fieldKey="appRole"
value={user.appRole}
options={[
{ value: 'user', label: 'Пользователь' },
{ value: 'admin', label: 'Администратор' },
{ value: 'accountant', label: 'Бухгалтер' },
]}
userId={userId}
canEdit={isAdminView}
/>
<ProfileSelectRow
label="Статус"
fieldKey="statusId"
value={user.statusId ? String(user.statusId) : ''}
options={userStatuses.map((s) => ({ value: String(s.id), label: s.name }))}
serialize={(v) => (v === '' ? null : Number(v))}
displayValue={
user.status ? (
<span className="inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: user.status.color }} />
{user.status.name}
</span>
) : undefined
}
userId={userId}
canEdit={isAdminView}
/>
<ProfileToggleRow label="Активен" fieldKey="isActive" value={user.isActive} userId={userId} canEdit={isAdminView} />
{/* Смена пароля другому пользователю — только админу; свой пароль — вкладка «Безопасность» */}
{isAdminView && !isSelf && (
<ProfilePasswordRow userId={userId} canEdit={canEdit} />
)}
</ProfileCard>
{/* Поля + Замещение: если кастомных полей нет — замещение отдельной карточкой */}
{customGroups.length > 0 ? (
<ProfileCard title="Поля">
{customGroups.map((group) => (
<div key={group.key}>
{showGroupTitles && (
<div className="px-2 pt-1.5 pb-0.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.title ?? 'Основное'}
</div>
)}
{group.fields.map((field) => (
<ProfileCustomFieldRow
key={field.id}
field={field}
value={getFieldValue(field.id, data.fieldValues)}
userId={userId}
users={allUsers}
canEdit={canEdit}
/>
))}
</div>
))}
{delegationBlock(true)}
</ProfileCard>
) : (
<ProfileCard title="Замещение">
{delegationBlock(false)}
</ProfileCard>
)}
</div>
{/* Вторичные разделы компактными чипами */}
<div className="rounded-lg border bg-card text-card-foreground shadow-sm py-3 px-4">
<Tabs defaultValue="dossier" className="w-full">
<TabsList className="flex flex-wrap justify-start gap-1 h-auto bg-transparent p-0 mb-2">
<TabsTrigger value="dossier" className={sectionChipClass}>Досье</TabsTrigger>
<TabsTrigger value="history" className={sectionChipClass}>История</TabsTrigger>
{/* Уведомления, Offline и Сессии — для себя и для админа на чужой карточке */}
{(isSelf || isAdminView) && (
<>
<TabsTrigger value="notifications" className={sectionChipClass}>Уведомления</TabsTrigger>
<TabsTrigger value="offline" className={sectionChipClass}>Offline</TabsTrigger>
<TabsTrigger value="sessions" className={sectionChipClass}>Сессии</TabsTrigger>
</>
)}
{/* Интерфейс, Безопасность и GPS — только на своём профиле:
тема/скин локальны для устройства, смена пароля другому — в карточке «Роль и доступ» */}
{isSelf && (
<>
<TabsTrigger value="interface" className={sectionChipClass}>Интерфейс</TabsTrigger>
<TabsTrigger value="security" className={sectionChipClass}>Безопасность</TabsTrigger>
<TabsTrigger value="gps" className={sectionChipClass}>GPS</TabsTrigger>
</>
)}
</TabsList>
{/* Dossier tab */}
<TabsContent value="dossier" className="space-y-4">
{linkedTasksLoading ? (
<ListSkeleton rows={4} />
) : (
<DossierTab userId={userId} tasks={linkedTasksData?.tasks || []} />
)}
</TabsContent>
{/* History tab */}
<TabsContent value="history" className="space-y-4">
{auditLogLoading ? (
<ListSkeleton rows={4} />
) : (
<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>
<TabsContent value="sessions" className="space-y-4">
<ProfileSessionsTab userId={userId} />
</TabsContent>
</>
)}
{/* Интерфейс, Безопасность и GPS — только на своём профиле */}
{isSelf && (
<>
<TabsContent value="interface" className="space-y-4">
<ProfileInterfaceTab userId={userId} />
</TabsContent>
<TabsContent value="security" className="space-y-4">
<ProfileSecurityTab />
</TabsContent>
<TabsContent value="gps" className="space-y-4">
<ProfileGpsTab />
</TabsContent>
</>
)}
</Tabs>
</div>
</div>
</Layout>
);
}