generic src for react admin
This commit is contained in:
29
package-lock.json
generated
29
package-lock.json
generated
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.2.1",
|
"version": "0.3.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "latest",
|
"@emotion/react": "latest",
|
||||||
"@emotion/styled": "latest",
|
"@emotion/styled": "latest",
|
||||||
"@mui/icons-material": "latest",
|
"@mui/icons-material": "latest",
|
||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
|
"@tanstack/react-query": "^5.96.1",
|
||||||
"axios": "latest",
|
"axios": "latest",
|
||||||
"markdown-to-jsx": "latest",
|
"markdown-to-jsx": "latest",
|
||||||
"marked": "latest",
|
"marked": "latest",
|
||||||
@@ -1408,6 +1409,30 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.96.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.1.tgz",
|
||||||
|
"integrity": "sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.96.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.1.tgz",
|
||||||
|
"integrity": "sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.96.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -10,15 +10,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "latest",
|
"@emotion/react": "latest",
|
||||||
"@emotion/styled": "latest",
|
"@emotion/styled": "latest",
|
||||||
"@mui/material": "latest",
|
|
||||||
"@mui/icons-material": "latest",
|
"@mui/icons-material": "latest",
|
||||||
|
"@mui/material": "latest",
|
||||||
|
"@tanstack/react-query": "^5.96.1",
|
||||||
|
"axios": "latest",
|
||||||
|
"markdown-to-jsx": "latest",
|
||||||
|
"marked": "latest",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
"react-dom": "latest",
|
"react-dom": "latest",
|
||||||
"react-markdown": "latest",
|
"react-markdown": "latest",
|
||||||
"markdown-to-jsx": "latest",
|
"remark-gfm": "latest"
|
||||||
"remark-gfm": "latest",
|
|
||||||
"marked": "latest",
|
|
||||||
"axios": "latest"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
|
|||||||
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