feat: add reusable DataTable and EntityForm components

This commit is contained in:
2026-02-28 20:06:38 +01:00
parent f8a6e49038
commit e23f1255fe
2 changed files with 144 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
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 Paper from '@mui/material/Paper'
import IconButton from '@mui/material/IconButton'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import Typography from '@mui/material/Typography'
export interface Column<T> {
key: keyof T
label: string
render?: (row: T) => React.ReactNode
}
interface Props<T extends { id: number }> {
columns: Column<T>[]
rows: T[]
onEdit: (row: T) => void
onDelete: (row: T) => void
}
export default function DataTable<T extends { id: number }>({ columns, rows, onEdit, onDelete }: Props<T>) {
if (rows.length === 0) {
return <Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>No entries yet.</Typography>
}
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: '#f8fafc' }}>
{columns.map((col) => (
<TableCell key={String(col.key)} sx={{ fontWeight: 600, fontSize: 12, color: '#64748b' }}>
{col.label}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 600, fontSize: 12, color: '#64748b' }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} hover>
{columns.map((col) => (
<TableCell key={String(col.key)}>
{col.render ? col.render(row) : String(row[col.key] ?? '')}
</TableCell>
))}
<TableCell align="right">
<IconButton size="small" onClick={() => onEdit(row)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => onDelete(row)} color="error"><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
}

View File

@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Button from '@mui/material/Button'
import TextField from '@mui/material/TextField'
import MenuItem from '@mui/material/MenuItem'
import Stack from '@mui/material/Stack'
export interface FieldDef {
name: string
label: string
required?: boolean
type?: 'text' | 'select' | 'number'
options?: { value: string | number; label: string }[]
}
interface Props {
open: boolean
title: string
fields: FieldDef[]
initialValues?: Record<string, unknown>
onClose: () => void
onSubmit: (values: Record<string, unknown>) => Promise<void>
}
export default function EntityForm({ open, title, fields, initialValues, onClose, onSubmit }: Props) {
const [values, setValues] = useState<Record<string, unknown>>({})
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (open) setValues(initialValues ?? {})
}, [open, initialValues])
const handleChange = (name: string, value: unknown) => setValues((v) => ({ ...v, [name]: value }))
const handleSubmit = async () => {
setSubmitting(true)
try { await onSubmit(values) } finally { setSubmitting(false) }
}
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{fields.map((f) =>
f.type === 'select' ? (
<TextField
key={f.name}
select
label={f.label}
required={f.required}
value={values[f.name] ?? ''}
onChange={(e) => handleChange(f.name, e.target.value)}
size="small"
>
{f.options?.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
) : (
<TextField
key={f.name}
label={f.label}
required={f.required}
type={f.type ?? 'text'}
value={values[f.name] ?? ''}
onChange={(e) => handleChange(f.name, e.target.value)}
size="small"
/>
)
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Saving…' : 'Save'}
</Button>
</DialogActions>
</Dialog>
)
}