import { useState, useEffect, useRef, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Link } from 'wouter'; import { Button } from '@/components/ui/button'; import { CreateTaskDialog } from '@/components/CreateTaskDialog'; import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; import { Link2, Table, ExternalLink, PlusCircle, FileText, Upload, X, Download, Clock, Pencil, PencilOff, MapPin, Building2 } from 'lucide-react'; import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; import { addFileToken, openFilePresigned } from '@/lib/fileUtils'; import { normalizeSelectOptions, getSelectOptionLabel } from '@/lib/fieldOptions'; import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; import { fieldControlClass, fieldLabelClass } from '@/components/fields/field-styles'; import { FileViewerModal, detectViewerType } from '@/components/FileViewerModal'; import { SafeHtmlRenderer } from '@/components/SafeHtmlRenderer'; import GeoField, { type GeoValue } from '@/components/GeoField'; import GeoNavigationPicker from '@/components/GeoNavigationPicker'; import { useOfflineSyncContext } from '@/contexts/OfflineSyncContext'; import { saveTask, getTask, getDirectory, saveDirectory } from '@/lib/tasksCache'; import { FORMULA_ERROR, FORMULA_CIRCULAR_ERROR } from '@/lib/formulaEngine'; import CompanyFieldInput, { type DadataCompany } from '@/components/CompanyFieldInput'; import ContractNumberField from '@/components/ContractNumberField'; import ContractNumberDisplay from '@/components/ContractNumberDisplay'; import TableFieldSelect from '@/components/TableFieldSelect'; import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; import TableFieldWithSubdirectory from '@/components/TableFieldWithSubdirectory'; import ContactField from '@/components/ContactField'; import { UserRoleRenderer, UserRoleEditor } from '@/components/fields'; import { TaskFieldSelect } from '@/components/task-form-fields'; import { DocumentTemplateField } from '@/components/fields/DocumentTemplateField'; import { useSkinComponents } from '@/themes/ThemeProvider'; import { isFieldValueEmpty, computeFieldState, buildRequiredConditionContext, buildClientFieldValues, getAllConditionFields, type ClientFormField, } from '@/lib/fieldConditions'; // ── Типы полей/задачи (вынесены из TaskDetail, используются также пресетами представления) ── export interface FormFieldData { id: number; name: string; code: string; type: string; isRequired: boolean; placeholder?: string; options?: unknown; defaultValue?: string; validationRules?: any; requiredRules?: { enabled?: boolean; statusId?: number | "__creation__" | null; logic?: "and" | "or"; conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; } | null; immutableRules?: { enabled?: boolean; statusId?: number | "__creation__" | null; logic?: "and" | "or"; conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; } | null; visibilityRules?: { enabled?: boolean; statusId?: number | "__creation__" | null; logic?: "and" | "or"; conditions?: Array<{ fieldCode: string; operator?: "contains" | "empty" | "filled"; value?: string }>; } | null; tabId?: number | null; linkedFormId?: number | null; taskRelationType?: 'parent' | 'child' | null; buttonActionType?: 'open_url' | 'create_task' | 'generate_document' | null; buttonUrl?: string | null; buttonFormId?: number | null; companyAutofill?: Record; maxFileCount?: number | null; maxFileSizeMB?: number | null; autoProlongation?: boolean; autoProlongationLabel?: string; allowMultiple?: boolean; } export interface TaskData { id: number; title: string; description?: string; currentStatusId: number; assignedTo?: number; createdBy: number; createdAt: string; updatedAt: string; completedAt?: string; isCompleted?: boolean; dueDate?: string; parentTaskId?: number | null; depth?: number; } export interface TaskFieldValueData { id: number; taskId: number; fieldId: number; value: string | number | boolean | null; } // Минимальная форма кэша детали задачи для optimistic-обновления значений полей interface TaskDetailCacheShape { fieldValues?: TaskFieldValueData[]; } // Компонент для отображения значения связанной задачи function LinkedTaskDisplay({ linkedFormId, taskId, relationType }: { linkedFormId: number; taskId: string | number; relationType?: 'parent' | 'child' | null }) { const { isOnline } = useOfflineSyncContext(); const [offlineTask, setOfflineTask] = useState<{ id: number; title: string } | null>(null); const { data: tasksData, isLoading } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({ queryKey: ['/api/forms', linkedFormId, 'tasks'], enabled: !!linkedFormId && !!taskId && isOnline, staleTime: 5 * 60 * 1000, }); // Offline fallback: resolve task title from IDB task-cache useEffect(() => { const numId = Number(taskId); if (!isOnline && !tasksData && numId) { getTask(numId).then(cached => { if (cached?.data && typeof cached.data === 'object') { const d = cached.data as { task?: { id: number; title: string } }; if (d.task) setOfflineTask({ id: d.task.id, title: d.task.title }); } }); } if (isOnline) setOfflineTask(null); }, [isOnline, tasksData, taskId]); if (isLoading && isOnline) return Загрузка...; const liveTask = tasksData?.tasks?.find(t => t.id.toString() === taskId?.toString()); const task = liveTask ?? offlineTask; if (!task) return Задача не найдена; return ( {task.title} {relationType === 'parent' && ( Родитель )} {relationType === 'child' && ( Дочерняя )} ); } // Компонент для отображения значения из справочника type DirectoryData = { table: { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> } }; function TableValueDisplay({ tableId, displayColumn, rowId }: { tableId: number; displayColumn?: string; rowId: string | number | Array }) { const { isOnline } = useOfflineSyncContext(); const [offlineData, setOfflineData] = useState(null); const isArray = Array.isArray(rowId); const rowIds = isArray ? rowId.map(String) : [String(rowId)]; const { data: tableData, isLoading } = useQuery({ queryKey: ['/api/directories', tableId], enabled: !!tableId && rowIds.length > 0 && isOnline, staleTime: 5 * 60 * 1000, }); // Persist to IDB whenever live data arrives useEffect(() => { if (tableData && tableId) { saveDirectory(tableId, tableData).catch(() => {}); } }, [tableData, tableId]); // Load from IDB when offline and live data is unavailable useEffect(() => { if (!isOnline && !tableData && tableId && rowIds.length > 0) { getDirectory(tableId).then(cached => { if (cached) setOfflineData(cached.data as DirectoryData); }).catch(() => {}); } }, [isOnline, tableData, tableId, rowIds]); const effectiveData = tableData ?? offlineData; if (isLoading && !offlineData) return Загрузка...; const table = effectiveData?.table; if (!table) return Таблица не найдена; let displayColumnIndex = table.columns?.findIndex(c => c.name === displayColumn); if (displayColumnIndex === undefined || displayColumnIndex < 0) { displayColumnIndex = 0; } const rows = table.rows || []; const labels = rowIds .map((id) => { const row = rows.find((r) => r.id.toString() === id); if (!row) return null; return row.values[displayColumnIndex] || row.values[0] || `#${id}`; }) .filter(Boolean); if (labels.length === 0) return Запись не найдена; return ( {labels.join(', ')} ); } // Компонент для рендеринга кнопки с действием function ButtonField({ field, taskId, allFieldValues, allFields, }: { field: FormFieldData; taskId?: number; allFieldValues?: Record; allFields?: FormFieldData[]; }) { const [createDialogOpen, setCreateDialogOpen] = useState(false); const { toast } = useToast(); const buttonOptions = (field.options || {}) as { documentTemplateId?: number; generateFormat?: 'docx' | 'pdf' | 'both'; generateReturnTo?: string; relationType?: 'none' | 'parent' | 'child'; copyFields?: Array<{ sourceFieldId: number; targetFieldId: number }>; }; const buildInitialValues = (): Record | undefined => { const copyFields = buttonOptions.copyFields; if (!copyFields || copyFields.length === 0 || !allFieldValues || !allFields) return undefined; const fieldById = new Map(allFields.map(f => [f.id, f])); const initialValues: Record = {}; for (const mapping of copyFields) { const sourceField = fieldById.get(mapping.sourceFieldId); if (!sourceField) continue; const value = allFieldValues[sourceField.code]; if (value === undefined || value === null || value === '') continue; initialValues[`customField_${mapping.targetFieldId}`] = value; } return Object.keys(initialValues).length > 0 ? initialValues : undefined; }; const handleCreated = async (createdTask: { id: number; title: string }) => { const relationType = buttonOptions.relationType; if (!taskId || !relationType || relationType === 'none') return; try { if (relationType === 'parent') { // Новая задача — родительская для текущей await apiRequest('POST', `/api/tasks/${createdTask.id}/relations`, { relatedTaskId: taskId, type: 'child', }); } else if (relationType === 'child') { // Новая задача — дочерняя для текущей await apiRequest('POST', `/api/tasks/${taskId}/relations`, { relatedTaskId: createdTask.id, type: 'child', }); } queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'related'] }); queryClient.invalidateQueries({ queryKey: ['/api/tasks', createdTask.id, 'related'] }); } catch (error: any) { toast({ title: 'Связь не создана', description: error.message || 'Не удалось связать задачи', variant: 'destructive', }); } }; const handleButtonClick = () => { if (field.buttonActionType === 'open_url' && field.buttonUrl) { window.open(field.buttonUrl, '_blank', 'noopener,noreferrer'); } else if (field.buttonActionType === 'create_task' && field.buttonFormId) { setCreateDialogOpen(true); } else if (field.buttonActionType === 'generate_document' && taskId) { const templateId = buttonOptions.documentTemplateId; if (!templateId) return; const format = buttonOptions.generateFormat || 'both'; const returnTo = buttonOptions.generateReturnTo || window.location.href; const url = `/api/documents/generate-link?templateId=${templateId}&taskId=${taskId}&format=${format}&returnTo=${encodeURIComponent(returnTo)}`; window.location.href = url; } }; const isConfigured = field.buttonActionType && ( (field.buttonActionType === 'open_url' && field.buttonUrl) || (field.buttonActionType === 'create_task' && field.buttonFormId) || (field.buttonActionType === 'generate_document' && taskId && buttonOptions.documentTemplateId) ); return (
{field.buttonActionType === 'create_task' && field.buttonFormId && ( )}
); } // Типы полей, поддерживающие inline-редактирование const INLINE_EDITABLE_TYPES = new Set(['text', 'number', 'textarea', 'rich-text', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'company', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'task']); export interface InlineUserOption { id: number; fullName?: string | null; firstName?: string | null; lastName?: string | null; middleName?: string | null; email: string; } // Вспомогательная функция: форматирование размера файла function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} Б`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`; return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`; } type FileInfo = { url: string; name: string; size?: number }; // Безопасное преобразование значения в строку (защита от null/undefined/объектов) function safeString(value: unknown): string { if (value === null || value === undefined) return ''; if (typeof value === 'string') return value; if (typeof value === 'number' || typeof value === 'boolean') return String(value); try { return JSON.stringify(value); } catch { return String(value); } } // Парсим значение поля «файл» в массив FileInfo с поддержкой старых форматов function parseFileValue(initialValue: unknown): FileInfo[] { if (!initialValue) return []; // Новый формат: массив объектов if (Array.isArray(initialValue)) { return (initialValue as unknown[]).flatMap((item) => { if (item && typeof item === 'object') { const obj = item as Record; if (typeof obj.url === 'string') { return [{ url: obj.url, name: String(obj.name || obj.url), size: typeof obj.size === 'number' ? obj.size : undefined }]; } } return []; }); } // Старый формат: одиночный объект if (typeof initialValue === 'object') { const obj = initialValue as Record; if (typeof obj.url === 'string') { return [{ url: obj.url, name: String(obj.name || obj.url), size: typeof obj.size === 'number' ? obj.size : undefined }]; } return []; } // Строка (очень старый формат или JSON) if (typeof initialValue === 'string' && initialValue) { try { const parsed: unknown = JSON.parse(initialValue); return parseFileValue(parsed); } catch { return [{ url: initialValue, name: initialValue }]; } } return []; } // Компонент для поля типа «файл» с inline-загрузкой (поддержка нескольких файлов) function FileFieldRow({ field, value: initialValue, taskId, formId, }: { field: FormFieldData; value: unknown; taskId: number; formId?: number; }) { const { toast } = useToast(); const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const files = parseFileValue(initialValue); const [viewerFile, setViewerFile] = useState<{ url: string; name: string } | null>(null); const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (field.maxFileCount != null && files.length >= field.maxFileCount) { toast({ title: 'Превышен лимит файлов', description: `Максимальное количество файлов для этого поля: ${field.maxFileCount}`, variant: 'destructive', }); if (fileInputRef.current) fileInputRef.current.value = ''; return; } if (field.maxFileSizeMB != null) { const currentTotalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0); const limitBytes = field.maxFileSizeMB * 1024 * 1024; if (currentTotalBytes + file.size > limitBytes) { toast({ title: 'Превышен лимит размера', description: `Суммарный размер файлов не должен превышать ${field.maxFileSizeMB} МБ`, variant: 'destructive', }); if (fileInputRef.current) fileInputRef.current.value = ''; return; } } setIsUploading(true); try { const formData = new FormData(); formData.append('file', file); const uploadUrl = `/api/upload?fieldId=${field.id}&taskId=${taskId}`; const uploadRes = await fetch(uploadUrl, { method: 'POST', credentials: 'include', body: formData, }); if (!uploadRes.ok) { const errData = await uploadRes.json().catch(() => ({})); throw new Error(errData.error || 'Ошибка загрузки файла'); } const uploaded = await uploadRes.json(); const newFiles = [...files, { url: uploaded.url, name: uploaded.name, size: uploaded.size }]; await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { value: newFiles, }); queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); toast({ title: 'Файл загружен', description: uploaded.name }); } catch (err: unknown) { if (err instanceof OfflineQueuedError) return; const msg = err instanceof Error ? err.message : 'Не удалось загрузить файл. Проверьте соединение и попробуйте ещё раз.'; toast({ title: 'Ошибка загрузки', description: msg, variant: 'destructive' }); } finally { setIsUploading(false); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const handleRemoveFile = async (index: number) => { try { const newFiles = files.filter((_, i) => i !== index); await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { value: newFiles.length > 0 ? newFiles : null, }); queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); toast({ title: 'Файл удалён' }); } catch (err: unknown) { if (err instanceof OfflineQueuedError) return; const msg = err instanceof Error ? err.message : 'Не удалось удалить файл. Проверьте соединение и попробуйте ещё раз.'; toast({ title: 'Ошибка удаления', description: msg, variant: 'destructive' }); } }; return (
{field.name}:{' '} {field.maxFileCount != null && ( ({files.length}/{field.maxFileCount}) )} {isUploading ? ( Загрузка... ) : files.length > 0 ? ( <>
{files.map((fileInfo, idx) => { const vType = detectViewerType(fileInfo.url, fileInfo.name); const canPreview = vType === 'pdf' || vType === 'word' || vType === 'excel' || vType === 'powerpoint' || vType === 'video' || vType === 'audio' || vType === 'image'; return ( {canPreview ? ( ) : ( { e.preventDefault(); openFilePresigned(fileInfo.url); }} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-sm text-primary hover:underline min-w-0" data-testid={`file-link-${field.code}-${idx}`} > {fileInfo.name} {fileInfo.size !== undefined && ( ({formatFileSize(fileInfo.size)}) )} )} ); })}
) : ( Нет файла )}
{viewerFile && ( setViewerFile(null)} /> )}
); } // Вспомогательная функция: получить значение из данных DaData по ключу function getDadataFieldValue(key: string, company: DadataCompany): string { switch (key) { case 'name.full_with_opf': return company.data.name.full_with_opf || ''; case 'name.short_with_opf': return company.data.name.short_with_opf || ''; case 'inn': return company.data.inn || ''; case 'kpp': return company.data.kpp || ''; case 'ogrn': return company.data.ogrn || ''; case 'management.name': return company.data.management?.name || ''; case 'management.post': return company.data.management?.post || ''; default: return ''; } } // Сравнение значений полей: null/undefined/''/false считаем пустыми и равными друг другу function fieldValuesEqual(a: unknown, b: unknown): boolean { if (a === b) return true; const isEmpty = (v: unknown) => v === null || v === undefined || v === '' || v === false; if (isEmpty(a) && isEmpty(b)) return true; return JSON.stringify(a) === JSON.stringify(b); } // Клик по интерактивному элементу внутри read-view (ссылка, кнопка, контрол) — не открываем редактор export function isInteractiveClickTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; return !!target.closest('a, button, input, select, textarea, [role="button"], [role="checkbox"], [role="listbox"]'); } // Клик пришёл из портала Radix (выпадающие списки редакторов) — не считаем «кликом снаружи» export function isRadixPortalTarget(target: EventTarget | null): boolean { if (!(target instanceof Element)) return false; return !!target.closest('[data-radix-popper-content-wrapper], [role="listbox"], [data-radix-select-viewport], [data-radix-menu-content], [data-radix-popover-content]'); } export interface FieldValueRowProps { field: FormFieldData; value: string | number | boolean | null | undefined; task?: TaskData; taskId: number; formId?: number; users: InlineUserOption[]; roles?: Array<{ id: number; name: string }>; allFieldValues?: Record; allFields?: FormFieldData[]; statuses?: Array<{ id: number; position: number; isFinal?: boolean | null }>; isPending?: (url: string) => boolean; readOnly?: boolean; errorMessage?: string; onErrorClear?: () => void; // Позиция label: 'inline' — в строку (как сейчас), 'top' — над значением. // Если не задана — берётся из скина (Тема 2.0, components.label.position; дефолт 'inline'). labelPosition?: 'inline' | 'top'; // Дополнительные классы для label и строки (используются пресетами представления) labelClassName?: string; rowClassName?: string; } // Компонент для отображения одного поля с per-field inline-редактированием: // клик по значению открывает редактор только этой строки, сохранение — PATCH одного поля. export function FieldValueRow({ field, value: initialValue, task, taskId, formId, users, roles, allFieldValues, allFields, statuses, isPending, readOnly, errorMessage, onErrorClear, labelPosition, labelClassName, rowClassName, }: FieldValueRowProps) { const { toast } = useToast(); const skinComponents = useSkinComponents(); // Позиция label: проп пресета важнее настройки скина; дефолт скина — 'inline' (текущий вид) const isLabelTop = (labelPosition ?? skinComponents.label.position) === 'top'; // Вычисляем условное состояние поля (required/immutable/visible) для отображения меток и валидации const fieldState = useMemo(() => { if (!allFields) return { required: false, immutable: false, visible: true }; const fieldValuesArray = allFields.map((f) => ({ fieldId: f.id, value: allFieldValues?.[f.code] })); const allConditionFields = getAllConditionFields(allFields as ClientFormField[]); const context = buildRequiredConditionContext(users as any, roles || [], false, statuses); const taskForConditions = task ?? null; return computeFieldState( field as ClientFormField, taskForConditions, buildClientFieldValues(taskForConditions, fieldValuesArray, statuses), allConditionFields, context ); }, [field, task, allFieldValues, allFields, users, roles, statuses]); const isEditable = INLINE_EDITABLE_TYPES.has(field.type); const [navGeo, setNavGeo] = useState(null); const wrapperRef = useRef(null); // Двойное касание на мобильных — вход в редактирование (как до per-field рефакторинга) const lastTapRef = useRef(0); const handleDoubleTap = (e: React.TouchEvent) => { if (!canEdit) return; if (isInteractiveClickTarget(e.target)) return; const now = Date.now(); if (now - lastTapRef.current < 300) { e.stopPropagation(); enterEdit(); } lastTapRef.current = now; }; // Per-field режим редактирования: локальное состояние только этой строки const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(initialValue); const editValueRef = useRef(initialValue); const [localError, setLocalError] = useState(null); // Очередь обновлений autofill-полей, которые нужно сохранить вместе с основным полем const pendingAutofillRef = useRef>([]); // Флаги составных редакторов (сохранение по закрытию — клик вне строки) const isProlongationDate = (field.type === 'date' || field.type === 'datetime') && !!field.autoProlongation; const tableOpts = field.type === 'table' && field.options && !Array.isArray(field.options) ? (field.options as Record) : {}; const isTableSubdirectory = field.type === 'table' && !!tableOpts.showSubdirectory; const isCommitOnClose = field.type === 'geo' || field.type === 'contact' || field.type === 'contract-number' || field.type === 'company' || isProlongationDate || isTableSubdirectory; const canEdit = isEditable && !readOnly && !fieldState.immutable; // Пока поле не редактируется — синхронизируем локальный черновик со значением с сервера useEffect(() => { if (!isEditing) { editValueRef.current = initialValue; setEditValue(initialValue); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValue, isEditing]); const updateEditValue = (val: unknown) => { editValueRef.current = val; setEditValue(val); if (localError) setLocalError(null); }; const enterEdit = () => { if (isEditing || !isEditable || readOnly) return; if (fieldState.immutable) { toast({ title: 'Редактирование недоступно', description: `Поле «${field.name}» не может быть изменено в текущем статусе`, variant: 'destructive', }); return; } editValueRef.current = initialValue; setEditValue(initialValue); setLocalError(null); setIsEditing(true); }; const cancelEdit = () => { setIsEditing(false); setLocalError(null); // Сбрасываем очередь autofill-затирания (company onClear) — иначе она «утечёт» в следующее сохранение pendingAutofillRef.current = []; editValueRef.current = initialValue; setEditValue(initialValue); }; // Сохранение значения: required-валидация прямо в строке, затем PATCH одного поля const commitValue = (val: unknown, opts?: { close?: boolean }) => { const close = opts?.close !== false; if (fieldState.required && isFieldValueEmpty(val, field.type)) { setLocalError(`Поле «${field.name}» обязательно к заполнению`); return; } setLocalError(null); if (!fieldValuesEqual(val, initialValue)) { saveValue(val as string | number | boolean | Record | null | undefined); } else { // Значение не изменилось — сохранение пропускаем, autofill-очередь затираем pendingAutofillRef.current = []; } if (close) setIsEditing(false); }; // Клик вне строки закрывает редактор: составные редакторы сохраняются, остальные отменяются 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 (isCommitOnClose) { commitValue(editValueRef.current); } else { cancelEdit(); } }; document.addEventListener('click', handleOutsideClick); return () => document.removeEventListener('click', handleOutsideClick); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditing, isCommitOnClose]); // Esc — отмена редактирования без сохранения (для редакторов без собственной обработки) useEffect(() => { if (!isEditing) return; const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') cancelEdit(); }; document.addEventListener('keydown', handleEsc); return () => document.removeEventListener('keydown', handleEsc); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isEditing]); const saveValue = async (val: string | number | boolean | Record | null | undefined) => { // Optimistic-обновление кэша детали задачи: read-view меняется мгновенно, без мерцания const prevDetail = queryClient.getQueryData(['/api/tasks', taskId, 'detail']); if (prevDetail) { const prevFieldValues = prevDetail.fieldValues || []; const idx = prevFieldValues.findIndex(fv => fv.fieldId === field.id); const nextEntry: TaskFieldValueData = idx >= 0 ? { ...prevFieldValues[idx], value: val as string | number | boolean | null } : { id: -Date.now(), taskId, fieldId: field.id, value: val as string | number | boolean | null }; const nextFieldValues = idx >= 0 ? prevFieldValues.map((fv, i) => (i === idx ? nextEntry : fv)) : [...prevFieldValues, nextEntry]; const merged = { ...prevDetail, fieldValues: nextFieldValues }; queryClient.setQueryData(['/api/tasks', taskId, 'detail'], merged); saveTask(taskId, merged).catch(() => {}); } try { await apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${field.id}`, { value: val }); // Сохраняем pending autofill-поля (при выборе/очистке компании) const pendingUpdates = pendingAutofillRef.current; if (pendingUpdates.length > 0) { await Promise.all( pendingUpdates.map(({ fieldId, value }) => apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${fieldId}`, { value }) ) ); pendingAutofillRef.current = []; } queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'audit-log'] }); onErrorClear?.(); } catch (err: unknown) { if (err instanceof OfflineQueuedError) { // Основное поле поставлено в офлайн-очередь — ставим туда же autofill-поля const pendingUpdates = pendingAutofillRef.current; pendingAutofillRef.current = []; if (pendingUpdates.length > 0) { await Promise.allSettled( pendingUpdates.map(({ fieldId, value }) => apiRequest('PATCH', `/api/tasks/${taskId}/field-values/${fieldId}`, { value }) ) ); } return; } // Ошибка сохранения — откатываем optimistic-значение перезапросом детали queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); const msg = err instanceof Error ? err.message : 'Не удалось сохранить. Проверьте соединение и попробуйте ещё раз.'; toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); } }; // Отображение читаемого значения (без редактора) const renderReadValue = () => { // Формула — вычисляемое read-only поле с жёлтым фоном if (field.type === 'formula') { // The resolved value is already pre-computed (with topological ordering) in allFieldValues const precomputed = allFieldValues ? allFieldValues[field.code] : undefined; const result: string | number = (precomputed !== null && precomputed !== undefined) ? (precomputed as string | number) : ''; const isCircular = result === FORMULA_CIRCULAR_ERROR; const isError = result === FORMULA_ERROR || isCircular; return ( {isError ? ( {String(result)} ) : result === '' ? ( — ) : ( {String(result)} )} ); } if (initialValue === null || initialValue === undefined || initialValue === '') { return —; } if (field.type === 'task' && field.linkedFormId) { const taskIdVal = typeof initialValue === 'boolean' ? String(initialValue) : initialValue; return ( ); } if (field.type === 'table' && field.options) { const opts = field.options as { tableId?: number; displayColumn?: string }; if (opts.tableId) { const rowIdVal = typeof initialValue === 'boolean' ? String(initialValue) : initialValue; return ( ); } } if (field.type === 'company') { return ( {initialValue.toString()} ); } if (field.type === 'geo' && initialValue) { let geo: any; try { geo = typeof initialValue === 'string' ? JSON.parse(initialValue) : initialValue; } catch { geo = null; } const display = geo?.address || `${geo?.lat || ''}, ${geo?.lng || ''}` || null; if (!display) return —; return ( { e.preventDefault(); setNavGeo(geo); }} className="text-primary hover:underline inline-flex items-center gap-1 cursor-pointer" > {display} ); } if (field.type === 'checkbox' || field.type === 'toggle') { const checked = initialValue === true || initialValue === 'true'; return ( {checked ? 'Да' : 'Нет'} ); } if (field.type === 'checklist' && Array.isArray(initialValue)) { const items = Array.isArray(field.options) ? field.options : []; const checkedCount = initialValue.filter((v: any) => v?.checked).length; const totalCount = items.length; return ( Выполнено {checkedCount} из {totalCount} ); } if (field.type === 'date' || field.type === 'datetime') { if (initialValue === '__auto_prolongation__') { return {field.autoProlongationLabel || 'Автопролонгация'}; } if (initialValue) { try { const date = new Date(initialValue.toString()); if (!isNaN(date.getTime())) { return {date.toLocaleDateString('ru-RU')}; } } catch (_e) { /* показываем как есть */ } } } if (field.type === 'user') { return ( ); } if (field.type === 'select' || field.type === 'radio-group') { return {getSelectOptionLabel(field.options, initialValue) || initialValue.toString()}; } if (field.type === 'textarea' || field.type === 'rich-text') { return (
); } if (field.type === 'geo') { if (!initialValue) return —; const geo = initialValue as GeoValue; const lat = typeof geo.lat === 'string' ? parseFloat(geo.lat) : geo.lat; const lng = typeof geo.lng === 'string' ? parseFloat(geo.lng) : geo.lng; const display = geo.address || `${lat}, ${lng}`; return ( { e.preventDefault(); setNavGeo(geo); }} className="inline-flex items-center gap-1 text-primary hover:underline cursor-pointer" data-testid={`text-custom-field-${field.code}`} > {display} ); } if (field.type === 'contract-number' && initialValue) { return ( ); } if (field.type === 'contact' && initialValue) { let parsed: any; try { parsed = typeof initialValue === 'string' ? JSON.parse(initialValue) : initialValue; } catch { parsed = null; } if (!parsed?.name && !parsed?.position && !parsed?.phones?.length && !parsed?.emails?.length) { return —; } const line1 = [parsed.name, parsed.position].filter(Boolean).join(' '); const line2Parts: string[] = []; if (parsed.phones?.length) line2Parts.push(parsed.phones.join(', ')); if (parsed.emails?.length) line2Parts.push(parsed.emails.join(', ')); const line2 = line2Parts.join(' | '); return (
{line1 && {line1}} {line2 && {line2}}
); } return {safeString(initialValue)}; }; // Рендер редактора по типу поля (per-field: сохранение по blur/Enter/выбору/закрытию) const renderEditor = () => { // Единый стиль контрола карточки задачи (detail-вариант дизайн-системы полей) const inputClass = cn( fieldControlClass('detail'), 'leading-6', effectiveError && 'border-destructive ring-destructive' ); const textareaClass = cn( 'border border-input rounded px-1.5 py-0.5 text-base md:text-[13px] bg-background focus:outline-none focus:ring-1 focus:ring-ring w-auto min-w-[200px] max-w-[420px]', effectiveError && 'border-destructive ring-destructive' ); // Текст и число: сохранение по blur и Enter, Esc — отмена if (field.type === 'text' || field.type === 'number') { return ( updateEditValue(field.type === 'number' ? e.target.value.replace(/\s/g, '') : e.target.value)} onClick={e => e.stopPropagation()} autoFocus aria-label={field.name} aria-invalid={!!effectiveError} aria-describedby={effectiveError ? fieldErrorId : undefined} onBlur={() => commitValue(editValueRef.current)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } }} /> ); } // Многострочный текст: сохранение по blur, Esc — отмена (Enter — новая строка) if (field.type === 'textarea' || field.type === 'rich-text') { return ( updateEditValue(e.target.value)} onClick={e => e.stopPropagation()} autoFocus aria-label={field.name} aria-invalid={!!effectiveError} aria-describedby={effectiveError ? fieldErrorId : undefined} placeholder={field.type === 'rich-text' ? 'HTML: жирный, курсив,
  • список
