230 lines
8.9 KiB
JavaScript
230 lines
8.9 KiB
JavaScript
import { Component } from 'react';
|
||
import Box from '@mui/material/Box';
|
||
import Button from '@mui/material/Button';
|
||
import CircularProgress from '@mui/material/CircularProgress';
|
||
import IconButton from '@mui/material/IconButton';
|
||
import Stack from '@mui/material/Stack';
|
||
import Table from '@mui/material/Table';
|
||
import TableBody from '@mui/material/TableBody';
|
||
import TableCell from '@mui/material/TableCell';
|
||
import TableContainer from '@mui/material/TableContainer';
|
||
import TableHead from '@mui/material/TableHead';
|
||
import TableRow from '@mui/material/TableRow';
|
||
import Typography from '@mui/material/Typography';
|
||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||
import { PanelTile, PanelTileGrid } from '../panels';
|
||
|
||
/**
|
||
* Generic data-list GUI component — pure presentation, no data layer.
|
||
*
|
||
* Renders a PanelTileGrid > PanelTile with an optional create button, an empty
|
||
* state, and a table of rows with per-row edit/delete actions. Everything data
|
||
* related is injected via props, so this component never touches a storage
|
||
* manager, router, toaster, or backend error formatter.
|
||
*
|
||
* @component RecordList
|
||
* @import dashgui/lists
|
||
*
|
||
* @prop {'loading' | 'ready' | 'failed'} status - Controls which state renders: a centered spinner, the table, or the `error` node.
|
||
* @prop {object[]} rows - Row objects to render. Each row needs the `rowKey` field for the React key.
|
||
* @prop {Array<{key: string, label: ReactNode, render?: (row) => ReactNode, align?: string, padding?: string}>} columns - Column definitions. `key` is the row field (or used with `render`); `render(row)` customizes the cell; `align`/`padding` map to TableCell.
|
||
* @prop {ReactNode} [error] - Node rendered when `status === "failed"` (e.g. a formatted backend error).
|
||
* @prop {string} [rowKey=id] - Field used as the row identity / React key.
|
||
* @prop {string} [createLabel] - Label of the top-right create button. Omit to hide the button entirely.
|
||
* @prop {() => void} [onCreate] - Called when the create button is clicked.
|
||
* @prop {(row) => void} [onEdit] - Called when a row is clicked or its edit button is pressed. Omit to hide the edit button and row-click affordance.
|
||
* @prop {(row) => void} [onDelete] - Called when a row's delete button is pressed. Omit to hide the delete button.
|
||
* @prop {boolean} [deleteDisabled=false] - Disables the delete buttons — e.g. `busyId != null` while a delete is in flight.
|
||
* @prop {ReactNode} [emptyMessage] - Shown instead of the table when `rows` is empty. Omit to render nothing.
|
||
* @prop {string} [color] - Background color of the wrapping PanelTile.
|
||
* @prop {number | string} [minHeight=240] - Minimum height of the wrapping PanelTile.
|
||
* @prop {(row) => string} [getEditLabel] - aria-label for each edit button (defaults to `„<id>“ bearbeiten`).
|
||
* @prop {(row) => string} [getDeleteLabel] - aria-label for each delete button (defaults to `„<id>“ löschen`).
|
||
* @prop {object} [sx] - Extra styles passed to the wrapping PanelTile.
|
||
*
|
||
* @behaviour `status === "loading"` renders only a centered CircularProgress (no panel chrome).
|
||
* @behaviour `status === "failed"` renders only the `error` node — or nothing if `error` is omitted.
|
||
* @behaviour Clicking a row calls `onEdit(row)` (only when `onEdit` is provided); the action buttons stop propagation so they do not double-trigger the row click.
|
||
* @behaviour The actions column appears only if `onEdit` or `onDelete` is provided.
|
||
* @behaviour No data layer: this component never touches storage, routing, toasters, or backend error formatting.
|
||
* @behaviour When `color` is a hex value, the row dividers are tinted to a darker shade of it so the horizontal lines fit the tile background.
|
||
*/
|
||
class RecordList extends Component {
|
||
handleRowClick(row) {
|
||
this.props.onEdit?.(row);
|
||
}
|
||
|
||
/** Darken a #rrggbb / #rgb hex color by `amount` (0–1); falls back to the divider grey for non-hex colors. */
|
||
dividerColor(color, amount = 0.15) {
|
||
if (typeof color !== 'string') return 'divider';
|
||
const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||
if (!m) return 'divider';
|
||
|
||
let hex = m[1];
|
||
if (hex.length === 3) {
|
||
hex = hex
|
||
.split('')
|
||
.map((c) => c + c)
|
||
.join('');
|
||
}
|
||
const num = parseInt(hex, 16);
|
||
const channels = [16, 8, 0].map((shift) => {
|
||
const c = (num >> shift) & 0xff;
|
||
return Math.max(0, Math.round(c * (1 - amount)));
|
||
});
|
||
const toHex = (v) => v.toString(16).padStart(2, '0');
|
||
return `#${channels.map(toHex).join('')}`;
|
||
}
|
||
|
||
renderLoading() {
|
||
return (
|
||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||
<CircularProgress />
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
renderCreateButton() {
|
||
const { createLabel, onCreate } = this.props;
|
||
if (!createLabel) return null;
|
||
return (
|
||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||
<Button
|
||
type="button"
|
||
variant="contained"
|
||
size="small"
|
||
onClick={onCreate}
|
||
>
|
||
{createLabel}
|
||
</Button>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
renderEmpty() {
|
||
const { emptyMessage } = this.props;
|
||
if (!emptyMessage) return null;
|
||
return (
|
||
<Typography variant="body2" color="text.secondary">
|
||
{emptyMessage}
|
||
</Typography>
|
||
);
|
||
}
|
||
|
||
renderTable() {
|
||
const {
|
||
rows,
|
||
columns,
|
||
rowKey = 'id',
|
||
onEdit,
|
||
onDelete,
|
||
deleteDisabled,
|
||
getEditLabel,
|
||
getDeleteLabel,
|
||
color,
|
||
} = this.props;
|
||
|
||
const hasActions = Boolean(onEdit || onDelete);
|
||
const borderColor = this.dividerColor(color);
|
||
|
||
return (
|
||
<TableContainer>
|
||
<Table
|
||
size="small"
|
||
sx={{
|
||
'& .MuiTableCell-root': {
|
||
borderColor,
|
||
},
|
||
}}
|
||
>
|
||
<TableHead>
|
||
<TableRow>
|
||
{columns.map((col) => (
|
||
<TableCell key={col.key} align={col.align} padding={col.padding}>
|
||
{col.label}
|
||
</TableCell>
|
||
))}
|
||
{hasActions ? <TableCell padding="checkbox" /> : null}
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{rows.map((row) => {
|
||
const id = row[rowKey];
|
||
return (
|
||
<TableRow
|
||
key={id}
|
||
hover
|
||
sx={{ cursor: onEdit ? 'pointer' : undefined }}
|
||
onClick={() => this.handleRowClick(row)}
|
||
>
|
||
<TableCell
|
||
key={col.key}
|
||
align={col.align}
|
||
padding={col.padding}
|
||
sx={col.mono || col.key === 'number' || col.key === 'sku' ? { fontFamily: 'monospace' } : undefined}
|
||
>
|
||
{col.render ? col.render(row) : row[col.key]}
|
||
</TableCell>
|
||
{hasActions ? (
|
||
<TableCell
|
||
padding="checkbox"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||
{onEdit ? (
|
||
<IconButton
|
||
size="small"
|
||
aria-label={
|
||
getEditLabel?.(row) ?? `„${id}“ bearbeiten`
|
||
}
|
||
onClick={() => onEdit(row)}
|
||
>
|
||
<EditOutlinedIcon fontSize="small" />
|
||
</IconButton>
|
||
) : null}
|
||
{onDelete ? (
|
||
<IconButton
|
||
size="small"
|
||
aria-label={
|
||
getDeleteLabel?.(row) ?? `„${id}“ löschen`
|
||
}
|
||
disabled={deleteDisabled}
|
||
onClick={() => onDelete(row)}
|
||
>
|
||
<DeleteOutlinedIcon fontSize="small" />
|
||
</IconButton>
|
||
) : null}
|
||
</Box>
|
||
</TableCell>
|
||
) : null}
|
||
</TableRow>
|
||
);
|
||
})}
|
||
</TableBody>
|
||
</Table>
|
||
</TableContainer>
|
||
);
|
||
}
|
||
|
||
render() {
|
||
const { status, error, rows, color, minHeight = 240, sx } = this.props;
|
||
|
||
if (status === 'loading') return this.renderLoading();
|
||
if (status === 'failed') return error ?? null;
|
||
|
||
return (
|
||
<PanelTileGrid>
|
||
<PanelTile wide={3} minHeight={minHeight} color={color} sx={sx}>
|
||
<Stack spacing={2}>
|
||
{this.renderCreateButton()}
|
||
{rows.length === 0 ? this.renderEmpty() : this.renderTable()}
|
||
</Stack>
|
||
</PanelTile>
|
||
</PanelTileGrid>
|
||
);
|
||
}
|
||
}
|
||
|
||
export default RecordList;
|