generic src for react admin
This commit is contained in:
103
src_generic/components/AdminLayout.tsx
Normal file
103
src_generic/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
List,
|
||||
Typography,
|
||||
Divider,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
CssBaseline,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import TableViewIcon from '@mui/icons-material/TableView';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import { config } from '../config';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
onSelectResource: (resourceName: string | null) => void;
|
||||
selectedResourceName: string | null;
|
||||
onLogout: () => void;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
onSelectResource,
|
||||
selectedResourceName,
|
||||
onLogout,
|
||||
username
|
||||
}: AdminLayoutProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mr: 2 }}>
|
||||
{username}
|
||||
</Typography>
|
||||
<IconButton color="inherit" onClick={onLogout}>
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={selectedResourceName === null}
|
||||
onClick={() => onSelectResource(null)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<DashboardIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Dashboard" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{config.resources.map((res) => (
|
||||
<ListItem key={res.name} disablePadding>
|
||||
<ListItemButton
|
||||
selected={selectedResourceName === res.name}
|
||||
onClick={() => onSelectResource(res.name)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<TableViewIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={res.pluralLabel} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</Box>
|
||||
</Drawer>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
163
src_generic/components/GenericForm.tsx
Normal file
163
src_generic/components/GenericForm.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { ResourceConfig, ResourceField } from '../types/config';
|
||||
|
||||
interface GenericFormProps {
|
||||
config: ResourceConfig;
|
||||
initialData?: any;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function GenericForm({
|
||||
config,
|
||||
initialData = {},
|
||||
onSave,
|
||||
onCancel,
|
||||
loading,
|
||||
}: GenericFormProps) {
|
||||
const [formData, setFormData] = React.useState(initialData);
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Typography variant="h5">
|
||||
{initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`}
|
||||
</Typography>
|
||||
<Divider />
|
||||
|
||||
{Object.entries(config.fields).map(([key, field]) => (
|
||||
<FormField
|
||||
key={key}
|
||||
name={key}
|
||||
field={field}
|
||||
value={formData[key]}
|
||||
onChange={(val) => handleChange(key, val)}
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||
<Button variant="outlined" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" type="submit" loading={loading} disabled={loading}>
|
||||
Save {config.label}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({ name, field, value, onChange, disabled }: any) {
|
||||
const label = field.label;
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'enum' && field.options) {
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{field.options.map((opt: string) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'markdown' || field.type === 'string') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value || ''}
|
||||
multiline={field.type === 'markdown'}
|
||||
rows={field.type === 'markdown' ? 4 : 1}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type="number"
|
||||
value={value || 0}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={JSON.stringify(value)}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
src_generic/components/GenericTable.tsx
Normal file
95
src_generic/components/GenericTable.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Typography,
|
||||
Box,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
|
||||
interface GenericTableProps {
|
||||
config: ResourceConfig;
|
||||
data: any[];
|
||||
onEdit: (item: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
export default function GenericTable({
|
||||
config,
|
||||
data,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: GenericTableProps) {
|
||||
const fields = Object.entries(config.fields);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
|
||||
<Typography variant="h5">{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate}>
|
||||
Add {config.label}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{fields.map(([key, field]) => (
|
||||
<TableCell key={key}>{field.label}</TableCell>
|
||||
))}
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<TableRow key={item[config.primaryKey]}>
|
||||
{fields.map(([key, field]) => (
|
||||
<TableCell key={key}>
|
||||
{renderCellValue(item[key], field)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={() => onEdit(item)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onDelete(item[config.primaryKey])}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCellValue(value: any, field: any) {
|
||||
if (value === null || value === undefined) return '-';
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return value ? 'Yes' : 'No';
|
||||
case 'date':
|
||||
return new Date(value).toLocaleDateString();
|
||||
case 'object':
|
||||
return JSON.stringify(value);
|
||||
case 'array':
|
||||
return `${value.length} items`;
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
79
src_generic/components/ResourceView.tsx
Normal file
79
src_generic/components/ResourceView.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography, Button, Paper, CircularProgress } from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import GenericTable from './GenericTable';
|
||||
import GenericForm from './GenericForm';
|
||||
|
||||
interface ResourceViewProps {
|
||||
config: ResourceConfig;
|
||||
}
|
||||
|
||||
export default function ResourceView({ config }: ResourceViewProps) {
|
||||
const [view, setView] = React.useState<'list' | 'create' | 'edit'>('list');
|
||||
const [selectedItem, setSelectedItem] = React.useState<any>(null);
|
||||
|
||||
const { useList, useCreate, useUpdate, useDelete } = useResource(config);
|
||||
|
||||
const { data, isLoading, error } = useList();
|
||||
const createMutation = useCreate();
|
||||
const updateMutation = useUpdate();
|
||||
const deleteMutation = useDelete();
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
setSelectedItem(item);
|
||||
setView('edit');
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedItem(null);
|
||||
setView('create');
|
||||
};
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
if (view === 'edit') {
|
||||
const id = formData[config.primaryKey];
|
||||
await updateMutation.mutateAsync({ id, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setView('list');
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this item?')) {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <CircularProgress />;
|
||||
if (error) return <Typography color="error">Error loading {config.pluralLabel}</Typography>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{view === 'list' ? (
|
||||
<GenericTable
|
||||
config={config}
|
||||
data={data || []}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
) : (
|
||||
<Paper sx={{ p: 4 }}>
|
||||
<GenericForm
|
||||
config={config}
|
||||
initialData={selectedItem}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setView('list')}
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user