15 Commits

Author SHA1 Message Date
51762f8d18 loader fixes 2026-08-01 17:19:58 +05:30
002b22ff0e entity-wise-enrichment (#14)
## What's changed

Show an image preview in the detail view when a field's `uiType` is `image`, instead of rendering it through the generic list cell renderer.

- Added `Avatar` from MUI and render a 120x120 rounded preview for `image` fields in `DetailFieldRenderer`
- All other field types keep their existing rendering via `ListCellRenderer`

## Files changed

- `react-openapi/src/components/fields/DetailFieldRenderer.tsx`

Reviewed-on: #14
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-08-01 10:28:00 +00:00
885ccdcfa7 Align frontend with updated OpenAPI schema and PATCH operation (#13)
## Summary

Align frontend with updated OpenAPI schema (rename `format` → `bank`, add `pipeline`) and introduce a dedicated `PATCH` operation for partial updates in react-openapi.

## Changes

### react-openapi: new `patch` operation support
- Add `patch` to `ResourceConfig.operations` type
- Separate PUT/PATCH detection in resource-config transformer
- `update()` now always calls PUT; new `patch()` method returned conditionally when spec defines PATCH
- Admin form still uses PUT for full replacements

### Fetch Request custom pages: schema alignment
- Rename `format` → `bank` in create form fields and TypeScript models (`FileSource`, `EmailSource`)
- Add `pipeline` field to create form and `FetchRequestCreate` model
- Export `PipelineType` from barrel index

### Fetch Request retry: use PATCH
- Retry handler now calls `patch(id, { status: "pending" })` instead of the generic `update()`
- Removes `useMutation` wrapper; uses simple local `retrying` state for loading

### Fix Account display in detail page
- Resolve FK displayFormat target in detail field rendering (was always using fetch-request format)
- Fix page title to use `applyDisplayFormat` with resolved FK objects instead of non-existent `account_name`
- Move `useMemo` before early returns to fix hooks ordering violation crash

## Testing
- Custom fetch request create form includes `bank` (select) and `pipeline` (select) fields
- Retry sends `PATCH` instead of `PUT` — no more 500 errors
- Detail page shows proper account name instead of `—`

Reviewed-on: #13
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-07-30 18:49:56 +00:00
8795894c2c fixes 2026-07-26 15:06:17 +05:30
28cf6ccacf auth-fixes (#12)
## Summary

Wire spec-driven auth into the frontend. Auth config (server URL, paths) is extracted from the served OpenAPI spec by `AppProvider`. `AuthProvider` receives the config as a prop. 401 responses from both the main API and the auth server dispatch an `auth:unauthorized` event that triggers redirect to `/login`. Fix `ProfileRoutes` URL duplication bug.

## Changes

### Auth config from spec
- **`main.jsx`** — `AppProvider` wraps everything, loads spec, exposes `authConfig`. `AuthProvider` receives `authConfig` from `useAppContext()`. `onUnauthorized` passed to both `AppProvider` and `AuthProvider` wires `navigate("/login")`.

### 401 handling
- **`AppProvider.tsx`** — remove `onUnauthorized` prop (handled by `AuthProvider`'s event listener instead, avoiding double-navigation).
- **`useApi.ts`** — 401 response interceptor dispatches `auth:unauthorized` CustomEvent on `window`.

### Profile routing fix
- **`Admin.tsx:ProfileRoutes`** — replace nested `<Routes>` (which caused `/profile/me/me` URL duplication with React Router v6) with `useLocation()`/`useNavigate()` conditional rendering. Only allows `/profile/me` and `/profile/me/edit`.
- **`Admin.tsx:ProfileComponentWrapper`** — replace `pushState() + reload()` with React Router `navigate()` for both `onEdit` and `handleSubmit`.

### Debug logging
- Temporary console logs at every navigation point for diagnosing remaining issues.

Reviewed-on: #12
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-07-19 14:47:30 +00:00
6c720c390c fixes 2026-07-17 14:31:39 +05:30
83ecdcb500 upload functionality 2026-07-13 20:09:31 +05:30
f3135b8247 new react-openapi 2026-07-13 16:15:26 +05:30
72e7e843a4 new react-openapi 2026-07-13 04:35:58 +05:30
617f6bea6c new FetchRequest 2026-07-12 00:01:53 +05:30
e1ae4f0ebe removed unused pages 2026-07-11 17:23:42 +05:30
04aa16b564 fetch request fixes 2026-07-11 17:18:42 +05:30
99cc5afb7c fetch request fixes 2026-07-11 17:11:27 +05:30
afddb3cfbc removed dashboard and reports 2026-07-11 17:10:26 +05:30
b75505de25 added useItemSse 2026-07-11 17:09:50 +05:30
97 changed files with 4108 additions and 4834 deletions

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Install Deps" type="js.build_tools.npm"> <configuration default="false" name="Install Deps" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" /> <package-json value="$PROJECT_DIR$/../khata-ui/package.json" />
<command value="install" /> <command value="install" />
<node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" /> <node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" />
<envs /> <envs />

View File

@@ -1,12 +1,15 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev" type="js.build_tools.npm"> <configuration default="false" name="Run Dev" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" /> <package-json value="$PROJECT_DIR$/../khata-ui/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>
<script value="dev" /> <script value="dev" />
</scripts> </scripts>
<node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" /> <node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" />
<envs /> <envs />
<EXTENSION ID="com.intellij.lang.javascript.buildTools.npm.rc.StartBrowserRunConfigurationExtension">
<browser name="98ca6316-2f89-46d9-a9e5-fa9e2b0625b3" />
</EXTENSION>
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>

1588
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,32 +4,36 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc -b && vite build",
"serve": "vite preview" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^12.1.0", "@apidevtools/swagger-parser": "^12.1.0",
"@emotion/react": "latest", "@emotion/react": "^11.13.3",
"@emotion/styled": "latest", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "latest", "@mui/icons-material": "^5.16.7",
"@mui/material": "latest", "@mui/material": "^5.16.7",
"@mui/x-data-grid": "^8.28.2", "@mui/x-data-grid": "^7.22.0",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"axios": "latest", "axios": "^1.7.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"js-yaml": "^4.1.0",
"markdown-to-jsx": "latest", "markdown-to-jsx": "latest",
"marked": "latest", "marked": "latest",
"process": "^0.11.10", "process": "^0.11.10",
"react": "latest", "react": "^18.3.1",
"react-dom": "latest", "react-dom": "^18.3.1",
"react-markdown": "latest", "react-markdown": "latest",
"react-router-dom": "^7.13.2", "react-router-dom": "^6.28.0",
"remark-gfm": "latest" "remark-gfm": "latest"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@vitejs/plugin-react": "latest", "@types/node": "^25.9.3",
"typescript": "^6.0.3", "@types/react": "^18.3.12",
"vite": "latest" "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.1"
} }
} }

View File

@@ -0,0 +1,122 @@
import * as React from "react";
import {
Box,
TextField,
Button,
Typography,
CircularProgress,
} from "@mui/material";
export interface ProfileCreateProps {
defaultUsername?: string;
defaultEmail?: string;
onSubmit: (data: { name: string; email: string }) => Promise<void>;
onBack?: () => void;
loading?: boolean;
error?: string | null;
}
export function ProfileCreate({
defaultUsername,
defaultEmail,
onSubmit,
onBack,
loading = false,
error = null,
}: ProfileCreateProps) {
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState(defaultEmail ?? "");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit({ name: name || defaultUsername || "", email });
};
return (
<Box
sx={{
maxWidth: 480,
mx: "auto",
mt: 4,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: "background.paper",
}}
>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Complete Your Profile
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{defaultUsername && (
<span>
Welcome <strong>{defaultUsername}</strong>!{" "}
</span>
)}
Fill in your details to get started.
</Typography>
<form onSubmit={handleSubmit}>
{defaultUsername && (
<TextField
fullWidth
label="Username"
value={defaultUsername}
margin="normal"
disabled
/>
)}
<TextField
fullWidth
label="Full Name"
margin="normal"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultUsername ?? "Your name"}
required
autoFocus={!defaultUsername}
/>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={defaultEmail ?? "you@example.com"}
required
/>
{error && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Box sx={{ display: "flex", gap: 2, mt: 3 }}>
{onBack && (
<Button
fullWidth
variant="outlined"
onClick={onBack}
disabled={loading}
>
Back
</Button>
)}
<Button
fullWidth
type="submit"
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : "Create Profile"}
</Button>
</Box>
</form>
</Box>
);
}

111
react-auth/ProfileEdit.tsx Normal file
View File

@@ -0,0 +1,111 @@
import * as React from "react";
import {
Box,
TextField,
Button,
Typography,
CircularProgress,
} from "@mui/material";
export interface ProfileEditProps {
name: string;
username: string;
email: string;
onSubmit: (data: { name: string; email: string }) => Promise<void>;
onBack?: () => void;
loading?: boolean;
error?: string | null;
}
export function ProfileEdit({
name: initialName,
username,
email: initialEmail,
onSubmit,
onBack,
loading = false,
error = null,
}: ProfileEditProps) {
const [name, setName] = React.useState(initialName);
const [email, setEmail] = React.useState(initialEmail);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit({ name, email });
};
return (
<Box
sx={{
maxWidth: 480,
mx: "auto",
mt: 4,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: "background.paper",
}}
>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Edit Profile
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
value={username}
margin="normal"
disabled
/>
<TextField
fullWidth
label="Full Name"
margin="normal"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
/>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
{error && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Box sx={{ display: "flex", gap: 2, mt: 3 }}>
{onBack && (
<Button
fullWidth
variant="outlined"
onClick={onBack}
disabled={loading}
>
Cancel
</Button>
)}
<Button
fullWidth
type="submit"
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : "Save Changes"}
</Button>
</Box>
</form>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
import * as React from "react";
import {
Box,
Typography,
Button,
Avatar,
Paper,
CircularProgress,
} from "@mui/material";
export interface ProfileViewProps {
name: string;
username: string;
email: string;
onEdit?: () => void;
loading?: boolean;
}
export function ProfileView({
name,
username,
email,
onEdit,
loading = false,
}: ProfileViewProps) {
const initials = (name || username)
.split(" ")
.map((s) => s[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<Paper
sx={{
maxWidth: 480,
mx: "auto",
mt: 4,
p: 4,
borderRadius: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 3, mb: 3 }}>
<Avatar
sx={{
width: 64,
height: 64,
bgcolor: "primary.main",
fontSize: 24,
}}
>
{initials}
</Avatar>
<Box>
<Typography variant="h5" fontWeight="bold">
{name || username}
</Typography>
<Typography variant="body2" color="text.secondary">
@{username}
</Typography>
</Box>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="overline" color="text.secondary" display="block">
Email
</Typography>
<Typography variant="body1">{email}</Typography>
</Box>
{onEdit && (
<Button
fullWidth
variant="outlined"
onClick={onEdit}
disabled={loading}
sx={{ mt: 1 }}
>
{loading ? <CircularProgress size={20} /> : "Edit Profile"}
</Button>
)}
</Paper>
);
}

View File

@@ -17,7 +17,9 @@ export function attachAuthInterceptors(client: AxiosInstance) {
(res) => res, (res) => res,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
console.log("[authAxios] 401 from %s %s", error.config?.method, error.config?.url);
tokenStore.clear(); tokenStore.clear();
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
} }
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -3,6 +3,15 @@ import { tokenStore } from "./token";
import { createApiClient } from "./axios"; import { createApiClient } from "./axios";
import { AuthUser } from "./models"; import { AuthUser } from "./models";
export interface AuthServerConfig {
serverUrl: string;
loginPath: string;
registerPath: string;
logoutPath: string;
mePath: string;
introspectPath: string;
}
interface AuthContextModel { interface AuthContextModel {
currentUser: AuthUser | null; currentUser: AuthUser | null;
token: string | null; token: string | null;
@@ -17,24 +26,26 @@ const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export function AuthProvider({ export function AuthProvider({
children, children,
authBaseUrl, authConfig,
onUnauthorized,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
authBaseUrl: string; authConfig: AuthServerConfig;
onUnauthorized?: () => void;
}) { }) {
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null); const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(tokenStore.get()); const [token, setToken] = useState<string | null>(tokenStore.get());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const auth = createApiClient(authBaseUrl); const auth = createApiClient(authConfig.serverUrl);
const login = async (username: string, password: string) => { const login = async (username: string, password: string) => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await auth.post("/login", { username, password }); const res = await auth.post(authConfig.loginPath, { username, password });
const { access_token, user } = res.data; const { access_token, user } = res.data;
tokenStore.set(access_token); tokenStore.set(access_token);
@@ -52,7 +63,7 @@ export function AuthProvider({
setLoading(true); setLoading(true);
setError(null); setError(null);
await auth.post("/register", { username, password }); await auth.post(authConfig.registerPath, { username, password });
await login(username, password); await login(username, password);
} catch (e: any) { } catch (e: any) {
setError(e.response?.data?.detail ?? "Registration failed"); setError(e.response?.data?.detail ?? "Registration failed");
@@ -61,25 +72,55 @@ export function AuthProvider({
} }
}; };
const logout = () => { const logout = async () => {
try {
await auth.post(authConfig.logoutPath);
} catch {
// Server logout is best-effort; clear token locally regardless
}
tokenStore.clear(); tokenStore.clear();
setToken(null); setToken(null);
setCurrentUser(null); setCurrentUser(null);
}; };
const fetchCurrentUser = async () => { const fetchCurrentUser = async () => {
if (!token) return; if (!token) { console.log("[AuthProvider] fetchCurrentUser SKIP no token"); return; }
console.log("[AuthProvider] fetchCurrentUser calling %s%s", authConfig.serverUrl, authConfig.mePath);
try { try {
const me = await auth.get("/me"); const me = await auth.get(authConfig.mePath);
console.log("[AuthProvider] fetchCurrentUser SUCCESS", me.data);
setCurrentUser({ ...me.data }); setCurrentUser({ ...me.data });
} catch { } catch (e: any) {
logout(); console.log("[AuthProvider] fetchCurrentUser ERROR", e.message, e.response?.status);
tokenStore.clear();
setToken(null);
setCurrentUser(null);
onUnauthorized?.();
} }
}; };
useEffect(() => { useEffect(() => {
fetchCurrentUser(); console.log("[AuthProvider] useEffect token=%s serverUrl=%s", token, authConfig.serverUrl);
}, [token]); if (authConfig.serverUrl) {
fetchCurrentUser();
}
}, [token, authConfig.serverUrl]);
useEffect(() => {
const handler = () => {
console.log("[AuthProvider] auth:unauthorized event received");
tokenStore.clear();
setToken(null);
setCurrentUser(null);
onUnauthorized?.();
};
console.log("[AuthProvider] adding auth:unauthorized listener");
window.addEventListener("auth:unauthorized", handler);
return () => {
console.log("[AuthProvider] removing auth:unauthorized listener");
window.removeEventListener("auth:unauthorized", handler);
};
}, [onUnauthorized]);
return ( return (
<AuthContext.Provider <AuthContext.Provider

View File

@@ -1,6 +1,13 @@
export { AuthProvider, useAuth } from "./contexts"; export { AuthProvider, useAuth } from "./contexts";
export type { AuthServerConfig } from "./contexts";
export { createApiClient } from "./axios"; export { createApiClient } from "./axios";
export { AuthPage } from "./AuthPage"; export { AuthPage } from "./AuthPage";
export { ProfileCreate } from "./ProfileCreate";
export { ProfileEdit } from "./ProfileEdit";
export { ProfileView } from "./ProfileView";
export type { AuthUser } from "./models"; export type { AuthUser } from "./models";
export type { AuthMode } from "./AuthPage"; export type { AuthMode } from "./AuthPage";
export type { ProfileCreateProps } from "./ProfileCreate";
export type { ProfileEditProps } from "./ProfileEdit";
export type { ProfileViewProps } from "./ProfileView";
export { tokenStore } from "./token" export { tokenStore } from "./token"

View File

@@ -590,6 +590,28 @@ x-upload-url: /pets/{id}/photo
POST endpoint for file upload. The `{id}` placeholder is replaced with the current record ID. POST endpoint for file upload. The `{id}` placeholder is replaced with the current record ID.
#### `x-upload` (optional)
```yaml
path:
type: string
x-upload:
type: file # one of: image, pdf, html, csv, file
url: /uploads # POST endpoint that accepts raw binary + Content-Disposition header
```
Changes the form field to a file upload component. The file is POSTed as raw binary (`application/octet-stream`) to the specified `url` with a `Content-Disposition: attachment; filename="..."` header. The response must contain a `saved_as` field, which is stored as the field value.
| `type` | Display |
|------------|--------------------------------------------------|
| `image` | `<Avatar src="/uploads/{value}" />` |
| `pdf` | Clickable chip linking to `/uploads/{value}` |
| `html` | Clickable chip linking to `/uploads/{value}` |
| `csv` | Clickable chip linking to `/uploads/{value}` |
| `file` | Clickable chip linking to `/uploads/{value}` |
The chip includes a delete (X) button to replace the file.
### Property Types That Trigger Special Renderers ### Property Types That Trigger Special Renderers
| Condition | Form Renderer | List/Detail Renderer | | Condition | Form Renderer | List/Detail Renderer |
@@ -602,6 +624,7 @@ POST endpoint for file upload. The `{id}` placeholder is replaced with the curre
| `format: date` | `DateField` (date picker) | Typography | | `format: date` | `DateField` (date picker) | Typography |
| `format: date-time` | `DateField` (datetime-local picker) | Typography | | `format: date-time` | `DateField` (datetime-local picker) | Typography |
| `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` | | `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` |
| `x-upload` exists | `FileUploadField` (upload button + chip/Avatar) | Chip or Avatar |
| `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) | | `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) |
| None of the above (default) | `StringField` (text input) | Typography | | None of the above (default) | `StringField` (text input) | Typography |

View File

@@ -1,5 +1,6 @@
export { AppProvider } from "./src/context/AppProvider"; export { AppProvider } from "./src/context/AppProvider";
export { Admin } from "./src/components/Admin"; export { Admin, ProfileRoutes } from "./src/components/Admin";
export type { AdminProps, ProfileRoute } from "./src/components/Admin";
export { useAppContext } from "./src/context/AppContext"; export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource"; export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
@@ -7,5 +8,8 @@ export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
export { SseStreamView } from "./src/components/SseStreamView"; export { SseStreamView } from "./src/components/SseStreamView";
export { SseConnectionStatus } from "./src/components/SseConnectionStatus"; export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { getApi } from "./src/hooks/useApi"; export { getApi } from "./src/hooks/useApi";
export { useItemSse } from "./src/hooks/useItemSse";
export { sanitizePayload } from "./src/utils/sanitize-payload";
export type { FkResolver } from "./src/utils/sanitize-payload";
export type { FilterComponentProps } from "./src/context/useResource"; export type { FilterComponentProps } from "./src/context/useResource";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types"; export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents, AuthConfig } from "./src/types";

View File

@@ -0,0 +1,20 @@
{
"name": "react-openapi",
"private": true,
"version": "0.0.1",
"type": "module",
"main": "index.ts",
"types": "index.ts",
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"@mui/material": "^5.16.7",
"@mui/icons-material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"axios": "^1.7.7",
"js-yaml": "^4.1.0"
}
}

View File

@@ -1,14 +1,15 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
import { Box, CircularProgress } from "@mui/material"; import { Box, CircularProgress, Alert } from "@mui/material";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { Layout } from "./Layout"; import { Layout } from "./Layout";
import { ResourceList } from "./ResourceList"; import { ResourceList } from "./ResourceList";
import { ResourceForm } from "./ResourceForm"; import { ResourceForm } from "./ResourceForm";
import { ResourceDetail } from "./ResourceDetail"; import { ResourceDetail } from "./ResourceDetail";
import { ValidationAlert } from "./ValidationAlert"; import { ValidationAlert } from "./ValidationAlert";
import { getApi } from "../hooks/useApi";
interface AdminProps { export interface AdminProps {
basePath: string; basePath: string;
} }
@@ -30,18 +31,21 @@ export function Admin({ basePath }: AdminProps) {
if (resources.length === 0) { if (resources.length === 0) {
return ( return (
<Box sx={{ p: 4, textAlign: "center" }}> <Box sx={{ p: 4, textAlign: "center" }}>
No resources found in the OpenAPI spec with x-resource defined. No resources found in the OpenAPI spec.
</Box> </Box>
); );
} }
const topLevel = resources.filter((r) => !r.parent);
return ( return (
<> <>
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />} {warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
<Layout resources={resources} basePath={basePath}> <Layout resources={topLevel} basePath={basePath}>
<Routes> <Routes>
<Route index element={<Navigate to={`${basePath}/${resources[0].name}`} replace />} /> <Route index element={<Navigate to={`${basePath}/${topLevel[0]?.name ?? "profile"}`} replace />} />
{resources.map((r) => (
{topLevel.map((r) => (
<React.Fragment key={r.name}> <React.Fragment key={r.name}>
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} /> <Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
{!r.streaming && ( {!r.streaming && (
@@ -58,3 +62,181 @@ export function Admin({ basePath }: AdminProps) {
</> </>
); );
} }
export interface ProfileRoute {
path: string;
method: string;
operationId: string;
summary?: string;
}
/** Derive a friendly profile route path from the operation's spec path.
* e.g. "/users/me" → "me", "/users/me/" → "me" */
function friendlyProfilePath(specPath: string): string {
return specPath.replace(/^\/[^/]+\/?/, "").replace(/\/+$/, "");
}
export function ProfileRoutes() {
const { profileOperations, profileComponents } = useAppContext();
const ops = profileOperations;
const getOp = ops.find((o) => o.method === "GET");
const location = useLocation();
const navigate = useNavigate();
console.log("[ProfileRoutes] render pathname=%s ops=%o", location.pathname, ops.map(o => o.path));
// Redirect /:username to /me (only "me" and "me/edit" are valid)
const match = location.pathname.match(/^\/profile\/(.+)$/);
const username = match ? match[1] : "";
console.log("[ProfileRoutes] match=%o username=%s", match && match[1], username);
if (username !== "me" && username !== "me/edit") {
console.log("[ProfileRoutes] redirecting to /profile/me");
navigate("/profile/me", { replace: true });
return null;
}
const isEdit = username === "me/edit";
const mode = isEdit ? "edit" as const : "view" as const;
console.log("[ProfileRoutes] mode=%s", mode);
const op = ops.find((o) => {
if (isEdit && (o.method === "PUT" || o.method === "PATCH")) return true;
if (!isEdit && o.method === "GET") return true;
return false;
});
if (!op) return null;
let Component: React.ComponentType<any> | undefined;
if (mode === "view" && profileComponents.view) {
Component = profileComponents.view;
} else if (mode === "edit" && profileComponents.edit) {
Component = profileComponents.edit;
}
if (!Component) return null;
return (
<ProfileComponentWrapper
Component={Component}
mode={mode}
operation={op}
getOperation={getOp}
/>
);
}
function ProfileComponentWrapper({
Component,
mode,
operation,
getOperation,
}: {
Component: React.ComponentType<any>;
mode: "view" | "create" | "edit";
operation: { path: string; method: string };
getOperation?: { path: string };
}) {
const navigate = useNavigate();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(mode !== "create");
const [error, setError] = useState<string | null>(null);
console.log("[ProfileComponentWrapper] render mode=%s fetchPath=%s", mode, getOperation?.path ?? operation.path);
const fetchPath = getOperation?.path ?? operation.path;
const fetchData = async () => {
console.log("[ProfileComponentWrapper] fetchData START path=%s", fetchPath);
setLoading(true);
setError(null);
try {
const res = await getApi().get(fetchPath);
console.log("[ProfileComponentWrapper] fetchData SUCCESS", res.data);
setData(res.data);
} catch (e: any) {
console.log("[ProfileComponentWrapper] fetchData ERROR", e.message, e.response?.status);
setError(e.response?.data?.detail ?? e.message ?? "Failed to load profile");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (mode !== "create") {
console.log("[ProfileComponentWrapper] useEffect calling fetchData");
fetchData();
}
}, [fetchPath, mode]);
const handleSubmit = async (formData: Record<string, any>) => {
console.log("[ProfileComponentWrapper] handleSubmit START");
setLoading(true);
setError(null);
try {
if (mode === "create") {
await getApi().post(operation.path, formData);
} else {
await getApi()({ method: operation.method.toLowerCase(), url: operation.path, data: formData });
}
console.log("[ProfileComponentWrapper] handleSubmit SUCCESS, navigating to /profile/me");
const base = friendlyProfilePath(operation.path);
navigate(`/profile/${base}`, { replace: true });
} catch (e: any) {
console.log("[ProfileComponentWrapper] handleSubmit ERROR", e.message, e.response?.status);
setError(e.response?.data?.detail ?? e.message ?? "Operation failed");
} finally {
setLoading(false);
}
};
if (loading && !data && mode !== "create") {
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 4 }}>
<CircularProgress />
</Box>
);
}
if (error && !data && mode !== "create") {
return (
<Box sx={{ maxWidth: 480, mx: "auto", mt: 4 }}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
const props: Record<string, any> = {};
if (mode === "view") {
if (data) {
props.name = data.name;
props.username = data.username;
props.email = data.email;
}
props.onEdit = () => navigate("/profile/me/edit");
props.loading = loading;
}
if (mode === "create") {
props.defaultUsername = data?.username;
props.defaultEmail = data?.email;
props.onBack = () => window.history.back();
props.onSubmit = handleSubmit;
props.loading = loading;
props.error = error;
}
if (mode === "edit") {
if (data) {
props.name = data.name;
props.username = data.username;
props.email = data.email;
}
props.onBack = () => window.history.back();
props.onSubmit = handleSubmit;
props.loading = loading;
props.error = error;
}
return <Component {...props} />;
}

View File

@@ -7,6 +7,8 @@ import {
Paper, Paper,
Grid, Grid,
CircularProgress, CircularProgress,
Tabs,
Tab,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
@@ -14,12 +16,18 @@ import type { ResourceConfig } from "../types";
import { useResource } from "../context/useResource"; import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { DetailFieldRenderer, applyDisplayFormat } from "./fields"; import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
import { SseStreamView } from "./SseStreamView";
interface ResourceDetailProps { interface ResourceDetailProps {
resource: ResourceConfig; resource: ResourceConfig;
basePath: string; basePath: string;
} }
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) { export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
@@ -27,6 +35,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const { resources: allResources } = useAppContext(); const { resources: allResources } = useAppContext();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tabIndex, setTabIndex] = useState(0);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@@ -57,6 +66,16 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail); const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
const tabs = [{ label: "Details", key: "details" }];
if (resource.subResources) {
for (const subName of resource.subResources) {
const sub = allResources.find((r) => r.name === subName);
if (sub) {
tabs.push({ label: sub.displayName, key: subName });
}
}
}
return ( return (
<Box> <Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
@@ -85,25 +104,47 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
)} )}
</Box> </Box>
<Paper variant="outlined" sx={{ p: 3 }}> {tabs.length > 1 && (
<Grid container spacing={2}> <Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)} sx={{ mb: 1 }}>
{visibleFields.map((field) => { {tabs.map((t) => (
let value = data[field.name]; <Tab key={t.key} label={t.label} />
let fmt = resource.displayFormat; ))}
if (field.fk && typeof value === "object") { </Tabs>
const targetRes = allResources.find((r) => r.name === field.fk!.resource); )}
fmt = targetRes!.displayFormat;
} else if (field.refSchema && !field.fk && typeof value === "object") { <TabPanel value={tabIndex} index={0}>
fmt = field.inlineDisplayFormat ?? resource.displayFormat; <Paper variant="outlined" sx={{ p: 3 }}>
} <Grid container spacing={2}>
return ( {visibleFields.map((field) => {
<Grid size={12} key={field.name}> let value = data[field.name];
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} /> let fmt = resource.displayFormat;
</Grid> if (field.fk && typeof value === "object") {
); const targetRes = allResources.find((r) => r.name === field.fk!.resource);
})} fmt = targetRes!.displayFormat;
</Grid> } else if (field.refSchema && !field.fk && typeof value === "object") {
</Paper> fmt = field.inlineDisplayFormat ?? resource.displayFormat;
}
return (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} basePath={basePath} />
</Grid>
);
})}
</Grid>
</Paper>
</TabPanel>
{tabs.slice(1).map((t, i) => {
const sub = allResources.find((r) => r.name === t.key)!;
const pathParam = sub.parent?.pathParam ?? "id";
return (
<TabPanel key={t.key} value={tabIndex} index={i + 1}>
{sub.streaming ? (
<SseStreamView resource={sub} pathParams={{ [pathParam]: id! }} />
) : null}
</TabPanel>
);
})}
</Box> </Box>
); );
} }

View File

@@ -106,7 +106,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
} }
const opts = items.map((item: any) => ({ const opts = items.map((item: any) => ({
value: resolvePk(item, targetRes.primaryKey), value: item[targetRes.primaryKey],
label: applyFormat(item, targetRes.displayFormat), label: applyFormat(item, targetRes.displayFormat),
})); }));
console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3)); console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3));
@@ -139,9 +139,9 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
const targetRes = allResources.find((r) => r.name === rel.config.resource); const targetRes = allResources.find((r) => r.name === rel.config.resource);
if (targetRes) { if (targetRes) {
if (Array.isArray(val)) { if (Array.isArray(val)) {
resolved[rel.fieldName] = val.map((item: any) => resolvePk(item, targetRes.primaryKey)); resolved[rel.fieldName] = val.map((item: any) => item[targetRes.primaryKey]);
} else if (typeof val === "object") { } else if (typeof val === "object") {
resolved[rel.fieldName] = resolvePk(val, targetRes.primaryKey); resolved[rel.fieldName] = val[targetRes.primaryKey];
} }
} }
if (!rel.config.prefetch) { if (!rel.config.prefetch) {
@@ -236,8 +236,9 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
<Grid container spacing={2}> <Grid container spacing={2}>
{resource.orderedFields {resource.orderedFields
.filter((f) => !(f.name === resource.primaryKey && mode === "edit")) .filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
.filter((f) => !f.hidden?.form)
.map((field) => ( .map((field) => (
<Grid size={12} key={field.name}> <Grid item xs={12} sm={6} md={4} key={field.name}>
<FormFieldRenderer <FormFieldRenderer
field={field} field={field}
value={formData[field.name]} value={formData[field.name]}
@@ -281,11 +282,6 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
); );
} }
function resolvePk(item: any, pk: string): any {
const v = item?.[pk];
return v != null ? v : item?.[`_${pk}`];
}
function applyFormat(obj: any, format: string): string { function applyFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? ""); if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? "")); return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));

