Загрузка данных
import {DataGrid, GridColDef, GridColumnGroupingModel, GridRenderCellParams} from "@mui/x-data-grid";
import React, {useEffect, useState, useRef, useLayoutEffect } from "react";
import { Tooltip, Checkbox } from "@mui/material";
export interface HeadCell<T> {
id: keyof T; // Ключ данных
label: string; // Название столбца
groupName?: string; // Группа
tooltipField?: keyof T; // Ключ для подскадки
hidden?: boolean; // Скрыть столбец
}
interface CustomTableProps<T> {
onRowClick?: (row: T) => void; // событие одного клика по строке
onRowDoubleClick?: (row: T) => void; // Событие двойного клика
onRowContextMenu?: (row: T, e: React.MouseEvent) => void; // Вызов контекстного меню
editable?: boolean; // включает режим редактирования
initialEmptyRows?: number; // сколько пустых строк создать при старте
onAddRowRequest?: () => void; // Создание новой строки
onSaveAllRequest?: () => void; // сохранить все строки в базу
onDeleteRowRequest?: (row: T) => void; // Удалить строку
onRowUpdate?: (row: T) => void; // Событие редактирования
}
interface OverflowTooltipProps {
title: string;
children: React.ReactElement;
value: string;
}
const OverflowTooltip = ({ title, children, value }: OverflowTooltipProps) => {
const [isOverflowed, setIsOverflowed] = useState(false);
const textRef = useRef<HTMLDivElement>(null);
const visibleColumns = columns.filter(col => !col.hidden);
useLayoutEffect(() => {
const element = textRef.current;
if (!element) return;
// Создаем наблюдатель за размером
const resizeObserver = new ResizeObserver(() => {
const hasOverflow = element.scrollWidth > element.clientWidth;
setIsOverflowed(hasOverflow);
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect(); // Чистим при размонтировании
};
}, [value]); // Переподключаем если сменилось значение, чтобы сбросить стейт
// Приоритет:
// 1. title из tooltipField.
// 2. Если нет title и текст не влезает показываем значение ячейки.
const finalTooltipText = title || (isOverflowed ? value : "");
return (
<Tooltip
title={finalTooltipText}
disableHoverListener={!finalTooltipText}
arrow
enterDelay={100}
>
{/* Оборачиваем в div, чтобы реф всегда был на контейнере, который мы меряем */}
<div
ref={textRef}
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
width: '100%'
}}
>
{children}
</div>
</Tooltip>
);
};
export default function CustomTable<T extends { row_id?: number | string }>({
columns,
rows: initialRows = [],
onRowClick,
onRowDoubleClick,
onRowContextMenu,
editable = false,
onAddRowRequest,
onSaveAllRequest,
onDeleteRowRequest,
onRowUpdate,
}: CustomTableProps<T>) {
const [rows, setRows] = useState<T[]>([]);
useEffect(() => {
setRows(initialRows);
}, [initialRows]);
const handleKeyDown = (params: any, event: any) => {
if (!editable || !onAddRowRequest) return;
const isLastRow = params.id === rows.length - 1;
if (isLastRow && (event.key === "Enter" || event.key === "ArrowDown")) {
event.preventDefault();
onAddRowRequest();
}
};
const [selectedId, setSelectedId] = useState<number | string | null>(null); //выбор первой строки
const gridColumns: GridColDef[] = columns.map((col) => {
const isBoolean = typeof (rows[0]?.[col.id] ?? "") === "boolean";
return {
field: col.id as string,
headerName: col.label,
flex: 1,
minWidth: 100,
editable: editable, // включаем редактирование
renderEditCell: isBoolean
? (params: GridRenderEditCellParams) => (
<Checkbox
checked={Boolean(params.value)}
onChange={(e) => params.api.setEditCellValue({
id: params.id,
field: params.field,
value: e.target.checked,
})}
autoFocus
/>
)
: undefined,
renderCell: (params: GridRenderCellParams) => {
const value = params.value;
const tooltipTextFromField = col.tooltipField ? String(params.row[col.tooltipField] ?? '') : "";
const cellValueString = String(value ?? '');
// Если это булево, оставляем старую логику без авто-тултипа
if (typeof value === 'boolean') {
const boolContent = (
<div style={{display: 'flex', justifyContent: 'center', width: '100%'}}>
{value ? <span style={{color: '#2e7d32', fontWeight: 'bold', fontSize: '20px'}}>✓</span> : null}
</div>
);
return tooltipTextFromField ?
<Tooltip title={tooltipTextFromField} arrow>{boolContent}</Tooltip> : boolContent;
}
if (isBoolean) {
return (
<Checkbox
checked={Boolean(params.value)}
disabled
/>
);
}
return (
<OverflowTooltip title={tooltipTextFromField} value={cellValueString}>
<span>{cellValueString}</span>
</OverflowTooltip>
);
return params.value;
},
};
});
// Создание групп
const groupingModel: GridColumnGroupingModel = [];
const groupsMap = new Map<string, string[]>();
columns.forEach((col) => {
if (col.groupName) {
const existing = groupsMap.get(col.groupName) || [];
groupsMap.set(col.groupName, [...existing, col.id as string]);
}
});
groupsMap.forEach((childrenFields, groupTitle) => {
groupingModel.push({
groupId: groupTitle,
headerName: groupTitle, // Название группы в шапке
headerAlign: 'center',
children: childrenFields.map(field => ({field})),
});
});
// Выбор первой строки
useEffect(() => {
if (rows.length > 0) {
const firstId = rows[0].row_id ?? 0;
setSelectedId(firstId);
onRowClick?.(rows[0]);
}
}, [rows]);
const handleGridContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
console.log("[ContextMenu] ПКМ по таблице target:", e.target);
e.preventDefault();
if (!onRowContextMenu) return;
// Пара десятков тестиков и всё заработало ЫЫЫЫЫЫЫЫЫ
const rowNode = (e.target as HTMLElement).closest('[data-id]');
const rowId = rowNode ? (rowNode as HTMLElement).getAttribute('data-id') : null;
console.log("[ContextMenu] найден rowId:", rowId);
if (rowId !== null) {
const idx = Number(rowId);
const clickedRow = rows[idx] as T | undefined;
if (clickedRow) {
console.log("[ContextMenu] передаём строку в onRowContextMenu:", clickedRow);
onRowContextMenu(clickedRow, e);
} else {
console.log("[ContextMenu] строка по rowId не найдена.");
}
} else {
console.log("[ContextMenu] клик вне строки");
}
};
type PluralForm = {
one: string;
few: string;
many: string;
};
function getPluralForm(count: number, options: PluralForm) {
const penultimateDigit = Math.floor(count / 10) % 10;
const lastDigit = count % 10;
let pluralForm = options.many;
if (penultimateDigit !== 1 && lastDigit > 1 && lastDigit < 5) {
pluralForm = options.few;
} else if (penultimateDigit !== 1 && lastDigit === 1) {
pluralForm = options.one;
}
return `${count} ${pluralForm}`;
}
const russianLocale = {
// Таблица
noRowsLabel: 'Данные отсутствуют',
// Иконки
columnMenuLabel: 'Меню',
columnHeaderSortIconLabel: "Сортировать",
//Меню
columnMenuShowColumns: 'Показать колонки',
columnMenuFilter: 'Фильтр',
columnMenuHideColumn: 'Скрыть',
columnMenuUnsort: 'Снять сортировку',
columnMenuSortAsc: 'Сортировать по возрастанию',
columnMenuSortDesc: 'Сортировать по убыванию',
//Мэнеджер колонок
columnMenuManageColumns: "Управление колонками",
columnsManagementSearchTitle: "Поиск",
columnsManagementReset: "Сбросить",
columnsManagementShowHideAllText: "Показать/Скрыть Всё",
// Фильтр
filterPanelDeleteIconLabel: 'Удалить',
filterPanelOperator: 'Операторы',
filterPanelColumns: 'Столбцы',
filterPanelInputLabel: 'Значение',
filterPanelInputPlaceholder: 'Значение фильтра',
columnHeaderFiltersTooltipActive: (count) =>
getPluralForm(count, {
one: 'активный фильтр',
few: 'активных фильтра',
many: 'активных фильтров',
}),
filterOperatorContains: 'содержит',
filterOperatorDoesNotContain: 'не содержит',
filterOperatorEquals: 'равен',
filterOperatorDoesNotEqual: 'не равен',
filterOperatorStartsWith: 'начинается с',
filterOperatorEndsWith: 'заканчивается на',
filterOperatorIsEmpty: 'пустой',
filterOperatorIsNotEmpty: 'не пустой',
filterOperatorIsAnyOf: 'любой из',
};
return (
<div
style={{
height: "100%",
width: "100%",
}}
onContextMenu={handleGridContextMenu}
>
<DataGrid
localeText={russianLocale}
rows={rows.map((item, index)=>({...item, row_id: index}))}
columns={gridColumns}
columnGroupingModel={groupingModel}
getRowId={(row) => row.row_id}
onRowClick={(params) => onRowClick && onRowClick(params.row as T)}
onRowDoubleClick={(params) =>
onRowDoubleClick && onRowDoubleClick(params.row as T)
}
onContextMenu={(e) => {
if (onRowContextMenu) {
e.preventDefault();
onRowContextMenu(rows, e);
}
}}
processRowUpdate={(newRow) => {
const updated = rows.map((r) =>
r.row_id === newRow.row_id ? newRow : r
);
setRows(updated);
onRowUpdate?.(newRow);
return newRow;
}}
onCellKeyDown={handleKeyDown}
experimentalFeatures={{ newEditingApi: true }}
selectionModel={selectedId ? [selectedId] : []}
paginationMode="server"
autoPageSize={true}
hideFooter={true}
disableColumnSelector={false}
columnGroupHeaderHeight={40}
headerHeight={40}
sx={{
'& .MuiDataGrid-columnHeaderTitle': { fontWeight: 'bold' },
}}
/>
</div>
);
}