Загрузка данных
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; // Скрыть столбец
vertical?: boolean; // Вертикальный заголовок (работает пока что криво)
widthCol?: number; // Размер колонок
}
interface CustomTableProps<T> {
onRowClick?: (row: T) => void; // событие одного клика по строке
onRowDoubleClick?: (row: T) => void; // Событие двойного клика
onRowContextMenu?: (row: T, e: React.MouseEvent) => void; // Вызов контекстного меню
editable?: boolean; // включает режим редактирования
initialEmptyRows?: number; // сколько пустых строк создать при старте
// onAddRow?: () => T;
// onSaveAll?: (rows: T[]) => void;
onAddRowRequest?: () => void; // Создание новой строки
onSaveAllRequest?: () => void; // сохранить все строки в базу
onDeleteRowRequest?: (row: T) => void; // Удалить строку
onRowUpdate?: (row: T) => void; // Событие редактирования
deleteField?: keyof T; // По какому столбцу отслеживать "удаление" (зачёркивание строки)
}
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);
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,
deleteField,
}: CustomTableProps<T>) {
const [rows, setRows] = useState<T[]>([]);
// Скрытие столбцов ---
const initialVisibility = Object.fromEntries(
columns.map(col => [col.id as string, !col.hidden])
);
const [columnVisibilityModel, setColumnVisibilityModel] = useState(initialVisibility);
// ---
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 {
const baseCol: GridColDef = {
field: col.id as string,
headerName: col.label,
flex: col.widthCol ? undefined : 1,
width: col.widthCol,
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;
},
};
if (col.vertical) {
baseCol.headerAlign = "center";
baseCol.renderHeader = () => (
<div
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
writingMode: "vertical-rl",
transform: "rotate(180deg)",
whiteSpace: "nowrap",
width: "20px",
fontWeight: "bold",
}}
>
{col.label}
</div>
);
}
return baseCol;
});
// Создание групп
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 = {
// мой перевод
};
return (
<div
style={{
height: "100%",
width: "100%",
}}
onContextMenu={handleGridContextMenu}
>
<DataGrid
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={(newModel) => setColumnVisibilityModel(newModel)}
localeText={russianLocale}
rows={rows.map((item, index)=>({...item, row_id: index}))}
columns={gridColumns}
columnGroupingModel={groupingModel}
getRowClassName={(params) => deleteField && params.row[deleteField] ? "row-delete" : ""}
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={50}
headerHeight={80}
sx={{
'& .MuiDataGrid-columnHeaderTitle': { fontWeight: 'bold', lineHeight: 1.1, whiteSpace: "normal" },
// '& .MuiDataGrid-columnHeaders' : { height: 40, maxHeight: 55},
"& .row-delete": {
position: 'relative',
'& .MuiDataGrid-cell': {
opacity: 0.7,
},
// Красная линия
'&::after': {
content: '""',
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: '2px',
backgroundColor: '#d32f2f',
transform: 'translateY(-50%)',
zIndex: 1,
pointerEvents: 'none',
},
}
}}
/>
</div>
);
}