View File

@@ -323,7 +323,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
} }
return ( return (
<TableCell key={col.name}> <TableCell key={col.name}>
<ListCellRenderer field={col} value={value} displayFormat={fmt} /> <ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} />
</TableCell> </TableCell>
); );
})} })}
@@ -387,12 +387,13 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
{detailRow && ( {detailRow && (
<Grid container spacing={2} sx={{ mt: 0.5 }}> <Grid container spacing={2} sx={{ mt: 0.5 }}>
{visibleColumns.map((col) => ( {visibleColumns.map((col) => (
<Grid key={col.name} size={{ xs: 12, sm: 6 }}> <Grid key={col.name} item xs={12} sm={6}>
<DetailFieldRenderer <DetailFieldRenderer
field={col} field={col}
value={detailRow[col.name]} value={detailRow[col.name]}
displayFormat={resource.displayFormat} displayFormat={resource.displayFormat}
/> basePath={basePath}
/>
</Grid> </Grid>
))} ))}
</Grid> </Grid>

View File

@@ -9,9 +9,10 @@ import { SseConnectionStatus } from "./SseConnectionStatus";
interface SseStreamViewProps { interface SseStreamViewProps {
resource: ResourceConfig; resource: ResourceConfig;
pathParams?: Record<string, string | number>;
} }
export function SseStreamView({ resource }: SseStreamViewProps) { export function SseStreamView({ resource, pathParams }: SseStreamViewProps) {
const { stream } = useResource(resource.name); const { stream } = useResource(resource.name);
const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name)); const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name));
const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarOpen, setSnackbarOpen] = useState(false);
@@ -31,7 +32,7 @@ export function SseStreamView({ resource }: SseStreamViewProps) {
}, },
onOpen: () => setSseConnected(resource.name, true), onOpen: () => setSseConnected(resource.name, true),
onError: () => setSseConnected(resource.name, false), onError: () => setSseConnected(resource.name, false),
}); }, pathParams);
return () => { return () => {
setSseConnected(resource.name, false); setSseConnected(resource.name, false);

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Box, Typography } from "@mui/material"; import { Box, Typography, Avatar } from "@mui/material";
import type { FieldConfig } from "../../types"; import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer"; import { ListCellRenderer } from "./ListCellRenderer";
@@ -7,9 +7,10 @@ interface DetailFieldProps {
field: FieldConfig; field: FieldConfig;
value: any; value: any;
displayFormat?: string; displayFormat?: string;
basePath?: string;
} }
export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) { export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
if (field.hidden?.detail) return null; if (field.hidden?.detail) return null;
return ( return (
@@ -17,7 +18,11 @@ export function DetailFieldRenderer({ field, value, displayFormat }: DetailField
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}> <Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label} {field.label}
</Typography> </Typography>
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} /> {field.uiType === "image" ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
)}
</Box> </Box>
); );
} }

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { TextField } from "@mui/material"; import { TextField, Chip, Box } from "@mui/material";
import type { FieldConfig } from "../../types"; import type { FieldConfig } from "../../types";
import { StringField } from "./renderers/StringField"; import { StringField } from "./renderers/StringField";
import { NumberField } from "./renderers/NumberField"; import { NumberField } from "./renderers/NumberField";
@@ -8,8 +8,10 @@ import { BooleanField } from "./renderers/BooleanField";
import { EnumField } from "./renderers/EnumField"; import { EnumField } from "./renderers/EnumField";
import { FkSelectField } from "./renderers/FkSelectField"; import { FkSelectField } from "./renderers/FkSelectField";
import { FkMultiSelectField } from "./renderers/FkMultiSelectField"; import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
import { FileUploadField } from "./renderers/FileUploadField";
import { ImageField } from "./renderers/ImageField"; import { ImageField } from "./renderers/ImageField";
import { JsonField } from "./renderers/JsonField"; import { JsonField } from "./renderers/JsonField";
import { DiscriminatorField } from "./renderers/DiscriminatorField";
interface FormFieldProps { interface FormFieldProps {
field: FieldConfig; field: FieldConfig;
@@ -25,6 +27,16 @@ interface FormFieldProps {
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) { export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
if (field.hidden?.form) return null; if (field.hidden?.form) return null;
if (field.upload) {
return (
<FileUploadField
field={field}
value={value}
onChange={onChange}
/>
);
}
if (field.readOnly && field.uiType !== "image") { if (field.readOnly && field.uiType !== "image") {
return ( return (
<StringField field={field} value={value} onChange={onChange} error={error} /> <StringField field={field} value={value} onChange={onChange} error={error} />
@@ -106,6 +118,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
); );
} }
if (field.oneOfOptions) {
return (
<DiscriminatorField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.refSchema && !field.fk) { if (field.refSchema && !field.fk) {
return ( return (
<JsonField <JsonField
@@ -116,6 +139,32 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
); );
} }
if (field.type === "array" && !field.fk) {
return (
<Box>
<Chip
label={`${field.label} (${Array.isArray(value) ? value.length : 0})`}
size="small"
color="default"
variant="outlined"
/>
</Box>
);
}
if (field.type === "object" && !field.fk) {
return (
<Box>
<Chip
label={field.label}
size="small"
color="default"
variant="outlined"
/>
</Box>
);
}
return ( return (
<StringField <StringField
field={field} field={field}

View File

@@ -1,24 +1,71 @@
import React from "react"; import React, { useState } from "react";
import { Box, Typography, Chip, Avatar } from "@mui/material"; import { useNavigate } from "react-router-dom";
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../types"; import type { FieldConfig } from "../../types";
import { applyDisplayFormat } from "./utils"; import { applyDisplayFormat } from "./utils";
import { InlineRefField } from "./renderers/InlineRefField"; import { InlineRefField } from "./renderers/InlineRefField";
import { extractFields } from "../../transformers/field-config";
import { useAppContext } from "../../context/AppContext";
interface ListCellProps { interface ListCellProps {
field: FieldConfig; field: FieldConfig;
value: any; value: any;
displayFormat?: string; displayFormat?: string;
basePath?: string;
} }
export function ListCellRenderer({ field, value, displayFormat }: ListCellProps) { export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) {
const navigate = useNavigate();
const { schemas } = useAppContext();
const [inlineItem, setInlineItem] = useState<any>(null);
if (value === null || value === undefined) { if (value === null || value === undefined) {
return <Typography variant="body2" color="text.disabled"></Typography>; return <Typography variant="body2" color="text.disabled"></Typography>;
} }
const handleFkClick = (e: React.MouseEvent, fkValue: any) => {
e.stopPropagation();
if (!basePath || !field.fk || typeof fkValue !== "object") return;
const id = fkValue?.id;
if (id != null) {
navigate(`${basePath}/${field.fk.resource}/${id}`);
}
};
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") { if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
return <InlineRefField field={field} value={value} displayFormat={displayFormat} />; return <InlineRefField field={field} value={value} />;
} }
const renderInlineItemFields = (itemValue: any) => {
const schema = field.refSchema ? schemas[field.refSchema] : undefined;
let fields: FieldConfig[] = [];
if (field.oneOfOptions && field.discriminatorProperty) {
const opt = field.oneOfOptions.find((o) => o.value === itemValue?.[field.discriminatorProperty!]);
if (opt) fields = opt.fields;
} else if (schema && field.refSchema) {
fields = extractFields(field.refSchema, schema, schemas);
}
return (
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{fields.map((sf) => {
const fv = itemValue?.[sf.name];
return (
<Grid key={sf.name} item xs={12} sm={6}>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{sf.label}
</Typography>
<Typography variant="body2">
{fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : String(fv)}
</Typography>
</Box>
</Grid>
);
})}
</Grid>
);
};
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) { if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
if (value.length === 0) { if (value.length === 0) {
return <Typography variant="body2" color="text.disabled"></Typography>; return <Typography variant="body2" color="text.disabled"></Typography>;
@@ -29,14 +76,40 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
const label = typeof item === "object" const label = typeof item === "object"
? applyDisplayFormat(item, displayFormat ?? "") ? applyDisplayFormat(item, displayFormat ?? "")
: String(item); : String(item);
return <Chip key={i} label={label} size="small" variant="outlined" />; return (
<Chip
key={i}
label={label}
size="small"
variant="outlined"
onClick={(e) => { e.stopPropagation(); setInlineItem(item); }}
sx={{ cursor: "pointer" }}
/>
);
})} })}
<Dialog open={!!inlineItem} onClose={() => setInlineItem(null)} maxWidth="sm" fullWidth>
<DialogTitle>{field.label}</DialogTitle>
<DialogContent dividers>
{inlineItem && renderInlineItemFields(inlineItem)}
</DialogContent>
<DialogActions>
<Button onClick={() => setInlineItem(null)}>Close</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }
if (field.fk && typeof value === "object" && !field.isArray) { if (field.fk && typeof value === "object" && !field.isArray) {
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>; return (
<Chip
label={applyDisplayFormat(value, displayFormat ?? "")}
size="small"
variant="outlined"
onClick={(e) => handleFkClick(e, value)}
sx={basePath ? { cursor: "pointer" } : undefined}
/>
);
} }
if (field.isArray && Array.isArray(value) && field.fk) { if (field.isArray && Array.isArray(value) && field.fk) {
@@ -44,7 +117,16 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{value.map((item: any, i: number) => { {value.map((item: any, i: number) => {
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item); const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
return <Chip key={i} label={label} size="small" variant="outlined" />; return (
<Chip
key={i}
label={label}
size="small"
variant="outlined"
onClick={(e) => handleFkClick(e, item)}
sx={basePath ? { cursor: "pointer" } : undefined}
/>
);
})} })}
</Box> </Box>
); );

View File

@@ -12,16 +12,20 @@ interface Props {
export function DateField({ field, value, onChange, error }: Props) { export function DateField({ field, value, onChange, error }: Props) {
const inputType = field.format === "date" ? "date" : "datetime-local"; const inputType = field.format === "date" ? "date" : "datetime-local";
const normalized = field.format === "date-time" && typeof value === "string"
? value.replace(/\.\d+Z$/, "").replace(/Z$/, "")
: value;
return ( return (
<TextField <TextField
fullWidth fullWidth
label={field.label} label={field.label}
type={inputType} type={inputType}
value={value ?? ""} value={normalized ?? ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
error={!!error} error={!!error}
helperText={error ?? field.description} helperText={error || field.description || undefined}
placeholder={field.description} placeholder={field.description || undefined}
size="small" size="small"
disabled={field.readOnly} disabled={field.readOnly}
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}

View File

@@ -0,0 +1,74 @@
import React, { useCallback } from "react";
import { Box, FormControl, InputLabel, Select, MenuItem, Typography } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { FormFieldRenderer } from "../FormFieldRenderer";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
}
export function DiscriminatorField({ field, value, onChange, error }: Props) {
const options = field.oneOfOptions ?? [];
const discProp = field.discriminatorProperty ?? "type";
const currentType = value?.[discProp] ?? "";
function defaultFieldValue(field: FieldConfig): any {
if (field.enumValues) return field.enumValues[0];
if (field.isArray) return [];
if (field.type === "object") return {};
if (field.type === "number" || field.type === "integer") return 0;
return null;
}
const handleTypeChange = useCallback((e: any) => {
const newType = e.target.value;
const option = options.find((o) => o.value === newType);
const newValue: Record<string, any> = { [discProp]: newType };
if (option) {
for (const f of option.fields) {
newValue[f.name] = defaultFieldValue(f);
}
}
onChange(newValue);
}, [discProp, onChange, options]);
const handleFieldChange = useCallback((fieldName: string, fieldValue: any) => {
onChange({ ...(value ?? {}), [fieldName]: fieldValue });
}, [onChange, value]);
const activeFields = options.find((o) => o.value === currentType)?.fields ?? [];
return (
<Box>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>{field.label}</InputLabel>
<Select
value={currentType}
label={field.label}
onChange={handleTypeChange}
error={!!error}
>
<MenuItem value="" disabled>Select type</MenuItem>
{options.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
{currentType && activeFields.length > 0 && (
<Box sx={{ pl: 2, borderLeft: "2px solid", borderColor: "divider" }}>
{activeFields.map((f) => (
<FormFieldRenderer
key={f.name}
field={f}
value={value?.[f.name]}
onChange={(v) => handleFieldChange(f.name, v)}
/>
))}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
}
const acceptMap: Record<string, string> = {
image: "image/*",
pdf: ".pdf",
csv: ".csv",
html: ".html,.htm",
};
export function FileUploadField({ field, value, onChange }: Props) {
const uploadConfig = field.upload!;
const inputRef = React.useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const arrayBuffer = await file.arrayBuffer();
const binary = new Uint8Array(arrayBuffer);
const api = getApi();
const res = await api.post(uploadConfig.url, binary, {
headers: {
"Content-Type": file.type,
"Content-Disposition": `attachment; filename="${file.name}"`,
},
});
onChange(res.data.saved_as);
if (inputRef.current) inputRef.current.value = "";
};
const handleReplace = () => {
onChange(null);
setTimeout(() => inputRef.current?.click(), 0);
};
const accept = acceptMap[uploadConfig.type] ?? undefined;
return (
<Box>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{field.label}
</Typography>
{value ? (
uploadConfig.type === "image" ? (
<Avatar src={`/uploads/${value}`} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<Chip
label={value}
component="a"
href={`/uploads/${value}`}
clickable
onDelete={handleReplace}
/>
)
) : (
<Button variant="outlined" component="label" size="small">
Upload {field.label}
<input type="file" hidden accept={accept} onChange={handleUpload} ref={inputRef} />
</Button>
)}
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</Box>
);
}

View File

@@ -1,5 +1,6 @@
import React from "react"; import React, { useState, useMemo } from "react";
import { TextField, Autocomplete } from "@mui/material"; import { TextField, Autocomplete, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import type { FieldConfig } from "../../../types"; import type { FieldConfig } from "../../../types";
interface Props { interface Props {
@@ -12,20 +13,86 @@ interface Props {
} }
export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) { export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) {
console.log(`[FkMultiSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} value=${JSON.stringify(value)}`); const [open, setOpen] = useState(false);
const [frozenValue, setFrozenValue] = useState<any[]>([]);
const handleOpen = () => {
onOpen?.();
setFrozenValue(value ?? []);
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const sortedOptions = useMemo(() => {
const sel = new Set(frozenValue);
const picked: { value: any; label: string }[] = [];
const rest: { value: any; label: string }[] = [];
for (const opt of fkOptions ?? []) {
(sel.has(opt.value) ? picked : rest).push(opt);
}
return [...picked, ...rest];
}, [fkOptions, frozenValue]);
return ( return (
<Autocomplete <Autocomplete
multiple multiple
options={fkOptions ?? []} disableCloseOnSelect
open={open}
onOpen={handleOpen}
onClose={handleClose}
options={sortedOptions}
getOptionLabel={(o) => o.label} getOptionLabel={(o) => o.label}
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []} value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))} onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
onOpen={() => onOpen?.()}
loading={fkLoading} loading={fkLoading}
renderOption={(props, option, { selected }) => (
<li {...props}>
{selected ? (
<DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} />
) : (
<Box sx={{ width: 22, mr: 1 }} />
)}
{option.label}
</li>
)}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={key}
{...tagProps}
label={tag.label.length > 10 ? `${tag.label.slice(0, 8)}..` : tag.label}
size="small"
onClick={open ? handleClose : handleOpen}
sx={{ cursor: "pointer" }}
/>
);
})}
{tagValue.length > maxChips && (
<Chip
label={`+${tagValue.length - maxChips}`}
size="small"
onClick={open ? handleClose : handleOpen}
sx={{ cursor: "pointer" }}
/>
)}
</>
);
}}
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label={field.label} helperText={field.description} size="small" /> <TextField {...params} label={field.label} helperText={field.description || undefined} size="small" />
)} )}
size="small" size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
disabled={field.readOnly} disabled={field.readOnly}
/> />
); );

View File

@@ -54,7 +54,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
<input type="file" hidden accept="image/*" onChange={handleUpload} /> <input type="file" hidden accept="image/*" onChange={handleUpload} />
</Button> </Button>
)} )}
<FormHelperText>{field.description}</FormHelperText> {field.description && <FormHelperText>{field.description}</FormHelperText>}
</Box> </Box>
); );
} }

