diff --git a/package-lock.json b/package-lock.json index 363645c..4ccc006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "aetoskia-blog-app", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aetoskia-blog-app", - "version": "0.2.1", + "version": "0.3.2", "dependencies": { "@emotion/react": "latest", "@emotion/styled": "latest", "@mui/icons-material": "latest", "@mui/material": "latest", + "@tanstack/react-query": "^5.96.1", "axios": "latest", "markdown-to-jsx": "latest", "marked": "latest", @@ -1408,6 +1409,30 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index 20c7056..e8460c1 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,16 @@ "dependencies": { "@emotion/react": "latest", "@emotion/styled": "latest", - "@mui/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-dom": "latest", "react-markdown": "latest", - "markdown-to-jsx": "latest", - "remark-gfm": "latest", - "marked": "latest", - "axios": "latest" + "remark-gfm": "latest" }, "devDependencies": { "@vitejs/plugin-react": "latest", diff --git a/src_generic/App.tsx b/src_generic/App.tsx new file mode 100644 index 0000000..3492c50 --- /dev/null +++ b/src_generic/App.tsx @@ -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 ( + + Welcome to the Admin Panel + Select a resource from the sidebar to manage data. + + + {config.resources.map(res => ( + + {res.pluralLabel} + + ))} + + + ); +} + +function AdminApp() { + const { currentUser, login, logout, loading, error } = useAuth(); + const [selectedResourceName, setSelectedResourceName] = React.useState(null); + + if (!currentUser) { + return ( + { + }} // 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 ( + + {selectedResource ? ( + + ) : ( + + )} + + ); +} + +export default function App() { + return ( + + + + + + + + ); +} diff --git a/src_generic/api/client.ts b/src_generic/api/client.ts new file mode 100644 index 0000000..2e54750 --- /dev/null +++ b/src_generic/api/client.ts @@ -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); diff --git a/src_generic/api/expenses_openapi.yaml b/src_generic/api/expenses_openapi.yaml new file mode 100644 index 0000000..f9478a9 --- /dev/null +++ b/src_generic/api/expenses_openapi.yaml @@ -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 diff --git a/src_generic/components/AdminLayout.tsx b/src_generic/components/AdminLayout.tsx new file mode 100644 index 0000000..ecad852 --- /dev/null +++ b/src_generic/components/AdminLayout.tsx @@ -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 ( + + + theme.zIndex.drawer + 1 }}> + + + Admin Panel + + + {username} + + + + + + + + + + + + onSelectResource(null)} + > + + + + + + + + + + {config.resources.map((res) => ( + + onSelectResource(res.name)} + > + + + + + + + ))} + + + + + + + {children} + + + ); +} diff --git a/src_generic/components/GenericForm.tsx b/src_generic/components/GenericForm.tsx new file mode 100644 index 0000000..364b5e4 --- /dev/null +++ b/src_generic/components/GenericForm.tsx @@ -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; + 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 ( + + + {initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`} + + + + {Object.entries(config.fields).map(([key, field]) => ( + handleChange(key, val)} + disabled={field.readOnly} + /> + ))} + + + + + + + ); +} + +function FormField({ name, field, value, onChange, disabled }: any) { + const label = field.label; + + if (field.type === 'boolean') { + return ( + onChange(e.target.checked)} + disabled={disabled} + /> + } + label={label} + /> + ); + } + + if (field.type === 'enum' && field.options) { + return ( + + {label} + + + ); + } + + if (field.type === 'markdown' || field.type === 'string') { + return ( + onChange(e.target.value)} + disabled={disabled} + required={field.required} + /> + ); + } + + if (field.type === 'number') { + return ( + onChange(Number(e.target.value))} + disabled={disabled} + required={field.required} + /> + ); + } + + if (field.type === 'date') { + return ( + onChange(e.target.value)} + disabled={disabled} + required={field.required} + /> + ); + } + + return ( + + ); +} diff --git a/src_generic/components/GenericTable.tsx b/src_generic/components/GenericTable.tsx new file mode 100644 index 0000000..410e24c --- /dev/null +++ b/src_generic/components/GenericTable.tsx @@ -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 ( + + + {config.pluralLabel} + + + + + + + + {fields.map(([key, field]) => ( + {field.label} + ))} + Actions + + + + {data.map((item) => ( + + {fields.map(([key, field]) => ( + + {renderCellValue(item[key], field)} + + ))} + + onEdit(item)}> + + + onDelete(item[config.primaryKey])}> + + + + + ))} + +
+
+
+ ); +} + +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); + } +} diff --git a/src_generic/components/ResourceView.tsx b/src_generic/components/ResourceView.tsx new file mode 100644 index 0000000..94e09de --- /dev/null +++ b/src_generic/components/ResourceView.tsx @@ -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(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 ; + if (error) return Error loading {config.pluralLabel}; + + return ( + + {view === 'list' ? ( + + ) : ( + + setView('list')} + loading={createMutation.isPending || updateMutation.isPending} + /> + + )} + + ); +} diff --git a/src_generic/config.ts b/src_generic/config.ts new file mode 100644 index 0000000..ed970f8 --- /dev/null +++ b/src_generic/config.ts @@ -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], +}; diff --git a/src_generic/hooks/useResource.ts b/src_generic/hooks/useResource.ts new file mode 100644 index 0000000..223aac9 --- /dev/null +++ b/src_generic/hooks/useResource.ts @@ -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(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(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(`${endpoint}/${id}`); + return res.data; + }, + enabled: !!id, + }); + + // --- CREATE --- + const useCreate = () => + useMutation({ + mutationFn: async (data: Partial) => { + const res = await api.post(endpoint, data); + return res.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [name, "list"] }); + }, + }); + + // --- UPDATE --- + const useUpdate = () => + useMutation({ + mutationFn: async ({ id, data }: { id: string; data: Partial }) => { + const res = await api.put(`${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, + }; +} diff --git a/src_generic/main.tsx b/src_generic/main.tsx new file mode 100644 index 0000000..080ad13 --- /dev/null +++ b/src_generic/main.tsx @@ -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( + + + +); diff --git a/src_generic/types/config.ts b/src_generic/types/config.ts new file mode 100644 index 0000000..e66563d --- /dev/null +++ b/src_generic/types/config.ts @@ -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; // for object or array items + readOnly?: boolean; +} + +export interface ResourceConfig { + name: string; + label: string; + pluralLabel: string; + endpoint: string; + primaryKey: string; + fields: Record; +} + +export interface AppConfig { + baseUrl: string; + authBaseUrl: string; + resources: ResourceConfig[]; +}