Инфраструктура автоматизаций: - триггер 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) и переключателем периодов по клику на иконку у поля
1382 lines
53 KiB
TypeScript
1382 lines
53 KiB
TypeScript
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>
|
||
);
|
||
}
|