View File

@@ -1,38 +1,83 @@
import React from "react"; import React, { useState } from "react";
import { Box, Typography, Chip } from "@mui/material"; import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../../types"; import type { FieldConfig } from "../../../types";
import { applyDisplayFormat } from "../utils"; import { applyDisplayFormat } from "../utils";
import { extractFields } from "../../../transformers/field-config";
import { useAppContext } from "../../../context/AppContext";
interface Props { interface Props {
field: FieldConfig; field: FieldConfig;
value: any; value: any;
displayFormat?: string;
} }
export function InlineRefField({ field, value, displayFormat }: Props) { const displayLabels: Record<string, string> = {
basic: "Basic",
heart_rate: "Heart Rate",
dental: "Dental",
vaccine: "Vaccine",
preop: "PreOp",
surgery: "Surgery",
};
export function InlineRefField({ field, value }: Props) {
const [open, setOpen] = useState(false);
const { schemas } = useAppContext();
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return <Typography variant="body2" color="text.disabled"></Typography>; return <Typography variant="body2" color="text.disabled"></Typography>;
} }
if (displayFormat) { const discProp = field.discriminatorProperty;
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>; const discValue = discProp ? value[discProp] : undefined;
} const discChip = discValue ? displayLabels[discValue] ?? discValue : undefined;
const tooltip = field.inlineDisplayFormat
? applyDisplayFormat(value, field.inlineDisplayFormat)
: undefined;
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined); const schema = field.refSchema ? schemas[field.refSchema] : undefined;
if (entries.length === 0) { let subFields: FieldConfig[] = [];
return <Typography variant="body2" color="text.disabled"></Typography>; if (field.oneOfOptions && field.discriminatorProperty) {
const activeOption = field.oneOfOptions.find((o) => o.value === value[field.discriminatorProperty!]);
if (activeOption) subFields = activeOption.fields;
} else if (schema && field.refSchema) {
subFields = extractFields(field.refSchema, schema, schemas);
} }
return ( return (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}> <>
{entries.map(([key, v]) => ( <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}>
{discChip && <Chip label={discChip} size="small" color="primary" variant="outlined" />}
<Chip <Chip
key={key} label={field.label}
label={`${key}: ${String(v)}`} title={tooltip}
size="small" size="small"
color="primary"
variant="outlined" variant="outlined"
onClick={() => setOpen(true)}
sx={{ cursor: "pointer" }}
/> />
))} </Box>
</Box> <Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{field.label}</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{subFields.map((sf) => (
<Grid key={sf.name} item xs={12} sm={6}>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{sf.label}
</Typography>
<Typography variant="body2">{value?.[sf.name] == null ? "—" : typeof value[sf.name] === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(value[sf.name], sf.inlineDisplayFormat) : JSON.stringify(value[sf.name])) : String(value[sf.name])}</Typography>
</Box>
</Grid>
))}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
</>
); );
} }

View File

@@ -17,6 +17,7 @@ import type { FieldConfig } from "../../../types";
import { useAppContext } from "../../../context/AppContext"; import { useAppContext } from "../../../context/AppContext";
import { extractFields } from "../../../transformers/field-config"; import { extractFields } from "../../../transformers/field-config";
import { FormFieldRenderer } from "../FormFieldRenderer"; import { FormFieldRenderer } from "../FormFieldRenderer";
import { applyDisplayFormat } from "../utils";
interface JsonFieldProps { interface JsonFieldProps {
field: FieldConfig; field: FieldConfig;
@@ -78,8 +79,8 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (!open) { if (!open) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return ( return (
<Button variant="outlined" onClick={handleOpen} size="small"> <Button variant="outlined" onClick={handleOpen} size="small" startIcon={field.isArray ? <AddIcon /> : undefined}>
Set {field.label} {field.isArray ? `Add ${field.label}` : `Set ${field.label}`}
</Button> </Button>
); );
} }
@@ -87,14 +88,14 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (field.isArray && Array.isArray(value)) { if (field.isArray && Array.isArray(value)) {
if (value.length === 0) { if (value.length === 0) {
return ( return (
<Button variant="outlined" onClick={handleOpen} size="small"> <Button variant="outlined" onClick={handleOpen} size="small" startIcon={<AddIcon />}>
Set {field.label} Add {field.label}
</Button> </Button>
); );
} }
return ( return (
<Chip <Chip
label={`${value.length} item${value.length !== 1 ? "s" : ""}`} label={`${field.label} (${value.length})`}
size="small" size="small"
color="primary" color="primary"
variant="outlined" variant="outlined"
@@ -105,15 +106,13 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
} }
if (typeof value === "object") { if (typeof value === "object") {
const summary = field.inlineDisplayFormat const tooltip = field.inlineDisplayFormat
? applyInlineFormat(value, field.inlineDisplayFormat) ? applyDisplayFormat(value, field.inlineDisplayFormat)
: Object.entries(value) : undefined;
.filter(([, v]) => v != null)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(" | ");
return ( return (
<Chip <Chip
label={summary || field.label} label={field.label}
title={tooltip}
size="small" size="small"
color="primary" color="primary"
variant="outlined" variant="outlined"
@@ -244,6 +243,10 @@ function buildDefaultShape(fields: FieldConfig[], schemas: Record<string, any>):
const refSchemaObj = schemas[f.refSchema!]; const refSchemaObj = schemas[f.refSchema!];
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : []; const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, schemas); shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, schemas);
} else if (f.isArray) {
shape[f.name] = [];
} else if (f.type === "object") {
shape[f.name] = {};
} else { } else {
shape[f.name] = null; shape[f.name] = null;
} }
@@ -255,7 +258,7 @@ function initEditValue(value: any, field: FieldConfig, schemas: Record<string, a
if (field.isArray) { if (field.isArray) {
return value ? value.map((item: any) => ({ ...item })) : []; return value ? value.map((item: any) => ({ ...item })) : [];
} }
if (value && typeof value === "object") { if (value != null && value !== "" && typeof value === "object") {
return { ...value }; return { ...value };
} }
return buildDefaultShape( return buildDefaultShape(
@@ -264,7 +267,4 @@ function initEditValue(value: any, field: FieldConfig, schemas: Record<string, a
); );
} }
function applyInlineFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
}

View File

@@ -27,8 +27,8 @@ export function NumberField({ field, value, onChange, error }: Props) {
} }
}} }}
error={!!error} error={!!error}
helperText={error ?? field.description} helperText={error || field.description || undefined}
placeholder={field.description} placeholder={field.description || undefined}
size="small" size="small"
disabled={field.readOnly} disabled={field.readOnly}
inputProps={isFloat ? { step: "any" } : undefined} inputProps={isFloat ? { step: "any" } : undefined}

View File

