diff --git a/client/src/components/TaskRegistry.tsx b/client/src/components/TaskRegistry.tsx index a837f47..e797545 100644 --- a/client/src/components/TaskRegistry.tsx +++ b/client/src/components/TaskRegistry.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { useState, useMemo, useEffect, useCallback, useRef, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; import { useQueries } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Button } from '@/components/ui/button'; @@ -36,9 +36,11 @@ import { apiRequest, queryClient, OfflineQueuedError } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; import { addFileToken, openFilePresigned } from '@/lib/fileUtils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import ContractNumberDisplay from '@/components/ContractNumberDisplay'; import RegistryCellEditor from './RegistryCellEditor'; import { InlineField } from '@/components/fields/InlineField'; +import { useSkinComponents } from '@/themes/ThemeProvider'; interface TaskAssigneeInfo { userId: number; @@ -264,11 +266,12 @@ const REGISTRY_QUICK_TOGGLE_TYPES = new Set(['checkbox', 'toggle']); // Порог виртуализации реестра: при меньшем числе строк рендерим как обычно const REGISTRY_VIRTUALIZATION_THRESHOLD = 30; -// Оценка высоты строки до измерения (h-10 по UI Style Guide) -const REGISTRY_ROW_ESTIMATED_HEIGHT = 40; const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = [], roles = [], formId, onFilteredCountChange, onTaskClick, filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters, searches, onAddSearch, onRemoveSearch, isAdmin, selectedTaskIds, anchorTaskId, onToggleSelected, onToggleSelectAll, onSelectRange, onSetAnchorTaskId, onClearSelection, onRequestBulkDelete }: TaskRegistryProps) => { const { toast } = useToast(); + // Вид шапки таблицы из скина: для plain-варианта sticky-шапке нужен явный фон, + // иначе при прокрутке сквозь неё проступают строки + const { table: skinTable } = useSkinComponents(); // Текущая редактируемая ячейка: { taskId, columnKey } const [editingCell, setEditingCell] = useState<{ taskId: number; columnKey: string } | null>(null); @@ -422,6 +425,77 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = const [showCompleted, setShowCompleted] = useState(() => { try { return JSON.parse(localStorage.getItem(`registry-completed-${formId}`) || 'false'); } catch { return false; } }); + // Максимум строк текста в ячейке: 'auto' (по умолчанию, текущее поведение) или 1..8. + // При 1 строка задачи всегда однострочная с ellipsis (полный текст в title). + const [maxLines, setMaxLines] = useState(() => { + try { + const v = Number(JSON.parse(localStorage.getItem(`registry-rowlines-${formId}`) || 'null')); + return Number.isFinite(v) && v >= 1 && v <= 8 ? Math.floor(v) : 'auto'; + } catch { return 'auto'; } + }); + + // CSS-стиль клампа многострочного текста; undefined в режиме auto + const clampStyle = (lines: number | 'auto'): CSSProperties | undefined => + lines === 'auto' + ? undefined + : { display: '-webkit-box', WebkitBoxOrient: 'vertical', WebkitLineClamp: lines, overflow: 'hidden' }; + + // Ширины колонок, заданные пользователем перетаскиванием правого края шапки. + // Колонки без записи — автоширина. Хранится в localStorage, как и прочие настройки реестра. + const [columnWidths, setColumnWidths] = useState<{ [key: string]: number }>(() => { + try { + const v = JSON.parse(localStorage.getItem(`registry-colwidths-${formId}`) || '{}'); + return v && typeof v === 'object' ? v : {}; + } catch { return {}; } + }); + useEffect(() => { + try { localStorage.setItem(`registry-colwidths-${formId}`, JSON.stringify(columnWidths)); } catch { /* ignore */ } + }, [columnWidths, formId]); + const hasCustomWidths = Object.keys(columnWidths).length > 0; + const resetColumnWidth = (key: string) => setColumnWidths(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + const startColumnResize = (columnKey: string, e: ReactPointerEvent) => { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + const th = e.currentTarget.closest('th'); + if (!th) return; + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startWidth = th.getBoundingClientRect().width; + const prevCursor = document.body.style.cursor; + const prevUserSelect = document.body.style.userSelect; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + const onMove = (ev: PointerEvent) => { + const w = Math.min(1200, Math.max(80, Math.round(startWidth + ev.clientX - startX))); + setColumnWidths(prev => ({ ...prev, [columnKey]: w })); + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + document.body.style.cursor = prevCursor; + document.body.style.userSelect = prevUserSelect; + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }; + + // Заголовок колонки с ручкой ресайза (drag по правому краю, двойной клик — сброс ширины) + const RegistryHead = ({ columnKey, children }: { columnKey: string; children?: ReactNode }) => ( + + {children} + startColumnResize(columnKey, e)} + onDoubleClick={e => { e.stopPropagation(); resetColumnWidth(columnKey); }} + onClick={e => e.stopPropagation()} + /> + + ); const formatDate = (dateString: string) => { if (!dateString) return ''; @@ -443,6 +517,10 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = try { localStorage.setItem(`registry-completed-${formId}`, JSON.stringify(showCompleted)); } catch { /* ignore */ } }, [showCompleted, formId]); + useEffect(() => { + try { localStorage.setItem(`registry-rowlines-${formId}`, JSON.stringify(maxLines)); } catch { /* ignore */ } + }, [maxLines, formId]); + // При появлении новых полей добавляем их в состояние (не затирая сохранённые) useEffect(() => { setVisibleColumns(prev => { @@ -556,6 +634,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = // Виртуализация строк реестра: рендерятся только видимые строки + overscan. // Фильтрация/сортировка/выделение работают по полному filteredTasks и не затронуты. const tableScrollRef = useRef(null); + // Оценка высоты строки до измерения (h-10 по UI Style Guide; однострочный режим ниже) + const REGISTRY_ROW_ESTIMATED_HEIGHT = maxLines === 'auto' ? 40 : 32; const shouldVirtualize = filteredTasks.length > REGISTRY_VIRTUALIZATION_THRESHOLD; const rowVirtualizer = useVirtualizer({ count: filteredTasks.length, @@ -768,6 +848,24 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = /> + {/* Максимум строк текста в ячейке */} +
+ + +
@@ -874,9 +972,16 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = )}
) : ( -
- - +
+
+ {/* Ширины колонок, заданные пользователем (drag шапки); без записи — автоширина */} + + {isAdmin && } + {columnOrder.filter(k => visibleColumns[k]).map(k => ( + + ))} + + {isAdmin && ( @@ -897,70 +1002,70 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = {columnOrder.filter(key => visibleColumns[key]).map(key => { if (key === 'title') { return ( - + - + ); } if (key === 'status') { return ( - + - + ); } if (key === 'assignedTo') { return ( - + - + ); } if (key === 'createdAt') { return ( - + - + ); } if (key === 'dueDate') { return ( - + - + ); } if (key === 'description') { - return Описание; + return Описание; } if (key === 'updatedAt') { return ( - + - + ); } if (key === 'createdBy') { return ( - + - + ); } if (key === 'completedAt') { return ( - + - + ); } if (key.startsWith('field_')) { const fieldId = parseInt(key.replace('field_', '')); const field = fields.find(f => f.id === fieldId); return field ? ( - + - + ) : null; } return null; @@ -1002,18 +1107,23 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = {columnOrder.filter(key => visibleColumns[key]).map(key => { if (key === 'title') { return ( - +
- {task.description && ( -

+ {task.description && (maxLines === 'auto' || maxLines >= 3) && ( +

{task.description}

)} @@ -1044,15 +1154,15 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = > {(task.assignees && task.assignees.length > 0) ? (
- - + + {task.assignees.map(a => `${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email).join(', ')}
) : task.assignedTo ? (
- - {getUserName(task.assignedTo)} + + {getUserName(task.assignedTo)}
) : ( — @@ -1094,9 +1204,13 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = } if (key === 'description') { return ( - + {task.description ? ( -

+

{task.description}

) : ( @@ -1119,8 +1233,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = return (
- - {getUserName(task.createdBy)} + + {getUserName(task.createdBy)}
); @@ -1155,7 +1269,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users = ) : REGISTRY_QUICK_TOGGLE_TYPES.has(field.type) ? (