Загрузка данных
import { DataGrid, GridColDef, GridColumnGroupingModel, GridRenderCellParams, GridRenderEditCellParams } 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; // включает режим редактирования
initialEmptyRows?: number; // сколько пустых строк создать при старте
onAddRowRequest?: () => void; // Создание новой строки
onSaveAllRequest?: () => void; // сохранить все строки в базу
onDeleteRowRequest?: (row: T) => void; // Удалить строку
onRowUpdate?: (row: T) => void; // Событие редактирования
deleteField?: keyof T; // По какому столбцу отслеживать "удаление" (зачёркивание строки)
// НОВЫЕ ПРОПСЫ ДЛЯ РАСКРЫТИЯ СТРОК
expandField?: keyof T; // По какому столбцу отслеживать возможность раскрытия
expandComponent?: React.ComponentType<{ id: string | number; row: T }>; // Компонент раскрытия, принимает id (или value) и всю строку
}
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,
// Деструктуризация новых пропсов
expandField,
expandComponent: ExpandComponent,
}: CustomTableProps<T>) {
const [baseRows, setBaseRows] = useState<T[]>([]);
const [expandedRowIds, setExpandedRowIds] = useState<(string | number)[]>([]);
// Скрытие столбцов [1]
const initialVisibility = Object.fromEntries(
columns.map(col => [col.id as string, !col.hidden])
);
const [columnVisibilityModel, setColumnVisibilityModel] = useState(initialVisibility);
useEffect(() => {
setBaseRows(initialRows);
}, [initialRows]);
const handleKeyDown = (params: any, event: any) => {
if (!editable || !onAddRowRequest) return;
const isLastRow = params.id === displayRows.length - 1; // проверяем по итоговому массиву
if (isLastRow && (event.key === "Enter" || event.key === "ArrowDown")) {
event.preventDefault();
onAddRowRequest();
}
};
const [selectedId, setSelectedId] = useState<number | string | null>(null);
// --- ФОРМИРОВАНИЕ ИТОГОВЫХ СТРОК С УЧЕТОМ РАСКРЫТЫХ ПАНЕЛЕЙ ---
const displayRows = React.useMemo(() => {
const result: any[] = [];
baseRows.forEach((row, index) => {
// Присваиваем row_id на основе индекса, как в вашем исходном коде [1]
const actualRowId = row.row_id ?? index;
const rowWithId = { ...row, row_id: actualRowId };
result.push(rowWithId);
// Если строка раскрыта, пушим вслед за ней специальный служебный объект панели
if (expandedRowIds.includes(actualRowId)) {
result.push({
row_id: `panel-${actualRowId}`, // Уникальный ID для DataGrid
isExpandedPanel: true,
parentRow: rowWithId, // Передаем родительские данные внутрь панели
});
}
});
return result;
}, [baseRows, expandedRowIds]);
// -------------------------------------------------------------
// Колонка для кнопки "+" / "-"
const expandColumn: GridColDef[] = expandField && ExpandComponent ? [
{
field: "__expand__",
headerName: "",
width: 50,
sortable: false,
filterable: false,
disableColumnMenu: true,
renderCell: (params) => {
// Если это строка-панель, кнопку не рисуем
if (params.row.isExpandedPanel) return null;
// Проверяем наличие данных в указанном столбце expandField
const hasExpandData = Boolean(params.row[expandField]);
if (!hasExpandData) return null;
const isExpanded = expandedRowIds.includes(params.id);
return (
<button
style={{
cursor: 'pointer',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold'
}}
onClick={(e) => {
e.stopPropagation(); // Чтобы не срабатывал клик по строке
setExpandedRowIds(prev =>
isExpanded ? prev.filter(id => id !== params.id) : [...prev, params.id]
);
}}
>
{isExpanded ? "−" : "+"}
</button>
);
}
}
] : [];
const gridColumns: GridColDef[] = columns.map((col) => {
const isBoolean = typeof (baseRows[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: 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) => {
// ЕСЛИ ЭТО НАША КАСTОМНАЯ СТРОКА-ПАНЕЛЬ
if (params.row.isExpandedPanel) {
// Рендерим кастомный компонент только в первой основной колонке данных,
// чтобы интерфейс не дублировался в каждой ячейке строки
const firstDataColumn = columns[0]?.id;
if (params.field !== firstDataColumn) return null;
const parentRow = params.row.parentRow;
const targetId = parentRow[expandField!]; // Берем значение из указанного столбца
return (
<div
style={{
position: 'absolute',
left: 0,
width: '100%',
padding: '10px 20px',
background: '#f9f9f9',
boxSizing: 'border-box',
zIndex: 2,
}}
onClick={(e) => e.stopPropagation()} // Изолируем клики внутри панели
>
{/* Рендерим переданный извне компонент вида <Test test={id} /> */}
<ExpandComponent id={targetId} row={parentRow} />
</div>
);
}
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 = {true} />;
}
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;
});
// Объединяем служебную колонку "+" и обычные колонки [1]
const finalColumns = [...expandColumn, ...gridColumns];
// Создание групп [1]
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 })),
});
});
// Выбор первой строки [1]
useEffect(() => {
if (baseRows.length > 0) {
const firstId = baseRows[0].row_id ?? 0;
setSelectedId(firstId);
onRowClick?.(baseRows[0]);
}
}, [baseRows]);
const handleGridContextMenu = (e: React.MouseEvent) => {
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) {
// Игнорируем контекстное меню для раскрытых панелей-заглушек
if (String(rowId).startsWith('panel-')) return;
const idx = Number(rowId);
const clickedRow = baseRows[idx] as T | undefined;
if (clickedRow) {
onRowContextMenu(clickedRow, e);
}
}
};
const russianLocale = {};
return (
<div
style={{ height: "100%", width: "100%" }}
onContextMenu={handleGridContextMenu}
>
<DataGrid
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={(newModel) => setColumnVisibilityModel(newModel)}
localeText={russianLocale}
// Передаем модифицированные строки (данные + раскрытые панели)
rows={displayRows}
columns={finalColumns}
columnGroupingModel={groupingModel}
// Динамически меняем высоту строки, если это раскрытая панель (например, 150px или авто)
getRowHeight={(params) => (params.model.isExpandedPanel ? 150 : 'auto')}
getRowClassName={(params) => {
if (params.row.isExpandedPanel) return "row-expanded-panel";
return deleteField && params.row[deleteField] ? "row-delete" : "";
}}
getRowId={(row) => row.row_id}
// Защищаем системные клики по строке панели
onRowClick={(params) => {
if (params.row.isExpandedPanel) return;
onRowClick && onRowClick(params.row as T);
}}
onRowDoubleClick={(params) => {
if (params.row.isExpandedPanel) return;
onRowDoubleClick && onRowDoubleClick(params.row as T);
}}
processRowUpdate={(newRow) => {
if (newRow.isExpandedPanel) return newRow;
const updated = baseRows.map((r, idx) => {
const currentId = r.row_id ?? idx;
return currentId === newRow.row_id ? newRow : r;
});
setBaseRows(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" },
// Стилизация строки-панели для объединения ячеек визуально
"& .row-expanded-panel": {
backgroundColor: "#f9f9f9 !important",
"& .MuiDataGrid-cell": {
borderBottom: "1px solid #e0e0e0",
overflow: "visible !important", // Позволяет компоненту растягиваться на всю ширину
}
},
"& .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',
},
}
}}
/>
);
}