Files
blog/src_generic/components/EnhancedTable.tsx
2026-04-03 20:09:36 +05:30

370 lines
13 KiB
TypeScript

import * as React from 'react';
import { alpha } from '@mui/material/styles';
import {
Box,
Typography,
Button,
IconButton,
Tooltip,
Card,
CardContent,
CardActions,
Grid,
Menu,
MenuItem,
useMediaQuery,
useTheme,
Divider,
Chip,
Stack,
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridActionsCellItem,
GridRenderCellParams,
GridPaginationModel,
} from '@mui/x-data-grid';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config';
interface EnhancedTableProps {
config: ResourceConfig;
data: any[];
total?: number;
paginationModel?: GridPaginationModel;
onPaginationModelChange?: (model: GridPaginationModel) => void;
loading?: boolean;
onEdit: (item: any) => void;
onDelete: (id: string) => void;
onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void;
}
export default function EnhancedTable({
config,
data,
total,
paginationModel,
onPaginationModelChange,
loading = false,
onEdit,
onDelete,
onCreate,
onNavigateToResource,
}: EnhancedTableProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate();
const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
if (field.type === 'number') muiType = 'number';
if (field.type === 'boolean') muiType = 'boolean';
if (field.type === 'date') muiType = 'date';
if (field.type === 'datetime') muiType = 'dateTime';
if (field.type === 'enum') muiType = 'singleSelect';
const col: GridColDef = {
field: key,
headerName: field.label,
type: muiType,
flex: 1,
minWidth: 150,
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
};
if (muiType === 'date' || muiType === 'dateTime') {
col.valueGetter = (value: any) => {
if (!value) return null;
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
};
}
if (muiType === 'singleSelect' && field.options) {
// @ts-ignore
col.valueOptions = field.options;
}
return col;
});
cols.push({
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 120,
getActions: (params) => [
<GridActionsCellItem
icon={<VisibilityIcon />}
label="View"
onClick={() => navigate(`/${config.name}/${params.id}`)}
/>,
<GridActionsCellItem
icon={<EditIcon />}
label="Edit"
onClick={() => navigate(`/${config.name}/edit/${params.id}`)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
label="Delete"
onClick={() => onDelete(params.id as string)}
/>,
],
});
return cols;
}, [config, onDelete, navigate, onNavigateToResource]);
if (isMobile) {
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate} size="small">
Add
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{data.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow
row={row}
config={config}
onEdit={onEdit}
onDelete={onDelete}
onNavigate={onNavigateToResource}
navigate={navigate}
/>
</Box>
))}
</Box>
</Box>
);
}
return (
<Box sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label}
</Button>
</Box>
<DataGrid
rows={data || []}
columns={columns}
autoHeight
paginationMode={config.pagination ? 'server' : 'client'}
rowCount={(() => {
if (!config.pagination) return data.length;
if (total !== undefined) return total;
// Graceful fallback for missing total count
const page = paginationModel?.page || 0;
const pageSize = paginationModel?.pageSize || 10;
if (data.length < pageSize) {
return page * pageSize + data.length;
}
// Enable 'Next' button by pretending there's at least one more page
return (page + 2) * pageSize;
})()}
loading={loading}
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
onPaginationModelChange={onPaginationModelChange}
getRowId={(row) => {
const pk = config.primaryKey;
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
for (const key of fallbackKeys) {
if (row[key] !== undefined && row[key] !== null) return row[key];
}
return `temp-id-${data.indexOf(row)}`;
}}
disableRowSelectionOnClick
pageSizeOptions={[10, 25, 50]}
sx={{
border: 'none',
'& .MuiDataGrid-cell:focus': { outline: 'none' },
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
}}
/>
</Box>
);
}
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const id = row[config.primaryKey];
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<Card variant="outlined" sx={{ borderRadius: 2 }}>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
#{id}
</Typography>
<IconButton size="small" onClick={handleClick}>
<MoreVertIcon fontSize="small" />
</IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/${id}`); }}>View</MenuItem>
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/edit/${id}`); }}>Edit</MenuItem>
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
</Menu>
</Box>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
<Box key={key}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
</Typography>
</Box>
))}
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
<Button size="small" onClick={() => navigate(`/${config.name}/${id}`)}>View Details</Button>
</CardActions>
</Card>
);
}
function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
if (!item) return "";
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
if (Array.isArray(displayField)) {
return displayField
.map(key => item[key])
.filter(val => val !== undefined && val !== null)
.join(' ');
}
return item[displayField] || item.id || JSON.stringify(item);
}
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
const value = params.value;
const isPk = fieldKey === config.primaryKey;
if (field.formatter) return field.formatter(value);
// 1. Single Relation
if (field.relation && value && !Array.isArray(value)) {
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
const displayValue = getFormattedDisplayValue(value, field.displayField);
return (
<Chip
label={displayValue}
size="small"
variant="outlined"
color="primary"
onClick={(e) => {
e.stopPropagation();
if (relationId) onNavigate?.(field.relation!, String(relationId));
}}
sx={{ cursor: 'pointer' }}
/>
);
}
// 2. Multi-Select (Array of relations or simple strings)
if (field.type === 'array' && Array.isArray(value)) {
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
return (
<Tooltip title={tooltipTitle} arrow placement="top">
<Stack direction="row" spacing={0.5} sx={{ overflow: 'hidden', flexWrap: 'nowrap' }}>
{value.map((item, idx) => (
<Chip
key={idx}
label={getFormattedDisplayValue(item, field.displayField)}
size="small"
variant="filled"
sx={{ maxWidth: 120 }}
onClick={(e) => {
e.stopPropagation();
if (field.relation) {
const id = typeof item === 'object' ? (item.id || item._id) : item;
if (id) onNavigate?.(field.relation!, String(id));
}
}}
/>
))}
</Stack>
</Tooltip>
);
}
// 3. Simple Objects
if (field.type === 'object' && value) {
return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
}
if (field.type === 'number' && typeof value === 'number') {
const isNegative = value < 0;
const color = isNegative ? 'error' : 'success';
return (
<Chip
label={value.toLocaleString()}
size="small"
color={color}
variant="filled"
sx={{
fontWeight: 'bold',
minWidth: 60,
// Soft background with bold text for a premium feel
bgcolor: (theme) => alpha(theme.palette[color].main, 0.15),
color: (theme) => theme.palette[color].dark,
'& .MuiChip-label': { px: 1.5 }
}}
/>
);
}
if (field.type === 'boolean') {
return value ? (
<Chip label="Yes" size="small" color="success" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
) : (
<Chip label="No" size="small" color="default" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
);
}
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
if (isPk && !isMobile) {
return (
<Chip
label={value}
size="small"
color="primary"
onClick={(e) => { e.stopPropagation(); navigate(`/${config.name}/${params.row[config.primaryKey]}`); }}
sx={{ cursor: 'pointer', fontWeight: 'bold' }}
/>
);
}
return value;
}