@@ -20,8 +20,8 @@ export function StringField({ field, value, onChange, error }: Props) {
value={value ?? ""} value={value ?? ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
error={!!error} error={!!error}
helperText={error ?? field.description} helperText={error || field.description || undefined}
placeholder={field.description} placeholder={field.description || undefined}
size="small" size="small"
disabled={field.readOnly} disabled={field.readOnly}
/> />

View File

@@ -1,9 +1,12 @@
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { ResourceConfig, SpecConfiguration, ValidationMessage } from "../types"; import type { AuthConfig, ProfileComponents, ProfileOperation, ResourceConfig, SpecConfiguration, ValidationMessage } from "../types";
export interface AppContextValue { export interface AppContextValue {
config: SpecConfiguration; config: SpecConfiguration;
resources: ResourceConfig[]; resources: ResourceConfig[];
profileOperations: ProfileOperation[];
profileComponents: ProfileComponents;
authConfig: AuthConfig;
schemas: Record<string, any>; schemas: Record<string, any>;
loading: boolean; loading: boolean;
errors: ValidationMessage[]; errors: ValidationMessage[];

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from "react"; import React, { useEffect, useState, useMemo } from "react";
import type { SpecConfiguration, ResourceConfig, ValidationMessage } from "../types"; import type { AuthConfig, ProfileOperation, SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
import { AppContext } from "./AppContext"; import { AppContext } from "./AppContext";
import { loadSpec } from "../spec-loader"; import { loadSpec } from "../spec-loader";
import { validateSpec } from "../spec-validator"; import { validateSpec } from "../spec-validator";
@@ -11,13 +11,58 @@ interface AppProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
function extractAuthConfig(spec: any): AuthConfig {
const bearer: Record<string, any> =
spec?.components?.securitySchemes?.bearerAuth ?? {};
return {
serverUrl: bearer["x-server-url"] ?? "",
loginPath: bearer["x-login-path"] ?? "/login",
registerPath: bearer["x-register-path"] ?? "/register",
logoutPath: bearer["x-logout-path"] ?? "/logout",
mePath: bearer["x-me-path"] ?? "/me",
introspectPath: bearer["x-introspect-path"] ?? "/introspect",
};
}
function extractProfileOperations(spec: any): ProfileOperation[] {
const ops: ProfileOperation[] = [];
const paths = spec.paths ?? {};
for (const [path, methods] of Object.entries<Record<string, any>>(paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (method.startsWith("x-")) continue;
if (operation["x-profile"] === true) {
ops.push({
path,
method: method.toUpperCase(),
operationId: operation.operationId ?? "",
summary: operation.summary,
});
}
}
}
return ops;
}
const DEFAULT_AUTH_CONFIG: AuthConfig = {
serverUrl: "",
loginPath: "/login",
registerPath: "/register",
logoutPath: "/logout",
mePath: "/me",
introspectPath: "/introspect",
};
export function AppProvider({ specConfiguration, children }: AppProviderProps) { export function AppProvider({ specConfiguration, children }: AppProviderProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resources, setResources] = useState<ResourceConfig[]>([]); const [resources, setResources] = useState<ResourceConfig[]>([]);
const [profileOperations, setProfileOperations] = useState<ProfileOperation[]>([]);
const [authConfig, setAuthConfig] = useState<AuthConfig>(DEFAULT_AUTH_CONFIG);
const [schemas, setSchemas] = useState<Record<string, any>>({}); const [schemas, setSchemas] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<ValidationMessage[]>([]); const [errors, setErrors] = useState<ValidationMessage[]>([]);
const [warnings, setWarnings] = useState<ValidationMessage[]>([]); const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
const profileComponents = specConfiguration.profileComponents ?? {};
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -36,6 +81,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
setErrors(errs); setErrors(errs);
setWarnings(warns); setWarnings(warns);
setSchemas(spec.components?.schemas ?? {}); setSchemas(spec.components?.schemas ?? {});
setProfileOperations(extractProfileOperations(spec));
setAuthConfig(extractAuthConfig(spec));
} }
if (errs.length === 0) { if (errs.length === 0) {
@@ -51,7 +98,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
} }
} catch (e: any) { } catch (e: any) {
if (!cancelled) { if (!cancelled) {
setErrors([{ type: "error", message: e.message ?? "Failed to load spec" }]); const lines = (e.message ?? "Failed to load spec").split("\n");
setErrors(lines.map((msg: string) => ({ type: "error" as const, message: msg })));
} }
} finally { } finally {
if (!cancelled) { if (!cancelled) {
@@ -71,12 +119,15 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
() => ({ () => ({
config: specConfiguration, config: specConfiguration,
resources, resources,
profileOperations,
profileComponents,
authConfig,
schemas, schemas,
loading, loading,
errors, errors,
warnings, warnings,
}), }),
[specConfiguration, resources, schemas, loading, errors, warnings] [specConfiguration, resources, profileOperations, profileComponents, authConfig, schemas, loading, errors, warnings]
); );
return React.createElement(AppContext.Provider, { value }, children); return React.createElement(AppContext.Provider, { value }, children);

View File

@@ -1,8 +1,10 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { Autocomplete, TextField } from "@mui/material"; import { Autocomplete, TextField, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types"; import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
import { useAppContext } from "./AppContext"; import { useAppContext } from "./AppContext";
import { getApi } from "../hooks/useApi"; import { getApi } from "../hooks/useApi";
import { sanitizePayload } from "../utils/sanitize-payload";
import { StringField } from "../components/fields/renderers/StringField"; import { StringField } from "../components/fields/renderers/StringField";
import { NumberField } from "../components/fields/renderers/NumberField"; import { NumberField } from "../components/fields/renderers/NumberField";
import { DateField } from "../components/fields/renderers/DateField"; import { DateField } from "../components/fields/renderers/DateField";
@@ -10,6 +12,7 @@ import { BooleanField } from "../components/fields/renderers/BooleanField";
import { EnumField } from "../components/fields/renderers/EnumField"; import { EnumField } from "../components/fields/renderers/EnumField";
import { FkSelectField } from "../components/fields/renderers/FkSelectField"; import { FkSelectField } from "../components/fields/renderers/FkSelectField";
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField"; import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
import { extractTokens, extractLocalParts, extractDomains, stripNonDigits } from "../utils/filter-utils";
function parseError(e: any): string { function parseError(e: any): string {
if (e.response?.data) { if (e.response?.data) {
@@ -53,8 +56,9 @@ interface UseResourceReturn {
get: (id: string | number, params?: Record<string, any>) => Promise<any>; get: (id: string | number, params?: Record<string, any>) => Promise<any>;
create: (data: any) => Promise<any>; create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>; update: (id: string | number, data: any) => Promise<any>;
patch?: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>; remove: (id: string | number) => Promise<void>;
stream?: (handlers: StreamHandlers) => StreamSubscription; stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
@@ -266,51 +270,297 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
); );
} }
function buildAutocompleteFilter(getDisplayValue: (row: any) => string) { // ── text filter (freeSolo, no dropdown) ──────────────────────
const StringAutocompleteFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => { function buildTextFilter() {
const TextFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
return (
<Autocomplete
freeSolo
size="small"
options={[]}
value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
/>
);
};
return TextFilter;
}
// ── token filter (multi-select, suggestions from current page) ─
function buildTokenFilter() {
const TokenFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
const pageTokens = useMemo(() => data ? extractTokens(data, field.name) : [], [data]);
const selected = value ? value.split(",").filter(Boolean) : [];
const sortedOptions = useMemo(() => {
const sel = new Set(selected);
const picked: string[] = [];
const rest: string[] = [];
for (const t of pageTokens) {
(sel.has(t) ? picked : rest).push(t);
}
return [...picked, ...rest];
}, [pageTokens, selected]);
const displayOptions = open ? frozenOpts : sortedOptions;
return (
<Autocomplete
multiple
freeSolo
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={open}
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
onClose={(_, reason) => {
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
}}
inputValue={inputValue}
onInputChange={(_, v, reason) => {
if (reason !== "reset") setInputValue(v);
}}
options={displayOptions}
value={selected}
onChange={(_, newVal) => onChange(newVal.join(","))}
filterOptions={(opts, { inputValue }) => {
if (!inputValue) return [];
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
}}
renderOption={(props, option, { selected: isSelected }) => {
const { key, ...rest } = props as any;
return (
<li key={key} {...rest}>
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
return TokenFilter;
}
// ── email filter (two-step local→domain, no server fetch) ───
function buildEmailFilter() {
const COMMON_DOMAINS = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "protonmail.com", "aol.com", "mail.com", "zoho.com", "yandex.com"];
const EmailFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
const [step, setStep] = useState<"local" | "domain">("local");
const [pendingLocal, setPendingLocal] = useState<string | null>(null);
const selected = value ? value.split(",").filter(Boolean) : [];
const handleChange = (_: any, newVal: string[], reason: string, details: any) => {
if (reason === "removeOption") {
if (step === "domain") { setStep("local"); setPendingLocal(null); }
onChange(newVal.join(","));
return;
}
if (reason !== "selectOption" || !details?.option) return;
if (step === "local") {
setPendingLocal(String(details.option));
setStep("domain");
} else if (step === "domain" && pendingLocal) {
const email = `${pendingLocal}@${String(details.option)}`;
onChange([...selected, email].join(","));
setStep("local"); setPendingLocal(null);
}
};
return (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={step === "domain"}
onClose={() => { if (step === "domain") { setStep("local"); setPendingLocal(null); } }}
options={step === "domain" ? COMMON_DOMAINS : []}
value={selected}
onChange={handleChange}
inputValue={pendingLocal ? `${pendingLocal}@` : undefined}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 18 ? `${tag.slice(0, 16)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField
{...params}
label={labelOverride ?? field.label}
placeholder={step === "domain" && pendingLocal ? "Select email domain..." : undefined}
size="small"
/>
)}
/>
);
};
return EmailFilter;
}
// ── phone filter (multi-select, digits only, suggestions from current page) ─
function buildPhoneFilter() {
const PhoneFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
const pageTokens = useMemo(() => {
if (!data) return [];
const tokens = new Set<string>();
for (const row of data) {
const val = row[field.name];
if (val != null && val !== "") {
const digits = stripNonDigits(String(val));
if (digits) tokens.add(digits);
}
}
return [...tokens].sort();
}, [data]);
const selected = value ? value.split(",").filter(Boolean) : [];
const sortedOptions = useMemo(() => {
const sel = new Set(selected);
const picked: string[] = [];
const rest: string[] = [];
for (const t of pageTokens) {
(sel.has(t) ? picked : rest).push(t);
}
return [...picked, ...rest];
}, [pageTokens, selected]);
const displayOptions = open ? frozenOpts : sortedOptions;
return (
<Autocomplete
multiple
freeSolo
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={open}
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
onClose={(_, reason) => {
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
}}
inputValue={inputValue}
onInputChange={(_, v, reason) => {
if (reason !== "reset") setInputValue(v);
}}
options={displayOptions}
value={selected}
onChange={(_, newVal) => onChange(newVal.map((v) => stripNonDigits(v)).filter(Boolean).join(","))}
filterOptions={(opts, { inputValue }) => {
if (!inputValue) return [];
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
}}
renderOption={(props, option, { selected: isSelected }) => {
const { key, ...rest } = props as any;
return (
<li key={key} {...rest}>
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
return PhoneFilter;
}
// ── routing ──────────────────────────────────────────────────
if (field.autocomplete) {
switch (field.autocomplete) {
case "text": return buildTextFilter();
case "token": return buildTokenFilter();
case "email": return buildEmailFilter();
case "phone": return buildPhoneFilter();
}
}
if (field.refSchema && field.inlineDisplayFormat) {
const RefFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const { resources, config } = useAppContext(); const { resources, config } = useAppContext();
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server"; const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
const [options, setOptions] = useState<string[]>([]); const [options, setOptions] = useState<string[]>([]);
const fetched = useRef(false); const fetched = useRef(false);
useEffect(() => { useEffect(() => {
if (filterMode === "client" && data) { const extract = (items: any[]) => {
const vals = new Set<string>(); const vals = new Set<string>();
for (const row of data) { for (const row of items) {
const v = getDisplayValue(row); const val = row[field.name];
if (v && v !== "") vals.add(v); if (val == null || typeof val !== "object") continue;
const v = field.inlineDisplayFormat!.replace(/\{(\w+)\}/g, (_: string, key: string) => String(val[key] ?? ""));
if (v) vals.add(v);
} }
setOptions([...vals].sort()); return [...vals].sort();
};
if (filterMode === "client" && data) {
setOptions(extract(data));
fetched.current = true; fetched.current = true;
} else if (filterMode === "server" && !fetched.current) { } else if (filterMode === "server" && !fetched.current) {
const cacheKey = resourceName + ":" + field.name; (async () => {
if (_stringOptionsCache.has(cacheKey)) { try {
setOptions(_stringOptionsCache.get(cacheKey)!); const api = getApi();
fetched.current = true; const selfRes = resources.find((r) => r.name === resourceName);
} else { if (!selfRes) { fetched.current = true; return; }
(async () => { const params: Record<string, any> = {};
try { if (selfRes.pagination) params.limit = 0;
const api = getApi(); const res = await api.get(selfRes.path, { params });
const selfRes = resources.find((r) => r.name === resourceName); const items = selfRes.pagination
if (!selfRes) { fetched.current = true; return; } ? (Array.isArray(res.data) ? res.data : (res.data.items ?? []))
const params: Record<string, any> = {}; : (Array.isArray(res.data) ? res.data : []);
if (selfRes.pagination) params.limit = 0; setOptions(extract(items));
const res = await api.get(selfRes.path, { params }); fetched.current = true;
let items: any[]; } catch { fetched.current = true; }
if (selfRes.pagination) { })();
items = Array.isArray(res.data) ? res.data : (res.data.items ?? []);
} else {
items = Array.isArray(res.data) ? res.data : [];
}
const values = [...new Set(items.map((r: any) => getDisplayValue(r)).filter(Boolean))].sort();
_stringOptionsCache.set(cacheKey, values);
setOptions(values);
fetched.current = true;
} catch {
fetched.current = true;
}
})();
}
} }
}, [data]); }, [data]);
@@ -322,33 +572,12 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
value={value || null} value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")} onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField {...params} label={labelOverride ?? field.label} size="small" />
{...params}
label={labelOverride ?? field.label}
size="small"
/>
)} )}
/> />
); );
}; };
return StringAutocompleteFilter; return RefFilter;
}
const isSimpleField =
!field.fk && !field.enumValues &&
field.type !== "boolean" && field.type !== "integer" && field.type !== "number" &&
field.format !== "date" && field.format !== "date-time";
if (isSimpleField && !field.refSchema) {
return buildAutocompleteFilter((row) => String(row[field.name] ?? ""));
}
if (field.refSchema && field.inlineDisplayFormat) {
return buildAutocompleteFilter((row) => {
const val = row[field.name];
if (val == null || typeof val !== "object") return "";
return field.inlineDisplayFormat!.replace(/\{(\w+)\}/g, (_, key) => String(val[key] ?? ""));
});
} }
return ({ value, onChange, labelOverride }) => ( return ({ value, onChange, labelOverride }) => (
@@ -361,14 +590,14 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
} }
export function useResource(resourceName: string): UseResourceReturn { export function useResource(resourceName: string): UseResourceReturn {
const { resources } = useAppContext(); const { resources, schemas } = useAppContext();
const resource = resources.find((r) => r.name === resourceName); const resource = resources.find((r) => r.name === resourceName);
const [state, setState] = useState<ResourceState>({ loading: false, error: null }); const [state, setState] = useState<ResourceState>({ loading: false, error: null });
const rPath = resource?.path; const rPath = resource?.path;
const rPagination = resource?.pagination; const rPagination = resource?.pagination;
const rUpdateMethod = resource?.updateMethod; const rPatch = resource ? (resource.operations.patch || undefined) : false;
const rStreaming = resource?.streaming; const rStreaming = resource?.streaming;
const rFields = resource?.fields; const rFields = resource?.fields;
@@ -434,6 +663,18 @@ export function useResource(resourceName: string): UseResourceReturn {
[rPath, setLoading, setError] [rPath, setLoading, setError]
); );
const resolveFk = useCallback(async (resourceName: string, id: any) => {
const targetRes = resources.find((r) => r.name === resourceName);
if (!targetRes) return id;
try {
const api = getApi();
const res = await api.get(`${targetRes.path}/${id}`);
return res.data;
} catch {
return id;
}
}, [resources]);
const create = useCallback( const create = useCallback(
async (data: any): Promise<any> => { async (data: any): Promise<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`); if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
@@ -441,7 +682,10 @@ export function useResource(resourceName: string): UseResourceReturn {
setError(null); setError(null);
try { try {
const api = getApi(); const api = getApi();
const res = await api.post(rPath, data); const sanitized = rFields && schemas
? await sanitizePayload(data, rFields, schemas, resolveFk)
: data;
const res = await api.post(rPath, sanitized);
return res.data; return res.data;
} catch (e: any) { } catch (e: any) {
setError(parseError(e)); setError(parseError(e));
@@ -450,7 +694,7 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false); setLoading(false);
} }
}, },
[rPath, setLoading, setError] [rPath, rFields, schemas, resolveFk, setLoading, setError]
); );
const update = useCallback( const update = useCallback(
@@ -460,8 +704,10 @@ export function useResource(resourceName: string): UseResourceReturn {
setError(null); setError(null);
try { try {
const api = getApi(); const api = getApi();
const method = rUpdateMethod ?? "put"; const sanitized = rFields && schemas
const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, data); ? await sanitizePayload(data, rFields, schemas, resolveFk)
: data;
const res = await api.put(`${rPath}/${id}`, sanitized);
return res.data; return res.data;
} catch (e: any) { } catch (e: any) {
setError(parseError(e)); setError(parseError(e));
@@ -470,7 +716,29 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false); setLoading(false);
} }
}, },
[rPath, rUpdateMethod, setLoading, setError] [rPath, rFields, schemas, resolveFk, setLoading, setError]
);
const _patch = useCallback(
async (id: string | number, data: any): Promise<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
setLoading(true);
setError(null);
try {
const api = getApi();
const sanitized = rFields && schemas
? await sanitizePayload(data, rFields, schemas, resolveFk)
: data;
const res = await api.patch(`${rPath}/${id}`, sanitized);
return res.data;
} catch (e: any) {
setError(parseError(e));
throw e;
} finally {
setLoading(false);
}
},
[rPath, rFields, schemas, resolveFk, setLoading, setError]
); );
const remove = useCallback( const remove = useCallback(
@@ -492,13 +760,19 @@ export function useResource(resourceName: string): UseResourceReturn {
); );
const stream = useCallback( const stream = useCallback(
(handlers: StreamHandlers): StreamSubscription => { (handlers: StreamHandlers, pathParams?: Record<string, string | number>): StreamSubscription => {
if (!rPath || !rStreaming) { if (!rPath || !rStreaming) {
throw new Error(`Resource "${resourceName}" does not support streaming`); throw new Error(`Resource "${resourceName}" does not support streaming`);
} }
const api = getApi(); const api = getApi();
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, ""); const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
const url = baseUrl + rPath; let resolvedPath = rPath;
if (pathParams) {
for (const [key, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(`{${key}}`, String(value));
}
}
const url = baseUrl + resolvedPath;
const es = new EventSource(url); const es = new EventSource(url);
es.onopen = () => handlers.onOpen?.(); es.onopen = () => handlers.onOpen?.();
@@ -539,6 +813,7 @@ export function useResource(resourceName: string): UseResourceReturn {
get, get,
create, create,
update, update,
patch: undefined,
remove, remove,
stream: undefined, stream: undefined,
loading: false, loading: false,
@@ -546,5 +821,5 @@ export function useResource(resourceName: string): UseResourceReturn {
}; };
} }
return { resource, components, list, get, create, update, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error }; return { resource, components, list, get, create, update, patch: rPatch ? _patch : undefined, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
} }

View File

@@ -1,13 +1,16 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { tokenStore } from "../../../react-auth/token";
let apiClient: AxiosInstance | null = null; let apiClient: AxiosInstance | null = null;
let _onUnauthorized: (() => void) | undefined;
export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance { export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance {
if (apiClient && apiClient.defaults.baseURL === baseUrl) { if (apiClient && apiClient.defaults.baseURL === baseUrl) {
_onUnauthorized = onUnauthorized;
return apiClient; return apiClient;
} }
_onUnauthorized = onUnauthorized;
apiClient = axios.create({ apiClient = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -24,11 +27,12 @@ export function initApi(baseUrl: string, getToken?: () => string | null): AxiosI
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(res) => res, (res) => res,
(error) => { (error) => {
if (error.response?.status === 401 && getToken) { if (error.response?.status === 401) {
const currentToken = getToken(); console.log("[useApi] 401 from %s %s - dispatching auth:unauthorized", error.config?.method, error.config?.url);
if (currentToken) { window.dispatchEvent(new CustomEvent("auth:unauthorized"));
tokenStore.clear(); _onUnauthorized?.();
} } else {
console.log("[useApi] non-401 error %s from %s %s", error.response?.status, error.config?.method, error.config?.url);
} }
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { getApi } from "./useApi";
interface ItemSseHandlers {
onEvent: (data: any) => void;
onOpen?: () => void;
onError?: (evt: Event) => void;
}
interface ItemSseResult {
connected: boolean;
events: any[];
}
export function useItemSse(url: string | null, handlers: ItemSseHandlers): ItemSseResult {
const [connected, setConnected] = useState(false);
const [events, setEvents] = useState<any[]>([]);
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
if (!url) return;
const api = getApi();
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
const fullUrl = baseUrl + url;
const es = new EventSource(fullUrl);
es.onopen = () => {
setConnected(true);
handlersRef.current.onOpen?.();
};
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
setEvents((prev) => [...prev, data]);
handlersRef.current.onEvent(data);
} catch {
// ignore malformed JSON
}
};
es.onerror = (e) => {
setConnected(false);
handlersRef.current.onError?.(e);
};
return () => {
es.close();
setConnected(false);
};
}, [url]);
const clearEvents = useCallback(() => setEvents([]), []);
return { connected, events };
}

View File

@@ -1,5 +1,21 @@
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types"; import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types";
function getSegments(path: string): string[] {
return path.split("/").filter(Boolean);
}
function getResponseSchemaRef(pathObj: any): string | undefined {
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"]
?? pathObj?.post?.responses?.["200"] ?? pathObj?.post?.responses?.["201"];
const content = response?.content;
if (!content) return;
for (const mediaType of Object.values(content) as any[]) {
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
}
}
export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] { export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
const messages: ValidationMessage[] = []; const messages: ValidationMessage[] = [];
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>; const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
@@ -17,114 +33,106 @@ export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration):
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" }); messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
} }
for (const [schemaName, schema] of Object.entries(schemas)) { for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
if (!schema || typeof schema !== "object") continue; if (!pathObj || typeof pathObj !== "object") continue;
const isResource = typeof schema["x-resource"] === "string"; const segments = getSegments(path);
const lastSeg = segments[segments.length - 1];
const isItemPath = /^\{.*\}$/.test(lastSeg);
const paramIdx = segments.findIndex((s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s));
const isSubResource = paramIdx >= 0 && !isItemPath;
if (!isResource) continue; const hasSSE = pathObj?.get?.["x-sse"] === true;
if (hasSSE) continue;
const resourcePath = `/${schema["x-resource"]}`; // Skip paths where all operations are profile-only (x-profile: true).
const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters");
const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true);
if (allProfile) continue;
if (!schema["x-primary-key"]) { if (isItemPath || isSubResource) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` }); const responseRef = getResponseSchemaRef(pathObj);
} if (responseRef) {
const schemaName = responseRef.split("/").pop()!;
if (!schema["x-display-format"]) { if (!schemas[schemaName]) {
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-display-format'` }); messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
}
if (!schema["x-list-columns"]) {
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-list-columns'` });
}
if (Array.isArray(schema["x-list-columns"])) {
const props = schema.properties ?? {};
for (const col of schema["x-list-columns"]) {
if (!props[col]) {
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
} }
} }
}
const props = schema.properties ?? {};
for (const [propName, _raw] of Object.entries(props)) {
const prop = _raw as any;
if (!prop || typeof prop !== "object") continue;
if (!prop["x-label"]) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
}
if (prop["x-order"] === undefined || prop["x-order"] === null) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
}
if (prop["$ref"] && !prop["x-fk"]) {
const refName = (prop["$ref"] as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
}
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
const refName = (prop.items.$ref as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
}
if (prop["x-fk"]) {
const fkResource = prop["x-fk"].resource as string;
const targetSchema = Object.entries(schemas as Record<string, any>).find(([, s]) => s?.["x-resource"] === fkResource);
if (!targetSchema) {
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no schema has x-resource="${fkResource}"` });
} else {
const [, target] = targetSchema;
if (!target["x-display-format"]) {
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-display-format'` });
}
if (!target["x-primary-key"]) {
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-primary-key'` });
}
}
}
}
if (!paths[resourcePath]) {
messages.push({ type: "error", message: `x-resource "${schema["x-resource"]}" points to path "${resourcePath}" but no such path exists` });
continue; continue;
} }
const collectionPath = paths[resourcePath] as any; const responseRef = getResponseSchemaRef(pathObj);
if (responseRef) {
const schemaName = responseRef.split("/").pop()!;
const schema = schemas[schemaName];
if (!schema) {
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
continue;
}
if (!collectionPath?.get) { if (!schema["x-primary-key"]) {
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` }); messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
}
if (!schema["x-display-format"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-display-format'` });
}
if (!schema["x-list-columns"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-list-columns'` });
}
if (Array.isArray(schema["x-list-columns"])) {
const props = schema.properties ?? {};
for (const col of schema["x-list-columns"]) {
if (!props[col]) {
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
}
}
}
const props = schema.properties ?? {};
for (const [propName, _raw] of Object.entries(props)) {
const prop = _raw as any;
if (!prop || typeof prop !== "object") continue;
if (!prop["x-label"]) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
}
if (prop["x-order"] === undefined || prop["x-order"] === null) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
}
if (prop["$ref"] && !prop["x-fk"]) {
const refName = (prop["$ref"] as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
}
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
const refName = (prop.items.$ref as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
}
if (prop["x-fk"]) {
const fkResource = prop["x-fk"].resource as string;
const fkPaths = Object.keys(paths).filter((p) => !/^\{.*\}$/.test(getSegments(p).pop() ?? ""));
const targetExists = fkPaths.some((p) => getSegments(p).pop() === fkResource);
if (!targetExists) {
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no path matches that resource name` });
}
}
}
} }
const isSSE = collectionPath?.get?.["x-sse"] === true; if (!pathObj?.get) {
if (isSSE) continue; messages.push({ type: "error", message: `"${path}" has no GET list endpoint — datatable cannot be populated` });
}
const listParams = collectionPath?.get?.parameters ?? []; const listParams = pathObj?.get?.parameters ?? [];
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit"); const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset"); const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
if (limitParam || offsetParam) { if (limitParam || offsetParam) {
if (!limitParam?.schema?.default) { if (!limitParam?.schema?.default) {
messages.push({ type: "error", message: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` }); messages.push({ type: "error", message: `"${path}.get" has pagination params but 'limit' schema is missing 'default'` });
} }
} }
if (!collectionPath?.post) { if (!pathObj?.post) {
messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` }); messages.push({ type: "error", message: `"${path}" has no POST endpoint — creation not possible` });
}
const itemPath = paths[`${resourcePath}/{id}`] as any;
if (!itemPath) {
messages.push({ type: "error", message: `No path "${resourcePath}/{id}" found — detail/update/delete not possible` });
} else {
if (!itemPath?.get) {
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` });
}
if (!itemPath?.put) {
messages.push({ type: "info", message: `"${resourcePath}/{id}" has no PUT endpoint — update not available` });
}
if (!itemPath?.delete) {
messages.push({ type: "info", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not available` });
}
} }
} }

View File

@@ -1,9 +1,89 @@
import type { FieldConfig } from "../types"; import type { FieldConfig, OneOfOption } from "../types";
const _validationErrors: string[] = [];
export function clearValidationErrors(): void {
_validationErrors.length = 0;
}
export function getValidationErrors(): string[] {
return [..._validationErrors];
}
function resolveRef(ref: string): string | undefined { function resolveRef(ref: string): string | undefined {
return ref.split("/").pop(); return ref.split("/").pop();
} }
function resolveAllOf(schema: any, schemas: Record<string, any>): any {
if (!schema || !schema.allOf) return schema;
const merged: any = { type: "object", properties: {}, required: [] };
for (const entry of schema.allOf) {
const resolved = entry.$ref
? resolveAllOf(schemas[resolveRef(entry.$ref)!] ?? {}, schemas)
: entry;
if (resolved.properties) {
Object.assign(merged.properties, resolved.properties);
}
if (resolved.required) {
merged.required.push(...resolved.required);
}
}
return merged;
}
function extractOneOfOptions(schema: any, schemas: Record<string, any>, discriminatorProperty: string): OneOfOption[] {
if (!schema.oneOf) return [];
const result = schema.oneOf.flatMap((option: any) => {
if (!option.$ref) return [];
const variantName = resolveRef(option.$ref);
if (!variantName) return [];
const variantSchema = schemas[variantName];
if (!variantSchema) return [];
const merged = resolveAllOf(variantSchema, schemas);
const props = merged.properties ?? {};
const requiredFields: string[] = merged.required ?? [];
const discriminatorProp = props[discriminatorProperty];
if (!discriminatorProp?.enum?.[0]) return [];
const value = discriminatorProp.enum[0];
const label = variantName.replace(/Note$/, "").replace(/([A-Z])/g, " $1").trim() || value;
const fields: FieldConfig[] = Object.entries(props)
.filter(([k]) => k !== discriminatorProperty)
.filter(([, p]: [string, any]) => p && typeof p === "object")
.map(([name, prop]: [string, any]) => {
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
const isPlainString = !prop["x-fk"] && !prop.enum && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
if (!autocomplete && isPlainString && prop["x-filterable"]) {
autocomplete = "text";
}
if (autocomplete && !prop["x-filterable"]) {
_validationErrors.push(`[field-config] field "${name}" in oneOf variant has x-autocomplete but is not x-filterable`);
}
return {
name,
label: prop["x-label"] ?? name,
description: prop["x-description"] ?? "",
type: prop.type ?? "string",
format: prop.format,
order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false,
sortable: prop["x-sortable"] ?? false,
readOnly: prop.readOnly ?? false,
required: requiredFields.includes(name),
enumValues: prop.enum,
fk: prop["x-fk"],
uiType: prop["x-ui-type"],
uploadUrl: prop["x-upload-url"],
upload: prop["x-upload"],
isArray: prop.type === "array",
autocomplete,
};
});
return [{ value, label, fields }];
});
return result;
}
export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] { export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
const props = schema.properties ?? {}; const props = schema.properties ?? {};
const requiredFields: string[] = schema.required ?? []; const requiredFields: string[] = schema.required ?? [];
@@ -30,13 +110,27 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
? refSchema["x-display-format"] ? refSchema["x-display-format"]
: undefined; : undefined;
const isDiscriminatedUnion = isRef && refSchema?.oneOf && refSchema?.discriminator;
const discriminatorProperty = isDiscriminatedUnion ? refSchema.discriminator.propertyName : undefined;
const oneOfOptions = isDiscriminatedUnion ? extractOneOfOptions(refSchema, schemas, discriminatorProperty!) : undefined;
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
const isPlainString = !prop["x-fk"] && !prop.enum && !isRef && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
if (!autocomplete && isPlainString && prop["x-filterable"]) {
autocomplete = "text";
console.warn(`[field-config] missing x-autocomplete on "${name}" in schema "${schemaName}", defaulting to "text"`);
}
if (autocomplete && !prop["x-filterable"]) {
_validationErrors.push(`[field-config] field "${name}" in schema "${schemaName}" has x-autocomplete but is not x-filterable`);
}
const field: FieldConfig = { const field: FieldConfig = {
name, name,
label: prop["x-label"], label: prop["x-label"],
description: prop["x-description"] ?? prop["x-label"] ?? name, description: prop["x-description"] ?? "",
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"), type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
format: prop.format, format: prop.format,
order: prop["x-order"], order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {}, hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false, filterable: prop["x-filterable"] ?? false,
sortable: prop["x-sortable"] ?? false, sortable: prop["x-sortable"] ?? false,
@@ -46,9 +140,13 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
fk: prop["x-fk"], fk: prop["x-fk"],
uiType: prop["x-ui-type"], uiType: prop["x-ui-type"],
uploadUrl: prop["x-upload-url"], uploadUrl: prop["x-upload-url"],
upload: prop["x-upload"],
refSchema: refSchemaName, refSchema: refSchemaName,
inlineDisplayFormat, inlineDisplayFormat,
isArray: prop.type === "array", isArray: prop.type === "array",
oneOfOptions,
discriminatorProperty,
autocomplete,
}; };
return field; return field;

View File

@@ -11,11 +11,7 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
if (!prop["x-fk"]) continue; if (!prop["x-fk"]) continue;
const fkResource = prop["x-fk"].resource as string; const fkResource = prop["x-fk"].resource as string;
const targetEntry = Object.entries(schemas).find(([, s]) => s?.["x-resource"] === fkResource);
const targetSchemaName = targetEntry ? targetEntry[0] : fkResource;
const prefetch = prop["x-fk"].prefetch ?? false; const prefetch = prop["x-fk"].prefetch ?? false;
console.log(`[FK] extracted relationship: field="${name}" target="${fkResource}" prefetch=${prefetch} rawPrefetch=${prop["x-fk"].prefetch}`);
rels.push({ rels.push({
fieldName: name, fieldName: name,
@@ -23,10 +19,9 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
resource: fkResource, resource: fkResource,
prefetch, prefetch,
}, },
targetSchemaName, targetSchemaName: fkResource,
}); });
} }
console.log(`[FK] total relationships extracted: ${rels.length}`);
return rels; return rels;
} }

View File

@@ -1,5 +1,5 @@
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types"; import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
import { extractFields } from "./field-config"; import { extractFields, clearValidationErrors, getValidationErrors } from "./field-config";
import { extractRelationships } from "./relationship-config"; import { extractRelationships } from "./relationship-config";
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null { function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
@@ -47,62 +47,154 @@ const SSE_RECEIVED_FIELD: FieldConfig = {
isArray: false, isArray: false,
}; };
function getSegments(path: string): string[] {
return path.split("/").filter(Boolean);
}
function getResponseSchemaRef(pathObj: any): string | undefined {
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"];
const content = response?.content;
if (!content) return;
for (const mediaType of Object.values(content) as any[]) {
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
}
}
function resolveRef(ref: string): string {
return ref.split("/").pop()!;
}
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
clearValidationErrors();
const schemas = spec.components?.schemas ?? {}; const schemas = spec.components?.schemas ?? {};
const paths = spec.paths ?? {}; const paths = spec.paths ?? {};
const configs: ResourceConfig[] = []; const configs: ResourceConfig[] = [];
const nameMap = new Map<string, ResourceConfig>();
for (const [schemaName, schema] of Object.entries(schemas)) { const sortedPaths = Object.keys(paths).sort(
if (!schema || typeof schema !== "object") continue; (a, b) => getSegments(a).length - getSegments(b).length
);
const resourceName = schema["x-resource"]; for (const path of sortedPaths) {
if (!resourceName || typeof resourceName !== "string") continue; const segments = getSegments(path);
const pathObj = paths[path];
const resourcePath = `/${resourceName}`; // Skip paths where ALL non-parameter operations are profile-only (x-profile: true).
const itemPath = `${resourcePath}/{id}`; // These are handled by Admin.tsx profile routes, not as CRUD resources.
const collectionPathObj = paths[resourcePath]; const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters");
const itemPathObj = paths[itemPath]; const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true);
if (allProfile) continue;
const fields = extractFields(schemaName, schema, schemas); const lastSeg = segments[segments.length - 1];
const relationships = extractRelationships(schema, schemas); const isItemPath = /^\{.*\}$/.test(lastSeg);
const hasSSE = collectionPathObj?.get?.["x-sse"] === true; const paramIdx = segments.findIndex(
(s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s)
);
if (isItemPath) {
const parentName = segments[segments.length - 2];
const parent = nameMap.get(parentName);
if (!parent) continue;
if (hasOperation(pathObj, "get")) parent.operations.get = true;
if (hasOperation(pathObj, "put")) parent.operations.update = true;
if (hasOperation(pathObj, "patch")) parent.operations.patch = true;
if (hasOperation(pathObj, "delete")) parent.operations.delete = true;
continue;
}
if (paramIdx >= 0) {
const resourceName = lastSeg;
const parentName = segments[paramIdx - 1];
const pathParamName = segments[paramIdx].replace(/[{}]/g, "");
const responseRef = getResponseSchemaRef(pathObj);
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
const schema = schemaName ? schemas[schemaName] : undefined;
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
const hasSSE = pathObj?.get?.["x-sse"] === true;
const resource: ResourceConfig = {
name: resourceName,
schemaName: schemaName ?? resourceName,
displayName: formatDisplayName(resourceName),
path,
primaryKey: schema?.["x-primary-key"] ?? "_received_at",
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
listColumns: schema?.["x-list-columns"] ?? [],
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [],
operations: hasSSE
? { list: true, get: false, create: false, update: false, patch: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, patch: false, delete: false },
updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj),
relationships: [],
streaming: hasSSE || undefined,
parent: { resource: parentName, pathParam: pathParamName },
};
resource.orderedFields = sortFields(resource.fields);
if (hasSSE) {
resource.listColumns = ["_received_at", ...resource.listColumns];
resource.primaryKey = "_received_at";
}
const parent = nameMap.get(parentName);
if (parent) {
parent.subResources = parent.subResources ?? [];
parent.subResources.push(resourceName);
}
nameMap.set(resourceName, resource);
configs.push(resource);
continue;
}
const resourceName = lastSeg;
const responseRef = getResponseSchemaRef(pathObj);
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
const schema = schemaName ? schemas[schemaName] : undefined;
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
const relationships = schema ? extractRelationships(schema, schemas) : [];
const hasSSE = pathObj?.get?.["x-sse"] === true;
const resource: ResourceConfig = { const resource: ResourceConfig = {
name: resourceName, name: resourceName,
schemaName, schemaName: schemaName ?? resourceName,
displayName: formatDisplayName(resourceName), displayName: formatDisplayName(resourceName),
path: resourcePath, path,
primaryKey: schema["x-primary-key"], primaryKey: schema?.["x-primary-key"] ?? "id",
displayFormat: schema["x-display-format"], displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
listColumns: schema["x-list-columns"], listColumns: schema?.["x-list-columns"] ?? [],
fields, fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: sortFields(fields), orderedFields: [],
operations: { operations: hasSSE
list: hasOperation(collectionPathObj, "get"), ? { list: true, get: false, create: false, update: false, patch: false, delete: false }
get: hasOperation(itemPathObj, "get"), : { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, patch: false, delete: false },
create: hasOperation(collectionPathObj, "post"), updateMethod: "put",
update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"), pagination: hasSSE ? null : detectPagination(pathObj),
delete: hasOperation(itemPathObj, "delete"),
},
updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
pagination: detectPagination(collectionPathObj),
relationships, relationships,
streaming: hasSSE || undefined, streaming: hasSSE || undefined,
}; };
resource.orderedFields = sortFields(resource.fields);
if (hasSSE) { if (hasSSE) {
resource.operations = { list: true, get: false, create: false, update: false, delete: false };
resource.updateMethod = "put";
resource.pagination = null;
resource.relationships = [];
resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];
resource.orderedFields = sortFields(resource.fields);
resource.listColumns = ["_received_at", ...resource.listColumns]; resource.listColumns = ["_received_at", ...resource.listColumns];
resource.primaryKey = "_received_at"; resource.primaryKey = "_received_at";
} }
nameMap.set(resourceName, resource);
configs.push(resource); configs.push(resource);
} }
const errors = getValidationErrors();
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
return configs; return configs;
} }

View File

@@ -6,12 +6,37 @@ export interface ResourceConfiguration {
}; };
} }
export interface ProfileComponents {
create?: React.ComponentType<any>;
edit?: React.ComponentType<any>;
view?: React.ComponentType<any>;
}
export interface SpecConfiguration { export interface SpecConfiguration {
specUrl: string; specUrl: string;
baseApiUrl?: string; baseApiUrl?: string;
title?: string; title?: string;
getToken?: () => string | null; getToken?: () => string | null;
resourceConfig?: Record<string, ResourceConfiguration>; resourceConfig?: Record<string, ResourceConfiguration>;
profileComponents?: ProfileComponents;
}
/** Auth server config extracted from securitySchemes extensions. */
export interface AuthConfig {
serverUrl: string;
loginPath: string;
registerPath: string;
logoutPath: string;
mePath: string;
introspectPath: string;
}
/** Represents a single operation marked with `x-profile: true` in the spec. */
export interface ProfileOperation {
path: string;
method: string;
operationId: string;
summary?: string;
} }
export interface ValidationMessage { export interface ValidationMessage {
@@ -40,6 +65,7 @@ export interface ResourceConfig {
get: boolean; get: boolean;
create: boolean; create: boolean;
update: boolean; update: boolean;
patch: boolean;
delete: boolean; delete: boolean;
}; };
updateMethod: "put" | "patch"; updateMethod: "put" | "patch";
@@ -50,6 +76,19 @@ export interface ResourceConfig {
} | null; } | null;
relationships: ResourceRelationship[]; relationships: ResourceRelationship[];
streaming?: boolean; streaming?: boolean;
parent?: { resource: string; pathParam: string };
subResources?: string[];
}
export interface FKFieldConfig {
resource: string;
prefetch: boolean;
}
export interface OneOfOption {
value: string;
label: string;
fields: FieldConfig[];
} }
export interface FieldConfig { export interface FieldConfig {
@@ -68,14 +107,13 @@ export interface FieldConfig {
fk?: FKFieldConfig; fk?: FKFieldConfig;
uiType?: string; uiType?: string;
uploadUrl?: string; uploadUrl?: string;
upload?: { type: string; url: string };
refSchema?: string; refSchema?: string;
inlineDisplayFormat?: string; inlineDisplayFormat?: string;
isArray: boolean; isArray: boolean;
} oneOfOptions?: OneOfOption[];
discriminatorProperty?: string;
export interface FKFieldConfig { autocomplete?: "text" | "token" | "email" | "phone";
resource: string;
prefetch: boolean;
} }
export interface OpenApiSpec { export interface OpenApiSpec {

View File

@@ -0,0 +1,46 @@
export function tokenize(text: string): string[] {
return text.split(/[,\s.]+/).filter(Boolean);
}
export function stripNonDigits(text: string): string {
return text.replace(/\D/g, "");
}
export function extractTokens(data: any[], fieldName: string): string[] {
const all = new Set<string>();
for (const row of data) {
const val = row[fieldName];
if (val != null && val !== "") {
for (const t of tokenize(String(val))) {
all.add(t);
}
}
}
return [...all].sort();
}
export function extractLocalParts(data: any[], fieldName: string): string[] {
const parts = new Set<string>();
for (const row of data) {
const val = row[fieldName];
if (val != null && val !== "") {
const m = String(val).match(/^([^@]+)@/);
if (m) parts.add(m[1]);
}
}
return [...parts].sort();
}
export function extractDomains(data: any[], fieldName: string, localPart: string): string[] {
const domains = new Set<string>();
const escaped = localPart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped}@(.+)$`);
for (const row of data) {
const val = row[fieldName];
if (val != null && val !== "") {
const m = String(val).match(re);
if (m) domains.add(m[1]);
}
}
return [...domains].sort();
}

View File

@@ -0,0 +1,67 @@
import type { FieldConfig } from "../types";
import { extractFields } from "../transformers/field-config";
export type FkResolver = (resourceName: string, fkValue: any) => Promise<any>;
export async function sanitizePayload(
data: any,
fields: FieldConfig[],
schemas: Record<string, any>,
resolveFk?: FkResolver,
): Promise<any> {
if (data == null || typeof data !== "object") return data;
if (Array.isArray(data)) {
return Promise.all(data.map((item) => {
if (item != null && typeof item === "object") {
return sanitizePayload(item, fields, schemas, resolveFk);
}
return item;
}));
}
const result: Record<string, any> = { ...data };
const fieldMap = new Map(fields.map((f) => [f.name, f]));
for (const key of Object.keys(result)) {
const field = fieldMap.get(key);
if (!field) continue;
const val = result[key];
if (field.fk) {
if (val == null || val === "") {
result[key] = null;
} else if (resolveFk) {
if (field.isArray && Array.isArray(val)) {
result[key] = await Promise.all(val.map((v: any) => resolveFk(field.fk!.resource, v)));
} else {
result[key] = await resolveFk(field.fk!.resource, val);
}
}
continue;
}
if (field.refSchema && !field.fk) {
if (val == null || val === "") {
result[key] = field.isArray ? [] : {};
} else if (typeof val === "object") {
const refSchemaObj = schemas[field.refSchema!];
const nestedFields = refSchemaObj
? extractFields(field.refSchema!, refSchemaObj, schemas)
: [];
result[key] = await sanitizePayload(val, nestedFields, schemas, resolveFk);
}
} else if (field.isArray) {
if (val == null || val === "") {
result[key] = [];
}
} else if (field.type === "object") {
if (val == null || val === "") {
result[key] = {};
}
}
}
return result;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "index.ts"]
}

