diff --git a/client/src/components/fields/editors/UserRoleEditor.tsx b/client/src/components/fields/editors/UserRoleEditor.tsx index 06d5ba5..6e24118 100644 --- a/client/src/components/fields/editors/UserRoleEditor.tsx +++ b/client/src/components/fields/editors/UserRoleEditor.tsx @@ -10,23 +10,9 @@ import { } from '@/components/ui/select'; import type { FieldEditorProps } from '../FieldRegistry'; -export const UserRoleEditor: React.FC = ({ field, value, onChange, users, roles }) => { +export const UserRoleEditor: React.FC = ({ field, value, onChange, users, roles, autoOpen = false }) => { const filter = (field.options as any)?.userFilter as { userIds?: number[]; roleIds?: number[] } | undefined; - const filteredUsers = useMemo(() => { - if (!users) return []; - if (!filter) return users; // нет фильтра — показываем всех - if (!filter.userIds || filter.userIds.length === 0) return []; // фильтр есть, но пользователи не выбраны - return users.filter((u) => filter.userIds!.includes(u.id)); - }, [users, filter]); - - const filteredRoles = useMemo(() => { - if (!roles) return []; - if (!filter) return roles; // нет фильтра — показываем все роли - if (!filter.roleIds || filter.roleIds.length === 0) return []; // фильтр есть, но роли не выбраны - return roles.filter((r) => filter.roleIds!.includes(r.id)); - }, [roles, filter]); - // Нормализуем value в строку вида user:${id} / role:${id} или __none__ const stringValue = React.useMemo(() => { if (value === null || value === undefined || value === '') return '__none__'; @@ -39,6 +25,43 @@ export const UserRoleEditor: React.FC = ({ field, value, onCha return '__none__'; }, [value]); + const filteredUsers = useMemo(() => { + if (!users) return []; + let list = users; + if (filter) { + // фильтр есть, но пользователи не выбраны — пустой список + list = !filter.userIds || filter.userIds.length === 0 ? [] : users.filter((u) => filter.userIds!.includes(u.id)); + } + // Текущее значение включаем всегда, даже если оно не проходит userFilter, + // иначе заполненное поле показывает пустой список и «не даёт себя изменить» + if (stringValue.startsWith('user:')) { + const currentId = Number(stringValue.slice(5)); + if (!list.some((u) => u.id === currentId)) { + const currentUser = users.find((u) => u.id === currentId); + if (currentUser) list = [currentUser, ...list]; + } + } + return list; + }, [users, filter, stringValue]); + + const filteredRoles = useMemo(() => { + if (!roles) return []; + let list = roles; + if (filter) { + // фильтр есть, но роли не выбраны — пустой список + list = !filter.roleIds || filter.roleIds.length === 0 ? [] : roles.filter((r) => filter.roleIds!.includes(r.id)); + } + // Текущая роль включается всегда (см. комментарий выше про пользователя) + if (stringValue.startsWith('role:')) { + const currentId = Number(stringValue.slice(5)); + if (!list.some((r) => r.id === currentId)) { + const currentRole = roles.find((r) => r.id === currentId); + if (currentRole) list = [currentRole, ...list]; + } + } + return list; + }, [roles, filter, stringValue]); + const handleChange = (v: string) => { if (!v || v === '__none__') { onChange(null); @@ -51,7 +74,7 @@ export const UserRoleEditor: React.FC = ({ field, value, onCha const hasRoles = filteredRoles.length > 0; return ( - diff --git a/client/src/components/ui/select.tsx b/client/src/components/ui/select.tsx index 8ba7fc1..316fed8 100644 --- a/client/src/components/ui/select.tsx +++ b/client/src/components/ui/select.tsx @@ -88,7 +88,9 @@ const SelectContent = React.forwardRef< className={cn( "p-1 overflow-y-auto", position === "popper" && - "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + // max-h-60 вместо h-[--radix-select-trigger-height]: иначе viewport залочен + // на высоту триггера и длинный список показывает один пункт + "max-h-60 w-full min-w-[var(--radix-select-trigger-width)]" )} onWheel={(e) => { const el = e.currentTarget; diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index e5176e3..31563c0 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -713,6 +713,19 @@ function FieldValueRow({ 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); @@ -772,6 +785,8 @@ function FieldValueRow({ const cancelEdit = () => { setIsEditing(false); setLocalError(null); + // Сбрасываем очередь autofill-затирания (company onClear) — иначе она «утечёт» в следующее сохранение + pendingAutofillRef.current = []; editValueRef.current = initialValue; setEditValue(initialValue); }; @@ -786,6 +801,9 @@ function FieldValueRow({ setLocalError(null); if (!fieldValuesEqual(val, initialValue)) { saveValue(val as string | number | boolean | Record | null | undefined); + } else { + // Значение не изменилось — сохранение пропускаем, autofill-очередь затираем + pendingAutofillRef.current = []; } if (close) setIsEditing(false); }; @@ -1061,7 +1079,7 @@ function FieldValueRow({ 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-full min-w-[200px]', + '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] max-w-[420px]', effectiveError && 'border-destructive ring-destructive' ); // Текст и число: сохранение по blur и Enter, Esc — отмена @@ -1202,7 +1220,6 @@ function FieldValueRow({ onClick={e => e.stopPropagation()} autoFocus aria-invalid={!!effectiveError} - onBlur={() => cancelEdit()} onKeyDown={e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelEdit(); } }} @@ -1263,6 +1280,7 @@ function FieldValueRow({ onChange={(val) => commitValue(val)} users={users} roles={roles} + autoOpen /> ); @@ -1281,7 +1299,7 @@ function FieldValueRow({ // Компания: выбор подсказки сохраняет сразу (с autofill), закрытие — сохраняет введённый текст if (field.type === 'company') { return ( -
e.stopPropagation()}> +
e.stopPropagation()}> updateEditValue(val)} @@ -1348,7 +1366,7 @@ function FieldValueRow({ // Мультивыбор строк: сохранение по каждому переключению if (field.allowMultiple) { return ( -
e.stopPropagation()}> +
e.stopPropagation()}> e.stopPropagation()}> +
e.stopPropagation()}> e.stopPropagation()}> +
e.stopPropagation()}>
{/* Левая колонка: индикаторы обязательности / неизменяемости / карандаш */}
@@ -1480,7 +1498,7 @@ function FieldValueRow({ {/* Контент поля: read-view и редактор занимают один и тот же слот (без оверлеев) */}
- + {field.name}: {isInstantToggle ? ( @@ -1494,7 +1512,7 @@ function FieldValueRow({ onClick={(e) => e.stopPropagation()} aria-invalid={!!effectiveError} /> - + {initialValue === true || initialValue === 'true' ? 'Да' : 'Нет'} @@ -1504,13 +1522,15 @@ function FieldValueRow({
) : (
{ + className={cn('min-w-0 text-[13px] leading-5', canEdit && 'cursor-text')} + title={canEdit ? 'Двойной клик для редактирования' : undefined} + onDoubleClick={(e) => { if (!canEdit) return; if (isInteractiveClickTarget(e.target)) return; e.stopPropagation(); enterEdit(); }} + onTouchEnd={handleDoubleTap} > {renderReadValue()}
@@ -2037,6 +2057,19 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde const dueDraftRef = useRef({ date: '', time: '' }); const dueEditorRef = useRef(null); + // Двойное касание на мобильных — вход в редактирование срока + const lastDueTapRef = useRef(0); + const handleDueDoubleTap = (e: React.TouchEvent) => { + if (readOnly) return; + if (isInteractiveClickTarget(e.target)) return; + const now = Date.now(); + if (now - lastDueTapRef.current < 300) { + e.stopPropagation(); + enterDueEdit(); + } + lastDueTapRef.current = now; + }; + const enterDueEdit = () => { if (readOnly || isEditingDue) return; const date = task.dueDate ? formatOrg(task.dueDate, 'yyyy-MM-dd') : ''; @@ -2259,7 +2292,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde
{/* Assignee + Roles */} {!hidden.has('assignedTo') && ( -
+
{!readOnly ? leftSlot(() => { setAssigneeDropdownOpen(v => !v); setAssigneeSearch(''); }) : emptySlot} @@ -2467,7 +2500,7 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde ); return (
{emptySlot} Срок @@ -2475,12 +2508,14 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde dueDateEditor ) : (
{ + className={cn('text-[13px] leading-5', !readOnly && 'cursor-text')} + title={!readOnly ? 'Двойной клик для редактирования' : undefined} + onDoubleClick={(e) => { if (readOnly) return; if (isInteractiveClickTarget(e.target)) return; enterDueEdit(); }} + onTouchEnd={handleDueDoubleTap} > {dueDateReadView}
@@ -2491,10 +2526,10 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {/* 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' }) : ''} @@ -2504,10 +2539,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' }) : '—'} @@ -2517,10 +2552,10 @@ function InlineSystemFields({ task, taskId, formId, getUserName, readOnly, hidde {/* Creator (read-only) */} {!hidden.has('createdBy') && task.createdBy && ( -
+
{emptySlot} Автор - {getUserName(task.createdBy)} + {getUserName(task.createdBy)}
)} @@ -2874,14 +2909,28 @@ function TaskTitleInline({ task, taskIdNumber, formId, isTaskFinal }: { task: Ta ); } + const lastTitleTapRef = useRef(0); + + const enterTitleEdit = () => { + if (isTaskFinal) return; + setDraft(task.title); + draftRef.current = task.title; + setIsEditing(true); + }; + return (
{ + title={!isTaskFinal ? 'Двойной клик для редактирования' : undefined} + onDoubleClick={enterTitleEdit} + onTouchEnd={(e) => { if (isTaskFinal) return; - setDraft(task.title); - draftRef.current = task.title; - setIsEditing(true); + const now = Date.now(); + if (now - lastTitleTapRef.current < 300) { + e.stopPropagation(); + enterTitleEdit(); + } + lastTitleTapRef.current = now; }} >

diff --git a/server/routes/task-fields.routes.ts b/server/routes/task-fields.routes.ts index 0674195..72ddca8 100644 --- a/server/routes/task-fields.routes.ts +++ b/server/routes/task-fields.routes.ts @@ -457,7 +457,7 @@ export function registerTaskFieldRoutes(router: ReturnType parseInt(k, 10)); - const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'checklist', 'contact', 'document_template']); + const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'rich-text', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'company', 'task', 'document_template']); const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!); const fieldMap = new Map(formFields.map(f => [f.id, f])); const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!);