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 `„“ bearbeiten`). * @prop {(row) => string} [getDeleteLabel] - aria-label for each delete button (defaults to `„“ 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 ( ); } renderCreateButton() { const { createLabel, onCreate } = this.props; if (!createLabel) return null; return ( ); } renderEmpty() { const { emptyMessage } = this.props; if (!emptyMessage) return null; return ( {emptyMessage} ); } 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 ( {columns.map((col) => ( {col.label} ))} {hasActions ? : null} {rows.map((row) => { const id = row[rowKey]; return ( this.handleRowClick(row)} > {col.render ? col.render(row) : row[col.key]} {hasActions ? ( e.stopPropagation()} > {onEdit ? ( onEdit(row)} > ) : null} {onDelete ? ( onDelete(row)} > ) : null} ) : null} ); })}
); } render() { const { status, error, rows, color, minHeight = 240, sx } = this.props; if (status === 'loading') return this.renderLoading(); if (status === 'failed') return error ?? null; return ( {this.renderCreateButton()} {rows.length === 0 ? this.renderEmpty() : this.renderTable()} ); } } export default RecordList;