View File

@@ -1,339 +0,0 @@
import * as React from "react";
import {
Box,
Container,
CircularProgress,
Alert,
TextField,
Paper,
Autocomplete,
Button
} from "@mui/material";
import DashboardView from "./components/Dashboard";
import {
DashboardState,
DashboardStateSetters,
DashboardFlow,
} from "./components/Dashboard";
import { configuration } from "./dashboard-config";
import {
useReport,
prepareReport,
} from "./features/report";
import { useReportSnapshotsList } from "./features/report-snapshots";
function formatSnapshotDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function Dashboard() {
const [state, setState] = React.useState<DashboardState>({
flow: "outflows",
periodType: "rolling",
selectedPeriodId: null,
selectedGroupKey: null,
comparison: false,
});
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
const [payeeInput, setPayeeInput] = React.useState<string[]>([]);
const [tagsInput, setTagsInput] = React.useState<string[]>([]);
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
const { data: snapshotsData } = useReportSnapshotsList();
const snapshotOptions = React.useMemo(() => {
const options: { label: string; value: string | null }[] = [
{ label: "Latest (auto)", value: null },
];
if (snapshotsData?.items) {
for (const snap of snapshotsData.items) {
options.push({
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
value: snap.snapshot_id,
});
}
}
return options;
}, [snapshotsData]);
const selectedSnapshotOption = snapshotOptions.find((o) => o.value === selectedSnapshotId) ?? snapshotOptions[0];
const report = useReport({
snapshot_id: selectedSnapshotId ?? undefined,
periods: ["daily", "weekly", "monthly", "all"],
flow: state.flow,
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
tags: appliedTags.length > 0 ? appliedTags : undefined,
});
React.useEffect(() => {
if (report.data) {
setLoadedPayees(prev => {
const pSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name);
});
});
});
});
return Array.from(pSet).sort();
});
setLoadedTags(prev => {
const tSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
});
});
});
});
return Array.from(tSet).sort();
});
}
}, [report.data]);
const toggleFlow =
React.useCallback(() => {
setState((prev) => ({
...prev,
flow:
prev.flow ===
"outflows"
? "inflows"
: "outflows",
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
}, []);
const setFlow =
React.useCallback(
(
flow: DashboardFlow
) => {
setState((prev) => ({
...prev,
flow,
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
},
[]
);
const togglePeriodType =
React.useCallback(() => {
setState((prev) => ({
...prev,
periodType:
prev.periodType ===
"rolling"
? "calendar"
: "rolling",
}));
}, []);
const toggleComparison =
React.useCallback(() => {
setState((prev) => ({
...prev,
comparison:
!prev.comparison,
}));
}, []);
const setSelectedPeriodId =
React.useCallback(
(
selectedPeriodId: DashboardState["selectedPeriodId"]
) => {
setState((prev) => ({
...prev,
selectedPeriodId,
}));
},
[]
);
const setSelectedGroupKey =
React.useCallback(
(
selectedGroupKey: DashboardState["selectedGroupKey"]
) => {
setState((prev) => ({
...prev,
selectedGroupKey,
}));
},
[]
);
const stateSetters: DashboardStateSetters =
React.useMemo(
() => ({
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
}),
[
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
]
);
const isLoading = report.isLoading;
const error = report.error;
if (isLoading && !report.data) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{String(error)}</Alert>
</Container>
);
}
if (!report.data) {
return null;
}
const data = prepareReport(report.data);
return (
<Box>
<Container>
<Paper
sx={{
mt: 4,
p: 2,
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: { xs: "stretch", sm: "flex-end" },
borderRadius: 4,
mb: -2 // pull up to be closer to the dashboard container below
}}
elevation={0}
variant="outlined"
>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Payee
</Box>
<Autocomplete
multiple
freeSolo
options={loadedPayees}
value={payeeInput}
onChange={(_, val) => setPayeeInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add payees..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Tags
</Box>
<Autocomplete
multiple
freeSolo
options={loadedTags}
value={tagsInput}
onChange={(_, val) => setTagsInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add tags..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: { sm: 220 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Snapshot
</Box>
<Autocomplete
options={snapshotOptions}
value={selectedSnapshotOption}
onChange={(_, option) => setSelectedSnapshotId(option?.value ?? null)}
getOptionLabel={(o) => o.label}
isOptionEqualToValue={(o, v) => o.value === v.value}
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Button
variant="contained"
size="large"
onClick={() => {
setAppliedPayees(payeeInput);
setAppliedTags(tagsInput);
}}
disabled={isLoading}
sx={{ height: 40, borderRadius: 2 }}
>
Apply
</Button>
</Paper>
</Container>
<DashboardView
config={configuration}
data={data}
state={state}
stateSetters={stateSetters}
isFetching={report.isFetching}
/>
</Box>
);
}

View File

@@ -0,0 +1,194 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box, Container, Typography, Paper, Button, Alert,
CircularProgress,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
function FetchRequestList() {
const navigate = useNavigate();
const { list, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [rows, setRows] = useState<any[] | null>(null);
const columns = useMemo(() => {
if (!resource) return [];
return resource.listColumns
.map((n) => resource.fields.find((f) => f.name === n))
.filter(Boolean) as FieldConfig[];
}, [resource]);
useEffect(() => {
if (!resource) return;
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
}, [resource?.name]);
if (!rows) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={24} />
</Box>
);
}
if (rows.length === 0) {
return <Typography variant="body2" color="text.secondary">No fetch requests found.</Typography>;
}
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{rows.map((row, i) => {
const displayFormat = resource?.displayFormat ?? "";
return (
<Paper
key={row.id ?? i}
variant="outlined"
sx={{ p: 2, cursor: "pointer", "&:hover": { borderColor: "primary.main" } }}
onClick={() => navigate(`/fetch-requests/${row.id}`)}
>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{columns.map((col) => (
<Box key={col.name} sx={{ minWidth: 120 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{col.label}
</Typography>
<ListCellRenderer
field={col}
value={row[col.name]}
displayFormat={col.fk
? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? displayFormat)
: displayFormat}
/>
</Box>
))}
</Box>
</Paper>
);
})}
</Box>
);
}
export default function FetchRequestCreate() {
const { resources: allResources } = useAppContext();
const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]);
const { create } = useResource("fetch-requests");
const [formData, setFormData] = useState<Record<string, any>>({});
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ severity: "success" | "error"; message: string } | null>(null);
useEffect(() => {
if (!resource) return;
resource.relationships.forEach((rel) => {
const targetRes = allResources.find((r) => r.name === rel.config.resource);
if (!targetRes) return;
(async () => {
try {
const api = getApi();
const params: Record<string, any> = {};
if (targetRes.pagination) params.limit = 0;
const res = await api.get(targetRes.path, { params });
const items = targetRes.pagination
? (res.data.items ?? [])
: (Array.isArray(res.data) ? res.data : []);
const opts = items.map((item: any) => ({
value: item[targetRes.primaryKey],
label: applyDisplayFormat(item, targetRes.displayFormat),
}));
setFkOptions((prev) => ({ ...prev, [rel.fieldName]: opts }));
} catch (e) {
console.warn(`Failed to load FK options for ${rel.fieldName}:`, e);
}
})();
});
}, [resource]);
const formFields = useMemo(() => {
if (!resource) return [];
return resource.orderedFields.filter((f) => CREATE_FIELDS.includes(f.name));
}, [resource]);
const handleChange = (fieldName: string, value: any) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
setResult(null);
};
const handleSubmit = async () => {
setLoading(true);
setResult(null);
try {
const created = await create(formData);
const display = applyDisplayFormat(created, resource!.displayFormat);
setResult({ severity: "success", message: `Created: ${display}` });
setFormData({});
} catch (e: any) {
const detail = e?.response?.data?.detail;
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error");
setResult({ severity: "error", message: `Failed: ${msg}` });
} finally {
setLoading(false);
}
};
if (!resource) {
return (
<Alert severity="info">
The <strong>fetch-requests</strong> resource was not found in this spec.
</Alert>
);
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Typography variant="h5" fontWeight={800} gutterBottom>
Fetch Requests
</Typography>
<Paper variant="outlined" sx={{ p: 3, mb: 4, borderRadius: 2, position: "relative", overflow: "hidden", "&::before": { content: '""', position: "absolute", left: 0, top: 0, bottom: 0, width: 4, bgcolor: "primary.main" } }}>
<Box sx={{ ml: 0.5, mb: 2.5 }}>
<Typography variant="subtitle1" fontWeight={700}>
New Fetch Request
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, maxWidth: 480 }}>
{formFields.map((field) => (
<FormFieldRenderer
key={field.name}
field={field}
value={formData[field.name] ?? ""}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
/>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2, gap: 1 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request
</Button>
</Box>
{result && (
<Alert severity={result.severity} sx={{ mt: 1 }} onClose={() => setResult(null)}>
{result.message}
</Alert>
)}
</Paper>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Recent Fetch Requests
</Typography>
<FetchRequestList />
</Container>
);
}

View File

@@ -0,0 +1,360 @@
import React, { useMemo, useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Container,
Paper,
Typography,
Button,
Chip,
CircularProgress,
Alert,
Snackbar,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests";
import { PipelineStepper } from "./components/PipelineStepper";
import { AmbiguityResolver } from "./components/AmbiguityResolver";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} />,
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
};
function formatProgressMessage(msg: ProgressMessage): string {
if (msg.lines !== undefined) return `${msg.lines} lines`;
if (msg.blocks !== undefined) return `${msg.blocks} blocks`;
if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`;
if (msg.count !== undefined) return `${msg.count} items`;
if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`;
if (msg.error) return msg.error.slice(0, 80);
return "";
}
function sseIcon(status: SSEEvent["status"]) {
switch (status) {
case "started": return <CircularProgress size={14} />;
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return <FiberManualRecordIcon sx={{ fontSize: 14, color: "info.main" }} />;
}
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { get, patch, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false);
const [failNotif, setFailNotif] = useState<string | null>(null);
const feedRef = useRef<HTMLDivElement>(null);
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id!),
enabled: !!id,
});
const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
onEvent: (parsed: SSEEvent) => {
if (parsed.status === "progress" && parsed.message.count !== undefined) {
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
}
if (parsed.status === "completed" && parsed.message.count !== undefined) {
const stats: Record<string, number> = {};
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
if (Object.keys(stats).length) {
setStepStats((prev) => ({ ...prev, ...stats }));
}
}
if (parsed.status === "paused") {
refetchRequest();
}
if (parsed.status === "failed") {
setFailNotif(parsed.message.error || "Fetch request failed");
refetchRequest();
}
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
},
});
useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
const displayEvents = useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
for (let i = sseEvents.length - 1; i >= 0; i--) {
const e = sseEvents[i];
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
lastProgressIdx[e.step] = i;
}
}
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
return sseEvents.filter((e, i) => {
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
if (e.status === "started") {
return !sseEvents.slice(i + 1).some(
(later) => later.step === e.step && terminalStatuses.has(later.status),
);
}
return true;
});
}, [sseEvents]);
const handleRetry = async () => {
if (!id || !patch || retrying) return;
setRetrying(true);
try {
await patch(id, { status: "pending" });
refetchRequest();
} catch (err: any) {
setFailNotif(formatApiError(err));
} finally {
setRetrying(false);
}
};
const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = req?.status as FetchRequestStatus | undefined;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
const displayTitle = useMemo(() => {
if (!resource || !req) return "";
const resolved = Object.fromEntries(
resource.orderedFields.map((field) => {
const value = req[field.name];
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) return [field.name, applyDisplayFormat(value, target.displayFormat)];
}
return [field.name, value];
}),
);
return applyDisplayFormat(resolved, resource.displayFormat);
}, [resource, req, allResources]);
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
<CircularProgress />
</Box>
);
}
if (fetchError || !fetchRequest) {
return (
<Container sx={{ mt: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back
</Button>
<Alert severity="error">Failed to load fetch request</Alert>
</Container>
);
}
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip
icon={statusIcons[status] as any}
label={status.replace(/_/g, " ")}
color={statusColors[status]}
/>
<Typography variant="h6" fontWeight={600}>{displayTitle}</Typography>
<Chip
label={"path" in (req.source ?? {}) ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in (req.source ?? {}) ? "primary" : "secondary"}
/>
</Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
{detailFields.map((field) => {
const value = req[field.name];
let fmt = resource?.displayFormat;
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) fmt = target.displayFormat;
}
return (
<DetailFieldRenderer
key={field.name}
field={field}
value={value}
displayFormat={fmt}
/>
);
})}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary">
Retries: {retryCount}/{RETRY_MAX}
</Typography>
</Box>
{status === "failed" && !isRetryExhausted && (
<Button
variant="outlined"
size="small"
startIcon={<ReplayIcon />}
onClick={handleRetry}
disabled={retrying}
>
Retry
</Button>
)}
</Box>
</Paper>
{status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message}
</Alert>
)}
{isRetryExhausted && status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made.
</Alert>
)}
<PipelineStepper
fetchRequest={req}
sseEvents={sseEvents}
stepStats={stepStats}
liveParsedCount={liveParsedCount ?? 0}
/>
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
Progress Events
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
<Box
ref={feedRef}
sx={{
maxHeight: 300,
overflowY: "auto",
p: 2,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{displayEvents.length === 0 ? (
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
Waiting for events...
</Typography>
) : (
displayEvents.map((evt, i) => (
<Box
key={i}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
p: 1,
borderRadius: 2,
bgcolor: "action.hover",
}}
>
{sseIcon(evt.status)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{evt.step.replace(/_/g, " ")}
</Typography>
{evt.message && formatProgressMessage(evt.message) && (
<Typography variant="caption" color="text.secondary">
{formatProgressMessage(evt.message)}
</Typography>
)}
</Box>
</Box>
))
)}
</Box>
</Paper>
<AmbiguityResolver fetchRequestId={id!} />
<Snackbar
open={!!failNotif}
autoHideDuration={6000}
onClose={() => setFailNotif(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
{failNotif}
</Alert>
</Snackbar>
</Container>
);
}

View File

@@ -0,0 +1,121 @@
import React from "react";
import {
Box,
Paper,
Typography,
Button,
Alert,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { useFetchRequestAmbiguities, useResolveAmbiguity } from "../../features/fetch-requests";
interface AmbiguityResolverProps {
fetchRequestId: string;
}
export function AmbiguityResolver({ fetchRequestId }: AmbiguityResolverProps) {
const { data: ambiguities, refetch } = useFetchRequestAmbiguities(fetchRequestId);
const resolveMutation = useResolveAmbiguity();
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
await resolveMutation.mutateAsync({
ambiguityId: ambiguity.id,
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
});
refetch();
};
if (!ambiguities || ambiguities.length === 0) return null;
const allResolved = ambiguities.every((a: any) => a.status === "resolved");
return (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Ambiguity Resolution
</Typography>
{allResolved ? (
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
All ambiguities resolved pipeline will resume on next poll cycle
</Alert>
) : (
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
Pipeline paused resolve ambiguities to continue
</Alert>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ambiguities.map((ambiguity: any) => {
const isResolved = ambiguity.status === "resolved";
return (
<Paper
key={ambiguity.id}
sx={{
p: 2,
borderRadius: 3,
border: 1,
borderColor: isResolved ? "success.main" : "divider",
opacity: isResolved ? 0.8 : 1,
}}
variant="outlined"
>
<Box sx={{ fontFamily: "monospace", fontSize: "0.85rem", mb: 1.5, p: 1, bgcolor: "grey.900", borderRadius: 1, color: "grey.100" }}>
{ambiguity.line}
</Box>
<Box sx={{ display: "flex", gap: 3, mb: 1.5, flexWrap: "wrap" }}>
<Box>
<Typography variant="caption" color="text.secondary">OCR Amount</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_amount}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">OCR Balance</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_balance}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Previous Balance</Typography>
<Typography variant="body2">{ambiguity.prev_balance}</Typography>
</Box>
</Box>
{isResolved ? (
<Alert severity="success" sx={{ py: 0.5, borderRadius: 2 }} icon={<CheckCircleIcon />}>
Resolved: {ambiguity.chosen?.amount} / {ambiguity.chosen?.balance}
</Alert>
) : (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{ambiguity.candidates.map((candidate: any, ci: number) => {
const isCredit = candidate.amount > 0;
const isDebit = candidate.amount < 0;
const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined;
return (
<Button
key={ci}
variant="outlined"
size="small"
onClick={() => handleResolve(ambiguity, candidate)}
disabled={resolveMutation.isPending}
sx={{
borderColor: cColor,
color: cColor,
"&:hover": cColor ? { borderColor: cColor } : undefined,
}}
>
{candidate.amount} / {candidate.balance}
</Button>
);
})}
</Box>
)}
</Paper>
);
})}
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,209 @@
import React, { useMemo } from "react";
import {
Box,
Paper,
Typography,
Stepper,
Step,
StepLabel,
LinearProgress,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import CircularProgress from "@mui/material/CircularProgress";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../../features/fetch-requests";
const STEP_LABELS = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeProgressPercent(
status: FetchRequestStatus,
liveCount: number,
seenSteps: Set<string>,
stepStats: Record<string, number>,
txnBlockCount: number,
txnDictCount: number,
): number {
if (status === "pending") return 0;
if (status === "completed") return 100;
let pct = 0;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
if (txnBlockCount > 0) {
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);
pct += Math.min(1, current / txnBlockCount) * 20;
}
if (txnDictCount > 0) {
pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50;
pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20;
}
return Math.round(Math.min(100, pct));
}
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
if (status === "completed") return STEP_LABELS.length;
if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return STEP_LABELS.length;
if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3;
if (seenSteps.has("enrich/completed")) return 3;
if (seenSteps.has("enrich")) return 2;
if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2;
if (seenSteps.has("txn_dicts")) return 1;
if (seenSteps.has("txn_blocks/completed")) return 1;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0;
if (status === "processing" || status === "paused") return 0;
return -1;
}
function computeStepMessages(
fetchRequest: any,
stepStats: Record<string, number>,
liveParsedCount: number,
txnBlockCount: number,
): Record<number, string> {
const msgs: Record<number, string> = {};
const source = fetchRequest?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}
interface PipelineStepperProps {
fetchRequest: any;
sseEvents: SSEEvent[];
stepStats: Record<string, number>;
liveParsedCount: number;
}
export function PipelineStepper({ fetchRequest, sseEvents, stepStats, liveParsedCount }: PipelineStepperProps) {
const status = (fetchRequest?.status ?? "pending") as FetchRequestStatus;
const seenSteps = useMemo(() => {
const steps = new Set<string>();
for (const evt of sseEvents) {
steps.add(evt.step);
if (evt.status === "completed") steps.add(`${evt.step}/completed`);
if (evt.status === "failed") steps.add(`${evt.step}/failed`);
if (evt.status === "started") steps.add(`${evt.step}/started`);
if (evt.status === "progress") steps.add(`${evt.step}/progress`);
}
return steps;
}, [sseEvents]);
const txnBlockCount = useMemo(() => {
const blocks = fetchRequest?.source?.txn_blocks;
if (!blocks) return 0;
return Object.values(blocks).reduce(
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
}, [fetchRequest]);
const txnDictCount = useMemo(() => {
const source = fetchRequest?.source;
if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts;
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
}, [fetchRequest, stepStats]);
const displayParsedCount = useMemo(() => {
if (liveParsedCount && liveParsedCount > 0) return liveParsedCount;
const source = fetchRequest?.source;
const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
if (persistedCount > 0) return persistedCount;
const dicts = source?.txn_dicts;
if (Array.isArray(dicts) && dicts.length > 0) return dicts.length;
return 0;
}, [liveParsedCount, fetchRequest]);
const activeStep = computeActiveStep(status, seenSteps);
const progressPercent = computeProgressPercent(status, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount);
const stepMessages = computeStepMessages(fetchRequest, stepStats, liveParsedCount, txnBlockCount);
return (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{STEP_LABELS.map((label, index) => {
const isCompleted = index < activeStep;
const isActive = index === activeStep;
const isPaused = status === "paused" && isActive;
const isFailed = status === "failed" && isActive;
let icon: React.ReactNode;
if (isCompleted) {
icon = <CheckCircleIcon sx={{ color: "success.main" }} />;
} else if (isFailed) {
icon = <ErrorIcon sx={{ color: "error.main" }} />;
} else if (isPaused) {
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
} else if (isActive) {
icon = <CircularProgress size={20} />;
} else {
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
}
return (
<Step key={label}>
<StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
>
<Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMessages[index] && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMessages[index]}
</Typography>
)}
</StepLabel>
</Step>
);
})}
</Stepper>
<Box sx={{ mt: 3, mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Overall Progress</Typography>
{["processing", "paused"].includes(status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions
</Typography>
)}
</Box>
<LinearProgress
variant="determinate"
value={progressPercent}
color={status === "failed" ? "error" : status === "completed" ? "success" : "primary"}
sx={{ borderRadius: 1, height: 8, transition: "width 0.3s ease" }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: "block" }}>
{progressPercent}%
</Typography>
</Box>
</Paper>
);
}

View File

@@ -1,681 +0,0 @@
import * as React from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Container,
Paper,
Typography,
Button,
Chip,
CircularProgress,
Alert,
Stepper,
Step,
StepLabel,
StepIcon,
LinearProgress,
IconButton,
Snackbar,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import {
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./features/fetch-requests";
import type {
FetchRequestStatus,
SSEEvent,
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useAppContext, useResource } from "../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} />,
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
};
function computeProgressPercent(
status: FetchRequestStatus,
liveCount: number,
seenSteps: Set<string>,
stepStats: Record<string, number>,
txnBlockCount: number,
txnDictCount: number,
): number {
if (status === "pending") return 0;
if (status === "completed") return 100;
let pct = 0;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
if (txnBlockCount > 0) {
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);
pct += Math.min(1, current / txnBlockCount) * 20;
}
if (txnDictCount > 0) {
pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50;
pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20;
}
return Math.round(Math.min(100, pct));
}
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
if (status === "completed") return stepLabels.length;
if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return stepLabels.length;
if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3;
if (seenSteps.has("enrich/completed")) return 3;
if (seenSteps.has("enrich")) return 2;
if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2;
if (seenSteps.has("txn_dicts")) return 1;
if (seenSteps.has("txn_blocks/completed")) return 1;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0;
if (status === "processing" || status === "paused") return 0;
return -1;
}
function formatProgressMessage(msg: ProgressMessage): string {
if (msg.lines !== undefined) return `${msg.lines} lines`;
if (msg.blocks !== undefined) return `${msg.blocks} blocks`;
if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`;
if (msg.count !== undefined) return `${msg.count} items`;
if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`;
if (msg.error) return msg.error.slice(0, 80);
return "";
}
function sseIcon(status: SSEEvent["status"]) {
switch (status) {
case "started": return <CircularProgress size={14} />;
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return (
<FiberManualRecordIcon
sx={{ fontSize: 14, color: "info.main" }}
/>
);
}
}
function isMathValid(candidate: { amount: number; balance: number }, prevBalance: number) {
return (
candidate.balance === prevBalance + candidate.amount ||
candidate.balance === prevBalance - candidate.amount ||
Math.abs(candidate.balance - (prevBalance + candidate.amount)) < 0.01 ||
Math.abs(candidate.balance - (prevBalance - candidate.amount)) < 0.01
);
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { config } = useAppContext();
const { get, update } = useResource("fetch-requests");
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id!),
enabled: !!id,
});
const updateMutation = useMutation({
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
});
const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
const [sseConnected, setSseConnected] = React.useState(false);
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
const [failNotif, setFailNotif] = React.useState<string | null>(null);
const sseRef = React.useRef<EventSource | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null);
const txnBlockCount = React.useMemo(() => {
const blocks = (fetchRequest as any)?.source?.txn_blocks;
if (!blocks) return 0;
return Object.values(blocks).reduce(
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
}, [fetchRequest]);
const stepMessages = React.useMemo(() => {
const msgs: Record<number, string> = {};
const source = (fetchRequest as any)?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
React.useEffect(() => {
if (!id || !config?.baseApiUrl) return;
const url = `${config.baseApiUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url);
sseRef.current = es;
es.onopen = () => setSseConnected(true);
es.onerror = () => setSseConnected(false);
es.onmessage = (event) => {
try {
const parsed: SSEEvent = JSON.parse(event.data);
setSseEvents((prev) => [...prev, parsed]);
if (parsed.status === "progress" && parsed.message.count !== undefined) {
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
}
if (parsed.status === "completed" && parsed.message.count !== undefined) {
const stats: Record<string, number> = {};
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
if (Object.keys(stats).length) {
setStepStats((prev) => ({ ...prev, ...stats }));
}
}
if (parsed.status === "paused") {
refetchRequest();
refetchAmbiguities();
}
if (parsed.status === "failed") {
setFailNotif(parsed.message.error || "Fetch request failed");
refetchRequest();
}
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
} catch {
// ignore malformed events
}
};
return () => {
es.close();
sseRef.current = null;
};
}, [id, config?.baseApiUrl]);
React.useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
const displayEvents = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
for (let i = sseEvents.length - 1; i >= 0; i--) {
const e = sseEvents[i];
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
lastProgressIdx[e.step] = i;
}
}
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
return sseEvents.filter((e, i) => {
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
if (e.status === "started") {
return !sseEvents.slice(i + 1).some(
(later) => later.step === e.step && terminalStatuses.has(later.status),
);
}
return true;
});
}, [sseEvents]);
const seenSteps = React.useMemo(() => {
const steps = new Set<string>();
for (const evt of sseEvents) {
steps.add(evt.step);
if (evt.status === "completed") steps.add(`${evt.step}/completed`);
if (evt.status === "failed") steps.add(`${evt.step}/failed`);
if (evt.status === "started") steps.add(`${evt.step}/started`);
if (evt.status === "progress") steps.add(`${evt.step}/progress`);
}
return steps;
}, [sseEvents]);
const displayParsedCount = React.useMemo(() => {
if (liveParsedCount && liveParsedCount > 0) return liveParsedCount;
const source = (fetchRequest as any)?.source;
const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
if (persistedCount > 0) return persistedCount;
const dicts = source?.txn_dicts;
if (Array.isArray(dicts) && dicts.length > 0) return dicts.length;
return 0;
}, [liveParsedCount, fetchRequest]);
const txnDictCount = React.useMemo(() => {
const source = (fetchRequest as any)?.source;
if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts;
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
}, [fetchRequest, stepStats]);
const progressPercent = React.useMemo(
() => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
displayParsedCount,
seenSteps,
stepStats,
txnBlockCount,
txnDictCount,
),
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
);
const handleRetry = async () => {
if (!id) return;
try {
await updateMutation.mutateAsync({ id, data: { status: "pending" } });
} catch (err: any) {
setFailNotif(formatApiError(err));
}
};
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
await resolveMutation.mutateAsync({
ambiguityId: ambiguity.id,
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
});
refetchAmbiguities();
};
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
<CircularProgress />
</Box>
);
}
if (fetchError || !fetchRequest) {
return (
<Container sx={{ mt: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back
</Button>
<Alert severity="error">Failed to load fetch request</Alert>
</Container>
);
}
const req = fetchRequest as any;
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? [];
const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? [];
const hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0;
const ambiguitiesLoading = !ambiguities;
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip
icon={statusIcons[req.status as FetchRequestStatus] as any}
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status as FetchRequestStatus]}
/>
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography variant="caption" color="text.secondary">Date Range</Typography>
<Typography variant="body2">
{(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Created</Typography>
<Typography variant="body2">{new Date(req.created_at).toLocaleString()}</Typography>
</Box>
{req.completed_at && (
<Box>
<Typography variant="caption" color="text.secondary">Completed</Typography>
<Typography variant="body2">{new Date(req.completed_at).toLocaleString()}</Typography>
</Box>
)}
</Box>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Overall Progress
</Typography>
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions
</Typography>
)}
</Box>
<LinearProgress
variant="determinate"
value={progressPercent}
color={req.status === "failed" ? "error" : req.status === "completed" ? "success" : "primary"}
sx={{ borderRadius: 1, height: 8, transition: "width 0.3s ease" }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: "block" }}>
{progressPercent}%
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary">
Retries: {retryCount}/{RETRY_MAX}
</Typography>
<LinearProgress
variant="determinate"
value={(retryCount / RETRY_MAX) * 100}
color={isRetryExhausted ? "error" : "primary"}
sx={{ mt: 0.5, borderRadius: 1, height: 6 }}
/>
</Box>
{req.status === "failed" && !isRetryExhausted && (
<Button
variant="outlined"
size="small"
startIcon={<ReplayIcon />}
onClick={handleRetry}
disabled={updateMutation.isPending}
>
Retry
</Button>
)}
</Box>
</Paper>
{req.status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message}
</Alert>
)}
{isRetryExhausted && req.status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made.
</Alert>
)}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{stepLabels.map((label, index) => {
const isCompleted = index < activeStep;
const isActive = index === activeStep;
const isPaused = req.status === "paused" && isActive;
const isFailed = req.status === "failed" && isActive;
let icon: React.ReactNode;
if (isCompleted) {
icon = <CheckCircleIcon sx={{ color: "success.main" }} />;
} else if (isFailed) {
icon = <ErrorIcon sx={{ color: "error.main" }} />;
} else if (isPaused) {
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
} else if (isActive) {
icon = <CircularProgress size={20} />;
} else {
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
}
const stepMsg = stepMessages[index];
return (
<Step key={label}>
<StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
>
<Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMsg}
</Typography>
)}
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
Progress Events
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
<Box
ref={feedRef}
sx={{
maxHeight: 300,
overflowY: "auto",
p: 2,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{displayEvents.length === 0 ? (
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
Waiting for events...
</Typography>
) : (
displayEvents.map((evt, i) => (
<Box
key={i}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
p: 1,
borderRadius: 2,
bgcolor: "action.hover",
}}
>
{sseIcon(evt.status)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{evt.step.replace(/_/g, " ")}
</Typography>
{evt.message && formatProgressMessage(evt.message) && (
<Typography variant="caption" color="text.secondary">
{formatProgressMessage(evt.message)}
</Typography>
)}
</Box>
<Typography variant="caption" color="text.disabled">
{new Date().toLocaleTimeString()}
</Typography>
</Box>
))
)}
</Box>
</Paper>
{hasAmbiguities && (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Ambiguity Resolution
</Typography>
{allResolved ? (
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
All ambiguities resolved pipeline will resume on next poll cycle
</Alert>
) : (
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
Pipeline paused resolve ambiguities to continue
</Alert>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ambiguities.map((ambiguity: any) => {
const isResolved = ambiguity.status === "resolved";
return (
<Paper
key={ambiguity.id}
sx={{
p: 2,
borderRadius: 3,
border: 1,
borderColor: isResolved ? "success.main" : "divider",
opacity: isResolved ? 0.8 : 1,
}}
variant="outlined"
>
<Box sx={{ fontFamily: "monospace", fontSize: "0.85rem", mb: 1.5, p: 1, bgcolor: "grey.900", borderRadius: 1, color: "grey.100" }}>
{ambiguity.line}
</Box>
<Box sx={{ display: "flex", gap: 3, mb: 1.5, flexWrap: "wrap" }}>
<Box>
<Typography variant="caption" color="text.secondary">OCR Amount</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_amount}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">OCR Balance</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_balance}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Previous Balance</Typography>
<Typography variant="body2">{ambiguity.prev_balance}</Typography>
</Box>
</Box>
{isResolved ? (
<Alert severity="success" sx={{ py: 0.5, borderRadius: 2 }} icon={<CheckCircleIcon />}>
Resolved: {ambiguity.chosen?.amount} / {ambiguity.chosen?.balance}
</Alert>
) : (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{ambiguity.candidates.map((candidate: any, ci: number) => {
const isCredit = candidate.amount > 0;
const isDebit = candidate.amount < 0;
const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined;
return (
<Button
key={ci}
variant="outlined"
size="small"
onClick={() => handleResolve(ambiguity, candidate)}
disabled={resolveMutation.isPending}
sx={{
borderColor: cColor,
color: cColor,
"&:hover": cColor ? { borderColor: cColor } : undefined,
}}
>
{candidate.amount} / {candidate.balance}
</Button>
);
})}
</Box>
)}
</Paper>
);
})}
</Box>
</Paper>
)}
<Snackbar
open={!!failNotif}
autoHideDuration={6000}
onClose={() => setFailNotif(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
{failNotif}
</Alert>
</Snackbar>
</Container>
);
}

View File

@@ -1,619 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
ToggleButtonGroup,
ToggleButton,
Chip,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Tooltip,
TextField,
Select,
MenuItem,
InputLabel,
FormControl,
OutlinedInput,
Autocomplete,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import {
useUploadFile,
} from "./features/fetch-requests";
import type {
FetchRequest,
FetchRequestStatus,
FileSource,
EmailSource,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResource, FormFieldRenderer } from "../react-openapi";
import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <ScheduleIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} sx={{ mr: 0.5 }} />,
paused: <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />,
raw_expenses_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
enriched_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />,
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
};
const STATUS_OPTIONS: FetchRequestStatus[] = [
"pending",
"processing",
"paused",
"raw_expenses_done",
"enriched_done",
"completed",
"failed",
];
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
function formatDateRange(start?: string, end?: string) {
if (!start && !end) return "\u2014";
const s = start ? new Date(start).toLocaleDateString() : "?";
const e = end ? new Date(end).toLocaleDateString() : "?";
return `${s} \u2192 ${e}`;
}
function shortId(fp: string) {
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
}
export default function FetchRequests() {
const navigate = useNavigate();
const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
const [accountName, setAccountName] = React.useState("");
const [payorUsername, setPayorUsername] = React.useState("aetos");
const [format, setFormat] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [uploadedPath, setUploadedPath] = React.useState<string | null>(null);
const [fromEmail, setFromEmail] = React.useState("");
const [subject, setSubject] = React.useState("");
const [rawTerms, setRawTerms] = React.useState("");
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const fr = useResource("fetch-requests");
const { list, create, update, remove, resource: fetchRes } = fr;
const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
queryFn: () => list({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
}),
});
const { list: listAccounts } = useResource("accounts");
const { data: accountsData } = useQuery({
queryKey: ["accounts", "list"],
queryFn: () => listAccounts(),
});
const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]);
const fields = fetchRes?.orderedFields ?? [];
const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
const formatOptions: string[] = formatField?.enumValues ?? [];
const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
const createMutation = useMutation({
mutationFn: (data: any) => create(data),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => remove(id),
});
const uploadMutation = useUploadFile();
const requests = listData?.items ?? [];
const handleUpload = async () => {
if (!file) return;
const result = await uploadMutation.mutateAsync(file);
if (result?.saved_as) {
setUploadedPath(result.saved_as);
if (!format) setFormat(file.name.split(".").pop() || "");
setSnackbar({ message: `File uploaded: ${result.saved_as}`, severity: "success" });
}
};
const handleCreate = async () => {
if (!accountName) return;
let source: FileSource | EmailSource;
if (sourceType === "file") {
if (!uploadedPath || !format) return;
source = { path: uploadedPath, format } as FileSource;
} else {
if (!format) return;
const emailSource: EmailSource = { format };
if (fromEmail) emailSource.from_email = fromEmail;
if (subject) emailSource.subject = subject;
if (rawTerms.trim()) emailSource.raw_terms = rawTerms.split(",").map((s) => s.trim()).filter(Boolean);
source = emailSource;
}
try {
const result = await createMutation.mutateAsync({
source,
account_name: accountName,
payor_username: payorUsername,
...(startDate ? { start_date: new Date(startDate).toISOString() } : {}),
...(endDate ? { end_date: new Date(endDate).toISOString() } : {}),
});
setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm();
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) {
if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
}
}
};
const resetForm = () => {
setAccountName("");
setFormat("");
setFile(null);
setUploadedPath(null);
setFromEmail("");
setSubject("");
setRawTerms("");
setStartDate("");
setEndDate("");
};
const handleRetry = async (req: FetchRequest) => {
try {
await updateMutation.mutateAsync({ id: req.id, data: { status: "pending" } });
setSnackbar({ message: "Retrying fetch request", severity: "success" });
} catch {
setSnackbar({ message: "Failed to retry", severity: "error" });
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
setSnackbar({ message: "Fetch request deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete", severity: "error" });
}
setDeleteTarget(null);
};
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request
</Typography>
<ToggleButtonGroup
value={sourceType}
exclusive
onChange={(_, val) => val && setSourceType(val)}
sx={{ mb: 3 }}
size="small"
>
<ToggleButton value="file">File Upload</ToggleButton>
<ToggleButton value="email">Email Fetch</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{sourceType === "file" ? (
<>
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
<Button variant="outlined" component="label" startIcon={<CloudUploadIcon />}>
Choose File
<input type="file" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</Button>
<Typography variant="body2" sx={{ flex: 1, color: "text.secondary" }}>
{file ? file.name : "No file selected"}
</Typography>
<Button
variant="contained"
onClick={handleUpload}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? "Uploading..." : "Upload"}
</Button>
</Box>
{uploadedPath && (
<Alert severity="success" sx={{ py: 0 }}>
Uploaded as: {uploadedPath}
</Alert>
)}
{formatField ? (
<FormFieldRenderer
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)}
</>
) : (
<>
{formatField ? (
<FormFieldRenderer
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
</>
)}
<Autocomplete
options={accountOptions}
value={accountName || null}
onChange={(_, val) => setAccountName(val ?? "")}
renderInput={(params) => (
<TextField {...params} label="Account Name" size="small" required />
)}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
{payorUsernameField ? (
<FormFieldRenderer
field={payorUsernameField}
value={payorUsername}
onChange={setPayorUsername}
/>
) : (
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField ? (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer
field={startDateField}
value={startDate}
onChange={setStartDate}
/>
</Box>
) : (
<TextField
label="Start Date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)}
{endDateField ? (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer
field={endDateField}
value={endDate}
onChange={setEndDate}
/>
</Box>
) : (
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)}
</Box>
<Button
variant="contained"
onClick={handleCreate}
disabled={createMutation.isPending || !accountName || (sourceType === "file" && (!uploadedPath || !format)) || (sourceType === "email" && !format)}
>
{createMutation.isPending ? "Creating..." : "Create Fetch Request"}
</Button>
</Box>
</Paper>
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Status</InputLabel>
<Select
multiple
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as string[])}
input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")}
>
{STATUS_OPTIONS.map((s: string) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
options={accountOptions}
value={accountFilter || null}
onChange={(_, val) => setAccountFilter(val ?? "")}
renderInput={(params) => (
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
)}
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
<ToggleButtonGroup
value={sourceFilter}
exclusive
onChange={(_, val) => val && setSourceFilter(val)}
size="small"
>
{sourceTypeOptions.map((opt) => (
<ToggleButton key={opt} value={opt}>
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"}
</ToggleButton>
))}
</ToggleButtonGroup>
<Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : requests.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No fetch requests yet
</Box>
) : (
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<Box
key={req.id}
component="tr"
onClick={() => navigate(`/fetch-requests/${req.id}`)}
sx={{
cursor: "pointer",
borderBottom: 1,
borderColor: "divider",
"&:hover": { bgcolor: "action.hover" },
"&:last-child": { borderBottom: 0 },
}}
>
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{shortId(req.fingerprint)}
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(req.fingerprint);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{req.account_name}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{formatDateRange((req as any).start_date, (req as any).end_date)}
</Typography>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
<Chip
icon={statusIcons[req.status] as any}
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status]}
size="small"
/>
</Tooltip>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{(req.retry_count ?? 0) > 0 ? (
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{req.retry_count}/{RETRY_MAX}
</Typography>
) : (
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
\u2014
</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
{formatDate(req.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && (
<Tooltip title="Resolve ambiguities">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/fetch-requests/${req.id}`);
}}
>
<WarningAmberIcon fontSize="small" color="warning" />
</IconButton>
</Tooltip>
)}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRetry(req);
}}
>
<ReplayIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(req);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</Paper>
)}
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Fetch Request?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the fetch request and all associated data.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -101,9 +101,7 @@ export default function Header({
}} }}
> >
{[ {[
{ label: "Dashboard", path: "/dashboard" },
{ label: "Fetch", path: "/fetch-requests" }, { label: "Fetch", path: "/fetch-requests" },
{ label: "Reports", path: "/reports" },
].map(({ label, path }) => ( ].map(({ label, path }) => (
<Button <Button
key={path} key={path}
@@ -136,7 +134,7 @@ export default function Header({
</Button> </Button>
<Button <Button
color="inherit" color="inherit"
onClick={() => navigate("/admin/profile")} onClick={() => navigate("/profile/me")}
sx={{ textTransform: "none", fontWeight: 500 }} sx={{ textTransform: "none", fontWeight: 500 }}
> >
{currentUser.username} {currentUser.username}
@@ -153,7 +151,7 @@ export default function Header({
<Button <Button
color="inherit" color="inherit"
variant="outlined" variant="outlined"
onClick={() => navigate("/admin")} onClick={() => navigate("/login")}
sx={{ textTransform: "none" }} sx={{ textTransform: "none" }}
> >
Login Login

View File

@@ -236,7 +236,7 @@ export default function Home() {
<Grid container spacing={3}> <Grid container spacing={3}>
{features.map((f) => ( {features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}> <Grid key={f.title} item xs={12} sm={6} md={3}>
<FeatureCard {...f} /> <FeatureCard {...f} />
</Grid> </Grid>
))} ))}

View File

@@ -1,303 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Chip,
TextField,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useResource } from "../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
interface ReportSnapshotQuery {
accounts?: string[];
ignore_self?: boolean;
start_date?: string;
end_date?: string;
}
interface ReportSnapshot {
id: string;
snapshot_id: string;
created_at: string;
query?: ReportSnapshotQuery;
}
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
export default function ReportSnapshots() {
const [ignoreSelf, setIgnoreSelf] = React.useState(true);
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [minAmount, setMinAmount] = React.useState("");
const [maxAmount, setMaxAmount] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
const { list, create, remove } = useResource("reports");
const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["reports", "list"],
queryFn: () => list(),
});
const createMutation = useMutation({
mutationFn: (data: any) => create(data),
});
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
},
});
const snapshots: ReportSnapshot[] = listData?.items ?? [];
const handleCreate = async () => {
try {
const payload: Record<string, any> = {};
if (ignoreSelf) payload.ignore_self = true;
if (startDate) payload.start_date = new Date(startDate).toISOString();
if (endDate) payload.end_date = new Date(endDate).toISOString();
if (minAmount) payload.min_amount = parseFloat(minAmount);
if (maxAmount) payload.max_amount = parseFloat(maxAmount);
const result = await createMutation.mutateAsync(payload);
const snapshotId = (result as any)?.snapshot_id;
if (snapshotId) {
setCreatedSnapshotId(snapshotId);
setSnackbar({ message: `Snapshot created: ${snapshotId}`, severity: "success" });
} else {
setSnackbar({ message: "Snapshot created", severity: "success" });
}
resetForm();
} catch (err: any) {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create snapshot", severity: "error" });
}
};
const resetForm = () => {
setIgnoreSelf(true);
setStartDate("");
setEndDate("");
setMinAmount("");
setMaxAmount("");
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.snapshot_id);
setSnackbar({ message: "Snapshot deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete snapshot", severity: "error" });
}
setDeleteTarget(null);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Report Snapshots
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Generate New Snapshot
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", gap: 2 }}>
<Box sx={{ flex: 1 }}>
<TextField
label="Start Date"
type="date"
value={startDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
/>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
/>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<Box sx={{ flex: 1 }}>
<TextField
label="Min Amount"
type="number"
value={minAmount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMinAmount(e.target.value)}
size="small"
/>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
label="Max Amount"
type="number"
value={maxAmount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMaxAmount(e.target.value)}
size="small"
/>
</Box>
</Box>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
</Button>
{createdSnapshotId && (
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
</Alert>
)}
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Existing Snapshots
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : snapshots.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No snapshots yet
</Box>
) : (
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{snapshots.map((snap: ReportSnapshot) => (
<Box
key={snap.id}
component="tr"
sx={{ borderBottom: 1, borderColor: "divider", "&:last-child": { borderBottom: 0 }, "&:hover": { bgcolor: "action.hover" } }}
>
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{snap.snapshot_id}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(snap.snapshot_id);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{formatDate(snap.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{snap.query ? (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
</Box>
) : (
<Typography variant="body2" color="text.secondary">\u2014</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</Paper>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Snapshot?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the report snapshot.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -1,26 +0,0 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { useAuth, AuthPage } from "../react-auth";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { currentUser, loading, error, login, register } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = React.useState<"login" | "register">("login");
if (currentUser) {
return <>{children}</>;
}
return (
<AuthPage
mode={mode}
onBack={() => navigate("/")}
onSwitchMode={() => setMode(mode === "login" ? "register" : "login")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}

View File

@@ -1,61 +0,0 @@
import * as React from "react";
import {
ReportData,
GroupKey,
} from "../../features/report";
export type DashboardFlow = "outflows" | "inflows";
export type DashboardPeriodType = "rolling" | "calendar";
export type DashboardSelectedPeriodId = string | null;
export interface DashboardState {
flow: DashboardFlow;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean;
}
export interface DashboardStateSetters {
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleFlow: () => void;
togglePeriodType: () => void;
toggleComparison: () => void;
}
export interface DashboardSection {
id: string;
title: string;
component: React.ComponentType<any>;
summary?: string;
settings?: Record<string, any>;
}
export interface DashboardConfig {
sections: DashboardSection[];
}
export interface DashboardViewProps {
config: DashboardConfig;
data: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
}
export interface ColorScheme {
primary: string;
surface: string;
text: string;
}
export interface ComponentProps extends DashboardSection {
reportData: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
colorScheme: ColorScheme;
}

View File

@@ -1,105 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Grid,
ToggleButton,
ToggleButtonGroup,
Button
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { DashboardViewProps } from "./Dashboard.models";
export default function DashboardView({
config,
data,
state,
stateSetters,
isFetching,
}: DashboardViewProps) {
const theme = useTheme();
const {
flow,
selectedGroupKey,
} = state;
const colorScheme = flow === "outflows" ? theme.palette.flows.outflows : theme.palette.flows.inflows;
return (
<Container
sx={{
mt: 4,
mb: 4,
background: `linear-gradient(180deg, ${alpha(colorScheme.primary, theme.palette.mode === "dark" ? 0.06 : 0.04)} 0%, transparent 100%)`,
borderRadius: 4,
p: 2,
transition: "background 0.3s ease",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
mb: 3,
}}
>
<ToggleButtonGroup
value={flow}
exclusive
onChange={stateSetters.toggleFlow}
sx={{
borderRadius: 3,
overflow: "hidden",
"& .MuiToggleButton-root": {
px: 3,
textTransform: "none",
color: "text.secondary",
},
"&.Mui-selected": {
bgcolor: colorScheme.primary,
color: "white",
borderColor: colorScheme.primary,
},
}}
>
<ToggleButton value="outflows">Outflows</ToggleButton>
<ToggleButton value="inflows">Inflows</ToggleButton>
</ToggleButtonGroup>
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
<Button
size="small"
sx={{ mt: 1, textTransform: "none" }}
onClick={() => stateSetters.setSelectedGroupKey(null)}
>
Clear Drill-down
</Button>
)}
</Box>
<Grid container spacing={4}>
{config.sections.map((section) => {
const Component = section.component;
return (
<Grid key={section.id} size={12}>
<Component
{...section}
reportData={data}
state={state}
stateSetters={stateSetters}
isFetching={isFetching}
colorScheme={colorScheme}
/>
</Grid>
);
})}
</Grid>
</Container>
);
}

View File

@@ -1,2 +0,0 @@
export { default } from "./Dashboard.view";
export * from "./Dashboard.models";

View File

@@ -1,73 +0,0 @@
import { ReportData } from "../../features/report";
import {
mergeBucketPeriods,
getAmount,
PeriodKey,
} from "../report.helpers";
import { ChartDataPoint } from "./HistoryChart.models";
// ─── Tab → PeriodKey ─────────────────────────────────────────
const TAB_TO_KEY: Record<string, PeriodKey> = {
Daily: "daily",
Weekly: "weekly",
Monthly: "monthly",
"All Time": "all",
};
export function tabToKey(tab: string): PeriodKey {
return TAB_TO_KEY[tab] ?? "all";
}
// ─── Comparison ──────────────────────────────────────────────
function attachComparison(
points: ChartDataPoint[],
key: PeriodKey
): ChartDataPoint[] {
const getCompareIndex = (i: number) => {
if (key === "daily") return i - 7;
if (key === "weekly") return i - 4;
if (key === "monthly") return i - 12;
return -1;
};
return points.map((p, i) => {
const ci = getCompareIndex(i);
return {
...p,
compare:
ci >= 0 && points[ci]
? {
id: points[ci].id,
label: points[ci].label,
amount: points[ci].amount,
}
: undefined,
};
});
}
// ─── Main adapter ────────────────────────────────────────────
export function buildChartData(
reportData: ReportData,
key: PeriodKey,
flow: "outflows" | "inflows",
comparison: boolean
): ChartDataPoint[] {
const merged = mergeBucketPeriods(reportData.buckets, key);
let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id,
label: p.label,
amount: getAmount(p),
}));
if (comparison) {
points = attachComparison(points, key);
}
return points;
}

View File

@@ -1,10 +0,0 @@
export interface _ChartDataPoint {
id: string;
label: string;
amount: number;
highlighted?: boolean;
}
export interface ChartDataPoint extends _ChartDataPoint {
compare?: _ChartDataPoint;
}

View File

@@ -1,21 +0,0 @@
import * as React from "react";
import { ComponentProps } from "../Dashboard";
import { ChartDataPoint } from "./HistoryChart.models";
export interface HistoryChartProps extends ComponentProps {
settings: {
tabs: string[];
};
}
export interface HistoryChartViewProps extends HistoryChartProps {
activeTab: string;
setActiveTab: (v: string) => void;
currentData: ChartDataPoint[];
visibleData: ChartDataPoint[];
maxAmount: number;
visibleCount: number;
startIndex: number;
setStartIndex: React.Dispatch<React.SetStateAction<number>>;
activeDataKey: string;
}

View File

@@ -1,96 +0,0 @@
import * as React from "react";
import HistoryChartView from "./HistoryChart.view";
import { buildChartData, tabToKey } from "./HistoryChart.adapter";
import { HistoryChartProps } from "./HistoryChart.props";
export default function HistoryChart(props: HistoryChartProps) {
const {
settings,
reportData,
state,
stateSetters,
isFetching,
} = props;
const { flow, comparison, selectedPeriodId } = state;
const { setSelectedPeriodId } = stateSetters;
const { tabs } = settings;
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const [startIndex, setStartIndex] = React.useState(0);
const activeDataKey = tabToKey(activeTab);
const currentData = React.useMemo(() => {
return buildChartData(reportData, activeDataKey, flow, comparison);
}, [reportData, activeDataKey, flow, comparison]);
const maxAmount =
currentData.length > 0
? Math.max(
...currentData.flatMap((d) =>
comparison
? [d.amount, ...(d.compare ? [d.compare.amount] : [])]
: [d.amount]
),
1
)
: 1;
const visibleCountMap = {
daily: 7,
weekly: 6,
monthly: 4,
all: 4,
};
const visibleCount = visibleCountMap[activeDataKey] ?? 4;
const total = currentData.length;
const clampedStartIndex = Math.min(
startIndex,
Math.max(total - visibleCount, 0)
);
React.useEffect(() => {
if (startIndex !== clampedStartIndex) {
setStartIndex(clampedStartIndex);
}
}, [startIndex, clampedStartIndex]);
const visibleData = currentData.slice(
clampedStartIndex,
clampedStartIndex + visibleCount
);
React.useEffect(() => {
setSelectedPeriodId(null);
}, [activeTab]);
React.useEffect(() => {
if (
selectedPeriodId &&
!visibleData.some((p) => p.id === selectedPeriodId)
) {
setSelectedPeriodId(null);
}
}, [visibleData, selectedPeriodId]);
return (
<HistoryChartView
{...props}
activeTab={activeTab}
setActiveTab={setActiveTab}
currentData={currentData}
visibleData={visibleData}
maxAmount={maxAmount}
visibleCount={visibleCount}
startIndex={clampedStartIndex}
setStartIndex={setStartIndex}
activeDataKey={activeDataKey}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { ChartDataPoint } from "./HistoryChart.models";
export const formatDisplay = (
point: ChartDataPoint,
tab: string,
comparison: boolean
) => {
const base = point.amount;
const cmp = point.compare?.amount ?? 0;
const formatShort = (val: number) => {
if (tab === "monthly" && val >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (tab === "weekly" && val >= 1000) {
return `${(val / 1000).toFixed(1)}K`;
}
return val.toLocaleString("en-IN");
};
if (!comparison) return `${formatShort(base)}`;
const diff = base - cmp;
const sign = diff >= 0 ? "+" : "-";
return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`;
};

View File

@@ -1,205 +0,0 @@
import * as React from "react";
import {
Box,
Typography,
ToggleButtonGroup,
ToggleButton,
Paper
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import IconButton from "@mui/material/IconButton";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import {
HistoryChartViewProps,
} from "./HistoryChart.props";
import { formatDisplay } from "./HistoryChart.utils";
export default function HistoryChartView({
title,
summary,
settings,
state,
stateSetters,
isFetching,
colorScheme,
activeTab,
setActiveTab,
currentData,
visibleData,
maxAmount,
visibleCount,
startIndex,
setStartIndex,
activeDataKey,
}: HistoryChartViewProps) {
const { flow, periodType, selectedPeriodId, comparison } = state;
const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters;
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const total = currentData.length;
const maxStartIndex = Math.max(total - visibleCount, 0);
const clampedStartIndex = Math.min(startIndex, maxStartIndex);
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) setActiveTab(newTab);
};
const canGoLeft = clampedStartIndex > 0;
const canGoRight = clampedStartIndex < maxStartIndex;
const handlePrev = () => {
if (!canGoLeft) return;
setStartIndex((prev) => Math.max(prev - visibleCount, 0));
};
const handleNext = () => {
if (!canGoRight) return;
setStartIndex((prev) => {
const next = prev + visibleCount;
return Math.min(next, maxStartIndex);
});
};
return (
<Paper
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: isDark ? "background.paper" : colorScheme.surface,
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
</Typography>
{summary && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{summary}
</Typography>
)}
<ToggleButtonGroup value={activeTab} exclusive onChange={handleTabChange} fullWidth sx={{ mb: 4 }}>
{settings.tabs.map((tab) => (
<ToggleButton key={tab} value={tab}>
{tab}
</ToggleButton>
))}
</ToggleButtonGroup>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<ToggleButtonGroup value={periodType} exclusive onChange={togglePeriodType} size="small">
<ToggleButton value="rolling">Rolling</ToggleButton>
<ToggleButton value="calendar">Calendar</ToggleButton>
</ToggleButtonGroup>
<ToggleButton
value="compare"
selected={comparison}
onChange={toggleComparison}
size="small"
>
Compare
</ToggleButton>
</Box>
{currentData.length > 0 ? (
<Box sx={{ position: "relative", mt: 4 }}>
{canGoLeft && (
<IconButton onClick={handlePrev} size="small" sx={{ position: "absolute", left: 0, top: "50%" }}>
<ChevronLeftIcon fontSize="small" />
</IconButton>
)}
<Box sx={{ display: "flex", alignItems: "flex-end", height: 220, mt: 4 }}>
{visibleData.map((point) => {
const currentHeight = (point.amount / maxAmount) * 100;
const compareHeight = comparison
? ((point.compare?.amount ?? 0) / maxAmount) * 100
: 0;
const isSelected = selectedPeriodId === point.id;
const display = formatDisplay(point, activeDataKey, comparison);
return (
<Box
key={point.id}
onClick={() =>
setSelectedPeriodId(isSelected ? null : point.id)
}
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
cursor: "pointer",
height: "100%"
}}
>
<Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, height: "100%" }}>
{comparison && (
<Box
sx={{
width: 8,
height: `${compareHeight}%`,
bgcolor: alpha(colorScheme.primary, 0.4),
borderRadius: "4px 4px 0 0"
}}
/>
)}
<Box
sx={{
width: 12,
height: `${currentHeight}%`,
bgcolor: isSelected ? "warning.main" : colorScheme.primary,
borderRadius: "4px 4px 0 0"
}}
/>
</Box>
<Typography variant="caption">
{point.label}
</Typography>
{comparison && point.compare && (
<Typography variant="caption" color="text.secondary">
{point.compare.label}
</Typography>
)}
<Typography variant="caption">
{display}
</Typography>
</Box>
);
})}
</Box>
{canGoRight && (
<IconButton onClick={handleNext} size="small" sx={{ position: "absolute", right: 0, top: "50%" }}>
<ChevronRightIcon fontSize="small" />
</IconButton>
)}
</Box>
) : (
<Box sx={{ height: 200, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Typography color="text.secondary">No Data Available</Typography>
</Box>
)}
</Paper>
);
}

View File

@@ -1,2 +0,0 @@
export { default } from "./HistoryChart";
export * from "./HistoryChart.models";

View File

@@ -1,31 +0,0 @@
import { ReportData, GroupKey } from "../../features/report";
import {
formatCurrency,
extractFilteredTransactions,
} from "../report.helpers";
import { LatestItem } from "./LatestItems.models";
// ─── Main adapter ────────────────────────────────────────────
export function buildLatestItems(
reportData: ReportData,
selectedPeriodId: string | null | undefined,
selectedGroupKey: GroupKey | null | undefined,
flow: "outflows" | "inflows"
): LatestItem[] {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
return txns
.sort(
(a, b) =>
new Date(b.occurred_at).getTime() -
new Date(a.occurred_at).getTime()
)
.map((t, index) => ({
id: index + 1,
title: t.payee.name,
subtitle: t.tags.map((tag) => tag.name).join(", "),
amount: formatCurrency(t.amount),
timeAgo: new Date(t.occurred_at).toLocaleDateString("en-IN"),
}));
}

View File

@@ -1,7 +0,0 @@
export interface LatestItem {
id: string | number;
title: string;
subtitle: string;
amount: string;
timeAgo: string;
}

View File

@@ -1,10 +0,0 @@
import { ComponentProps } from "../Dashboard";
import { LatestItem } from "./LatestItems.models";
export interface LatestItemsProps extends ComponentProps {}
export interface LatestItemsViewProps extends LatestItemsProps {
items: LatestItem[];
canExpand: boolean;
onExpand: () => void;
}

View File

@@ -1,40 +0,0 @@
import * as React from "react";
import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view";
import { LatestItemsProps } from "./LatestItems.props";
export default function LatestItems(props: LatestItemsProps) {
const {
reportData,
state,
stateSetters,
isFetching,
} = props;
const { flow, selectedPeriodId, selectedGroupKey } = state;
const [visibleCount, setVisibleCount] = React.useState(5);
// Reset count when flow changes to start clean
React.useEffect(() => {
setVisibleCount(5);
}, [flow]);
const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow);
}, [reportData, selectedPeriodId, selectedGroupKey, flow]);
const visibleItems = React.useMemo(() => {
return allItems.slice(0, visibleCount);
}, [allItems, visibleCount]);
const canExpand = visibleCount < allItems.length;
return (
<LatestItemsView
{...props}
items={visibleItems}
canExpand={canExpand}
onExpand={() => setVisibleCount((prev) => prev + 5)}
/>
);
}

View File

@@ -1,93 +0,0 @@
import * as React from "react";
import {
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
IconButton,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { LatestItemsViewProps } from "./LatestItems.props";
export default function LatestItemsView({
items,
title,
canExpand,
onExpand,
isFetching,
colorScheme,
}: LatestItemsViewProps) {
const accentColor = colorScheme?.primary || "";
return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2, opacity: isFetching ? 0.6 : 1, transition: "opacity 0.3s ease", pointerEvents: isFetching ? "none" : "auto" }}>
<Box sx={{ mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
{title}
</Typography>
</Box>
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: alpha(accentColor, 0.13),
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
/>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600}>
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/>
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700}>
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
{canExpand && (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<IconButton size="small" onClick={onExpand}>
<ExpandMoreIcon />
</IconButton>
</Box>
)}
</List>
</Box>
);
}

View File

@@ -1,2 +0,0 @@
export { default } from "./LatestItems";
export * from "./LatestItems.models";

View File

@@ -1,14 +0,0 @@
import { ComponentProps } from "../Dashboard";
export interface ProgressCardProps extends ComponentProps {
settings: {
compact: boolean;
};
}
export interface ProgressCardViewProps extends ProgressCardProps {
progressAmount: number;
totalAmount: number;
selected: boolean;
onClick: () => void;
}

View File

@@ -1,129 +0,0 @@
import * as React from "react";
import {
Box,
Typography,
Paper,
LinearProgress,
Divider,
linearProgressClasses
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { getPercentage, formatCurrency } from "../report.helpers";
import { ProgressCardViewProps } from "./ProgressCard.props";
export default function ProgressCardView({
title,
settings,
isFetching,
colorScheme,
progressAmount,
totalAmount,
selected,
onClick,
}: ProgressCardViewProps) {
const theme = useTheme();
const percentage = getPercentage(progressAmount, totalAmount);
const formattedProgress = formatCurrency(progressAmount);
const formattedTotal = formatCurrency(totalAmount);
return (
<Paper
elevation={settings.compact ? 2 : 4}
onClick={onClick}
sx={{
width: "100%",
p: settings.compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: settings.compact ? 3 : 4,
transform: selected ? "scale(1.02)" : "scale(1)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
bgcolor: colorScheme.surface,
color: colorScheme.text,
display: "flex",
flexDirection: "column",
alignItems: settings.compact ? "flex-start" : "center",
justifyContent: "center",
position: "relative",
overflow: "hidden",
border: selected
? `2px solid ${colorScheme.primary}`
: "1px solid",
borderColor: selected ? colorScheme.primary : "divider",
boxShadow: "none",
opacity: isFetching ? 0.6 : 1,
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography
variant={settings.compact ? "body2" : "subtitle1"}
fontWeight={700}
sx={{
opacity: 0.95,
mb: settings.compact ? 1.5 : 2,
width: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
letterSpacing: 0.5,
}}
>
{title}
</Typography>
<Box sx={{ mb: settings.compact ? 2 : 3, width: "100%" }}>
<Typography
variant={settings.compact ? "h5" : "h3"}
fontWeight={900}
sx={{
mb: 0.5,
lineHeight: 1.2,
}}
>
{formattedProgress}
</Typography>
<Divider
sx={{
my: 1,
borderColor: "divider",
width: "100%",
}}
/>
<Typography
variant={settings.compact ? "caption" : "body2"}
sx={{
opacity: 0.85,
fontWeight: 500,
display: "block",
color: alpha(colorScheme.text, 0.85),
}}
>
of {formattedTotal}
</Typography>
</Box>
<Box sx={{ width: "100%", mt: "auto" }}>
<LinearProgress
variant="determinate"
value={percentage}
sx={{
height: settings.compact ? 6 : 10,
borderRadius: 5,
[`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: alpha(theme.palette.divider, 0.5),
},
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: colorScheme.primary,
boxShadow: `0 0 8px ${alpha(colorScheme.primary, 0.4)}`,
},
}}
/>
</Box>
</Paper>
);
}

View File

@@ -1,31 +0,0 @@
import { GroupKey, ReportData } from "../../features/report";
import {
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
export interface PayeeItem {
name: string;
amount: number;
}
export function extractTopPayees(
reportData: ReportData,
flow: "outflows" | "inflows",
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: PayeeItem[]; total: number } {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.payee && txn.payee.name) {
return [txn.payee.name];
}
return [];
});
return {
items,
total,
};
}

View File

@@ -1,83 +0,0 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import ProgressCardView from "./ProgressCard.view";
import { extractTopPayees } from "./TopPayees.adapter";
import { ProgressCardProps } from "./ProgressCard.props";
export default function TopPayees(props: ProgressCardProps) {
const {
title,
reportData,
state,
stateSetters,
isFetching,
} = props
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
return (
<Paper
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: "background.paper",
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
}}
>
{items.map((item) => {
const isSelected = !!selectedGroupKey?.payee?.includes(item.name);
return (
<ProgressCardView
{...props}
key={item.name}
title={item.name}
progressAmount={item.amount}
totalAmount={total}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
if (isSelected) {
delete newKey.payee;
} else {
newKey.payee = [item.name];
}
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
}
}}
/>
);
})}
</Box>
</Paper>
);
}

