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