diff --git a/client/src/components/profile/ProfileFieldRow.tsx b/client/src/components/profile/ProfileFieldRow.tsx new file mode 100644 index 0000000..e81b1f5 --- /dev/null +++ b/client/src/components/profile/ProfileFieldRow.tsx @@ -0,0 +1,1431 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Pencil, X, TrendingUp, Plus, Check } from 'lucide-react'; +import { apiRequest, queryClient } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions'; +import { fieldControlClass } from '@/components/fields/field-styles'; +import { isInteractiveClickTarget, isRadixPortalTarget } from '@/components/task-fields/FieldValueRow'; +import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; +import GeoField from '@/components/GeoField'; +import ContactField from '@/components/ContactField'; +import CompanyFieldInput from '@/components/CompanyFieldInput'; +import TableFieldSelect from '@/components/TableFieldSelect'; +import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; +import { FieldHistoryDialog } from '@/components/user/FieldHistoryDialog'; +import { CONTACT_TAGS } from '@/components/user/ContactListEditor'; +import { authService } from '@/services/auth.service'; + +// ── Per-field строки профиля пользователя (по образцу FieldValueRow из задач) ── +// read-view + двойной клик / двойной тап / карандаш → редактор в том же слоте → +// commit с optimistic-обновлением кэша профиля; Esc/клик вне — отмена +// (для составных редакторов geo/contact/company — сохранение по закрытию). + +// Минимальная форма кэша профиля для optimistic-обновлений +interface ProfileCacheShape { + user?: Record; + fieldValues?: Array<{ id: number; userId: number; fieldId: number; value: unknown }>; +} + +const profileCacheKey = (userId: number) => ['/api/users', userId, 'profile'] as const; + +export interface ProfileCustomField { + id: number; + name: string; + code: string; + type: string; + isRequired: boolean; + placeholder?: string; + options?: any; + linkedFormId?: number | null; + allowMultiple?: boolean; + maxFileCount?: number | null; + maxFileSizeMB?: number | null; +} + +export interface ProfileUserOption { + id: number; + firstName?: string | null; + lastName?: string | null; + middleName?: string | null; + fullName?: string | null; + email: string; +} + +function userDisplayName(u: ProfileUserOption): string { + return u.fullName || [u.firstName, u.middleName, u.lastName].filter(Boolean).join(' ') || u.email; +} + +// Сравнение значений: null/undefined/'' считаем пустыми и равными друг другу +function valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const isEmpty = (v: unknown) => v === null || v === undefined || v === ''; + if (isEmpty(a) && isEmpty(b)) return true; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +function parseJsonValue(value: unknown): any { + if (value === null || value === undefined) return null; + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return null; + } + } + return value; +} + +function parseFiles(value: unknown): Array<{ url: string; name: string; size?: number }> { + if (!value) return []; + let v = value; + if (typeof v === 'string') { + const raw = v; + try { + v = JSON.parse(raw); + } catch { + return [{ url: raw, name: raw }]; + } + } + if (Array.isArray(v)) return v.filter((f: any) => f && typeof f.url === 'string'); + if (typeof v === 'object' && typeof (v as any).url === 'string') return [v as any]; + return []; +} + +// ── Хук inline-редактирования одной строки ── +interface UseInlineEditOptions { + enabled: boolean; + onCommit: (value: T) => void; + validate?: (value: T) => string | null; + // Составные редакторы (geo/contact/company) — сохранение по закрытию (клик вне строки) + commitOnClose?: boolean; +} + +function useInlineEdit(currentValue: T, opts: UseInlineEditOptions) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraftState] = useState(currentValue); + const draftRef = useRef(currentValue); + const [error, setError] = useState(null); + const wrapperRef = useRef(null); + const lastTapRef = useRef(0); + const optsRef = useRef(opts); + optsRef.current = opts; + + // Пока поле не редактируется — синхронизируем черновик со значением с сервера + useEffect(() => { + if (!isEditing) { + draftRef.current = currentValue; + setDraftState(currentValue); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentValue, isEditing]); + + const setDraft = (v: T) => { + draftRef.current = v; + setDraftState(v); + if (error) setError(null); + }; + + const enterEdit = () => { + if (!optsRef.current.enabled || isEditing) return; + draftRef.current = currentValue; + setDraftState(currentValue); + setError(null); + setIsEditing(true); + }; + + const cancelEdit = () => { + setIsEditing(false); + setError(null); + draftRef.current = currentValue; + setDraftState(currentValue); + }; + + // Сохранение: валидация прямо в строке; при неизменном значении запрос пропускается. + // close: false — для редакторов с сохранением по каждому переключению (мультивыбор, чек-лист) + const commitEdit = (v?: T, commitOpts?: { close?: boolean }): boolean => { + const val = v === undefined ? draftRef.current : v; + if (optsRef.current.validate) { + const err = optsRef.current.validate(val); + if (err) { + setError(err); + return false; + } + } + setError(null); + if (!valuesEqual(val, currentValue)) optsRef.current.onCommit(val); + if (commitOpts?.close !== false) setIsEditing(false); + return true; + }; + + // Клик вне строки закрывает редактор: составные сохраняются, остальные отменяются + useEffect(() => { + if (!isEditing) return; + const handleOutsideClick = (e: MouseEvent) => { + const target = e.target; + if (wrapperRef.current?.contains(target as Node)) return; + if (isRadixPortalTarget(target)) return; + if (optsRef.current.commitOnClose) { + commitEdit(); + } else { + cancelEdit(); + } + }; + document.addEventListener('click', handleOutsideClick); + return () => document.removeEventListener('click', handleOutsideClick); + }); + + // Esc — отмена редактирования без сохранения (для редакторов без собственной обработки) + useEffect(() => { + if (!isEditing) return; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') cancelEdit(); + }; + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + }); + + // Двойное касание на мобильных — вход в редактирование + const handleDoubleTap = (e: React.TouchEvent) => { + if (!optsRef.current.enabled) return; + if (isInteractiveClickTarget(e.target)) return; + const now = Date.now(); + if (now - lastTapRef.current < 300) { + e.stopPropagation(); + enterEdit(); + } + lastTapRef.current = now; + }; + + return { isEditing, draft, setDraft, error, wrapperRef, enterEdit, cancelEdit, commitEdit, handleDoubleTap }; +} + +// ── Общий каркас строки: label + read-view/редактор + карандаш ── +function RowFrame({ + label, + required, + error, + editable, + isEditing, + editor, + readView, + wrapperRef, + onEnterEdit, + onDoubleTap, + testId, +}: { + label: string; + required?: boolean; + error?: string | null; + editable: boolean; + isEditing: boolean; + editor?: React.ReactNode; + readView: React.ReactNode; + wrapperRef?: React.Ref; + onEnterEdit?: () => void; + onDoubleTap?: (e: React.TouchEvent) => void; + testId?: string; +}) { + return ( +
+ + {label} + {required && *} + +
+ {isEditing && editor != null ? ( +
{editor}
+ ) : ( +
{ + if (!editable || !onEnterEdit) return; + if (isInteractiveClickTarget(e.target)) return; + e.stopPropagation(); + onEnterEdit(); + }} + onTouchEnd={onDoubleTap} + > + {readView} +
+ )} + {error && ( +
+ {error} +
+ )} +
+ {/* Карандаш: на мобильном виден всегда, на desktop — по hover строки */} + {editable && !isEditing && onEnterEdit && ( + + )} +
+ ); +} + +// ── Сохранение системных полей: PATCH /api/users/:id с одним ключом + optimistic-кэш ── +export function useSaveUserSystemFields(userId: number) { + const { toast } = useToast(); + return async (patch: Record): Promise => { + const key = profileCacheKey(userId); + const prev = queryClient.getQueryData(key); + if (prev?.user) { + queryClient.setQueryData(key, { ...prev, user: { ...prev.user, ...patch } }); + } + try { + await apiRequest('PATCH', `/api/users/${userId}`, patch); + // Инвалидация по префиксу ключа заодно обновляет и audit-log профиля + queryClient.invalidateQueries({ queryKey: key }); + return true; + } catch (err) { + // Ошибка сохранения — откатываем optimistic-значение + if (prev) queryClient.setQueryData(key, prev); + const msg = err instanceof Error ? err.message : 'Не удалось сохранить. Проверьте соединение и попробуйте ещё раз.'; + toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); + return false; + } + }; +} + +// ── Текстовое системное поле (Имя/Отчество/Фамилия/Email/Телефон/Должность) ── +export function ProfileSystemTextRow({ + label, + fieldKey, + value, + userId, + canEdit, + placeholder, + required, + validate, + testId, +}: { + label: string; + fieldKey: string; + value: string | null | undefined; + userId: number; + canEdit: boolean; + placeholder?: string; + required?: boolean; + validate?: (v: string) => string | null; + testId?: string; +}) { + const save = useSaveUserSystemFields(userId); + const edit = useInlineEdit(value ?? '', { + enabled: canEdit, + validate: (v) => { + const trimmed = v.trim(); + if (required && !trimmed) return `Поле «${label}» обязательно к заполнению`; + if (validate && trimmed) return validate(trimmed); + return null; + }, + onCommit: (v) => { + const trimmed = v.trim(); + void save({ [fieldKey]: trimmed === '' ? null : trimmed }); + }, + }); + + return ( + {value} + ) : ( + — + ) + } + editor={ + edit.setDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + autoFocus + aria-label={label} + aria-invalid={!!edit.error} + placeholder={placeholder} + onBlur={() => edit.commitEdit()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + edit.commitEdit(); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + edit.cancelEdit(); + } + }} + /> + } + /> + ); +} + +// ── Системное поле-выбор (Роль, Статус): сохранение сразу по выбору ── +export function ProfileSelectRow({ + label, + fieldKey, + value, + options, + userId, + canEdit, + placeholder, + serialize, + displayValue, + testId, +}: { + label: string; + fieldKey: string; + value: string; + options: Array<{ value: string; label: string }>; + userId: number; + canEdit: boolean; + placeholder?: string; + serialize?: (v: string) => unknown; + displayValue?: React.ReactNode; + testId?: string; +}) { + const save = useSaveUserSystemFields(userId); + const edit = useInlineEdit(value, { + enabled: canEdit, + onCommit: (v) => { + void save({ [fieldKey]: serialize ? serialize(v) : v }); + }, + }); + + const defaultDisplay = value + ? options.find((o) => o.value === value)?.label ?? value + : null; + + return ( + {defaultDisplay} + ) : ( + — + )) + } + editor={ + + } + /> + ); +} + +// ── Системный переключатель Да/Нет (Активен): мгновенное переключение по клику ── +export function ProfileToggleRow({ + label, + fieldKey, + value, + userId, + canEdit, + testId, +}: { + label: string; + fieldKey: string; + value: boolean; + userId: number; + canEdit: boolean; + testId?: string; +}) { + const save = useSaveUserSystemFields(userId); + const valueClass = cn('text-[13px] leading-5 font-medium', value ? 'text-green-600' : 'text-muted-foreground'); + return ( + void save({ [fieldKey]: !value })} + title="Нажмите для переключения" + > + {value ? 'Да' : 'Нет'} + + ) : ( + {value ? 'Да' : 'Нет'} + ) + } + /> + ); +} + +// ── Строка смены пароля (админ на чужом профиле): по клику инпут + «Применить» ── +export function ProfilePasswordRow({ userId, canEdit }: { userId: number; canEdit: boolean }) { + const { toast } = useToast(); + const [isEditing, setIsEditing] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const wrapperRef = useRef(null); + + const cancel = () => { + setIsEditing(false); + setPassword(''); + setError(null); + }; + + const apply = async () => { + if (password.length < 8) { + setError('Пароль должен содержать минимум 8 символов'); + return; + } + setPending(true); + const result = await authService.changeUserPassword(userId, password); + setPending(false); + if (result.success) { + toast({ title: 'Пароль обновлён' }); + cancel(); + } else { + setError(result.error || 'Ошибка при изменении пароля'); + } + }; + + // Клик вне строки / Esc — отмена без сохранения + useEffect(() => { + if (!isEditing) return; + const handleOutsideClick = (e: MouseEvent) => { + if (wrapperRef.current?.contains(e.target as Node)) return; + cancel(); + }; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') cancel(); + }; + document.addEventListener('click', handleOutsideClick); + document.addEventListener('keydown', handleEsc); + return () => { + document.removeEventListener('click', handleOutsideClick); + document.removeEventListener('keydown', handleEsc); + }; + }); + + return ( + { + if (!canEdit) return; + setPassword(''); + setError(null); + setIsEditing(true); + }} + readView={Задать новый пароль…} + editor={ +
e.stopPropagation()}> + { + setPassword(e.target.value); + if (error) setError(null); + }} + autoFocus + placeholder="Минимум 8 символов" + aria-label="Новый пароль" + aria-invalid={!!error} + data-testid="input-password" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void apply(); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + cancel(); + } + }} + /> + +
+ } + /> + ); +} + +// ── Дополнительные телефоны / email: строки со ✕ + «+ Добавить» ── +// Список сохраняется одним PATCH /api/users/:id (ключ additionalPhones/additionalEmails) при любом изменении +export function ProfileExtraContactList({ + kind, + items, + userId, + canEdit, +}: { + kind: 'phone' | 'email'; + items: Array<{ value: string; tag: string }>; + userId: number; + canEdit: boolean; +}) { + const save = useSaveUserSystemFields(userId); + const fieldKey = kind === 'phone' ? 'additionalPhones' : 'additionalEmails'; + const label = kind === 'phone' ? 'Доп. телефон' : 'Доп. email'; + const placeholder = kind === 'phone' ? '+7 ...' : 'email@example.com'; + + const [adding, setAdding] = useState(false); + const [newValue, setNewValue] = useState(''); + const [newTag, setNewTag] = useState(CONTACT_TAGS[0]); + + const confirmAdd = async () => { + const v = newValue.trim(); + if (!v) return; + const ok = await save({ [fieldKey]: [...items, { value: v, tag: newTag }] }); + if (ok) { + setAdding(false); + setNewValue(''); + setNewTag(CONTACT_TAGS[0]); + } + }; + + return ( + <> + {items.map((item, idx) => ( +
+ {label} + + {item.value} · {item.tag} + + {canEdit && ( + + )} +
+ ))} + {adding ? ( +
+ {label} +
+ setNewValue(e.target.value)} + placeholder={placeholder} + autoFocus + aria-label={label} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void confirmAdd(); + } + if (e.key === 'Escape') { + e.preventDefault(); + setAdding(false); + setNewValue(''); + } + }} + /> + + + +
+
+ ) : ( + canEdit && ( +
+ + +
+ ) + )} + + ); +} + +// ── Read-view значения справочника (поле типа table) ── +function ProfileTableValue({ options, value }: { options: any; value: unknown }) { + const tableId = options?.tableId; + const ids = Array.isArray(value) + ? value.map(String) + : value !== null && value !== undefined && value !== '' + ? [String(value)] + : []; + const { data } = useQuery<{ + table: { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }; + }>({ + queryKey: ['/api/directories', tableId], + enabled: !!tableId && ids.length > 0, + staleTime: 5 * 60 * 1000, + }); + if (ids.length === 0) return —; + const table = data?.table; + if (!table) return Загрузка...; + let col = table.columns?.findIndex((c) => c.name === options?.displayColumn) ?? -1; + if (col < 0) col = 0; + const labels = ids + .map((id) => { + const row = table.rows.find((r) => String(r.id) === id); + return row ? row.values[col] || row.values[0] || `#${id}` : null; + }) + .filter(Boolean); + if (labels.length === 0) return Запись не найдена; + return <>{labels.join(', ')}; +} + +// ── Кастомное поле профиля: per-field редактирование ── +// commit → PATCH /api/users/:id/field-values/:fieldId с optimistic-кэшем profile +export function ProfileCustomFieldRow({ + field, + value, + userId, + users, + canEdit, +}: { + field: ProfileCustomField; + value: unknown; + userId: number; + users: ProfileUserOption[]; + canEdit: boolean; +}) { + const { toast } = useToast(); + const [historyOpen, setHistoryOpen] = useState(false); + const readOnly = !!field.options?.readOnly || !canEdit; + + // Типы без редактора: formula — вычисляемое, button — действие (в профиле не используется) + const hasEditor = !readOnly && field.type !== 'formula' && field.type !== 'button'; + + // Составные редакторы — сохранение по закрытию (клик вне строки); + // мультивыборы/чек-лист/file сохраняют по каждому изменению, закрытие просто выходит + const isCommitOnClose = + field.type === 'geo' || + field.type === 'contact' || + field.type === 'company' || + field.type === 'checklist' || + field.type === 'file' || + ((field.type === 'select' || field.type === 'radio-group' || field.type === 'table') && !!field.allowMultiple); + + const save = async (val: unknown): Promise => { + const key = profileCacheKey(userId); + const prev = queryClient.getQueryData(key); + if (prev) { + const fvs = prev.fieldValues || []; + const idx = fvs.findIndex((fv) => fv.fieldId === field.id); + const next = + idx >= 0 + ? fvs.map((fv, i) => (i === idx ? { ...fv, value: val } : fv)) + : [...fvs, { id: -Date.now(), userId, fieldId: field.id, value: val }]; + queryClient.setQueryData(key, { ...prev, fieldValues: next }); + } + try { + await apiRequest('PATCH', `/api/users/${userId}/field-values/${field.id}`, { value: val }); + queryClient.invalidateQueries({ queryKey: key }); + return true; + } catch (err) { + if (prev) queryClient.setQueryData(key, prev); + const msg = err instanceof Error ? err.message : 'Не удалось сохранить. Проверьте соединение и попробуйте ещё раз.'; + toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); + return false; + } + }; + + const edit = useInlineEdit(value, { + enabled: hasEditor && field.type !== 'checkbox' && field.type !== 'toggle', + commitOnClose: isCommitOnClose, + validate: (v) => { + const empty = + v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0); + if (field.isRequired && empty) return `Поле «${field.name}» обязательно к заполнению`; + return null; + }, + onCommit: (v) => { + void save(v); + }, + }); + + // Кнопка графика истории оценок: доступна всегда (даже при readOnly) + const historyButton = + field.type === 'history-number' ? ( + <> + + + + ) : null; + + // Числовые поля: пустая строка → null, числовая строка → number (для history-number истории) + const commitNumber = () => { + const t = String(edit.draft ?? '').trim(); + if (t === '') { + edit.commitEdit(null); + return; + } + const n = Number(t); + edit.commitEdit(Number.isFinite(n) ? n : t); + }; + + const emptyView = —; + + const renderReadValue = (): React.ReactNode => { + if (value === null || value === undefined || value === '') return emptyView; + switch (field.type) { + case 'select': + case 'radio-group': { + if (field.allowMultiple && Array.isArray(value)) { + const labels = value.map((v) => getSelectOptionLabel(field.options, v)).filter(Boolean); + return labels.length ? labels.join(', ') : emptyView; + } + return getSelectOptionLabel(field.options, value as any) || String(value); + } + case 'checkbox': + case 'toggle': + return value === true || value === 'true' ? 'Да' : 'Нет'; + case 'user': { + const u = users.find((x) => x.id === Number(value)); + return u ? userDisplayName(u) : String(value); + } + case 'date': + case 'datetime': { + try { + const d = new Date(String(value)); + if (!isNaN(d.getTime())) { + return field.type === 'datetime' ? d.toLocaleString('ru-RU') : d.toLocaleDateString('ru-RU'); + } + } catch { + /* показываем как есть */ + } + return String(value); + } + case 'table': + return ; + case 'geo': { + const geo = parseJsonValue(value); + const display = geo?.address || (geo?.lat != null ? `${geo.lat}, ${geo.lng}` : null); + return display || emptyView; + } + case 'contact': { + const c = parseJsonValue(value); + if (!c || typeof c !== 'object') return emptyView; + const line1 = [c.name, c.position].filter(Boolean).join(' '); + const line2 = [...(c.phones || []), ...(c.emails || [])].join(', '); + return [line1, line2].filter(Boolean).join(' · ') || emptyView; + } + case 'checklist': { + if (Array.isArray(value)) { + const total = Array.isArray(field.options) ? field.options.length : value.length; + const done = value.filter((v: any) => v?.checked).length; + return `Выполнено ${done} из ${total}`; + } + return emptyView; + } + case 'file': { + const files = parseFiles(value); + if (!files.length) return emptyView; + return ( + + {files.map((f, i) => ( + + {f.name} + + ))} + + ); + } + case 'formula': + return ( + + {String(value)} + + ); + case 'button': + return ( + + ); + default: + return String(value); + } + }; + + const inputClass = cn( + fieldControlClass('detail'), + 'leading-6', + edit.error && 'border-destructive ring-destructive' + ); + + const renderEditor = (): React.ReactNode => { + switch (field.type) { + // Текст: сохранение по blur и Enter, Esc — отмена + case 'text': + case 'contract-number': + case 'task': + return ( + edit.setDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + autoFocus + aria-label={field.name} + aria-invalid={!!edit.error} + placeholder={field.placeholder} + onBlur={() => edit.commitEdit()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + edit.commitEdit(); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + edit.cancelEdit(); + } + }} + /> + ); + // Число / оценка: сохранение по blur и Enter + case 'number': + case 'history-number': + return ( +
+ edit.setDraft(e.target.value.replace(/\s/g, ''))} + onClick={(e) => e.stopPropagation()} + autoFocus + aria-label={field.name} + aria-invalid={!!edit.error} + placeholder={field.placeholder} + onBlur={commitNumber} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitNumber(); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + edit.cancelEdit(); + } + }} + /> + {historyButton} +
+ ); + // Многострочный текст: сохранение по blur, Esc — отмена + case 'textarea': + case 'rich-text': + return ( + edit.setDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + autoFocus + aria-label={field.name} + aria-invalid={!!edit.error} + placeholder={field.placeholder} + onBlur={() => edit.commitEdit()} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + edit.cancelEdit(); + } + }} + /> + ); + // Дата/время: сохранение по выбору (change), ✕ — очистить, Esc — отмена + case 'date': + case 'datetime': { + const isDateTime = field.type === 'datetime'; + const completeRe = isDateTime ? /^.{16}$/ : /^\d{4}-\d{2}-\d{2}$/; + return ( +
+ { + const v = e.target.value; + edit.setDraft(v); + if (v === '' || completeRe.test(v)) edit.commitEdit(v === '' ? null : v); + }} + onClick={(e) => e.stopPropagation()} + autoFocus + aria-label={field.name} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + edit.commitEdit(); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + edit.cancelEdit(); + } + }} + /> + {edit.draft ? ( + + ) : null} +
+ ); + } + case 'select': + case 'radio-group': { + const opts = normalizeSelectOptions(field.options); + // Мультивыбор: сохранение по каждому переключению, выход — клик вне / Esc + if (field.allowMultiple) { + const selected = Array.isArray(edit.draft) + ? new Set(edit.draft.map((v: unknown) => String(v))) + : edit.draft != null && edit.draft !== '' + ? new Set([String(edit.draft)]) + : new Set(); + return ( +
e.stopPropagation()}> + ({ value: o.value, label: o.label }))} + selected={selected} + onChange={(next) => { + const arr = Array.from(next); + edit.setDraft(arr); + edit.commitEdit(arr, { close: false }); + }} + /> +
+ ); + } + // Одиночный выбор: сохранение сразу по выбору + return ( + + ); + } + // Пользователь: сохранение сразу по выбору + case 'user': + return ( + + ); + // Чек-лист: сохранение по каждому переключению, выход — клик вне / Esc + case 'checklist': { + const items = Array.isArray(field.options) ? field.options : []; + const valueArr = Array.isArray(edit.draft) ? (edit.draft as Array<{ id?: number; label?: string; checked?: boolean }>) : []; + return ( +
e.stopPropagation()}> + {items.map((item: unknown, idx: number) => { + const itemLabel = typeof item === 'string' ? item : String((item as any)?.label ?? (item as any)?.name ?? item); + const checked = valueArr.find((c) => c.id === idx)?.checked ?? false; + return ( + + ); + })} +
+ ); + } + // Компания: выбор подсказки сохраняет сразу, закрытие — сохраняет введённый текст + case 'company': + return ( +
e.stopPropagation()}> + edit.setDraft(val)} + onSelect={(company) => { + edit.setDraft(company?.value ?? ''); + if (company?.value) edit.commitEdit(company.value); + }} + placeholder={field.placeholder} + /> +
+ ); + // Гео: составной редактор, сохранение по закрытию + case 'geo': + return ( +
e.stopPropagation()}> + edit.setDraft(geo)} /> +
+ ); + // Контакт: составной редактор, сохранение по закрытию + case 'contact': + return ( +
e.stopPropagation()}> + edit.setDraft(contact)} /> +
+ ); + case 'table': { + // Мультивыбор строк: сохранение по каждому переключению + if (field.allowMultiple) { + return ( +
e.stopPropagation()}> + { + edit.setDraft(val); + edit.commitEdit(val, { close: false }); + }} + /> +
+ ); + } + // Одиночный выбор строки: сохранение сразу по выбору + return ( +
e.stopPropagation()}> + edit.commitEdit(val)} + /> +
+ ); + } + // Файл: загрузка/удаление сохраняют сразу, закрытие — клик вне / Esc + case 'file': { + const files = parseFiles(edit.draft); + return ( +
e.stopPropagation()}> + {files.map((f, idx) => ( +
+ + {f.name} + + +
+ ))} + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + const formData = new FormData(); + formData.append('file', file); + try { + const res = await fetch('/api/upload', { + method: 'POST', + credentials: 'include', + body: formData, + }); + if (!res.ok) throw new Error('Ошибка загрузки файла'); + const uploaded = await res.json(); + const next = [...files, { url: uploaded.url, name: uploaded.name, size: uploaded.size }]; + edit.setDraft(next); + void save(next); + } catch (err) { + toast({ + title: 'Ошибка загрузки', + description: err instanceof Error ? err.message : 'Не удалось загрузить файл', + variant: 'destructive', + }); + } + }} + /> +
+ ); + } + default: + return ( + edit.setDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + autoFocus + aria-label={field.name} + placeholder={field.placeholder} + onBlur={() => edit.commitEdit()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + edit.commitEdit(); + } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + edit.cancelEdit(); + } + }} + /> + ); + } + }; + + // checkbox/toggle — мгновенное переключение по клику, без входа в режим редактирования + if (field.type === 'checkbox' || field.type === 'toggle') { + const checked = value === true || value === 'true'; + return ( + + void save(e.target.checked)} + onClick={(e) => e.stopPropagation()} + aria-label={field.name} + /> + {checked ? 'Да' : 'Нет'} + + } + /> + ); + } + + return ( + + {renderReadValue()} + {historyButton} + + ) : ( + renderReadValue() + ) + } + editor={renderEditor()} + /> + ); +} diff --git a/client/src/components/user/ContactListEditor.tsx b/client/src/components/user/ContactListEditor.tsx index e298e2a..7ecefbc 100644 --- a/client/src/components/user/ContactListEditor.tsx +++ b/client/src/components/user/ContactListEditor.tsx @@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Plus, Trash2 } from 'lucide-react'; -const CONTACT_TAGS = ['Рабочий', 'Свой', 'Основной']; +export const CONTACT_TAGS = ['Рабочий', 'Свой', 'Основной']; type ContactItem = { value: string; tag: string }; diff --git a/client/src/pages/UserProfilePage.tsx b/client/src/pages/UserProfilePage.tsx index 1262e4b..66221ee 100644 --- a/client/src/pages/UserProfilePage.tsx +++ b/client/src/pages/UserProfilePage.tsx @@ -1,34 +1,28 @@ 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 { 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 { 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 { 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 { 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 { + ProfileSystemTextRow, + ProfileSelectRow, + ProfileToggleRow, + ProfilePasswordRow, + ProfileExtraContactList, + ProfileCustomFieldRow, +} from '@/components/profile/ProfileFieldRow'; import { ProfileNotificationsTab } from '@/components/profile/ProfileNotificationsTab'; import { ProfileOfflineTab } from '@/components/profile/ProfileOfflineTab'; import { ProfileInterfaceTab } from '@/components/profile/ProfileInterfaceTab'; @@ -143,39 +137,6 @@ function delegationState(d: UserDelegation, today: string): 'active' | '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; - function getFieldValue(fieldId: number, values: UserProfileFieldValue[]): any { return values.find((v) => v.fieldId === fieldId)?.value ?? null; } @@ -332,346 +293,23 @@ function HistoryTab({ auditLog }: { auditLog: AuditLogEntry[] }) { ); } -// Поле history-number: обычный ввод значения + кнопка с графиком истории оценок. -// График доступен всегда (просмотр), редактирование значения — как у остальных полей. -// readOnly (options.readOnly): ввод недоступен — только значение текстом и график. -function HistoryNumberFieldInput({ - field, - value, - onChange, - userId, - readOnly, -}: { - field: UserProfileField; - value: any; - onChange: (val: any) => void; - userId: number; - readOnly?: boolean; -}) { - const [historyOpen, setHistoryOpen] = useState(false); +// Карточка раздела профиля (по макету v4): заголовок с разделителем + строки полей +function ProfileCard({ title, children }: { title: string; children: React.ReactNode }) { return ( -
- {readOnly ? ( - {value ?? '—'} - ) : ( - onChange(e.target.value)} - placeholder={field.placeholder} - required={field.isRequired} - /> - )} - - +
+
{title}
+ {children}
); } -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; -}) { - // Поля с options.readOnly — только отображение (редактирование запрещено на сервере) - const readOnly = !!field.options?.readOnly; - if (field.type === 'history-number') { - return ( - - ); - } - if (readOnly) { - return {value === null || value === undefined || value === '' ? '—' : String(value)}; - } - switch (field.type) { - case 'text': - case 'contract-number': - return ( - onChange(e.target.value)} - placeholder={field.placeholder} - required={field.isRequired} - /> - ); - case 'number': - return ( - onChange(e.target.value === '' ? null : Number(e.target.value))} - placeholder={field.placeholder} - required={field.isRequired} - /> - ); - case 'textarea': - return ( -