Files
iistwin/client/src/pages/UserProfilePage.tsx
Ильяс Султанов 4d65049e02 feat: рейтинговые автоматизации, мультивыбор user/role-полей, график history-number
Инфраструктура автоматизаций:
- триггер task.status_changed оживлён: вызов из executeTaskTransition (fire-and-forget) + фильтр triggerConfig.statusId
- ctx.users.setFieldValue(userId, fieldCodeOrId, value): запись user_profile_field_values с аудитом; для history-number — append в user_field_history (миграция 0078)
- документация sandbox обновлена (Automations.tsx, mcp.ts, AGENTS.md) по критическому правилу 2

Мультивыбор пользователей и ролей в user-поле:
- чекбокс «Множественный выбор (пользователи и роли)» в настройках поля (form_fields.allow_multiple)
- значение — массив ['user:ID','role:ID'], обратная совместимость с одиночными/legacy значениями
- UserRoleMultiSelect в карточке задачи, реестре и диалоге создания (роли добавлены и в одиночный сценарий создания)
- синк в task_assignees итерирует массив; role:ID раскрывается в членов роли; PATCH single — diff старого/нового значений
- отображение массивов: реестр, title-шаблоны, условия полей, MCP update_task_fields
- список орг-ролей (/api/roles) загружается всем пользователям (сервер и так был открыт) — роли доступны не только админам

График history-number в профиле:
- GET /api/users/:id/field-history/:fieldId?period=week|month|quarter|year (агрегация AVG по бакетам)
- диалог с LineChart (recharts) и переключателем периодов по клику на иконку у поля
2026-09-04 19:39:10 +03:00

