generic src for react admin
This commit is contained in:
77
src_generic/App.tsx
Normal file
77
src_generic/App.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import ResourceView from "./components/ResourceView";
|
||||
import { config } from "./config";
|
||||
import { Box, Typography, Paper } from '@mui/material';
|
||||
import AppTheme from "../src/shared-theme/AppTheme";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function Dashboard() {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>Welcome to the Admin Panel</Typography>
|
||||
<Typography variant="body1">Select a resource from the sidebar to manage data.</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 3, mt: 4 }}>
|
||||
{config.resources.map(res => (
|
||||
<Paper key={res.name} sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h6">{res.pluralLabel}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminApp() {
|
||||
const { currentUser, login, logout, loading, error } = useAuth();
|
||||
const [selectedResourceName, setSelectedResourceName] = React.useState<string | null>(null);
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<AuthPage
|
||||
mode="login"
|
||||
login={login}
|
||||
register={async () => {
|
||||
}} // Disable registration for Admin
|
||||
loading={loading}
|
||||
error={error}
|
||||
onSwitchMode={() => {
|
||||
}} onBack={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}} currentUser={undefined} />
|
||||
);
|
||||
}
|
||||
|
||||
const selectedResource = config.resources.find(r => r.name === selectedResourceName);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
username={currentUser.username}
|
||||
onLogout={logout}
|
||||
selectedResourceName={selectedResourceName}
|
||||
onSelectResource={setSelectedResourceName}
|
||||
>
|
||||
{selectedResource ? (
|
||||
<ResourceView key={selectedResource.name} config={selectedResource} />
|
||||
) : (
|
||||
<Dashboard />
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppTheme>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
||||
<AdminApp />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</AppTheme>
|
||||
);
|
||||
}
|
||||
5
src_generic/api/client.ts
Normal file
5
src_generic/api/client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApiClient } from "../../auth/src";
|
||||
import { config } from "../config";
|
||||
|
||||
export const api = createApiClient(config.baseUrl);
|
||||
export const auth = createApiClient(config.authBaseUrl);
|
||||
391
src_generic/api/expenses_openapi.yaml
Normal file
391
src_generic/api/expenses_openapi.yaml
Normal file
@@ -0,0 +1,391 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Expenses Service
|
||||
version: "1.0.0"
|
||||
description: OpenAPI-first service for tracking expenses and cashflow.
|
||||
|
||||
paths:
|
||||
/expenses:
|
||||
post:
|
||||
operationId: create_expense
|
||||
summary: Create a new expense or income entry
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExpenseInput"
|
||||
responses:
|
||||
"201":
|
||||
description: Expense created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Expense"
|
||||
get:
|
||||
operationId: list_expenses
|
||||
summary: List expenses
|
||||
parameters:
|
||||
- name: from
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- name: to
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
responses:
|
||||
"200":
|
||||
description: List of expenses
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Expense"
|
||||
|
||||
/expenses/{expense_id}:
|
||||
get:
|
||||
operationId: get_expense
|
||||
summary: Get expense by ID
|
||||
parameters:
|
||||
- name: expense_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Expense found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Expense"
|
||||
"404":
|
||||
description: Expense not found
|
||||
|
||||
/accounts:
|
||||
post:
|
||||
operationId: create_account
|
||||
summary: Create an account
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AccountCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Account created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Account"
|
||||
get:
|
||||
operationId: list_accounts
|
||||
summary: List accounts
|
||||
responses:
|
||||
"200":
|
||||
description: List of accounts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Account"
|
||||
|
||||
/accounts/{account_id}:
|
||||
get:
|
||||
operationId: get_account
|
||||
summary: Get account by ID
|
||||
parameters:
|
||||
- name: account_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Account found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Account"
|
||||
"404":
|
||||
description: Account not found
|
||||
put:
|
||||
operationId: update_account
|
||||
summary: Update an account
|
||||
parameters:
|
||||
- name: account_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AccountUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: Account updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Account"
|
||||
"404":
|
||||
description: Account not found
|
||||
delete:
|
||||
operationId: delete_account
|
||||
summary: Delete an account
|
||||
parameters:
|
||||
- name: account_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: Account deleted
|
||||
"404":
|
||||
description: Account not found
|
||||
|
||||
/tags:
|
||||
post:
|
||||
operationId: create_tag
|
||||
summary: Create a tag
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Tag created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Tag"
|
||||
get:
|
||||
operationId: list_tags
|
||||
summary: List tags
|
||||
responses:
|
||||
"200":
|
||||
description: List of tags
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Tag"
|
||||
|
||||
/tags/{tag_id}:
|
||||
get:
|
||||
operationId: get_tag
|
||||
summary: Get tag by ID
|
||||
parameters:
|
||||
- name: tag_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Tag found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Tag"
|
||||
"404":
|
||||
description: Tag not found
|
||||
put:
|
||||
operationId: update_tag
|
||||
summary: Update a tag
|
||||
parameters:
|
||||
- name: tag_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/TagUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: Tag updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Tag"
|
||||
"404":
|
||||
description: Tag not found
|
||||
delete:
|
||||
operationId: delete_tag
|
||||
summary: Delete a tag
|
||||
parameters:
|
||||
- name: tag_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: Tag deleted
|
||||
"404":
|
||||
description: Tag not found
|
||||
|
||||
components:
|
||||
schemas:
|
||||
PublicUser:
|
||||
type: object
|
||||
required: [id, username]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
|
||||
PayeeType:
|
||||
type: string
|
||||
enum: [merchant, person, transfer, other]
|
||||
|
||||
Payee:
|
||||
type: object
|
||||
required: [type, name]
|
||||
properties:
|
||||
type:
|
||||
$ref: "#/components/schemas/PayeeType"
|
||||
name:
|
||||
type: string
|
||||
|
||||
AccountType:
|
||||
type: string
|
||||
enum: [cash, bank, credit_card, wallet, other]
|
||||
|
||||
Account:
|
||||
type: object
|
||||
required: [id, name, type, currency]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: "#/components/schemas/AccountType"
|
||||
currency:
|
||||
type: string
|
||||
is_active:
|
||||
type: boolean
|
||||
|
||||
AccountCreate:
|
||||
type: object
|
||||
required: [name, type, currency]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
$ref: "#/components/schemas/AccountType"
|
||||
currency:
|
||||
type: string
|
||||
is_active:
|
||||
type: boolean
|
||||
|
||||
AccountUpdate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/AccountCreate"
|
||||
|
||||
Tag:
|
||||
type: object
|
||||
required: [id, name]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent_id:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
TagCreate:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
parent_id:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
TagUpdate:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/TagCreate"
|
||||
|
||||
ExpenseInput:
|
||||
type: object
|
||||
required:
|
||||
- payor
|
||||
- payee
|
||||
- amount
|
||||
- account
|
||||
- occurred_at
|
||||
properties:
|
||||
payor:
|
||||
type: string
|
||||
payee:
|
||||
$ref: "#/components/schemas/Payee"
|
||||
amount:
|
||||
type: number
|
||||
format: float
|
||||
account:
|
||||
type: string
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
occurred_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
Expense:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- payor
|
||||
- payee
|
||||
- amount
|
||||
- account
|
||||
- occurred_at
|
||||
- created_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
payor:
|
||||
$ref: "#/components/schemas/PublicUser"
|
||||
payee:
|
||||
$ref: "#/components/schemas/Payee"
|
||||
amount:
|
||||
type: number
|
||||
format: float
|
||||
account:
|
||||
$ref: "#/components/schemas/Account"
|
||||
tags:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Tag"
|
||||
occurred_at:
|
||||
type: string
|
||||
format: date-time
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
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>
|
||||
);
|
||||
}
|
||||
69
src_generic/config.ts
Normal file
69
src_generic/config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { AppConfig, ResourceConfig } from "./types/config";
|
||||
|
||||
const AccountResource: ResourceConfig = {
|
||||
name: "accounts",
|
||||
label: "Account",
|
||||
pluralLabel: "Accounts",
|
||||
endpoint: "/accounts",
|
||||
primaryKey: "id",
|
||||
fields: {
|
||||
id: { type: "string", label: "ID", readOnly: true },
|
||||
name: { type: "string", label: "Name", required: true },
|
||||
type: {
|
||||
type: "enum",
|
||||
label: "Type",
|
||||
required: true,
|
||||
options: ["cash", "bank", "credit_card", "wallet", "other"],
|
||||
},
|
||||
currency: { type: "string", label: "Currency", required: true },
|
||||
is_active: { type: "boolean", label: "Active" },
|
||||
},
|
||||
};
|
||||
|
||||
const TagResource: ResourceConfig = {
|
||||
name: "tags",
|
||||
label: "Tag",
|
||||
pluralLabel: "Tags",
|
||||
endpoint: "/tags",
|
||||
primaryKey: "id",
|
||||
fields: {
|
||||
id: { type: "string", label: "ID", readOnly: true },
|
||||
name: { type: "string", label: "Name", required: true },
|
||||
parent_id: { type: "string", label: "Parent ID" },
|
||||
},
|
||||
};
|
||||
|
||||
const ExpenseResource: ResourceConfig = {
|
||||
name: "expenses",
|
||||
label: "Expense",
|
||||
pluralLabel: "Expenses",
|
||||
endpoint: "/expenses",
|
||||
primaryKey: "id",
|
||||
fields: {
|
||||
id: { type: "string", label: "ID", readOnly: true },
|
||||
amount: { type: "number", label: "Amount", required: true },
|
||||
occurred_at: { type: "date", label: "Occurred At", required: true },
|
||||
payee: {
|
||||
type: "object",
|
||||
label: "Payee",
|
||||
required: true,
|
||||
schema: {
|
||||
name: { type: "string", label: "Name", required: true },
|
||||
type: {
|
||||
type: "enum",
|
||||
label: "Type",
|
||||
options: ["merchant", "person", "transfer", "other"],
|
||||
},
|
||||
},
|
||||
},
|
||||
account: { type: "string", label: "Account ID", required: true },
|
||||
tags: { type: "array", label: "Tags" },
|
||||
created_at: { type: "date", label: "Created At", readOnly: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const config: AppConfig = {
|
||||
baseUrl: import.meta.env.VITE_API_BASE_URL || "http://localhost:8000",
|
||||
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "http://localhost:8001",
|
||||
resources: [ExpenseResource, AccountResource, TagResource],
|
||||
};
|
||||
77
src_generic/hooks/useResource.ts
Normal file
77
src_generic/hooks/useResource.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { ResourceConfig } from "../types/config";
|
||||
|
||||
export function useResource<T = any>(config: ResourceConfig) {
|
||||
const queryClient = useQueryClient();
|
||||
const { name, endpoint, primaryKey } = config;
|
||||
|
||||
// --- READ ALL ---
|
||||
const useList = (params?: any) =>
|
||||
useQuery({
|
||||
queryKey: [name, "list", params],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
// --- READ ONE ---
|
||||
const useOne = (id: string | null) =>
|
||||
useQuery({
|
||||
queryKey: [name, "detail", id],
|
||||
queryFn: async () => {
|
||||
if (!id) return null;
|
||||
const res = await api.get<T>(`${endpoint}/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// --- CREATE ---
|
||||
const useCreate = () =>
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
const res = await api.post<T>(endpoint, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- UPDATE ---
|
||||
const useUpdate = () =>
|
||||
useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (updatedItem) => {
|
||||
// @ts-ignore
|
||||
const id = updatedItem[primaryKey];
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- DELETE ---
|
||||
const useDelete = () =>
|
||||
useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`${endpoint}/${id}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
useList,
|
||||
useOne,
|
||||
useCreate,
|
||||
useUpdate,
|
||||
useDelete,
|
||||
};
|
||||
}
|
||||
12
src_generic/main.tsx
Normal file
12
src_generic/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const root = createRoot(rootElement!);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
33
src_generic/types/config.ts
Normal file
33
src_generic/types/config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type FieldType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'markdown'
|
||||
| 'enum'
|
||||
| 'object'
|
||||
| 'array';
|
||||
|
||||
export interface ResourceField {
|
||||
type: FieldType;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[]; // for enum
|
||||
schema?: Record<string, ResourceField>; // for object or array items
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
pluralLabel: string;
|
||||
endpoint: string;
|
||||
primaryKey: string;
|
||||
fields: Record<string, ResourceField>;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
baseUrl: string;
|
||||
authBaseUrl: string;
|
||||
resources: ResourceConfig[];
|
||||
}
|
||||
Reference in New Issue
Block a user