isResizingColumn выставляется на mousedown и снимается по mouseup, blur окна и размонтированию: пока тянут, MRT замораживает тело таблицы (design.md D3). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
381 lines
11 KiB
JavaScript
381 lines
11 KiB
JavaScript
import React, { useMemo } from 'react';
|
||
import ColumnResizer from './ColumnResizer';
|
||
import { formatHeaderYear } from '../utils/headerYearUtils';
|
||
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
|
||
import { endColumnResize, startColumnResize } from '../utils/columnResizeInfo';
|
||
|
||
const getColumnPinnedStyles = (column) => {
|
||
const pinningPosition = column.getIsPinned();
|
||
|
||
return {
|
||
isPinned: Boolean(pinningPosition),
|
||
left: pinningPosition === 'left' ? column.getStart('left') : undefined,
|
||
right: pinningPosition === 'right' ? column.getAfter('right') : undefined,
|
||
};
|
||
};
|
||
|
||
const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => {
|
||
const sortedColumns = getOrderedVisibleColumns(table);
|
||
const tableState = table.getState();
|
||
const filteredColumns = sortedColumns.filter(
|
||
(col) => !(col.id in tableState.columnVisibility) || (col.id in tableState.columnVisibility && tableState.columnVisibility[col.id]),
|
||
);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: (theme) => theme.zIndex.table + 1,
|
||
backgroundColor: 'white',
|
||
}}>
|
||
{filteredColumns.map((column) => (
|
||
<ColumnNumberCell
|
||
key={`col-number-${column.id}`}
|
||
column={column}
|
||
table={table}
|
||
selectedColumnId={selectedColumnId}
|
||
onColumnSelect={onColumnSelect}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ColumnNumberCell = ({ column, table, selectedColumnId, onColumnSelect }) => {
|
||
const { isPinned, left, right } = getColumnPinnedStyles(column);
|
||
const allLeafColumns = table.getAllLeafColumns();
|
||
const originalIndex = allLeafColumns.findIndex((col) => col.id === column.id);
|
||
const selected = selectedColumnId === column.id;
|
||
|
||
return (
|
||
<div
|
||
key={`col-number-${column.id}`}
|
||
data-col-select-id={column.id}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onColumnSelect?.(column.id);
|
||
}}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
style={{
|
||
width: column.getSize(),
|
||
minWidth: column.getSize(),
|
||
maxWidth: column.getSize(),
|
||
padding: '2px 6px',
|
||
textAlign: 'center',
|
||
backgroundColor: selected ? '#589A2F' : '#FBFBFB',
|
||
color: selected ? '#ffffff' : '#5E5E5E',
|
||
boxSizing: 'border-box',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
height: '1.5rem',
|
||
border: '.063rem solid rgba(209, 213, 220, 1)',
|
||
borderBottom: 'none',
|
||
position: isPinned ? 'sticky' : 'relative',
|
||
left,
|
||
right,
|
||
zIndex: selected ? 16 : isPinned ? 20 : 'auto',
|
||
cursor: 'pointer',
|
||
userSelect: 'none',
|
||
}}>
|
||
{originalIndex}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const getColorBrightness = (hexColor) => {
|
||
if (!hexColor) return 255;
|
||
const color = hexColor.replace('#', '');
|
||
let r, g, b;
|
||
if (color.length === 3) {
|
||
r = Number.parseInt(color[0] + color[0], 16);
|
||
g = Number.parseInt(color[1] + color[1], 16);
|
||
b = Number.parseInt(color[2] + color[2], 16);
|
||
} else if (color.length === 6) {
|
||
r = Number.parseInt(color.substring(0, 2), 16);
|
||
g = Number.parseInt(color.substring(2, 4), 16);
|
||
b = Number.parseInt(color.substring(4, 6), 16);
|
||
} else {
|
||
return 255;
|
||
}
|
||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||
};
|
||
|
||
const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear }) => {
|
||
const column = header.column;
|
||
const headerSize = header.getSize();
|
||
const isPinned = Boolean(pinningPosition);
|
||
const leftOffset = pinningPosition === 'left' ? header.getStart() : undefined;
|
||
const rightOffset = pinningPosition === 'right' ? table.getRightTotalSize() - header.getStart() - headerSize : undefined;
|
||
const stickyTextLeft = isPinned ? undefined : table.getLeftTotalSize();
|
||
const stickyTextRight = isPinned ? undefined : table.getRightTotalSize();
|
||
|
||
const customBgColor = column.columnDef?.muiTableHeadCellProps?.sx?.backgroundColor;
|
||
|
||
const backgroundColor = customBgColor ? customBgColor : 'rgba(243, 244, 246, 1)';
|
||
|
||
const color = useMemo(() => {
|
||
const brightness = getColorBrightness(backgroundColor);
|
||
return brightness < 128 ? '#ffffff' : '#000000';
|
||
}, [backgroundColor]);
|
||
|
||
const handleChangeWidth = (deltaWidth) => {
|
||
const size = header.getSize();
|
||
onChangeWidth(header, size + deltaWidth);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: isPinned ? 'sticky' : 'relative',
|
||
left: leftOffset,
|
||
right: rightOffset,
|
||
zIndex: isPinned ? 21 : 'auto',
|
||
width: `${headerSize}px`,
|
||
minWidth: `${headerSize}px`,
|
||
maxWidth: `${headerSize}px`,
|
||
flex: '0 0 auto',
|
||
}}>
|
||
<div
|
||
onClick={() => onClick(header)}
|
||
style={{
|
||
width: '100%',
|
||
minWidth: '100%',
|
||
maxWidth: '100%',
|
||
position: 'relative',
|
||
padding: '4px 6px',
|
||
textAlign: 'center',
|
||
fontWeight: 600,
|
||
fontSize: '12px',
|
||
lineHeight: 1,
|
||
backgroundColor: backgroundColor,
|
||
color: '#424242',
|
||
boxSizing: 'border-box',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
height: '3rem',
|
||
border: '.063rem solid rgba(209, 213, 220, 1)',
|
||
}}>
|
||
<div
|
||
style={{
|
||
textAlign: 'center',
|
||
position: isPinned ? 'relative' : 'sticky',
|
||
left: stickyTextLeft,
|
||
right: stickyTextRight,
|
||
zIndex: 1,
|
||
paddingRight: '0.5rem',
|
||
paddingLeft: '0.5rem',
|
||
backgroundColor: backgroundColor,
|
||
color: color,
|
||
maxWidth: 'calc(100% - 1rem)',
|
||
boxSizing: 'border-box',
|
||
}}>
|
||
{header.isPlaceholder ? null : formatHeaderYear(column.columnDef.header, budgetYear)}
|
||
</div>
|
||
<ColumnResizer
|
||
columnId={column.id}
|
||
reportDragDeltaX={handleChangeWidth}
|
||
onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
|
||
onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const FilterRow = ({ table }) => {
|
||
const sortedColumns = getOrderedVisibleColumns(table);
|
||
const tableState = table.getState();
|
||
|
||
const filteredColumns = sortedColumns.filter(
|
||
(col) => !(col.id in tableState.columnVisibility) || (col.id in tableState.columnVisibility && tableState.columnVisibility[col.id]),
|
||
);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
position: 'sticky',
|
||
top: '1.5rem',
|
||
zIndex: 20,
|
||
backgroundColor: 'white',
|
||
}}>
|
||
{filteredColumns.map((column) => (
|
||
<FilterCell key={`filter-${column.id}`} column={column} />
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Компонент ячейки фильтра
|
||
const FilterCell = ({ column }) => {
|
||
const { isPinned, left, right } = getColumnPinnedStyles(column);
|
||
|
||
// Определяем тип фильтра на основе колонки
|
||
const filterVariant = column.columnDef.filterVariant || 'text';
|
||
|
||
// Для select фильтра
|
||
const selectOptions = column.columnDef.filterSelectOptions || [];
|
||
|
||
const handleFilterChange = (value) => {
|
||
column.setFilterValue(value);
|
||
};
|
||
|
||
// Рендер разных типов фильтров
|
||
const renderFilterInput = () => {
|
||
switch (filterVariant) {
|
||
case 'select':
|
||
return (
|
||
<select
|
||
value={column.getFilterValue() ?? ''}
|
||
onChange={(e) => handleFilterChange(e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.25rem 0.375rem',
|
||
fontSize: '0.6875rem',
|
||
border: '1px solid #ddd',
|
||
borderRadius: '0.25rem',
|
||
backgroundColor: 'white',
|
||
}}>
|
||
<option value=''>Все</option>
|
||
{selectOptions.map((option) => (
|
||
<option key={option} value={option}>
|
||
{option}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
|
||
case 'range': {
|
||
const [min, max] = column.getFilterValue() || ['', ''];
|
||
return (
|
||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||
{' '}
|
||
{/* 4px */}
|
||
<input
|
||
type='number'
|
||
placeholder='От'
|
||
value={min}
|
||
onChange={(e) => column.setFilterValue([e.target.value, max])}
|
||
style={{
|
||
width: '50%',
|
||
padding: '0.25rem',
|
||
fontSize: '0.6875rem',
|
||
}}
|
||
/>
|
||
<input
|
||
type='number'
|
||
placeholder='До'
|
||
value={max}
|
||
onChange={(e) => column.setFilterValue([min, e.target.value])}
|
||
style={{
|
||
width: '50%',
|
||
padding: '0.25rem',
|
||
fontSize: '0.6875rem',
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
default: // 'text'
|
||
return (
|
||
<input
|
||
type='text'
|
||
value={column.getFilterValue() ?? ''}
|
||
onChange={(e) => handleFilterChange(e.target.value)}
|
||
placeholder='Поиск...'
|
||
style={{
|
||
width: '100%',
|
||
padding: '0.25rem 0.375rem',
|
||
fontSize: '0.6875rem',
|
||
border: '1px solid #ddd',
|
||
borderRadius: '0.25rem',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
width: column.getSize(),
|
||
minWidth: column.getSize(),
|
||
maxWidth: column.getSize(),
|
||
padding: '4px 6px',
|
||
backgroundColor: isPinned ? '#f5f5f5' : 'white',
|
||
boxSizing: 'border-box',
|
||
height: '2.5rem',
|
||
border: '.063rem solid rgba(209, 213, 220, 1)',
|
||
borderTop: 'none',
|
||
position: isPinned ? 'sticky' : 'relative',
|
||
left,
|
||
right,
|
||
zIndex: isPinned ? 19 : 'auto',
|
||
}}>
|
||
{renderFilterInput()}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData, budgetYear }) => {
|
||
const handleHeaderClick = (header) => {
|
||
if (!header.subHeaders?.length && header.column?.id) {
|
||
onColumnSelect?.(header.column.id);
|
||
}
|
||
};
|
||
|
||
if (!isLoadingData) {
|
||
return <div></div>;
|
||
}
|
||
|
||
const headerSections = [
|
||
{ groups: table.getLeftHeaderGroups(), pinningPosition: 'left' },
|
||
{ groups: table.getCenterHeaderGroups(), pinningPosition: undefined },
|
||
{ groups: table.getRightHeaderGroups(), pinningPosition: 'right' },
|
||
];
|
||
const headerRowsCount = Math.max(...headerSections.map(({ groups }) => groups.length));
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
style={{
|
||
zIndex: (theme) => theme.zIndex.table,
|
||
position: 'relative',
|
||
width: 'max-content',
|
||
}}>
|
||
{/* Строка с номерами колонок */}
|
||
<ColumnNumbersRow table={table} selectedColumnId={selectedColumnId} onColumnSelect={onColumnSelect} />
|
||
|
||
{table.getState().showColumnFilters && <FilterRow table={table} />}
|
||
|
||
{/* Pinned и обычные заголовки строятся отдельными группами. */}
|
||
{Array.from({ length: headerRowsCount }, (_, rowIndex) => (
|
||
<div key={`header-row-${rowIndex}`} style={{ display: 'flex' }}>
|
||
{headerSections.flatMap(({ groups, pinningPosition }) =>
|
||
(groups[rowIndex]?.headers || []).map((header) => (
|
||
<HeaderCell
|
||
budgetYear={budgetYear}
|
||
key={`${pinningPosition || 'center'}-${header.id}`}
|
||
header={header}
|
||
table={table}
|
||
pinningPosition={pinningPosition}
|
||
onClick={handleHeaderClick}
|
||
onChangeWidth={onChangeWidth}
|
||
/>
|
||
)),
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export const TableHead = React.memo(TableHeadComponent);
|