Загрузка данных
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> {
columns: HeadCell<T>[];
rows?: T[];
onRowClick?: (row: T) => void; // событие одного клика по строке
onRowDoubleClick?: (row: T) => void; // Событие двойного клика
onRowContextMenu?: (row: T, e: React.MouseEvent) => void; // Вызов контекстного меню
editable?: boolean; // включает режим редактирования
onAddRowRequest?: () => void; // Создание новой строки
onSaveAllRequest?: () => void; // сохранить все строки в базу
onDeleteRowRequest?: (row: T) => void; // Удалить строку
onRowUpdate?: (row: T) => void; // Событие редактирования
deleteField?: keyof T; // По какому столбцу отслеживать "удаление" (зачёркивание строки)
expandField?: string; // По какому столбцу отслеживать возможность раскрытия
expandComponent?: React.ReactNode; // Принимает готовый JSX: <Comp x={} y={} />
}
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]);
const finalTooltipText = title || (isOverflowed ? value : "");
return (
<Tooltip
title={finalTooltipText}
disableHoverListener={!finalTooltipText}
arrow
enterDelay={100}
>
<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,
expandComponent,
expandField,
}: CustomTableProps<T>) {
const [rows, setRows] = useState<T[]>([]);
const [columnVisibilityModel, setColumnVisibilityModel] = useState(
Object.fromEntries(columns.map(col => [col.id as string, !col.hidden]))
);
const [expandedRows, setExpandedRows] = useState<(string | number)[]>([]);
const [selectedId, setSelectedId] = useState<number | string | null>(null);
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 expandColumn: GridColDef[] = expandField && expandComponent ? [
{
field: "__expand__",
headerName: "",
width: 50,
sortable: false,
filterable: false,
// В детальной строке растягиваем эту ячейку на всю ширину таблицы (colSpan)
colSpan: (params) => {
if (params.row.isDetailPanel) {
return finalColumns.length;
}
return undefined;
},
renderCell: (params) => {
// Если это строка подвала (детальный компонент), рендерим переданный извне компонент
if (params.row.isDetailPanel) {
return (
<div style={{ padding: '12px', width: '100%', whiteSpace: 'normal' }}>
{expandComponent}
</div>
);
}
const hasExpand = Boolean(params.row[expandField as string]);
if (!hasExpand) return null;
const expanded = expandedRows.includes(params.id);
return (
<button
style={{ cursor: 'pointer', width: 24, height: 24 }}
onClick={(e) => {
e.stopPropagation();
setExpandedRows((prev) =>
expanded ? prev.filter((id) => id !== params.id) : [...prev, params.id]
);
}}
>
{expanded ? "-" : "+"}
</button>
);
},
},
] : [];
const gridColumns: GridColDef[] = columns.map((col) => {
const isBoolean = typeof (rows[0]?.[col.id] ?? "") === "boolean";
const baseCol: GridColDef = {
field: col.id as string,
headerName: col.label,
flex: col.widthCol ? undefined : 1,
width: col.widthCol,
editable: editable,
renderEditCell: isBoolean
? (params) => (
<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) => {
// Если родительская ячейка занята colSpan-ом детальной панели, ничего не рендерим
if (params.row.isDetailPanel) return null;
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;
}
return (
<OverflowTooltip title={tooltipTextFromField} value={cellValueString}>
<span>{cellValueString}</span>
</OverflowTooltip>
);
},
};
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 finalColumns = [...expandColumn, ...gridColumns];
// Группировка колонок
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>) => {
e.preventDefault();
if (!onRowContextMenu) return;
const rowNode = (e.target as HTMLElement).closest('[data-id]');
const rowId = rowNode ? (rowNode as HTMLElement).getAttribute('data-id') : null;
if (rowId !== null) {
// Ищем строку в исходном массиве rows, игнорируя detail-панели
const clickedRow = rows.find((r, idx) => String(r.row_id ?? idx) === String(rowId));
if (clickedRow) {
onRowContextMenu(clickedRow, e);
}
}
};
// Преобразуем плоский список строк: если ID строки есть в expandedRows,
// сразу за ней внедряем искусственную строку для отображения компонента
const displayRows = rows.reduce((acc: any[], item, index) => {
const rowId = item.row_id ?? index;
const actualRow = { ...item, row_id: rowId };
acc.push(actualRow);
if (expandedRows.includes(rowId)) {
acc.push({
row_id: `detail-${rowId}`, // Уникальный ID для DataGrid
isDetailPanel: true, // Флаг для кастомного рендера
});
}
return acc;
}, []);
return (
<div style={{ height: "100%", width: "100%" }} onContextMenu={handleGridContextMenu}>
<DataGrid
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={(newModel) => setColumnVisibilityModel(newModel)}
rows={displayRows}
columns={finalColumns}
columnGroupingModel={groupingModel}
getRowClassName={(params) => {
if (params.row.isDetailPanel) return "row-detail-panel";
return deleteField && params.row[deleteField] ? "row-delete" : "";
}}
getRowId={(row) => row.row_id}
// Динамическая высота: для детальной панели авто-высота, для обычных строк стандартная
getRowHeight={(params) => (params.model.isDetailPanel ? 'auto' : null)}
onRowClick={(params) => {
if (params.row.isDetailPanel) return; // Игнорируем клики по детальной панели
onRowClick && onRowClick(params.row as T);
}}
onRowDoubleClick={(params) => {
if (params.row.isDetailPanel) return;
onRowDoubleClick && onRowDoubleClick(params.row as T);
}}
processRowUpdate={(newRow) => {
const updated = rows.map((r, idx) => {
const rId = r.row_id === undefined ? idx : r.row_id;
return rId === 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}
columnGroupHeaderHeight={50}
headerHeight={80}
sx={{
'& .MuiDataGrid-columnHeaderTitle': { fontWeight: 'bold', lineHeight: 1.1, whiteSpace: "normal" },
'& .row-detail-panel': {
backgroundColor: '#f9f9f9',
'&:hover': { backgroundColor: '#f9f9f9 !important' },
},
'& .row-detail-panel .MuiDataGrid-cell': {
padding: 0,
overflow: 'visible',
},
"& .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',
},
}
}}
/>
);
}