generic src for react admin

This commit is contained in:
2026-04-01 14:22:14 +05:30
parent 4d06859cb0
commit 14dcd19b17
13 changed files with 1137 additions and 7 deletions

77
src_generic/App.tsx Normal file
View 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>
);
}

View 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);

View 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

View 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>
);
}

View 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
/>
);
}

View 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);
}
}

View 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
View 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],
};

View 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
View 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>
);

View 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[];
}