From 8a8c798eb7a3d0b12fe1cea8bdbbc9209261f5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Mon, 3 Aug 2026 17:17:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(TaskDetail):=20per-field=20inline-=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Клик по полю редактирует только его; автосохранение по blur/Enter/выбору, Esc — отмена - Одиночный PATCH /api/tasks/:id/field-values/:fieldId + optimistic-кэш + офлайн-очередь - Строка поля: фиксированные метрики read=edit (min-h 32/40px), редактор в потоке — ноль layout shift - Удалены глобальный edit-режим, draft-машинерия, batch-сохранение и StickyActionBar - Удалён мёртвый код components/task-detail/ (NotionFieldRow, useInlineEdit, useDirtyFields) - Новый редактор checklist; составные редакторы сохраняют по закрытию - Полировка: hover-подсветка, карандаш по hover, fade-in, семантические токены, тач-таргеты 40px --- .../components/task-detail/NotionFieldRow.tsx | 107 -- .../task-detail/StickyActionBar.tsx | 74 -- .../components/task-detail/useDirtyFields.ts | 34 - .../components/task-detail/useInlineEdit.ts | 71 - client/src/pages/TaskDetail.tsx | 1151 +++++++++-------- 5 files changed, 633 insertions(+), 804 deletions(-) delete mode 100644 client/src/components/task-detail/NotionFieldRow.tsx delete mode 100644 client/src/components/task-detail/StickyActionBar.tsx delete mode 100644 client/src/components/task-detail/useDirtyFields.ts delete mode 100644 client/src/components/task-detail/useInlineEdit.ts diff --git a/client/src/components/task-detail/NotionFieldRow.tsx b/client/src/components/task-detail/NotionFieldRow.tsx deleted file mode 100644 index 1824375..0000000 --- a/client/src/components/task-detail/NotionFieldRow.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useRef, useCallback } from 'react'; -import { cn } from '@/lib/utils'; -import { Pencil } from 'lucide-react'; - -interface NotionFieldRowProps { - label: string; - required?: boolean; - editable?: boolean; - isEditing?: boolean; - className?: string; - children: React.ReactNode; - editor?: React.ReactNode; - onEnterEdit?: () => void; - showEditHint?: boolean; -} - -const DOUBLE_TAP_MS = 300; - -function isInteractiveTarget(target: EventTarget | null): boolean { - if (!(target instanceof Element)) return false; - const tag = target.tagName.toLowerCase(); - if (tag === 'input' || tag === 'textarea' || tag === 'select' || tag === 'button' || tag === 'a') return true; - if (target.closest('button, a, input, textarea, select, [role="button"], [role="listbox"]')) return true; - return false; -} - -export function NotionFieldRow({ - label, - required = false, - editable = true, - isEditing = false, - className, - children, - editor, - onEnterEdit, - showEditHint = true, -}: NotionFieldRowProps) { - const rowRef = useRef(null); - const lastTapRef = useRef(0); - - const handleDoubleClick = useCallback((e: React.MouseEvent) => { - if (!editable || isEditing) return; - if (isInteractiveTarget(e.target)) return; - e.stopPropagation(); - onEnterEdit?.(); - }, [editable, isEditing, onEnterEdit]); - - const handleTouchEnd = useCallback((e: React.TouchEvent) => { - if (!editable || isEditing) return; - if (isInteractiveTarget(e.target)) return; - const now = Date.now(); - if (now - lastTapRef.current < DOUBLE_TAP_MS) { - e.stopPropagation(); - onEnterEdit?.(); - } - lastTapRef.current = now; - }, [editable, isEditing, onEnterEdit]); - - return ( -
- {/* Label column — right-aligned on desktop */} -
- {required && ( - * - )} - - {label} - -
- - {/* Value column */} -
- {isEditing && editor ? ( -
e.stopPropagation()}> - {editor} -
- ) : ( -
-
- {children} -
- {editable && showEditHint && ( - - )} -
- )} -
-
- ); -} diff --git a/client/src/components/task-detail/StickyActionBar.tsx b/client/src/components/task-detail/StickyActionBar.tsx deleted file mode 100644 index c62587d..0000000 --- a/client/src/components/task-detail/StickyActionBar.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { Save, X, Paperclip } from 'lucide-react'; - -interface StickyActionBarProps { - visible: boolean; - isSaving?: boolean; - onSave: () => void; - onCancel: () => void; - onAttach?: () => void; -} - -export function StickyActionBar({ - visible, - isSaving = false, - onSave, - onCancel, - onAttach, -}: StickyActionBarProps) { - if (!visible) return null; - - return ( -
-
- {/* Left: Attach button */} -
- {onAttach && ( - - )} - - Есть несохраненные изменения - -
- - {/* Right: Cancel + Save */} -
- - -
-
-
- ); -} diff --git a/client/src/components/task-detail/useDirtyFields.ts b/client/src/components/task-detail/useDirtyFields.ts deleted file mode 100644 index 0ea6fd9..0000000 --- a/client/src/components/task-detail/useDirtyFields.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useState, useCallback } from 'react'; - -export function useDirtyFields() { - const [dirtyFields, setDirtyFields] = useState>(new Set()); - - const markDirty = useCallback((fieldId: number) => { - setDirtyFields(prev => { - if (prev.has(fieldId)) return prev; - return new Set(prev).add(fieldId); - }); - }, []); - - const clearDirty = useCallback((fieldId: number) => { - setDirtyFields(prev => { - const next = new Set(prev); - next.delete(fieldId); - return next; - }); - }, []); - - const clearAllDirty = useCallback(() => { - setDirtyFields(new Set()); - }, []); - - const isDirty = dirtyFields.size > 0; - - return { - dirtyFields, - isDirty, - markDirty, - clearDirty, - clearAllDirty, - }; -} diff --git a/client/src/components/task-detail/useInlineEdit.ts b/client/src/components/task-detail/useInlineEdit.ts deleted file mode 100644 index 2bd7035..0000000 --- a/client/src/components/task-detail/useInlineEdit.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useState, useCallback, useRef } from 'react'; - -interface UseInlineEditOptions { - initialValue: T; - onSave: (value: T) => void | Promise; -} - -export function useInlineEdit({ initialValue, onSave }: UseInlineEditOptions) { - const [isEditing, setIsEditing] = useState(false); - const [draftValue, setDraftValue] = useState(initialValue); - const [isSaving, setIsSaving] = useState(false); - const originalValueRef = useRef(initialValue); - const isDirtyRef = useRef(false); - - const enterEdit = useCallback(() => { - setDraftValue(originalValueRef.current); - isDirtyRef.current = false; - setIsEditing(true); - }, []); - - const exitEdit = useCallback(() => { - setIsEditing(false); - setDraftValue(originalValueRef.current); - isDirtyRef.current = false; - }, []); - - const saveEdit = useCallback(async () => { - if (!isDirtyRef.current) { - setIsEditing(false); - return; - } - setIsSaving(true); - try { - await onSave(draftValue); - originalValueRef.current = draftValue; - isDirtyRef.current = false; - setIsEditing(false); - } catch (err) { - // Error handled by caller (toast) - throw err; - } finally { - setIsSaving(false); - } - }, [draftValue, onSave]); - - const updateDraft = useCallback((value: T) => { - setDraftValue(value); - isDirtyRef.current = true; - }, []); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - saveEdit(); - } else if (e.key === 'Escape') { - exitEdit(); - } - }, [saveEdit, exitEdit]); - - return { - isEditing, - draftValue, - isSaving, - isDirty: () => isDirtyRef.current, - setDraftValue: updateDraft, - enterEdit, - exitEdit, - saveEdit, - handleKeyDown, - }; -} diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 0da9903..e5176e3 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -50,16 +50,12 @@ import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; import TableFieldWithSubdirectory from '@/components/TableFieldWithSubdirectory'; import ContactField from '@/components/ContactField'; import { UserRoleRenderer, UserRoleEditor } from '@/components/fields'; -import { InlineField } from '@/components/fields/InlineField'; import { TaskFieldSelect } from '@/components/task-form-fields'; -import { StickyActionBar } from '@/components/task-detail/StickyActionBar'; -import { useDirtyFields } from '@/components/task-detail/useDirtyFields'; import { TaskDocumentButton } from '@/components/tasks/TaskDocumentButton'; import { TaskQrButton } from '@/components/TaskQrButton'; import { DocumentTemplateField } from '@/components/fields/DocumentTemplateField'; import { - validateRequiredFields, - validateRequiredFieldsDetailed, + isFieldValueEmpty, computeFieldState, buildRequiredConditionContext, buildClientFieldValues, @@ -641,7 +637,28 @@ function getDadataFieldValue(key: string, company: DadataCompany): string { } } -// Компонент для отображения одного поля с поддержкой inline-редактирования +// Сравнение значений полей: 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 (ссылка, кнопка, контрол) — не открываем редактор +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 (выпадающие списки редакторов) — не считаем «кликом снаружи» +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]'); +} + +// Компонент для отображения одного поля с per-field inline-редактированием: +// клик по значению открывает редактор только этой строки, сохранение — PATCH одного поля. function FieldValueRow({ field, value: initialValue, @@ -657,10 +674,6 @@ function FieldValueRow({ readOnly, errorMessage, onErrorClear, - isGlobalEditMode, - draftValue, - onDraftChange, - onRequestGlobalEditMode, }: { field: FormFieldData; value: string | number | boolean | null | undefined; @@ -676,10 +689,6 @@ function FieldValueRow({ readOnly?: boolean; errorMessage?: string; onErrorClear?: () => void; - isGlobalEditMode?: boolean; - draftValue?: string | number | boolean | string[] | number[] | null | undefined; - onDraftChange?: (value: string | number | boolean | string[] | number[] | null | undefined) => void; - onRequestGlobalEditMode?: () => void; }) { const { toast } = useToast(); @@ -704,33 +713,48 @@ function FieldValueRow({ const [navGeo, setNavGeo] = useState(null); const wrapperRef = useRef(null); + // 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>([]); - // В глобальном режиме редактирования используем черновое значение из TaskDetail, - // иначе показываем исходное значение. - const editorValue = isGlobalEditMode ? draftValue : initialValue; + // Флаги составных редакторов (сохранение по закрытию — клик вне строки) + 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 lastTapRef = useRef(0); - const handleTouchEnd = (e: React.TouchEvent) => { - const now = Date.now(); - if (now - lastTapRef.current < 300) { - handleEnterEdit(e); + const canEdit = isEditable && !readOnly && !fieldState.immutable; + + // Пока поле не редактируется — синхронизируем локальный черновик со значением с сервера + useEffect(() => { + if (!isEditing) { + editValueRef.current = initialValue; + setEditValue(initialValue); } - lastTapRef.current = now; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValue, isEditing]); + + const updateEditValue = (val: unknown) => { + editValueRef.current = val; + setEditValue(val); + if (localError) setLocalError(null); }; - const isInteractiveTarget = (target: EventTarget | null): boolean => { - if (!(target instanceof Element)) return false; - return !!target.closest('a, button, input, select, textarea, [role="button"]'); - }; - - const handleEnterEdit = (e?: React.MouseEvent | React.TouchEvent) => { - e?.stopPropagation(); - if (isGlobalEditMode) return; - if (e && isInteractiveTarget(e.target)) return; - if (!isEditable || readOnly) return; + const enterEdit = () => { + if (isEditing || !isEditable || readOnly) return; if (fieldState.immutable) { toast({ title: 'Редактирование недоступно', @@ -739,10 +763,78 @@ function FieldValueRow({ }); return; } - onRequestGlobalEditMode?.(); + editValueRef.current = initialValue; + setEditValue(initialValue); + setLocalError(null); + setIsEditing(true); }; + const cancelEdit = () => { + setIsEditing(false); + setLocalError(null); + 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); + } + 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: TaskDetailResponse = { ...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-поля (при выборе/очистке компании) @@ -772,6 +864,8 @@ function FieldValueRow({ } return; } + // Ошибка сохранения — откатываем optimistic-значение перезапросом детали + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); const msg = err instanceof Error ? err.message : 'Неизвестная ошибка'; toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); } @@ -854,7 +948,7 @@ function FieldValueRow({ { e.preventDefault(); setNavGeo(geo); }} - className="text-blue-600 hover:underline inline-flex items-center gap-1 cursor-pointer" + className="text-primary hover:underline inline-flex items-center gap-1 cursor-pointer" > {display} @@ -917,7 +1011,7 @@ function FieldValueRow({ { e.preventDefault(); setNavGeo(geo); }} - className="inline-flex items-center gap-1 text-blue-600 hover:underline cursor-pointer" + className="inline-flex items-center gap-1 text-primary hover:underline cursor-pointer" data-testid={`text-custom-field-${field.code}`} > @@ -957,136 +1051,161 @@ function FieldValueRow({ ); } - return {safeString(initialValue)}; + return {safeString(initialValue)}; }; - // Рендер редактора по типу поля + // Рендер редактора по типу поля (per-field: сохранение по blur/Enter/выбору/закрытию) const renderEditor = () => { const inputClass = cn( - 'border border-input rounded px-1.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring w-auto max-w-[320px] h-7 leading-6', - errorMessage && 'border-destructive ring-destructive' + 'border border-input rounded px-1.5 text-[13px] bg-background focus:outline-none focus:ring-1 focus:ring-ring w-auto max-w-[320px] h-7 leading-6', + effectiveError && 'border-destructive ring-destructive' ); const textareaClass = cn( - 'border border-input rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring w-auto min-w-[200px]', - errorMessage && 'border-destructive ring-destructive' + 'border border-input rounded px-1.5 py-0.5 text-sm bg-background focus:outline-none focus:ring-1 focus:ring-ring w-full min-w-[200px]', + effectiveError && 'border-destructive ring-destructive' ); + // Текст и число: сохранение по blur и Enter, Esc — отмена if (field.type === 'text' || field.type === 'number') { return ( onDraftChange?.(field.type === 'number' ? e.target.value.replace(/\s/g, '') : e.target.value)} + value={editValue === null || editValue === undefined ? '' : String(editValue)} + onChange={e => updateEditValue(field.type === 'number' ? e.target.value.replace(/\s/g, '') : e.target.value)} onClick={e => e.stopPropagation()} autoFocus - aria-invalid={!!errorMessage} + aria-invalid={!!effectiveError} + onBlur={() => commitValue(editValueRef.current)} onKeyDown={e => { - if (e.key === 'Escape') { onDraftChange?.(initialValue); } + 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 ( onDraftChange?.(e.target.value)} + value={editValue === null || editValue === undefined ? '' : String(editValue)} + onChange={e => updateEditValue(e.target.value)} onClick={e => e.stopPropagation()} autoFocus - aria-invalid={!!errorMessage} + aria-invalid={!!effectiveError} placeholder={field.type === 'rich-text' ? 'HTML: жирный, курсив,
  • список
' : undefined} + onBlur={() => commitValue(editValueRef.current)} onKeyDown={e => { - if (e.key === 'Escape') { onDraftChange?.(initialValue); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } }} /> ); } - if (field.type === 'date') { - const isProlongation = editorValue === '__auto_prolongation__'; + // Дата/время без автопролонгации: сохранение по выбору (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 ( -
+
onDraftChange?.(e.target.value)} + type={isDateTime ? 'datetime-local' : 'date'} + className={cn(inputClass, 'w-auto min-w-[130px]')} + value={editValue ? String(editValue).slice(0, isDateTime ? 16 : 10) : ''} + onChange={e => { + const v = e.target.value; + updateEditValue(v); + if (v === '' || completeRe.test(v)) commitValue(v); + }} onClick={e => e.stopPropagation()} autoFocus - disabled={isProlongation} - aria-invalid={!!errorMessage} + aria-invalid={!!effectiveError} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } + }} /> - {field.autoProlongation && ( - - )} + {editValue ? ( + + ) : null}
); } - if (field.type === 'datetime') { - const isProlongation = editorValue === '__auto_prolongation__'; + // Дата/время с автопролонгацией: составной редактор, сохранение по закрытию (клик вне / Enter) + if (field.type === 'date' || field.type === 'datetime') { + const isProlongation = editValue === '__auto_prolongation__'; return ( -
+
e.stopPropagation()}> onDraftChange?.(e.target.value)} - onClick={e => e.stopPropagation()} + type={field.type === 'datetime' ? 'datetime-local' : 'date'} + className={cn(inputClass, 'w-auto min-w-[130px]')} + value={isProlongation ? '' : (editValue ? String(editValue).slice(0, field.type === 'datetime' ? 16 : 10) : '')} + onChange={e => updateEditValue(e.target.value)} autoFocus disabled={isProlongation} - aria-invalid={!!errorMessage} + aria-invalid={!!effectiveError} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); commitValue(editValueRef.current); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } + }} /> - {field.autoProlongation && ( - - )} +
); } if (field.type === 'select' || field.type === 'radio-group') { const opts = normalizeSelectOptions(field.options); + // Мультивыбор: сохранение по каждому переключению, выход — клик вне / Esc if (field.allowMultiple) { - const selected = Array.isArray(editorValue) - ? new Set(editorValue.map((v: any) => String(v))) - : editorValue != null && editorValue !== '' - ? new Set([String(editorValue)]) + 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()}> +
e.stopPropagation()}> ({ value: o.value, label: o.label }))} selected={selected} - onChange={(next) => onDraftChange?.(Array.from(next))} + onChange={(next) => { + const arr = Array.from(next); + updateEditValue(arr); + commitValue(arr, { close: false }); + }} />
); } + // Одиночный выбор: сохранение сразу по выбору return ( ); } - if (field.type === 'checkbox' || field.type === 'toggle') { + // Чек-лист: сохранение по каждому переключению, выход — клик вне / 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 ( - onDraftChange?.(e.target.checked)} - onClick={(e) => e.stopPropagation()} - autoFocus - aria-invalid={!!errorMessage} - /> +
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) if (field.type === 'user') { return (
e.stopPropagation()}> onDraftChange?.(val)} + value={editValue as string | number | null | undefined} + onChange={(val) => commitValue(val)} users={users} roles={roles} />
); } + // Гео: составной редактор, сохранение по закрытию if (field.type === 'geo') { return (
e.stopPropagation()}> onDraftChange?.(val as unknown as string | number | boolean | null | undefined)} + value={editValue as unknown as GeoValue | string | null} + onChange={val => updateEditValue(val)} />
); } + // Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст if (field.type === 'company') { return ( -
e.stopPropagation()}> +
e.stopPropagation()}> onDraftChange?.(val)} + value={editValue === null || editValue === undefined ? '' : String(editValue)} + onChange={val => updateEditValue(val)} onSelect={company => { - onDraftChange?.(company.value); + updateEditValue(company.value); if (field.companyAutofill && allFields) { pendingAutofillRef.current = Object.entries(field.companyAutofill) .filter(([, code]) => code && code !== 'none') @@ -1147,7 +1295,7 @@ function FieldValueRow({ return targetField ? [{ fieldId: targetField.id, value: getDadataFieldValue(dadataKey, company) }] : []; }); } - saveValue(company.value); + commitValue(company.value); }} onClear={() => { if (field.companyAutofill && allFields) { @@ -1163,13 +1311,14 @@ function FieldValueRow({
); } + // Номер договора: составной редактор, сохранение по закрытию if (field.type === 'contract-number') { const cnConfig = (field.options as any)?.contractNumber; return (
e.stopPropagation()}> onDraftChange?.(JSON.stringify(val))} + value={editValue as any} + onChange={(val) => updateEditValue(JSON.stringify(val))} config={cnConfig} formId={formId} fieldId={field.id} @@ -1179,59 +1328,74 @@ function FieldValueRow({ ); } if (field.type === 'table') { - const opts = field.options && !Array.isArray(field.options) ? (field.options as Record) : {}; - const showSubdirectory = !!opts.showSubdirectory; - return ( -
e.stopPropagation()}> - {showSubdirectory ? ( + const showSubdirectory = isTableSubdirectory; + // Подсправочник: составной редактор, сохранение по закрытию + if (showSubdirectory) { + return ( +
e.stopPropagation()}> onDraftChange?.(val)} + value={editValue} + onChange={(val) => updateEditValue(val)} allowMultiple={field.allowMultiple || false} /> - ) : field.allowMultiple ? ( +
+ ); + } + // Мультивыбор строк: сохранение по каждому переключению + if (field.allowMultiple) { + return ( +
e.stopPropagation()}> onDraftChange?.(val)} + value={editValue} + onChange={(val) => { + updateEditValue(val); + commitValue(val, { close: false }); + }} /> - ) : ( - onDraftChange?.(val)} - /> - )} -
- ); - } - if (field.type === 'contact') { +
+ ); + } + // Одиночный выбор строки: сохранение сразу по выбору return ( -
e.stopPropagation()}> - onDraftChange?.(JSON.stringify(val))} +
e.stopPropagation()}> + commitValue(val)} />
); } - if (field.type === 'task' && field.linkedFormId) { + // Контакт: составной редактор, сохранение по закрытию + if (field.type === 'contact') { return (
e.stopPropagation()}> + updateEditValue(JSON.stringify(val))} + /> +
+ ); + } + // Связанная задача: сохранение сразу по выбору + if (field.type === 'task' && field.linkedFormId) { + return ( +
e.stopPropagation()}> onDraftChange?.(val)} + value={editValue as string | number | null | undefined} + onChange={(val) => commitValue(val)} showCreateButton={false} />
@@ -1265,14 +1429,16 @@ function FieldValueRow({ const fieldUrl = `/api/tasks/${taskId}/field-values/${field.id}`; const isFieldPending = isPending ? isPending(fieldUrl) : false; + const effectiveError = errorMessage || localError; + // checkbox/toggle — мгновенное переключение по клику, без входа в режим редактирования + const isInstantToggle = field.type === 'checkbox' || field.type === 'toggle'; + const editorNode = isEditing && !isInstantToggle ? renderEditor() : null; return ( <>
{/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */}
@@ -1283,79 +1449,74 @@ function FieldValueRow({ ) : fieldState.immutable ? ( ) : fieldState.required ? ( * ) : ( - /* Карандаш при наведении на editable поле */ - isEditable && !readOnly && !isGlobalEditMode && ( - + /* Карандаш: на мобильном всегда виден, на десктопе — по hover строки */ + isEditable && !readOnly && !isEditing && ( + ) )}
- {/* Контент поля */} + {/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */}
-
- +
+ {field.name}: - {(() => { - const readViewNode = renderReadValue(); - const editorNode = renderEditor(); - const canShowInlineEditor = isGlobalEditMode && isEditable && !fieldState.immutable && !readOnly && editorNode != null; - const isContractNumberField = field.type === 'contract-number'; - const isDateWithProlongation = (field.type === 'date' || field.type === 'datetime') && field.autoProlongation; - const needsWideOverlay = isContractNumberField || isDateWithProlongation; - // Блочные редакторы (geo ~90px, contact ~300px) не помещаются в строгий - // inset-0 оверлей поверх однострочного read-view: контент переполняет - // рамку и наезжает на соседние поля. Рендерим их в потоке — строка - // растёт до высоты редактора вместо наложения. - const isBlockEditor = field.type === 'geo' || field.type === 'contact'; - if (!canShowInlineEditor) return readViewNode; - if (isBlockEditor) return editorNode; - return ( - + commitValue(e.target.checked, { close: false })} + onClick={(e) => e.stopPropagation()} + aria-invalid={!!effectiveError} /> - ); - })()} + + {initialValue === true || initialValue === 'true' ? 'Да' : 'Нет'} + + + ) : isEditing && editorNode != null ? ( +
+ {editorNode} +
+ ) : ( +
{ + if (!canEdit) return; + if (isInteractiveClickTarget(e.target)) return; + e.stopPropagation(); + enterEdit(); + }} + > + {renderReadValue()} +
+ )}
- {isFieldPending && !isGlobalEditMode && ( + {isFieldPending && ( )} - {errorMessage && ( - {errorMessage} + {effectiveError && ( + {effectiveError} )}
@@ -1435,10 +1596,6 @@ interface TabFieldsContentProps { isFieldImmutableForTask: (field: FormFieldData) => boolean; fieldErrors: Record; setFieldErrors: React.Dispatch>>; - isTaskEditMode?: boolean; - draftFieldValues: Record; - onDraftChange?: (fieldId: number, value: string | number | boolean | string[] | number[] | null | undefined) => void; - onRequestGlobalEditMode?: () => void; } const TabFieldsContent = ({ @@ -1458,10 +1615,6 @@ const TabFieldsContent = ({ isFieldImmutableForTask, fieldErrors, setFieldErrors, - isTaskEditMode, - draftFieldValues, - onDraftChange, - onRequestGlobalEditMode, }: TabFieldsContentProps) => { const { isPending: isUrlPending, getPendingBody } = useOfflineSyncContext(); const visibleFields = fields.filter( @@ -1495,10 +1648,6 @@ const TabFieldsContent = ({ readOnly={isTaskFinal} errorMessage={fieldErrors[field.id]} onErrorClear={() => setFieldErrors((prev) => { const next = { ...prev }; delete next[field.id]; return next; })} - isGlobalEditMode={isTaskEditMode} - draftValue={draftFieldValues[field.id] ?? effectiveValue} - onDraftChange={(val) => onDraftChange?.(field.id, val)} - onRequestGlobalEditMode={onRequestGlobalEditMode} /> ); @@ -1870,7 +2019,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } ); } -function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hiddenSystemFields, users, isTaskFinal, isTaskEditMode, draftDueDate, onDraftDueDateChange, draftDueTime, onDraftDueTimeChange, onRequestGlobalEditMode }: { task: TaskData; taskId: number; formId?: number; getUserName: (id: number) => string; readOnly?: boolean; hiddenSystemFields?: string[] | null; users?: UserSummary[]; isTaskFinal?: boolean; isTaskEditMode?: boolean; draftDueDate?: string; onDraftDueDateChange?: (val: string) => void; draftDueTime?: string; onDraftDueTimeChange?: (val: string) => void; onRequestGlobalEditMode?: () => void }) { +function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hiddenSystemFields, users, isTaskFinal }: { task: TaskData; taskId: number; formId?: number; getUserName: (id: number) => string; readOnly?: boolean; hiddenSystemFields?: string[] | null; users?: UserSummary[]; isTaskFinal?: boolean }) { const { toast } = useToast(); const { user: currentUser } = useAuth(); const isAdminUser = isAdmin(currentUser); @@ -1881,6 +2030,79 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); const [assigneeSearch, setAssigneeSearch] = useState(''); + // Per-field редактирование срока: локальный черновик даты/времени + const [isEditingDue, setIsEditingDue] = useState(false); + const [dueDateDraft, setDueDateDraft] = useState(''); + const [dueTimeDraft, setDueTimeDraft] = useState(''); + const dueDraftRef = useRef({ date: '', time: '' }); + const dueEditorRef = useRef(null); + + const enterDueEdit = () => { + if (readOnly || isEditingDue) return; + const date = task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : ''; + const time = task.dueDate && !/T00:00:00(\.000)?Z$/.test(task.dueDate) ? formatOrg(task.dueDate, 'HH:mm') : ''; + dueDraftRef.current = { date, time }; + setDueDateDraft(date); + setDueTimeDraft(time); + setIsEditingDue(true); + }; + + // Актуальная задача для сохранений из эффектов (после optimistic-обновлений проп устаревает) + const taskRef = useRef(task); + taskRef.current = task; + + // Сохранение срока через taskUpdateRequest (updatedAt — контроль конфликтов, как раньше в batch-сохранении) + const saveDueDate = async (dateStr: string, timeStr: string, opts?: { close?: boolean }) => { + const close = opts?.close !== false; + const currentTask = taskRef.current; + const originalDate = currentTask.dueDate ? formatOrg(currentTask.dueDate, 'yyyy-MM-dd') : ''; + const originalTime = currentTask.dueDate && !/T00:00:00(\.000)?Z$/.test(currentTask.dueDate) ? formatOrg(currentTask.dueDate, 'HH:mm') : ''; + if (close) setIsEditingDue(false); + if (dateStr === originalDate && timeStr === originalTime) return; + let parsedDueDate: string | null = null; + if (dateStr) { + parsedDueDate = timeStr ? parseOrgDateTime(dateStr, timeStr).toISOString() : dateStr; + } + // Optimistic-обновление кэша детали задачи + const prev = queryClient.getQueryData(['/api/tasks', taskId, 'detail']); + if (prev) { + const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, dueDate: parsedDueDate ?? undefined } }; + queryClient.setQueryData(['/api/tasks', taskId, 'detail'], merged); + saveTask(taskId, merged).catch(() => {}); + } + try { + await taskUpdateRequest(taskId, { dueDate: parsedDueDate }, currentTask.updatedAt, { dueDate: currentTask.dueDate ?? 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'] }); + } catch (err) { + if (err instanceof OfflineQueuedError) return; + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskId, 'detail'] }); + const msg = err instanceof Error ? err.message : 'Неизвестная ошибка'; + toast({ title: 'Ошибка сохранения срока', description: msg, variant: 'destructive' }); + } + }; + + // Клик вне редактора срока — сохранить, Esc — отмена + useEffect(() => { + if (!isEditingDue) return; + const handleOutsideClick = (e: MouseEvent) => { + if (dueEditorRef.current?.contains(e.target as Node)) return; + if (isRadixPortalTarget(e.target)) return; + saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time); + }; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') setIsEditingDue(false); + }; + document.addEventListener('click', handleOutsideClick); + document.addEventListener('keydown', handleEsc); + return () => { + document.removeEventListener('click', handleOutsideClick); + document.removeEventListener('keydown', handleEsc); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditingDue]); + // Task roles (доступ по ролям) const { data: taskRolesData, refetch: refetchTaskRoles } = useQuery<{ success: boolean; roles: TaskRoleData[] }>({ queryKey: ['/api/tasks', taskId, 'roles'], @@ -1987,6 +2209,17 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde const filteredOrgRoles = orgRoles.filter(r => !assignedRoleIds.has(r.id) && r.name.toLowerCase().includes(assigneeSearch.toLowerCase())); + // Клик вне блока системных полей закрывает dropdown ответственных + useEffect(() => { + if (!assigneeDropdownOpen) return; + const handleOutsideClick = (e: MouseEvent) => { + if (wrapperRef.current?.contains(e.target as Node)) return; + setAssigneeDropdownOpen(false); + }; + document.addEventListener('click', handleOutsideClick); + return () => document.removeEventListener('click', handleOutsideClick); + }, [assigneeDropdownOpen]); + // Левая колонка: карандашик для назначения ответственного const leftSlot = (onPencil: () => void) => (
@@ -2026,12 +2259,21 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
{/* Assignee + Roles */} {!hidden.has('assignedTo') && ( -
+
{!readOnly ? leftSlot(() => { setAssigneeDropdownOpen(v => !v); setAssigneeSearch(''); }) : emptySlot} Ответственный -
+
{ + if (readOnly) return; + // Клики по кнопкам снятия ответственных/ролей не открывают dropdown + if (e.target instanceof Element && e.target.closest('button')) return; + setAssigneeDropdownOpen(v => !v); + setAssigneeSearch(''); + }} + > {/* Multiple assignees + roles as tags */}
{hasNoAssignees && ( @@ -2177,49 +2419,82 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde ); const dueDateEditor = ( -
+
onDraftDueDateChange?.(e.target.value)} + className="border rounded px-1.5 h-7 text-[13px] leading-6 bg-background focus:outline-none focus:ring-1 focus:ring-ring" + value={dueDateDraft} + onChange={e => { + const v = e.target.value; + dueDraftRef.current.date = v; + setDueDateDraft(v); + // Дата выбрана полностью — сохраняем сразу, редактор остаётся для времени + if (v === '' || /^\d{4}-\d{2}-\d{2}$/.test(v)) { + saveDueDate(v, dueDraftRef.current.time, { close: false }); + } + }} autoFocus + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setIsEditingDue(false); } + }} /> onDraftDueTimeChange?.(e.target.value)} + className="border rounded px-1.5 h-7 text-[13px] leading-6 bg-background focus:outline-none focus:ring-1 focus:ring-ring w-[90px]" + value={dueTimeDraft} + onChange={e => { + dueDraftRef.current.time = e.target.value; + setDueTimeDraft(e.target.value); + }} + onBlur={() => saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time)} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); saveDueDate(dueDraftRef.current.date, dueDraftRef.current.time); } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setIsEditingDue(false); } + }} /> + {dueDateDraft && ( + + )}
); return (
{emptySlot} Срок - onRequestGlobalEditMode?.()} - fitOverlay={false} - overlayMinWidth={260} - overlayClassName="overflow-visible" - className="items-center" - /> + {isEditingDue ? ( + dueDateEditor + ) : ( +
{ + if (readOnly) return; + if (isInteractiveClickTarget(e.target)) return; + enterDueEdit(); + }} + > + {dueDateReadView} +
+ )}
); })()} {/* Created (read-only) */} {!hidden.has('createdAt') && ( -
+
{emptySlot} Создана - + {task.createdAt ? new Date(task.createdAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''} @@ -2229,10 +2504,10 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {/* Completed at (read-only, shown only when task is in final status) */} {isTaskFinal && !hidden.has('completedAt') && ( -
+
{emptySlot} Завершена - + {task.completedAt ? new Date(task.completedAt).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'} @@ -2242,10 +2517,10 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {/* Creator (read-only) */} {!hidden.has('createdBy') && task.createdBy && ( -
+
{emptySlot} Автор - {getUserName(task.createdBy)} + {getUserName(task.createdBy)}
)} @@ -2360,14 +2635,6 @@ interface TaskContentProps { getUserName: (id: number) => string; systemFieldsTabId?: number | null; hasTabs: boolean; - isTaskEditMode?: boolean; - draftFieldValues: Record; - onDraftFieldChange?: (fieldId: number, value: string | number | boolean | string[] | number[] | null | undefined) => void; - onRequestGlobalEditMode?: () => void; - draftDueDate?: string; - onDraftDueDateChange?: (val: string) => void; - draftDueTime?: string; - onDraftDueTimeChange?: (val: string) => void; } function TaskContent({ @@ -2395,14 +2662,6 @@ function TaskContent({ getUserName, systemFieldsTabId, hasTabs, - isTaskEditMode, - draftFieldValues, - onDraftFieldChange, - onRequestGlobalEditMode, - draftDueDate, - onDraftDueDateChange, - draftDueTime, - onDraftDueTimeChange, }: TaskContentProps) { const currentTab = useMemo(() => { if ( @@ -2501,12 +2760,6 @@ function TaskContent({ readOnly={isTaskFinal} hiddenSystemFields={form?.hiddenSystemFields} users={users} - isTaskEditMode={isTaskEditMode} - draftDueDate={draftDueDate} - onDraftDueDateChange={onDraftDueDateChange} - draftDueTime={draftDueTime} - onDraftDueTimeChange={onDraftDueTimeChange} - onRequestGlobalEditMode={onRequestGlobalEditMode} /> )} )} @@ -2537,6 +2786,111 @@ function TaskContent({ ); } +// Заголовок задачи в шапке с per-field редактированием (клик → инпут, blur/Enter — сохранить, Esc — отмена) +function TaskTitleInline({ task, taskIdNumber, formId, isTaskFinal }: { task: TaskData; taskIdNumber: number; formId: number; isTaskFinal: boolean }) { + const { toast } = useToast(); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(task.title); + const draftRef = useRef(task.title); + const wrapperRef = useRef(null); + + // Пока не редактируем — синхронизируем черновик с актуальным заголовком + useEffect(() => { + if (!isEditing) { + setDraft(task.title); + draftRef.current = task.title; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [task.title, isEditing]); + + const saveTitle = async (val: string) => { + if (!isEditing) return; + setIsEditing(false); + const title = val.trim(); + if (!title) { + toast({ title: 'Ошибка', description: 'Название задачи обязательно', variant: 'destructive' }); + setDraft(task.title); + draftRef.current = task.title; + return; + } + if (title === task.title) return; + // Optimistic-обновление кэша детали задачи + const prev = queryClient.getQueryData(['/api/tasks', taskIdNumber, 'detail']); + if (prev) { + const merged: TaskDetailResponse = { ...prev, task: { ...prev.task, title } }; + queryClient.setQueryData(['/api/tasks', taskIdNumber, 'detail'], merged); + saveTask(taskIdNumber, merged).catch(() => {}); + } + try { + await taskUpdateRequest(taskIdNumber, { title }, task.updatedAt, { title: task.title }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] }); + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'audit-log'] }); + if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); + } catch (err: unknown) { + if (err instanceof OfflineQueuedError) return; + queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] }); + const msg = err instanceof Error ? err.message : 'Неизвестная ошибка'; + toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); + } + }; + + // Клик вне инпута — сохранить, Esc — отмена + useEffect(() => { + if (!isEditing) return; + const handleOutsideClick = (e: MouseEvent) => { + if (wrapperRef.current?.contains(e.target as Node)) return; + saveTitle(draftRef.current); + }; + document.addEventListener('click', handleOutsideClick); + return () => document.removeEventListener('click', handleOutsideClick); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEditing]); + + if (isEditing) { + return ( +
+ { + draftRef.current = e.target.value; + setDraft(e.target.value); + }} + onBlur={() => saveTitle(draftRef.current)} + onKeyDown={e => { + if (e.key === 'Enter') { e.preventDefault(); saveTitle(draftRef.current); } + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + setDraft(task.title); + draftRef.current = task.title; + setIsEditing(false); + } + }} + data-testid="input-task-title" + /> +
+ ); + } + + return ( +
{ + if (isTaskFinal) return; + setDraft(task.title); + draftRef.current = task.title; + setIsEditing(true); + }} + > +

+ {task.title} +

+
+ ); +} + const TaskDetail = () => { const { id, taskId } = useParams<{ id: string; taskId: string }>(); const [, setLocation] = useLocation(); @@ -2702,24 +3056,8 @@ const TaskDetail = () => { // True when there is a queued (not yet synced) transition request for this task const isTransitionPending = pendingUrls.has(`/api/tasks/${taskIdNumber}/transition`); - // Глобальный режим редактирования задачи - const [isTaskEditMode, setIsTaskEditMode] = useState(false); - const [draftFieldValues, setDraftFieldValues] = useState>({}); - - // Notion-style dirty fields tracking (new sticky bottom bar) - const { isDirty, markDirty, clearAllDirty } = useDirtyFields(); - const [draftDueDate, setDraftDueDate] = useState(''); - const [draftDueTime, setDraftDueTime] = useState(''); - const [draftTitle, setDraftTitle] = useState(''); - const [isSavingBatch, setIsSavingBatch] = useState(false); - - // Сбрасываем режим редактирования при смене задачи + // Сбрасываем ошибки полей при смене задачи useEffect(() => { - setIsTaskEditMode(false); - setDraftFieldValues({}); - setDraftDueDate(''); - setDraftDueTime(''); - setDraftTitle(''); setFieldErrors({}); }, [taskIdNumber]); @@ -3134,156 +3472,6 @@ const TaskDetail = () => { const currentStatus = formStatuses.find(s => s.id === hookTaskCurrentStatusId); const isTaskFinal = !!(currentStatus?.isFinal || task?.isCompleted); - // Сравнение значений полей: null/undefined/''/false считаем пустыми и равными друг другу - const valuesEqual = (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); - }; - - const enterTaskEditMode = useCallback(() => { - if (isTaskFinal || !task) return; - const next: Record = {}; - for (const f of fields) { - const fv = fieldValues.find((v: TaskFieldValueData) => v.fieldId === f.id); - next[f.id] = fv?.value ?? null; - } - setDraftFieldValues(next); - setDraftDueDate(task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : ''); - if (task.dueDate) { - const isAllDay = /T00:00:00(\.000)?Z$/.test(task.dueDate); - setDraftDueTime(isAllDay ? '' : formatOrg(task.dueDate, 'HH:mm')); - } else { - setDraftDueTime(''); - } - setDraftTitle(task.title); - setFieldErrors({}); - setIsTaskEditMode(true); - }, [fields, fieldValues, task, isTaskFinal]); - - const exitTaskEditMode = useCallback(() => { - setIsTaskEditMode(false); - setDraftFieldValues({}); - setDraftDueDate(''); - setDraftDueTime(''); - setDraftTitle(''); - setFieldErrors({}); - }, []); - - const handleDraftFieldChange = useCallback((fieldId: number, value: string | number | boolean | string[] | number[] | null | undefined) => { - setDraftFieldValues(prev => ({ ...prev, [fieldId]: value })); - setFieldErrors(prev => { const next = { ...prev }; delete next[fieldId]; return next; }); - }, []); - - const handleSaveTask = useCallback(async () => { - if (!task || !fields.length) return; - setIsSavingBatch(true); - try { - // Собираем симулированные значения всех полей для валидации - const simulatedFieldValues = fields.map(f => { - const hasDraft = Object.prototype.hasOwnProperty.call(draftFieldValues, f.id); - const original = fieldValues.find((v: TaskFieldValueData) => v.fieldId === f.id)?.value; - return { fieldId: f.id, value: hasDraft ? draftFieldValues[f.id] : original }; - }); - - // Проверяем обязательные поля - const requiredErrors = validateRequiredFieldsDetailed( - task, - fields as ClientFormField[], - simulatedFieldValues, - users as any, - reminderOrgRolesData?.roles || [], - false, - conditionContextStatuses, - true - ); - - const nextErrors: Record = {}; - for (const err of requiredErrors) nextErrors[err.fieldId] = err.message; - - // Проверяем неизменяемость изменённых полей - const changedFieldIds: number[] = []; - for (const f of fields) { - const hasDraft = Object.prototype.hasOwnProperty.call(draftFieldValues, f.id); - if (!hasDraft) continue; - const draftVal = draftFieldValues[f.id]; - const originalVal = fieldValues.find((v: TaskFieldValueData) => v.fieldId === f.id)?.value; - if (!valuesEqual(draftVal, originalVal)) { - changedFieldIds.push(f.id); - const state = computeFieldState(f as ClientFormField, task, simulatedFieldValues, allConditionFields as ClientFormField[], conditionContext); - if (state.immutable) { - nextErrors[f.id] = `Поле «${f.name}» не может быть изменено в текущем статусе`; - } - } - } - - // Название задачи обязательно - if (!draftTitle.trim()) { - toast({ title: 'Ошибка', description: 'Название задачи обязательно', variant: 'destructive' }); - setIsSavingBatch(false); - return; - } - - if (Object.keys(nextErrors).length > 0) { - setFieldErrors(nextErrors); - toast({ title: 'Ошибка сохранения', description: 'Проверьте обязательные и недоступные для изменения поля', variant: 'destructive' }); - setIsSavingBatch(false); - return; - } - setFieldErrors({}); - - const fieldValuesPayload = changedFieldIds.map(id => ({ fieldId: id, value: draftFieldValues[id] })); - const promises: Promise[] = []; - if (fieldValuesPayload.length > 0) { - promises.push(apiRequest('POST', `/api/tasks/${taskIdNumber}/field-values`, { fieldValues: fieldValuesPayload })); - } - - const originalDueDate = task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : ''; - const originalDueTime = task.dueDate && !/T00:00:00(\.000)?Z$/.test(task.dueDate) - ? formatOrg(task.dueDate, 'HH:mm') - : ''; - if (draftDueDate !== originalDueDate || draftDueTime !== originalDueTime) { - let parsedDueDate: string | null = null; - if (draftDueDate) { - if (draftDueTime) { - parsedDueDate = parseOrgDateTime(draftDueDate, draftDueTime).toISOString(); - } else { - parsedDueDate = draftDueDate; // date-only → all-day - } - } - promises.push(taskUpdateRequest(taskIdNumber, { dueDate: parsedDueDate }, task.updatedAt, { dueDate: task.dueDate ?? null })); - } - if (draftTitle.trim() !== task.title) { - promises.push(taskUpdateRequest(taskIdNumber, { title: draftTitle.trim() }, task.updatedAt, { title: task.title })); - } - - if (promises.length === 0) { - exitTaskEditMode(); - setIsSavingBatch(false); - return; - } - - await Promise.all(promises); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] }); - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'audit-log'] }); - if (formId) queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); - toast({ title: 'Сохранено' }); - exitTaskEditMode(); - } catch (err: unknown) { - if (err instanceof OfflineQueuedError) { - queryClient.invalidateQueries({ queryKey: ['/api/tasks', taskIdNumber, 'detail'] }); - toast({ title: 'Изменения поставлены в очередь' }); - exitTaskEditMode(); - return; - } - const msg = err instanceof Error ? err.message : 'Неизвестная ошибка'; - toast({ title: 'Ошибка сохранения', description: msg, variant: 'destructive' }); - } finally { - setIsSavingBatch(false); - } - }, [task, fields, fieldValues, draftFieldValues, draftDueDate, draftDueTime, draftTitle, taskIdNumber, formId, users, reminderOrgRolesData, conditionContextStatuses, allConditionFields, conditionContext, exitTaskEditMode, toast]); - if (!formId || isNaN(formId) || !taskIdNumber || isNaN(taskIdNumber)) { return ( @@ -3525,21 +3713,9 @@ const TaskDetail = () => { getUserName={getUserName} systemFieldsTabId={systemFieldsTabId} hasTabs={hasTabs} - isTaskEditMode={isTaskEditMode} - draftFieldValues={draftFieldValues} - onDraftFieldChange={handleDraftFieldChange} - onRequestGlobalEditMode={enterTaskEditMode} - draftDueDate={draftDueDate} - onDraftDueDateChange={setDraftDueDate} - draftDueTime={draftDueTime} - onDraftDueTimeChange={setDraftDueTime} /> ); - // REMOVED: editModeBar (top bar) — replaced with StickyActionBar at bottom - // See StickyActionBar component at the bottom of the page layout - const editModeBar = null; - // Мобильный accordion — все разделы задачи в виде сворачиваемых панелей // Важно: не объявляем как компонент внутри TaskDetail, чтобы избежать remount полей. const getTabMeta = (tab: FormTab) => { @@ -3577,12 +3753,6 @@ const TaskDetail = () => { hiddenSystemFields={form.hiddenSystemFields} users={users} isTaskFinal={isTaskFinal} - isTaskEditMode={isTaskEditMode} - draftDueDate={draftDueDate} - onDraftDueDateChange={setDraftDueDate} - draftDueTime={draftDueTime} - onDraftDueTimeChange={setDraftDueTime} - onRequestGlobalEditMode={enterTaskEditMode} /> )} {isTable ? ( @@ -3617,10 +3787,6 @@ const TaskDetail = () => { isFieldImmutableForTask={isFieldImmutableForTask} fieldErrors={fieldErrors} setFieldErrors={setFieldErrors} - isTaskEditMode={isTaskEditMode} - draftFieldValues={draftFieldValues} - onDraftChange={handleDraftFieldChange} - onRequestGlobalEditMode={enterTaskEditMode} /> )} @@ -3756,40 +3922,7 @@ const TaskDetail = () => { #{task.id} - -

- {task.title} -

-
- } - editor={ - setDraftTitle(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSaveTask(); - } - if (e.key === 'Escape') exitTaskEditMode(); - }} - data-testid="input-task-title" - /> - } - isEditing={isTaskEditMode && !isTaskFinal} - editable={!isTaskFinal} - onEnterEdit={enterTaskEditMode} - fitOverlay={false} - overlayMinWidth={200} - overlayClassName="overflow-visible" - /> +
@@ -3961,8 +4094,6 @@ const TaskDetail = () => {
)} - {/* REMOVED: editModeBar (top bar) — replaced with StickyActionBar at bottom */} - {/* Мобильный таб-бар — только Задача / Чат */} {isMobile && chatEnabled && (
@@ -4276,22 +4407,6 @@ const TaskDetail = () => { {/* (debug overlay removed) */} - - {/* Notion-style sticky bottom action bar */} - { - if (isTaskEditMode) { - handleSaveTask(); - } - clearAllDirty(); - }} - onCancel={() => { - exitTaskEditMode(); - clearAllDirty(); - }} - /> ); };