896 lines
38 KiB
TypeScript
896 lines
38 KiB
TypeScript
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>
|
||
);
|
||
}
|