View File

@@ -1,31 +0,0 @@
import { ReportData, GroupKey } from "../../features/report";
import {
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
export interface TagItem {
tag: string;
amount: number;
}
export function extractTopTags(
reportData: ReportData,
flow: "outflows" | "inflows",
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: TagItem[]; total: number } {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.tags && txn.tags.length > 0) {
return txn.tags.map((t) => (typeof t === "string" ? t : t.name));
}
return ["Untagged"];
});
return {
items: items.map((item) => ({ tag: item.name, amount: item.amount })),
total,
};
}

View File

@@ -1,83 +0,0 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import ProgressCardView from "./ProgressCard.view";
import { extractTopTags } from "./TopTags.adapter";
import { ProgressCardProps } from "./ProgressCard.props";
export default function TopTags(props: ProgressCardProps) {
const {
title,
reportData,
state,
stateSetters,
isFetching,
} = props
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
return (
<Paper
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: "background.paper",
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
}}
>
{items.map((item) => {
const isSelected = !!selectedGroupKey?.tags?.includes(item.tag);
return (
<ProgressCardView
{...props}
key={item.tag}
title={item.tag}
progressAmount={item.amount}
totalAmount={total}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
if (isSelected) {
delete newKey.tags;
} else {
newKey.tags = [item.tag];
}
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
}
}}
/>
);
})}
</Box>
</Paper>
);
}

