feat: add reusable DataTable and EntityForm components
This commit is contained in:
61
frontend/src/components/DataTable.tsx
Normal file
61
frontend/src/components/DataTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
frontend/src/components/EntityForm.tsx
Normal file
83
frontend/src/components/EntityForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user