' : undefined} onBlur={() => commitValue(editValueRef.current)} onKeyDown={e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } }} /> ); } // Дата/время без автопролонгации: сохранение по выбору (change), ✕ — очистить, Esc — отмена if ((field.type === 'date' || field.type === 'datetime') && !field.autoProlongation) { const isDateTime = field.type === 'datetime'; const completeRe = isDateTime ? /^.{16}$/ : /^\d{4}-\d{2}-\d{2}$/; return (
{ const v = e.target.value; updateEditValue(v); if (v === '' || completeRe.test(v)) commitValue(v); }} onClick={e => e.stopPropagation()} autoFocus aria-label={field.name} aria-invalid={!!effectiveError} aria-describedby={effectiveError ? fieldErrorId : undefined} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } }} /> {editValue ? ( ) : null}
); } // Дата/время с автопролонгацией: составной редактор, сохранение по закрытию (клик вне / Enter) if (field.type === 'date' || field.type === 'datetime') { const isProlongation = editValue === '__auto_prolongation__'; return (
e.stopPropagation()}> updateEditValue(e.target.value)} autoFocus disabled={isProlongation} aria-label={field.name} aria-invalid={!!effectiveError} aria-describedby={effectiveError ? fieldErrorId : undefined} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } }} />
); } if (field.type === 'select' || field.type === 'radio-group') { const opts = normalizeSelectOptions(field.options); // Мультивыбор: сохранение по каждому переключению, выход — клик вне / Esc if (field.allowMultiple) { const selected = Array.isArray(editValue) ? new Set(editValue.map((v: unknown) => String(v))) : editValue != null && editValue !== '' ? new Set([String(editValue)]) : new Set(); return (
e.stopPropagation()}> ({ value: o.value, label: o.label }))} selected={selected} onChange={(next) => { const arr = Array.from(next); updateEditValue(arr); commitValue(arr, { close: false }); }} />
); } // Одиночный выбор: сохранение сразу по выбору return ( ); } // Чек-лист: сохранение по каждому переключению, выход — клик вне / Esc if (field.type === 'checklist') { const items = Array.isArray(field.options) ? field.options : []; const valueArr = Array.isArray(editValue) ? (editValue as Array<{ itemId?: string; id?: string; checked?: boolean }>) : []; return (
e.stopPropagation()}> {items.map((item: unknown, idx: number) => { const it = item as { id?: string; value?: string; label?: string; name?: string; period?: string }; const itemId = it.id || it.value || String(idx); const itemLabel = it.label || it.name || String(item); const checkedItem = valueArr.find((v) => (v.itemId || v.id) === itemId); const isChecked = !!checkedItem?.checked; return ( ); })}
); } // Пользователь/роль: сохранение сразу по выбору (radix Select); // мультивыбор — сохранение по каждому переключению, выход — клик вне / Esc if (field.type === 'user') { return (
e.stopPropagation()}> { if (field.allowMultiple) updateEditValue(val); commitValue(val, { close: !field.allowMultiple }); }} users={users} roles={roles} autoOpen />
); } // Гео: составной редактор, сохранение по закрытию if (field.type === 'geo') { return (
e.stopPropagation()}> updateEditValue(val)} />
); } // Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст if (field.type === 'company') { return (
e.stopPropagation()}> updateEditValue(val)} onSelect={company => { updateEditValue(company.value); if (field.companyAutofill && allFields) { pendingAutofillRef.current = Object.entries(field.companyAutofill) .filter(([, code]) => code && code !== 'none') .flatMap(([dadataKey, code]) => { const targetField = allFields.find(f => f.code === code); return targetField ? [{ fieldId: targetField.id, value: getDadataFieldValue(dadataKey, company) }] : []; }); } commitValue(company.value); }} onClear={() => { if (field.companyAutofill && allFields) { pendingAutofillRef.current = Object.values(field.companyAutofill) .filter(code => code && code !== 'none') .flatMap(code => { const targetField = allFields.find(f => f.code === code); return targetField ? [{ fieldId: targetField.id, value: '' }] : []; }); } }} />
); } // Номер договора: составной редактор, сохранение по закрытию if (field.type === 'contract-number') { const cnConfig = (field.options as any)?.contractNumber; return (
e.stopPropagation()}> updateEditValue(JSON.stringify(val))} config={cnConfig} formId={formId} fieldId={field.id} compact />
); } if (field.type === 'table') { const showSubdirectory = isTableSubdirectory; // Подсправочник: составной редактор, сохранение по закрытию if (showSubdirectory) { return (
e.stopPropagation()}> updateEditValue(val)} allowMultiple={field.allowMultiple || false} size="detail" />
); } // Мультивыбор строк: сохранение по каждому переключению if (field.allowMultiple) { return (
e.stopPropagation()}> { updateEditValue(val); commitValue(val, { close: false }); }} />
); } // Одиночный выбор строки: сохранение сразу по выбору return (
e.stopPropagation()}> commitValue(val)} />
); } // Контакт: составной редактор, сохранение по закрытию if (field.type === 'contact') { return (
e.stopPropagation()}> updateEditValue(JSON.stringify(val))} />
); } // Связанная задача: сохранение сразу по выбору if (field.type === 'task' && field.linkedFormId) { return (
e.stopPropagation()}> commitValue(val)} showCreateButton={false} size="detail" />
); } return null; }; // Кнопка — всегда read-only (своя логика действий) if (field.type === 'button') { return ; } // Файл — своя логика загрузки if (field.type === 'file') { return ; } // Шаблон документа — выбор шаблона и генерация if (field.type === 'document_template') { return ( saveValue(val)} readOnly={readOnly} /> ); } const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`; const isFieldPending = isPending ? isPending(fieldUrl) : false; const effectiveError = errorMessage || localError; const fieldErrorId = `fv-error-${field.id}`; // checkbox/toggle — мгновенное переключение по клику, без входа в режим редактирования const isInstantToggle = field.type === 'checkbox' || field.type === 'toggle'; const editorNode = isEditing && !isInstantToggle ? renderEditor() : null; // Значение поля: мгновенный переключатель / редактор / read-view (общий для обеих раскладок label) const valueNode = isInstantToggle ? ( ) : isEditing && editorNode != null ? (
{editorNode}
) : (
{ if (!canEdit) return; if (isInteractiveClickTarget(e.target)) return; e.stopPropagation(); enterEdit(); }} onTouchEnd={handleDoubleTap} > {renderReadValue()}
); return ( <>
{/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */}
{isFieldPending ? ( ) : fieldState.immutable ? ( ) : fieldState.required ? ( * ) : ( /* Карандаш: на мобильном всегда виден, на десктопе — по hover строки или фокусу */ isEditable && !readOnly && !isEditing && ( ) )}
{/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */}
{isLabelTop ? ( <> {/* Вертикальная раскладка (label.position === 'top'): label-ряд сверху h-5, значение снизу */}
{field.name}
{valueNode}
) : (
{field.name}: {valueNode}
)} {isFieldPending && ( ожидает )} {effectiveError && ( {effectiveError} )}
{ if (!open) setNavGeo(null); }} /> ); }