View File

@@ -1,2 +0,0 @@
export { default } from "./ProgressCard.view";
export * from "./ProgressCard.props";

View File

@@ -1,230 +0,0 @@
import {
ReportPeriod,
ReportBucket,
GroupKey,
PeriodType,
ReportData,
Transaction,
} from "../features/report";
// ─── Types ────────────────────────────────────────────────────
export type PeriodKey = PeriodType;
export type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
// ─── Period helpers ───────────────────────────────────────────
const PREFIX_TO_KEY: Record<string, PeriodKey> = {
D: "daily",
W: "weekly",
M: "monthly",
ALL: "all",
};
/**
* Derive the period key from a decorated-period id.
* E.g. `"W:2026-04-28_2026-05-04"` → `"weekly"`
*/
export function periodIdToKey(periodId: string): PeriodKey {
const prefix = periodId.split(":")[0];
return PREFIX_TO_KEY[prefix] ?? "all";
}
// ─── Metric helpers ───────────────────────────────────────────
export function getAmount(period: ReportPeriod): number {
return period.metric.sum;
}
function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) {
const sum = a.sum + b.sum;
const count = a.count + b.count;
return {
...a,
sum,
count,
average: count > 0 ? sum / count : 0,
transactions:
a.transactions || b.transactions
? [...(a.transactions || []), ...(b.transactions || [])]
: undefined,
};
}
/**
* Merge periods with the same id across all buckets, summing
* their metrics and concatenating transactions.
*
* Returns sorted by start date ascending.
*/
export function mergeBucketPeriods(
buckets: ReportBucket[],
key: PeriodKey
): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>();
for (const bucket of buckets) {
const periods = (bucket.periods[key] || []) as DecoratedPeriod[];
for (const p of periods) {
const existing = map.get(p.id);
if (!existing) {
map.set(p.id, {
...p,
metric: { ...p.metric },
});
} else {
map.set(p.id, {
...existing,
metric: mergeMetric(existing.metric, p.metric),
});
}
}
}
return Array.from(map.values()).sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
}
// ─── Formatting ───────────────────────────────────────────────
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
// ─── Group filtering ──────────────────────────────────────────
/**
* Check if a bucket's group_key matches the selected GroupKey.
* Every dimension present in `selected` must exist in the bucket
* and contain all the selected values.
*/
export function matchesGroupKey(
bucket: ReportBucket,
selected: GroupKey
): boolean {
for (const [dim, values] of Object.entries(selected)) {
const bucketValues = bucket.group_key[dim];
if (!bucketValues) return false;
if (!(values as string[]).every((v) => bucketValues.includes(v)))
return false;
}
return true;
}
/**
* Return only buckets matching the selected group key,
* or all buckets if no selection.
*/
export function filterBuckets(
buckets: ReportBucket[],
selectedGroupKey: GroupKey | null
): ReportBucket[] {
if (!selectedGroupKey) return buckets;
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
}
export function extractFilteredTransactions(
reportData: ReportData,
selectedPeriodId: string | null | undefined,
selectedGroupKey: GroupKey | null | undefined
): Transaction[] {
let txns: Transaction[] = [];
if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(reportData.buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId);
txns = selected?.metric.transactions || [];
} else {
const periods = mergeBucketPeriods(reportData.buckets, "all");
if (periods.length > 0) {
const period = periods.reduce((latest, p) =>
new Date(p.start).getTime() > new Date(latest.start).getTime()
? p
: latest
, periods[0]);
txns = period?.metric.transactions || [];
}
}
if (selectedGroupKey) {
txns = txns.filter((txn) => {
let match = true;
if (selectedGroupKey.tags && selectedGroupKey.tags.length > 0) {
if (!txn.tags) {
match = false;
} else {
const txnTags = txn.tags.map((t: any) =>
typeof t === "string" ? t : t.name
);
if (
!selectedGroupKey.tags.every((selectedTag) =>
txnTags.includes(selectedTag)
)
) {
match = false;
}
}
}
if (match && selectedGroupKey.payee && selectedGroupKey.payee.length > 0) {
if (!txn.payee || !txn.payee.name) {
match = false;
} else {
if (!selectedGroupKey.payee.includes(txn.payee.name)) {
match = false;
}
}
}
return match;
});
}
return txns;
}
export function aggregateTransactions(
transactions: Transaction[],
keyExtractor: (txn: Transaction) => string[],
limit = 4
): { items: { name: string; amount: number }[]; total: number } {
const map = new Map<string, number>();
for (const txn of transactions) {
const keys = keyExtractor(txn);
for (const key of keys) {
map.set(key, (map.get(key) || 0) + txn.amount);
}
}
const items = Array.from(map.entries()).map(([name, amount]) => ({
name,
amount,
}));
items.sort((a, b) => b.amount - a.amount);
const top = items.slice(0, limit);
const total = top.reduce((sum, item) => sum + item.amount, 0);
return { items: top, total };
}

