feat(registry): горизонтальная прокрутка внизу области, sticky-шапка, ресайз колонок, лимит строк в ячейке

- ui/table: пропс wrapperClassName (twMerge), в реестре wrapper = w-max min-w-full
  overflow-visible — переполнение по ширине всплывает в контейнер реестра,
  скроллбар оказывается внизу видимой области, а не под всей таблицей
- TableHeader sticky top-0 (фон tr для plain-варианта скина)
- drag правого края шапки меняет ширину колонки (table-fixed + colgroup,
  min 80 / max 1200 px), двойной клик — сброс; ширины хранятся в localStorage
  per-form (registry-colwidths-<formId>) — у каждого пользователя свои
- настройка «Строк в ячейке» (Авто / 1..8) в диалоге настроек реестра:
  CSS line-clamp на текстовых ячейках, хранится в registry-rowlines-<formId>;
  оценка высоты виртуализатора ниже в однострочном режиме

check/build чисто, vitest 129/129
This commit is contained in:
2026-09-14 23:21:02 +03:00
parent c8b7741b11
commit e32def495d
2 changed files with 158 additions and 43 deletions

View File

@@ -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<number | 'auto'>(() => {
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<HTMLElement>) => {
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 }) => (
<TableHead className="relative">
{children}
<span
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize select-none touch-none z-10 hidden md:block"
title="Перетащите, чтобы изменить ширину. Двойной клик — сброс"
onPointerDown={e => startColumnResize(columnKey, e)}
onDoubleClick={e => { e.stopPropagation(); resetColumnWidth(columnKey); }}
onClick={e => e.stopPropagation()}
/>
</TableHead>
);
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<HTMLDivElement>(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 =
/>
<Label htmlFor="show-completed">Показать завершённые</Label>
</div>
{/* Максимум строк текста в ячейке */}
<div className="flex items-center justify-between gap-2 pb-2 border-b">
<Label htmlFor="registry-max-lines">Строк в ячейке</Label>
<Select
value={String(maxLines)}
onValueChange={v => setMaxLines(v === 'auto' ? 'auto' : Number(v))}
>
<SelectTrigger id="registry-max-lines" className="h-8 w-32" data-testid="select-max-lines">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Авто</SelectItem>
{[1, 2, 3, 4, 5, 6, 7, 8].map(n => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DndContext sensors={columnSensors} collisionDetection={closestCenter} onDragEnd={handleColumnDragEnd}>
<SortableContext items={columnOrder} strategy={verticalListSortingStrategy}>
<div className="space-y-1 max-h-[50vh] overflow-y-auto pr-1">
@@ -874,9 +972,16 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
)}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<div className="rounded-md border w-fit min-w-full">
<Table wrapperClassName="w-max min-w-full overflow-visible" className={hasCustomWidths ? 'table-fixed' : ''}>
{/* Ширины колонок, заданные пользователем (drag шапки); без записи — автоширина */}
<colgroup>
{isAdmin && <col style={{ width: 32 }} />}
{columnOrder.filter(k => visibleColumns[k]).map(k => (
<col key={k} style={columnWidths[k] ? { width: columnWidths[k] } : undefined} />
))}
</colgroup>
<TableHeader className={`sticky top-0 z-10${skinTable.header === 'plain' ? ' [&_tr]:bg-background' : ''}`}>
<TableRow>
{isAdmin && (
<TableHead className="w-8 px-2">
@@ -897,70 +1002,70 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
{columnOrder.filter(key => visibleColumns[key]).map(key => {
if (key === 'title') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="title" label="Название" filterValue={filters['title'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('title')} />
</TableHead>
</RegistryHead>
);
}
if (key === 'status') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="status" label="Статус" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('status')} />
</TableHead>
</RegistryHead>
);
}
if (key === 'assignedTo') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="assignedTo" label="Ответственный" filterValue={filters['assignedTo'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('assignedTo')} />
</TableHead>
</RegistryHead>
);
}
if (key === 'createdAt') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="createdAt" label="Создана" filterValue={filters['createdAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdAt')} />
</TableHead>
</RegistryHead>
);
}
if (key === 'dueDate') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="dueDate" label="Срок" filterValue={filters['dueDate'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('dueDate')} />
</TableHead>
</RegistryHead>
);
}
if (key === 'description') {
return <TableHead key={key}>Описание</TableHead>;
return <RegistryHead key={key} columnKey={key}>Описание</RegistryHead>;
}
if (key === 'updatedAt') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="updatedAt" label="Обновлена" filterValue={filters['updatedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('updatedAt')} />
</TableHead>
</RegistryHead>
);
}
if (key === 'createdBy') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="createdBy" label="Автор" filterValue={filters['createdBy'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('createdBy')} />
</TableHead>
</RegistryHead>
);
}
if (key === 'completedAt') {
return (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column="completedAt" label="Завершена" filterValue={filters['completedAt'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions('completedAt')} />
</TableHead>
</RegistryHead>
);
}
if (key.startsWith('field_')) {
const fieldId = parseInt(key.replace('field_', ''));
const field = fields.find(f => f.id === fieldId);
return field ? (
<TableHead key={key}>
<RegistryHead key={key} columnKey={key}>
<ColumnFilter column={key} label={field.name} filterValue={filters[key] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchTaskColumnSuggestions(key)} />
</TableHead>
</RegistryHead>
) : 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 (
<TableCell key={key} className="py-1 font-medium max-w-xs">
<TableCell key={key} className={`py-1 font-medium ${columnWidths.title ? '' : 'max-w-xs'}`}>
<div>
<button
className={`font-medium text-sm text-primary hover:text-primary/80 hover:underline text-left truncate block w-full ${isCompleted ? 'line-through text-muted-foreground' : ''}`}
className={`font-medium text-sm text-primary hover:text-primary/80 hover:underline text-left w-full block ${maxLines === 'auto' ? 'truncate' : ''} ${isCompleted ? 'line-through text-muted-foreground' : ''}`}
style={clampStyle(maxLines)}
title={task.title}
data-testid={`button-task-title-${task.id}`}
onClick={() => onTaskClick?.(task)}
>
{task.title}
</button>
{task.description && (
<p className={`text-xs text-muted-foreground truncate mt-0.5 ${isCompleted ? 'line-through' : ''}`} title={task.description}>
{task.description && (maxLines === 'auto' || maxLines >= 3) && (
<p
className={`text-xs text-muted-foreground mt-0.5 ${maxLines === 'auto' ? 'truncate' : ''} ${isCompleted ? 'line-through' : ''}`}
style={maxLines === 'auto' ? undefined : clampStyle(1)}
title={task.description}
>
{task.description}
</p>
)}
@@ -1044,15 +1154,15 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
>
{(task.assignees && task.assignees.length > 0) ? (
<div className="flex items-center">
<User className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
<span className="text-sm" style={clampStyle(maxLines)}>
{task.assignees.map(a => `${a.user.firstName || ''} ${a.user.middleName || ''} ${a.user.lastName || ''}`.trim() || a.user.email).join(', ')}
</span>
</div>
) : task.assignedTo ? (
<div className="flex items-center">
<User className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">{getUserName(task.assignedTo)}</span>
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
<span className="text-sm" style={clampStyle(maxLines)}>{getUserName(task.assignedTo)}</span>
</div>
) : (
<span className="text-muted-foreground text-sm">—</span>
@@ -1094,9 +1204,13 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
}
if (key === 'description') {
return (
<TableCell key={key} className="py-2 max-w-sm">
<TableCell key={key} className={`py-2 ${columnWidths.description ? '' : 'max-w-sm'}`}>
{task.description ? (
<p className="text-sm truncate" title={task.description}>
<p
className={`text-sm ${maxLines === 'auto' ? 'truncate' : ''}`}
style={clampStyle(maxLines)}
title={task.description}
>
{task.description}
</p>
) : (
@@ -1119,8 +1233,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
return (
<TableCell key={key} className="py-1">
<div className="flex items-center">
<User className="w-3 h-3 mr-1 text-muted-foreground" />
<span className="text-sm">{getUserName(task.createdBy)}</span>
<User className="w-3 h-3 mr-1 text-muted-foreground shrink-0" />
<span className="text-sm" style={clampStyle(maxLines)}>{getUserName(task.createdBy)}</span>
</div>
</TableCell>
);
@@ -1155,7 +1269,8 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
) : REGISTRY_QUICK_TOGGLE_TYPES.has(field.type) ? (
<button
type="button"
className="text-sm cursor-pointer hover:text-primary"
className="text-sm cursor-pointer hover:text-primary text-left w-full"
style={clampStyle(maxLines)}
onClick={() => handleQuickToggle(task.id, field.id, rawValue)}
onDoubleClick={() => setEditingCell({ taskId: task.id, columnKey: key })}
>
@@ -1165,7 +1280,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
<InlineField
ariaLabel={field.name}
readView={
<span className="text-sm">
<span className="text-sm" style={clampStyle(maxLines)}>
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</span>
}
@@ -1193,7 +1308,7 @@ const TaskRegistry = ({ tasks, relatedTaskTitles = {}, fields, statuses, users =
className={field.type === 'textarea' || field.type === 'rich-text' ? 'items-start min-h-[24px] w-full' : 'items-center min-h-[24px] w-full'}
/>
) : (
<span className="text-sm">
<span className="text-sm" style={clampStyle(maxLines)}>
{displayFieldValue(field, rawValue, displayContext) || <span className="text-muted-foreground">—</span>}
</span>
)}

View File

@@ -6,9 +6,9 @@ import { tableRecipe } from "@/design-system/recipes"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className={tableRecipe.wrapper}>
React.HTMLAttributes<HTMLTableElement> & { wrapperClassName?: string }
>(({ className, wrapperClassName, ...props }, ref) => (
<div className={cn(tableRecipe.wrapper, wrapperClassName)}>
<table
ref={ref}
className={cn(tableRecipe.root, className)}