1382 lines
53 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 { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Layout from '@/components/Layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { isAdmin } from '@/lib/permissions';
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, Bell, WifiOff, Palette, Lock, MapPin, TrendingUp } from 'lucide-react';
import { Link } from 'wouter';
import CompanyFieldInput from '@/components/CompanyFieldInput';
import GeoField from '@/components/GeoField';
import TableFieldSelect from '@/components/TableFieldSelect';
import TableFieldMultiSelect from '@/components/TableFieldMultiSelect';
import { ContactListEditor } from '@/components/user/ContactListEditor';
import { FieldHistoryDialog } from '@/components/user/FieldHistoryDialog';
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';
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';
}
const contactItemSchema = z.object({
value: z.string().min(1, 'Обязательно'),
tag: z.string().min(1, 'Выберите тег'),
});
const systemSchema = z.object({
firstName: z.string().min(1, 'Имя обязательно'),
lastName: z.string().min(1, 'Фамилия обязательна'),
middleName: z.string().optional(),
email: z.string().email('Неверный формат email'),
phone: z.string().optional(),
additionalPhones: z.array(contactItemSchema).default([]),
additionalEmails: z.array(contactItemSchema).default([]),
position: z.string().optional(),
appRole: z.string().min(1, 'Выберите роль'),
isActive: z.boolean(),
statusId: z.union([z.number(), z.null()]).optional(),
password: z.string()
.optional()
.refine((password: string | undefined) => {
if (!password || password.trim() === '') return true;
return password.length >= 8 &&
/[A-Z]/.test(password) &&
/[a-z]/.test(password) &&
/\d/.test(password) &&
/[!@#$%^&*(),.?":{}|<>]/.test(password);
}, {
message: 'Пароль должен содержать минимум 8 символов, заглавные и строчные буквы, цифры и специальные символы'
}),
});
type SystemFormData = z.infer<typeof systemSchema>;
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>
);
}
// Поле history-number: обычный ввод значения + кнопка с графиком истории оценок.
// График доступен всегда (просмотр), редактирование значения — как у остальных полей.
function HistoryNumberFieldInput({
field,
value,
onChange,
userId,
}: {
field: UserProfileField;
value: any;
onChange: (val: any) => void;
userId: number;
}) {
const [historyOpen, setHistoryOpen] = useState(false);
return (
<div className="flex items-center gap-2">
<Input
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.isRequired}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
title="История оценок"
onClick={() => setHistoryOpen(true)}
>
<TrendingUp className="w-4 h-4" />
</Button>
<FieldHistoryDialog
userId={userId}
fieldId={field.id}
fieldName={field.name}
open={historyOpen}
onOpenChange={setHistoryOpen}
/>
</div>
);
}
function UserProfileFieldInput({
field,
value,
onChange,
users,
userId,
}: {
field: UserProfileField;
value: any;
onChange: (val: any) => void;
users: Array<{ id: number; firstName?: string; lastName?: string; middleName?: string; email: string }>;
userId: number;
}) {
switch (field.type) {
case 'text':
case 'contract-number':
return (
<Input
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.isRequired}
/>
);
case 'history-number':
return (
<HistoryNumberFieldInput
field={field}
value={value}
onChange={onChange}
userId={userId}
/>
);
case 'number':
return (
<Input
type="number"
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
placeholder={field.placeholder}
required={field.isRequired}
/>
);
case 'textarea':
return (
<Textarea
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.isRequired}
/>
);
case 'rich-text':
return (
<Textarea
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
required={field.isRequired}
/>
);
case 'date':
return (
<Input
type="date"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
required={field.isRequired}
/>
);
case 'datetime':
return (
<Input
type="datetime-local"
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
required={field.isRequired}
/>
);
case 'select': {
const options = normalizeSelectOptions(field.options);
if (field.allowMultiple) {
const selected = Array.isArray(value)
? new Set(value.map((v: any) => String(v)))
: value != null && value !== ''
? new Set([String(value)])
: new Set<string>();
return (
<SearchableMultiSelect
options={options.map((o) => ({ value: o.value, label: o.label }))}
selected={selected}
onChange={(next) => onChange(Array.from(next))}
placeholder={field.placeholder || 'Выберите...'}
/>
);
}
return (
<Select value={value ?? ''} onValueChange={(v) => onChange(v || null)}>
<SelectTrigger>
<SelectValue placeholder={field.placeholder || 'Выберите...'} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case 'checkbox':
case 'toggle':
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
/>
<Label>{field.name}</Label>
</div>
);
case 'user': {
return (
<Select value={value ? String(value) : ''} onValueChange={(v) => onChange(v ? Number(v) : null)}>
<SelectTrigger>
<SelectValue placeholder={field.placeholder || 'Выберите пользователя...'} />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.firstName} {u.middleName || ''} {u.lastName} ({u.email})
</SelectItem>
))}
</SelectContent>
</Select>
);
}
case 'company':
return (
<CompanyFieldInput
value={value ?? ''}
onChange={(val) => onChange(val)}
onSelect={(company) => onChange(company?.value ?? null)}
placeholder={field.placeholder}
/>
);
case 'geo':
return (
<GeoField
value={value}
onChange={(geo) => onChange(geo)}
disabled={false}
/>
);
case 'table': {
const tableOptions = field.options || {};
if (field.allowMultiple) {
return (
<TableFieldMultiSelect
fieldId={field.id}
fieldCode={field.code}
options={field.options}
value={value}
onChange={(rowIds) => onChange(rowIds)}
/>
);
}
return (
<TableFieldSelect
fieldId={field.id}
fieldCode={field.code}
options={field.options}
value={value}
onChange={(rowId) => onChange(rowId)}
/>
);
}
case 'contact':
return (
<ContactField
value={value}
onChange={(contact) => onChange(contact)}
disabled={false}
/>
);
case 'file': {
const files = Array.isArray(value) ? value : value ? [value] : [];
return (
<div className="space-y-2">
{files.map((file: any, idx: number) => (
<div key={idx} className="flex items-center gap-2">
<a href={file.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline text-sm">
{file.name || file.url}
</a>
<Button type="button" variant="ghost" size="sm" onClick={() => {
const newFiles = files.filter((_: any, i: number) => i !== idx);
onChange(newFiles.length ? newFiles : null);
}}>Удалить</Button>
</div>
))}
<Input
type="file"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
credentials: 'include',
body: formData,
});
if (res.ok) {
const data = await res.json();
onChange([...files, { url: data.url, name: data.name, size: data.size }]);
}
}}
/>
</div>
);
}
case 'formula':
return (
<div className="p-2 bg-yellow-50 border border-yellow-200 rounded font-mono text-sm">
{value ?? '—'}
</div>
);
case 'button':
return (
<Button type="button" variant="outline" disabled>
{field.name}
</Button>
);
case 'task': {
return (
<Input
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
placeholder={field.placeholder || 'ID задачи'}
required={field.isRequired}
/>
);
}
case 'checklist': {
const items = Array.isArray(field.options) ? field.options : [];
const checkedItems = Array.isArray(value) ? value : [];
return (
<div className="space-y-1">
{items.map((item: string, idx: number) => {
const checked = checkedItems.find((c: any) => c.id === idx)?.checked ?? false;
return (
<div key={idx} className="flex items-center space-x-2">
<Checkbox
checked={checked}
onCheckedChange={(c) => {
const newChecked = [...checkedItems];
const existing = newChecked.find((x: any) => x.id === idx);
if (existing) {
existing.checked = !!c;
} else {
newChecked.push({ id: idx, label: item, checked: !!c });
}
onChange(newChecked);
}}
/>
<span className="text-sm">{item}</span>
</div>
);
})}
</div>
);
}
default:
return (
<Input
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
/>
);
}
}
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 [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('');
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,
});
const systemForm = useForm<SystemFormData>({
resolver: zodResolver(systemSchema),
defaultValues: {
firstName: '',
lastName: '',
middleName: '',
email: '',
phone: '',
additionalPhones: [],
additionalEmails: [],
position: '',
appRole: 'user',
isActive: true,
statusId: null,
password: '',
},
});
// Reset system form when data loaded
useMemo(() => {
if (data?.user) {
systemForm.reset({
firstName: data.user.firstName,
lastName: data.user.lastName,
middleName: data.user.middleName || '',
email: data.user.email,
phone: data.user.phone || '',
additionalPhones: data.user.additionalPhones || [],
additionalEmails: data.user.additionalEmails || [],
position: data.user.position || '',
appRole: data.user.appRole,
isActive: data.user.isActive,
statusId: data.user.statusId ?? null,
password: '',
});
}
}, [data?.user]);
const [customValues, setCustomValues] = useState<Record<number, any>>({});
// Initialize custom values when data loaded
useMemo(() => {
if (data?.fieldValues) {
const map: Record<number, any> = {};
data.fieldValues.forEach((v) => {
map[v.fieldId] = v.value;
});
setCustomValues(map);
}
}, [data?.fieldValues]);
const updateSystemMutation = useMutation({
mutationFn: async (formData: SystemFormData) => {
const { password, ...userUpdateData } = formData;
const userResult = await authService.updateUser(userId, userUpdateData as any);
if (password && password.trim() !== '') {
const passwordResult = await authService.changeUserPassword(userId, password);
if (!passwordResult.success) throw new Error(passwordResult.error || 'Ошибка при изменении пароля');
}
return userResult;
},
onSuccess: (result) => {
if (result.success) {
toast({ title: 'Сохранено', description: 'Базовая информация обновлена' });
queryClient.invalidateQueries({ queryKey: ['/api/users', userId, 'profile'] });
systemForm.setValue('password', '');
} else {
toast({ title: 'Ошибка', description: result.error, variant: 'destructive' });
}
},
onError: (error) => {
toast({ title: 'Ошибка', description: error instanceof Error ? error.message : 'Произошла ошибка при обновлении', variant: 'destructive' });
},
});
const saveCustomValuesMutation = useMutation({
mutationFn: async (values: Array<{ fieldId: number; value: any }>) => {
const res = await apiRequest('POST', `/api/users/${userId}/field-values`, { fieldValues: values });
return res;
},
onSuccess: () => {
toast({ title: 'Сохранено', description: 'Поля профиля обновлены' });
queryClient.invalidateQueries({ queryKey: ['/api/users', userId, 'profile'] });
},
onError: (err: any) => {
toast({ title: 'Ошибка', description: err?.message || 'Не удалось сохранить поля', variant: 'destructive' });
},
});
const onSubmitSystem = (formData: SystemFormData) => {
updateSystemMutation.mutate(formData);
};
const onSaveCustomValues = (tabFields: UserProfileField[]) => {
const values = tabFields.map((f) => ({
fieldId: f.id,
value: customValues[f.id] ?? null,
}));
saveCustomValuesMutation.mutate(values);
};
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</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;
const fieldsByTab = (tabId: number | null) =>
fields.filter((f) => (tabId ? f.tabId === tabId : !f.tabId));
return (
<Layout>
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center text-2xl font-bold text-primary">
{user.firstName?.[0]}{user.middleName?.[0] || ''}{user.lastName?.[0]}
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold">
{user.firstName} {user.middleName} {user.lastName}
</h1>
<div className="flex items-center gap-3 mt-1 text-muted-foreground flex-wrap">
<span className="flex items-center gap-1"><Mail className="w-4 h-4" /> {user.email}</span>
{user.phone && <span className="flex items-center gap-1">📞 {user.phone}</span>}
{user.position && <span className="flex items-center gap-1"><Briefcase className="w-4 h-4" /> {user.position}</span>}
<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>
)}
<Badge variant="outline" className="flex items-center gap-1">
<Shield className="w-3 h-3" />
{user.appRole === 'admin' ? 'Администратор' : user.appRole === 'accountant' ? 'Бухгалтер' : 'Пользователь'}
</Badge>
{(data?.organizationalRoles || []).map((role) => (
<Badge key={role.id} variant="outline" className="text-xs">{role.name}</Badge>
))}
</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>
{/* Tabs */}
<Tabs defaultValue="system" className="w-full">
<TabsList className="flex-wrap h-auto">
<TabsTrigger value="system">Основное</TabsTrigger>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={String(tab.id)}>
{tab.name}
</TabsTrigger>
))}
<TabsTrigger value="dossier" className="flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
Досье
</TabsTrigger>
<TabsTrigger value="history" className="flex items-center gap-1">
<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>
<TabsTrigger value="gps" className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5" />
GPS
</TabsTrigger>
</>
)}
</TabsList>
{/* System tab */}
<TabsContent value="system" className="space-y-4">
<form onSubmit={systemForm.handleSubmit(onSubmitSystem)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Имя</Label>
<Input {...systemForm.register('firstName')} disabled={!canEdit} />
{systemForm.formState.errors.firstName && (
<p className="text-xs text-destructive">{systemForm.formState.errors.firstName.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Отчество</Label>
<Input {...systemForm.register('middleName')} disabled={!canEdit} />
</div>
<div className="space-y-2">
<Label>Фамилия</Label>
<Input {...systemForm.register('lastName')} disabled={!canEdit} />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input {...systemForm.register('email')} disabled={!canEdit} />
</div>
<div className="space-y-2">
<Label>Телефон</Label>
<Input {...systemForm.register('phone')} disabled={!canEdit} />
</div>
<div className="space-y-2">
<Label>Должность</Label>
<Input {...systemForm.register('position')} disabled={!canEdit} />
</div>
<div className="md:col-span-3 space-y-4">
<ContactListEditor
control={systemForm.control as any}
name="additionalPhones"
label="Дополнительные телефоны"
placeholder="+7 ..."
disabled={!canEdit}
/>
</div>
<div className="md:col-span-3 space-y-4">
<ContactListEditor
control={systemForm.control as any}
name="additionalEmails"
label="Дополнительные email"
placeholder="email@example.com"
disabled={!canEdit}
/>
</div>
<div className="space-y-2">
<Label>Роль</Label>
<Select
value={systemForm.watch('appRole')}
onValueChange={(v) => systemForm.setValue('appRole', v)}
disabled={!isAdminView}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Пользователь</SelectItem>
<SelectItem value="admin">Администратор</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2 pt-6">
<Checkbox
id="isActive"
checked={systemForm.watch('isActive')}
onCheckedChange={(c) => systemForm.setValue('isActive', !!c)}
disabled={!isAdminView}
/>
<Label htmlFor="isActive">Активен</Label>
</div>
<div className="space-y-2">
<Label>Статус</Label>
<Select
value={systemForm.watch('statusId') ? String(systemForm.watch('statusId')) : 'none'}
onValueChange={(v) => systemForm.setValue('statusId', v === 'none' ? null : Number(v))}
disabled={!isAdminView}
>
<SelectTrigger>
<SelectValue placeholder="Выберите статус" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">—</SelectItem>
{userStatuses.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: s.color }} />
{s.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isAdmin(currentUser) && currentUser?.id !== userId && (
<div className="space-y-2">
<Label>Новый пароль</Label>
<Input
type="password"
placeholder="Оставьте пустым для сохранения текущего"
{...systemForm.register('password')}
disabled={!canEdit}
data-testid="input-password"
/>
{systemForm.formState.errors.password && (
<p className="text-xs text-destructive">
{typeof systemForm.formState.errors.password.message === 'string'
? systemForm.formState.errors.password.message
: 'Ошибка валидации пароля'}
</p>
)}
</div>
)}
</div>
{canEdit && (
<Button type="submit" disabled={updateSystemMutation.isPending}>
{updateSystemMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
<Save className="w-4 h-4 mr-2" />
Сохранить
</Button>
)}
</form>
{/* Замещение пользователя на период отсутствия */}
{canEdit && (
<div className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<UserCheck className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Замещение</h3>
</div>
{delegations.length === 0 ? (
<p className="text-xs text-muted-foreground">Замещения не настроены</p>
) : (
<div className="space-y-2">
{delegations.map((d) => {
const state = delegationState(d, todayStr);
return (
<div
key={d.id}
className="flex items-center gap-2 flex-wrap border border-border rounded px-2.5 py-2 text-xs"
data-testid={`delegation-row-${d.id}`}
>
<span className="font-medium">{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>}
<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 className="flex items-end gap-2 flex-wrap pt-1">
<div className="space-y-1">
<Label className="text-xs">Заместитель</Label>
<Select value={newDelegateId} onValueChange={setNewDelegateId}>
<SelectTrigger className="w-48 h-8 text-xs">
<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 className="space-y-1">
<Label className="text-xs">Форма</Label>
<Select value={newFormId} onValueChange={setNewFormId}>
<SelectTrigger className="w-48 h-8 text-xs">
<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="space-y-1">
<Label className="text-xs">С</Label>
<Input
type="date"
className="w-auto h-8 text-xs"
value={newDateFrom}
onChange={(e) => setNewDateFrom(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">По</Label>
<Input
type="date"
className="w-auto h-8 text-xs"
value={newDateTo}
onChange={(e) => setNewDateTo(e.target.value)}
/>
</div>
<Button
size="sm"
className="h-8"
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>
)}
</TabsContent>
{/* Custom tabs */}
{tabs.map((tab) => {
const tabFields = fieldsByTab(tab.id);
return (
<TabsContent key={tab.id} value={String(tab.id)} className="space-y-4">
{tabFields.length === 0 ? (
<p className="text-muted-foreground">Нет полей на этой вкладке</p>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{tabFields.map((field) => (
<div key={field.id} className="space-y-2">
<Label>
{field.name}
{field.isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<UserProfileFieldInput
field={field}
value={customValues[field.id] ?? getFieldValue(field.id, data.fieldValues)}
onChange={(val) => setCustomValues((prev) => ({ ...prev, [field.id]: val }))}
users={allUsers}
userId={userId}
/>
</div>
))}
</div>
{canEdit && (
<Button
onClick={() => onSaveCustomValues(tabFields)}
disabled={saveCustomValuesMutation.isPending}
>
{saveCustomValuesMutation.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
<Save className="w-4 h-4 mr-2" />
Сохранить
</Button>
)}
</>
)}
</TabsContent>
);
})}
{/* Dossier tab */}
<TabsContent value="dossier" className="space-y-4">
{linkedTasksLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<DossierTab userId={userId} tasks={linkedTasksData?.tasks || []} />
)}
</TabsContent>
{/* History tab */}
<TabsContent value="history" className="space-y-4">
{auditLogLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<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>
<TabsContent value="gps" className="space-y-4">
<ProfileGpsTab />
</TabsContent>
</>
)}
</Tabs>
</div>
</Layout>
);
}