View File

@@ -1,40 +0,0 @@
import HistoryChart from "./components/HistoryChart";
import LatestItems from "./components/LatestItems";
import { DashboardConfig } from "./components/Dashboard";
import TopTags from "./components/ProgressCard/TopTags";
import TopPayees from "./components/ProgressCard/TopPayees";
export const configuration: DashboardConfig = {
sections: [
{
id: "breakdown",
title: "Breakdown",
summary: "Interactive chronological tracking",
component: HistoryChart,
settings: {
tabs: ["Weekly", "Monthly"],
},
},
{
id: "top-categories",
title: 'Top Categories',
component: TopTags,
settings: {
compact: true,
},
},
{
id: "top-payees",
title: 'Top Payees',
component: TopPayees,
settings: {
compact: true,
},
},
{
id: "items",
title: 'Recent Transactions',
component: LatestItems,
},
],
};

View File

@@ -9,7 +9,7 @@ export type FetchRequestStatus =
export interface FileSource { export interface FileSource {
path: string; path: string;
format: string; bank: string;
raw_lines?: string[]; raw_lines?: string[];
txn_blocks?: Record<string, any>; txn_blocks?: Record<string, any>;
txn_dicts?: Record<string, any>[]; txn_dicts?: Record<string, any>[];
@@ -18,7 +18,7 @@ export interface FileSource {
} }
export interface EmailSource { export interface EmailSource {
format: string; bank: string;
from_email?: string; from_email?: string;
subject?: string; subject?: string;
raw_terms?: string[]; raw_terms?: string[];
@@ -26,9 +26,12 @@ export interface EmailSource {
txn_dicts_count?: number; txn_dicts_count?: number;
} }
export type PipelineType = "heuristic" | "llm" | "llama_parser";
export interface FetchRequestCreate { export interface FetchRequestCreate {
source: FileSource | EmailSource; source: FileSource | EmailSource;
account_name: string; account_name: string;
pipeline?: PipelineType;
payor_username?: string; payor_username?: string;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;

View File

@@ -14,6 +14,7 @@ export type {
SSEEventStep, SSEEventStep,
SSEEventStatus, SSEEventStatus,
ProgressMessage, ProgressMessage,
PipelineType,
} from "./fetch-requests.models"; } from "./fetch-requests.models";
export { RETRY_MAX, formatApiError } from "./fetch-requests.models"; export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
export { export {

View File

@@ -1,49 +1,7 @@
import { useResource, getApi } from "../../../react-openapi"; import { getApi } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models"; import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
export function useFetchRequestsList(params?: {
status?: string;
account_name?: string;
source_type?: string;
}) {
const { list } = useResource("fetch-requests");
return useQuery({
queryKey: ["fetch-requests", "list", params],
queryFn: () => list(params),
});
}
export function useFetchRequest(id: string) {
const { get } = useResource("fetch-requests");
return useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id),
enabled: !!id,
});
}
export function useCreateFetchRequest() {
const { create } = useResource("fetch-requests");
return useMutation({
mutationFn: (data: any) => create(data),
});
}
export function useUpdateFetchRequest() {
const { update } = useResource("fetch-requests");
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
}
export function useDeleteFetchRequest() {
const { remove } = useResource("fetch-requests");
return useMutation({
mutationFn: (id: string) => remove(id),
});
}
export function useUploadFile() { export function useUploadFile() {
return useMutation({ return useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
@@ -66,9 +24,7 @@ export function useFetchRequestAmbiguities(fetchRequestId: string) {
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"], queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => { queryFn: async () => {
const api = getApi(); const api = getApi();
const res = await api.get( const res = await api.get(`/fetch-requests/${fetchRequestId}/ambiguities`);
`/fetch-requests/${fetchRequestId}/ambiguities`
);
return res.data; return res.data;
}, },
enabled: !!fetchRequestId, enabled: !!fetchRequestId,
@@ -87,10 +43,7 @@ export function useResolveAmbiguity() {
payload: ResolveAmbiguityPayload; payload: ResolveAmbiguityPayload;
}) => { }) => {
const api = getApi(); const api = getApi();
const res = await api.post( const res = await api.post(`/ambiguities/${ambiguityId}/resolve`, payload);
`/ambiguities/${ambiguityId}/resolve`,
payload
);
return res.data; return res.data;
}, },
onSuccess: (data: any) => { onSuccess: (data: any) => {

View File

@@ -1,9 +0,0 @@
export type {
ReportSnapshot,
ReportQuery,
} from "./report-snapshots.models";
export {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./useReportSnapshots";

View File

@@ -1,15 +0,0 @@
export interface ReportQuery {
accounts?: string[] | null;
ignore_self?: boolean | null;
start_date?: string | null;
end_date?: string | null;
min_amount?: number | null;
max_amount?: number | null;
}
export interface ReportSnapshot {
id: string;
snapshot_id: string;
created_at: string;
query?: ReportQuery;
}

View File

@@ -1,28 +0,0 @@
import { useResource } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export function useReportSnapshotsList() {
const { list } = useResource("reports");
return useQuery({
queryKey: ["reports", "list"],
queryFn: () => list(),
});
}
export function useCreateSnapshot() {
const { create } = useResource("reports");
return useMutation({
mutationFn: (data: any) => create(data),
});
}
export function useDeleteSnapshot() {
const queryClient = useQueryClient();
const { remove } = useResource("reports");
return useMutation({
mutationFn: (id: string) => remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
},
});
}

View File

@@ -1,15 +0,0 @@
export {
useReport
} from './useReport'
export type {
Transaction,
ReportData,
ReportBucket,
ReportPeriod,
ReportQuery,
GroupKey,
PeriodType,
} from './report.models'
export {
prepareReport
} from './report.utils'

View File

@@ -1,112 +0,0 @@
export interface Payor {
id?: string;
name: string;
username: string;
email: string;
}
export interface Payee {
type: "merchant" | "person" | "transfer" | "other";
name: string;
}
export interface Account {
id: string;
name: string;
number: string;
type: "cash" | "bank" | "credit_card" | "wallet" | "other";
currency: string;
is_active?: boolean;
}
export interface Tag {
id: string;
name: string;
icon: string;
parent_id?: string | null;
}
export interface Transaction {
id: string;
payor: Payor;
payee: Payee;
amount: number;
account: Account;
tags: Tag[];
occurred_at: string;
created_at: string;
}
// -----------------------------
// Metrics
// -----------------------------
export interface ReportMetric {
sum: number;
count: number;
average: number;
transactions?: Transaction[];
}
// -----------------------------
// Period
// -----------------------------
export type PeriodType = "daily" | "weekly" | "monthly" | "all";
export interface ReportPeriod {
start: string;
end: string;
metric: ReportMetric;
}
// -----------------------------
// Group (bucket)
// -----------------------------
export type GroupKey = {
[dimension: string]: string[];
};
export interface ReportBucket {
group_key: GroupKey;
periods: {
daily?: ReportPeriod[];
weekly?: ReportPeriod[];
monthly?: ReportPeriod[];
all?: ReportPeriod[];
};
}
// -----------------------------
// Report Query
// -----------------------------
export interface ReportQuery {
accounts?: string[] | null;
ignore_self?: boolean | null;
start_date?: string | null;
end_date?: string | null;
min_amount?: number | null;
max_amount?: number | null;
}
// -----------------------------
// Final Report
// -----------------------------
export interface ReportData {
snapshot_id?: string | null;
flow?: "inflows" | "outflows" | null;
periods: PeriodType[];
tags?: string[] | null;
payee?: string[] | null;
buckets: ReportBucket[];
query: ReportQuery;
}

View File

@@ -1,117 +0,0 @@
import {
ReportData,
ReportPeriod,
PeriodType,
} from "./report.models";
/* ---------- ID BUILDING ---------- */
function formatDate(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function buildPeriodId(
type: PeriodType,
start: Date,
end: Date
): string {
const s = formatDate(start);
const e = formatDate(end);
switch (type) {
case "daily":
return `D:${s}_${e}`;
case "weekly":
return `W:${s}_${e}`;
case "monthly":
return `M:${s}_${e}`;
case "all":
return `ALL:${s}_${e}`;
default:
return `${s}_${e}`;
}
}
/* ---------- LABEL BUILDING ---------- */
const dayFmt = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
timeZone: "UTC",
});
const monthDayFmt = new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
const monthFmt = new Intl.DateTimeFormat("en-GB", {
month: "short",
timeZone: "UTC",
});
const yearFmt = new Intl.DateTimeFormat("en-GB", {
year: "numeric",
timeZone: "UTC",
});
function buildLabel(
type: PeriodType,
start: Date,
end: Date
): string {
switch (type) {
case "daily":
return dayFmt.format(start);
case "weekly": {
const sDay = start.getUTCDate();
const m = monthFmt.format(start);
return `${sDay} ${m}`;
}
case "monthly":
return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
}
}
/* ---------- MAIN ---------- */
function decoratePeriods(
type: PeriodType,
periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({
...p,
id: buildPeriodId(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
label: buildLabel(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
}));
}
export function prepareReport(reportData: ReportData): ReportData {
return {
...reportData,
buckets: reportData.buckets.map((bucket) => {
const newPeriods: typeof bucket.periods = {};
for (const type of reportData.periods) {
const arr = bucket.periods[type];
if (arr) {
newPeriods[type] = decoratePeriods(type, arr);
}
}
return {
...bucket,
periods: newPeriods,
};
}),
};
}

View File

@@ -1,22 +0,0 @@
import { useResource } from "../../../react-openapi";
import { useQuery } from "@tanstack/react-query";
export interface ReportParams {
snapshot_id?: string;
periods?: ("daily" | "weekly" | "monthly" | "all")[];
flow?: "inflows" | "outflows";
payee?: string[];
tags?: string[];
}
export function useReport(params: ReportParams) {
const { get } = useResource("reports");
const { snapshot_id, ...queryParams } = params;
return useQuery({
queryKey: ["reports", "read", params],
queryFn: () =>
get(snapshot_id ?? "latest", queryParams),
});
}

View File

@@ -4,23 +4,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { import {
BrowserRouter, BrowserRouter,
Routes, Routes,
Route Route,
useNavigate
} from "react-router-dom"; } from "react-router-dom";
import { import {
Box, Box,
CssBaseline, CssBaseline,
CircularProgress,
Toolbar Toolbar
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import FetchRequests from './FetchRequest/FetchRequestCreate';
import FetchRequests from './FetchRequests'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail';
import FetchRequestDetail from './FetchRequestDetail'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi';
import ReportSnapshots from './ReportSnapshots'; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth";
import { RequireAuth } from './RequireAuth';
import { AppProvider, Admin } from '../react-openapi';
import { Buffer } from 'buffer';
import process from 'process';
import { AuthProvider } from "../react-auth";
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme'; import AppTheme from './shared-theme/AppTheme';
@@ -28,57 +25,113 @@ import { specConfiguration } from './openapi-config';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
window.Buffer = Buffer;
window.process = process;
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; // Wire profile components from react-auth into the spec-driven admin
specConfiguration.profileComponents = {
create: ProfileCreate,
edit: ProfileEdit,
view: ProfileView,
};
function LoginPage() {
const { login, register, loading, error, currentUser } = useAuth();
const navigate = useNavigate();
return (
<AuthPage
mode="login"
onBack={() => navigate("/")}
onSwitchMode={() => navigate("/register")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}
function RegisterPage() {
const { login, register, loading, error, currentUser } = useAuth();
const navigate = useNavigate();
return (
<AuthPage
mode="register"
onBack={() => navigate("/")}
onSwitchMode={() => navigate("/login")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}
const routerMapping = [ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" }, { path: "/login", component: LoginPage, headerTitle: "Login" },
{ path: "/register", component: RegisterPage, headerTitle: "Register" },
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" }, { path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }, { path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
{ path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
{ path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" },
]; ];
/** Reads authConfig from AppProvider context and passes it to AuthProvider. */
function AppContent() {
const { authConfig, loading } = useAppContext();
const navigate = useNavigate();
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" }}>
<CircularProgress />
</Box>
);
}
return (
<AuthProvider authConfig={authConfig} onUnauthorized={() => navigate("/login")}>
<AppTheme>
<CssBaseline enableColorScheme />
<Header routerMapping={routerMapping} />
<Box sx={{ pb: 8 }}>
<Toolbar />
<Routes>
{routerMapping.map(({ path, component: Component }) => (
<Route
key={path}
path={path}
element={<Component basePath={path.replace(/\/\*$/, "")} />}
/>
))}
</Routes>
</Box>
<Footer />
</AppTheme>
</AuthProvider>
);
}
function AppWithAuth() {
const navigate = useNavigate();
return (
<AppProvider specConfiguration={specConfiguration} onUnauthorized={() => navigate("/login")}>
<AppContent />
</AppProvider>
);
}
root.render( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AppProvider specConfiguration={specConfiguration}> <BrowserRouter>
<BrowserRouter> <AppWithAuth />
<AuthProvider authBaseUrl={AUTH_BASE}> </BrowserRouter>
<AppTheme>
<CssBaseline enableColorScheme />
<Header routerMapping={routerMapping} />
<Box sx={{ pb: 8 }}>
<Toolbar />
<Routes>
{routerMapping.map(({ path, component: Component }) => (
<Route
key={path}
path={path}
element={
path.startsWith("/admin") ? (
<RequireAuth><Component basePath="/admin" /></RequireAuth>
) : (
<Component />
)
}
/>
))}
</Routes>
</Box>
<Footer />
</AppTheme>
</AuthProvider>
</BrowserRouter>
</AppProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -1,5 +1,5 @@
import type { SpecConfiguration } from "../react-openapi"; import type { SpecConfiguration } from "../react-openapi";
// import { tokenStore } from "../react-auth"; import { tokenStore } from "../react-auth";
const apiBase = import.meta.env.VITE_API_BASE_URL; const apiBase = import.meta.env.VITE_API_BASE_URL;
@@ -7,10 +7,10 @@ export const specConfiguration: SpecConfiguration = {
specUrl: `${apiBase}/openapi.json`, specUrl: `${apiBase}/openapi.json`,
baseApiUrl: apiBase, baseApiUrl: apiBase,
title: "Khata", title: "Khata",
getToken: () => tokenStore.get(),
resourceConfig: { resourceConfig: {
expenses: { expenses: {
filterOptions: { mode: "client" }, filterOptions: { mode: "client" },
}, },
}, },
// getToken: () => tokenStore.get(),
}; };

View File

@@ -63,8 +63,8 @@ export default function AppTheme({
); );
const theme = React.useMemo( const theme = React.useMemo(
() => () => {
createTheme({ const base = createTheme({
...getDesignTokens(mode), ...getDesignTokens(mode),
semantic, semantic,
@@ -75,7 +75,11 @@ export default function AppTheme({
...navigationCustomizations, ...navigationCustomizations,
...surfacesCustomizations, ...surfacesCustomizations,
}, },
}), });
(base as any).applyStyles = (m: "light" | "dark", styles: any) =>
base.palette.mode === m ? styles : {};
return base;
},
[mode, semantic] [mode, semantic]
); );

View File

@@ -4,7 +4,6 @@ import { SvgIconProps } from '@mui/material/SvgIcon';
import { buttonBaseClasses } from '@mui/material/ButtonBase'; import { buttonBaseClasses } from '@mui/material/ButtonBase';
import { dividerClasses } from '@mui/material/Divider'; import { dividerClasses } from '@mui/material/Divider';
import { menuItemClasses } from '@mui/material/MenuItem'; import { menuItemClasses } from '@mui/material/MenuItem';
import { selectClasses } from '@mui/material/Select';
import { tabClasses } from '@mui/material/Tab'; import { tabClasses } from '@mui/material/Tab';
import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded'; import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded';
import { gray, brand } from '../themePrimitives'; import { gray, brand } from '../themePrimitives';
@@ -73,7 +72,7 @@ export const navigationCustomizations: Components<Theme> = {
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none', boxShadow: 'none',
}, },
[`&.${selectClasses.focused}`]: { '&.Mui-focused': {
outlineOffset: 0, outlineOffset: 0,
borderColor: gray[400], borderColor: gray[400],
}, },
@@ -91,7 +90,7 @@ export const navigationCustomizations: Components<Theme> = {
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none', boxShadow: 'none',
}, },
[`&.${selectClasses.focused}`]: { '&.Mui-focused': {
outlineOffset: 0, outlineOffset: 0,
borderColor: 'hsl(210, 55%, 55%)', borderColor: 'hsl(210, 55%, 55%)',
}, },

11
src/shared-theme/theme-augment.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import "@mui/material/styles";
declare module "@mui/material/styles" {
interface Theme {
vars?: Record<string, any>;
applyStyles<T>(mode: "light" | "dark", styles: T): T | {};
}
interface ThemeOptions {
vars?: Record<string, any>;
}
}

View File

@@ -1,4 +1,5 @@
import { createTheme, alpha, PaletteMode, Shadows } from '@mui/material/styles'; import { createTheme, alpha, Shadows } from '@mui/material/styles';
import type { PaletteMode } from '@mui/material';
declare module '@mui/material/Paper' { declare module '@mui/material/Paper' {
interface PaperPropsVariantOverrides { interface PaperPropsVariantOverrides {