19 Commits

Author SHA1 Message Date
d8c6c3cdb3 added favicon 2026-05-24 16:02:36 +05:30
8f57bd1745 ignoreSelf default true 2026-05-24 15:58:14 +05:30
44c42892d8 copy ids 2026-05-24 15:57:32 +05:30
7de8914283 mobile view 1 filter per line fix 2026-05-24 14:51:59 +05:30
d767cf0a23 toggleDropdown for chip to open and close, limiting tag chars 2026-05-24 14:48:23 +05:30
6fe70496c0 clicking on ships open dropdown not expand chips 2026-05-24 14:39:35 +05:30
fa32ab17de sorted options only when selecting again 2026-05-24 14:35:39 +05:30
a8f5789c03 smarter multiselect 2026-05-24 14:21:07 +05:30
e1b8f4e0c3 Admin filter fixes 2026-05-24 14:09:41 +05:30
6fc24001a7 Snapshot Selector fixes 2026-05-24 14:06:20 +05:30
6803fb6b56 multi auto complete 2026-05-24 13:58:00 +05:30
4a3428ed8f fixes for mobile view broken 2026-05-24 13:52:34 +05:30
170769c317 fixes 2026-05-24 01:36:08 +05:30
653c8caecf fixes 2026-05-24 01:34:52 +05:30
a3970d6a7b filter in config and fixes 2026-05-24 01:21:51 +05:30
7ab5ce74b3 filter in admin 2026-05-24 00:59:33 +05:30
3a72985efb home changes for new routes 2026-05-23 22:18:18 +05:30
220c84776f fetch requests and reports routes 2026-05-23 21:48:39 +05:30
cccb4604fd fixes for latest openapi spec changes 2026-05-23 17:22:30 +05:30
45 changed files with 553 additions and 2643 deletions

View File

@@ -1,40 +0,0 @@
# Node modules
node_modules
**/node_modules
# Logs
*.log
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
build
dist
out
.next
.cache
.parcel-cache
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS files
.DS_Store
Thumbs.db
# IDE / Editor folders
.vscode
.idea
*.sublime-workspace
*.sublime-project
# Temporary files
*.swp
*.bak
*.tmp

View File

@@ -1,140 +0,0 @@
---
kind: pipeline
type: docker
name: default
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: fetch-tags
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- apk add --no-cache git
- git fetch --tags
- |
# Get latest Git tag and trim newline
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
echo "Latest Git tag fetched: $LATEST_TAG"
# Save to file for downstream steps
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
# Read back for verification
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
echo "Image tag read from file: $IMAGE_TAG"
# Validate
if [ -z "$IMAGE_TAG" ]; then
echo "❌ No git tags found! Cannot continue."
exit 1
fi
- name: check-remote-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Checking if apps/khata:$IMAGE_TAG exists on remote Docker..."
- echo "Existing Docker tags for apps/khata:"
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^apps/khata" || echo "(none)"
- |
if docker image inspect apps/khata:$IMAGE_TAG > /dev/null 2>&1; then
echo "✅ Docker image apps/khata:$IMAGE_TAG already exists — skipping build"
exit 78
else
echo "⚙️ Docker image apps/khata:$IMAGE_TAG not found — proceeding to build..."
fi
- name: build-image
image: docker:24
environment:
API_BASE_URL:
from_secret: API_BASE_URL
AUTH_BASE_URL:
from_secret: AUTH_BASE_URL
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔨 Building Docker image apps/khata:$IMAGE_TAG ..."
- |
docker build --network=host \
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
--build-arg VITE_AUTH_BASE_URL="$AUTH_BASE_URL" \
-t apps/khata:$IMAGE_TAG \
-t apps/khata:latest \
/drone/src
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- echo "🏷️ Tagging images with registry prefix..."
- docker tag apps/khata:$IMAGE_TAG $REGISTRY_HOST/apps/khata:$IMAGE_TAG
- docker tag apps/khata:$IMAGE_TAG $REGISTRY_HOST/apps/khata:latest
- echo "📤 Pushing apps/khata:$IMAGE_TAG ..."
- docker push $REGISTRY_HOST/apps/khata:$IMAGE_TAG
- echo "📤 Pushing apps/khata:latest ..."
- docker push $REGISTRY_HOST/apps/khata:latest
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "🛑 Stopping old container..."
- docker rm -f khata || true
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🚀 Starting container apps/khata:$IMAGE_TAG ..."
- |
docker run -d \
--name khata \
-p 3002:3000 \
-e NODE_ENV=production \
--restart always \
apps/khata:$IMAGE_TAG
# Trigger rules
trigger:
event:
- tag

View File

@@ -1,24 +0,0 @@
# Concept Overview
The application is a **metadatadriven admin UI** built on top of an OpenAPI description. By describing each resource in a small JSON config (type `ResourceConfig`), the UI automatically generates:
1. **Data tables** (with pagination, sorting, and actions) `EnhancedTable`.
2. **Dynamic filters** `FilterBar` creates appropriate filter widgets (autocomplete, numberrange, daterange) based on field metadata.
3. **Forms for create/edit** A generic form component can render inputs for every `ResourceField`, handling relations via the `displayFormat` template.
4. **Authentication layer** `reactauth` supplies a central `AuthProvider`, a `useAuth` hook, and route guarding, ensuring only authenticated users reach the admin pages.
### Core Principles
- **Declarative configuration**: Adding a new resource is just a JSON entry; no handcoded tables or forms.
- **Templatebased display**: `displayFormat` (e.g. `"{{firstName}} {{lastName}}"`) defines how related objects are shown across the UI, eliminating the need for separate `displayField` props.
- **Extensible UI**: Consumers can plug custom components (`components` prop) to override cell renderers, filter widgets, or action buttons without altering core logic.
- **Unified state**: TanStack Query caches server data, while `reactauth` manages JWTs and user info. Both are provided via React context for easy access.
- **Responsive design**: The UI automatically switches to a cardbased layout on mobile, preserving functionality with a consistent look.
### Migration Goal for Lovable
The current repo implements these ideas with a solid foundation but could benefit from:
- **Improved UI/UX** (e.g., better loading states, richer snackbars, darkmode toggle).
- **More robust error handling** (centralized toast system, retry logic on auth failures).
- **Enhanced theming** (customizable palette, brand colors).
- **Accessibility** (ARIA roles, keyboard navigation).
By reusing the existing `ResourceConfig` schema and `displayFormat` logic, the Lovable implementation can focus on UI polish and advanced handling while keeping the powerful codegeneration approach intact.

View File

@@ -1,34 +0,0 @@
# Design Overview
## ReactAuth
- **Purpose**: Centralize authentication flows (login, logout, token refresh) for the UI.
- **Key Concepts**
- **AuthProvider** React context that stores `user`, `accessToken`, and `isAuthenticated`.
- **useAuth hook** Exposes `login`, `logout`, `refreshToken`, and state values.
- **Route Guard** HOC/Component (`ProtectedRoute`) that redirects unauthenticated users to the login page.
- **UI**: Simple MUI forms, error handling with snackbars, and a loading spinner while the auth request is pending.
- **Extensibility**: Plugin point for additional providers (OAuth, SSO) via a `providers` map.
## ReactOpenAPI
- **Purpose**: Generate UI components directly from an OpenAPI spec (tables, filters, forms).
- **Core Modules**
- `ResourceConfig` & `ResourceField` Typed definitions that describe each endpoint and its fields, including `displayFormat` for rendering.
- `EnhancedTable` Datagrid component that renders rows according to the config, supports relation rendering, sorting, pagination, and custom cell renderers.
- `FilterBar` Dynamically builds filter controls (autocomplete, numberrange, daterange) based on the same config.
- **Data Flow**
1. Load OpenAPI spec → transform to `ResourceConfig` objects.
2. `useQuery` (TanStack) fetches data.
3. UI components consume the config to render tables and filter UI without handwritten column definitions.
- **Design Goals**
- **Zero boilerplate** Adding a new resource only requires a JSON config.
- **Consistency** All tables share pagination, actions, and styling.
- **Extensibility** Override components via `components` prop.
## src (Root Application)
- **Entry Point** `main.tsx` mounts the React app with `BrowserRouter` and wraps it with `AuthProvider`.
- **Routing** Routes are defined perresource (`/admin/:resource`, `/admin/:resource/edit/:id`). `ProtectedRoute` ensures auth.
- **State Management** TanStack Query handles server state; React Context handles auth state.
- **Theming** MUI theming with light/dark mode toggle (future enhancement).
---
These design notes serve as a concise reference for developers preparing a richer UI/UX implementation on the **lovable** platform.

View File

@@ -1,33 +0,0 @@
# Stage 1: Build
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock)
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy the rest of the app
COPY . .
# Build the app
ARG VITE_API_BASE_URL
ARG VITE_AUTH_BASE_URL
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL VITE_AUTH_BASE_URL=$VITE_AUTH_BASE_URL npm run build
# Stage 2: Static file server (BusyBox)
FROM busybox:latest
WORKDIR /app
# Copy only build frontend files
COPY --from=builder /app/dist /app
# Expose port
EXPOSE 3000
# Default command
CMD ["busybox", "httpd", "-f", "-p", "3000"]

View File

@@ -1,49 +0,0 @@
# Implementation Details
## ReactAuth
- **File Structure**
- `src/auth/AuthContext.tsx` Provides `AuthContext` and `AuthProvider`.
- `src/auth/useAuth.ts` Custom hook returning context values and actions.
- `src/auth/ProtectedRoute.tsx` Wrapper component that checks `isAuthenticated` and redirects.
- `src/auth/api.ts` Thin wrapper around `axios` for login, logout, refresh.
- **Logic**
1. On `login`, POST credentials → store `accessToken` & user info in context and `localStorage`.
2. An `axios` interceptor attaches the token to every request.
3. `refreshToken` runs on 401 responses; it attempts a silent refresh and updates the context.
4. `logout` clears context and storage, navigating back to `/login`.
- **UI Components**
- `LoginForm` MUI `TextField`s, validation, and submit handling.
- `AuthLoading` Fullscreen spinner displayed while session restoration runs on app boot.
## ReactOpenAPI
- **Core Files**
- `src/react-openapi/types/config.ts` Already defines `ResourceField` with `displayFormat`.
- `src/react-openapi/utils/options.ts` Helper `resolveTemplate` parses `{{field}}` placeholders using the item data.
- `src/react-openapi/components/EnhancedTable.tsx` Renders a MUI `DataGrid`. Uses `getFormattedDisplayValue` to compute readable labels for relation fields based on `displayFormat`.
- `src/react-openapi/components/FilterBar.tsx` Generates filter inputs; extracts option labels using the same `displayFormat` logic.
- **Data Fetching**
- `useResource(resourceName)` TanStack `useQuery` hook that builds the endpoint URL from `config.endpoint` and fetches data via the shared Axios instance.
- **Customization**
- `components` prop passed to `EnhancedTable`/`FilterBar` allows overriding cell renderers, filter widgets, and action buttons.
- **Error Handling**
- Centralized error toast (`useToast`) displays API errors.
- Table shows “No data” state when an empty array is returned.
## src (Application Core)
- **src/main.tsx** Sets up MUI theme, React Router, `AuthProvider`, and `QueryClientProvider`.
- **src/App.tsx** Defines routes:
```tsx
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route element={<ProtectedRoute />}>
<Route path="/admin/:resource" element={<ResourceList />} />
<Route path="/admin/:resource/edit/:id" element={<ResourceForm />} />
</Route>
</Routes>
```
- **src/pages/ResourceList.tsx** Reads `resource` from URL, loads its `ResourceConfig`, calls `useResource`, and renders `EnhancedTable` + `FilterBar`.
- **src/pages/ResourceForm.tsx** Dynamically builds a form based on `ResourceField` definitions, using `displayFormat` for default values.
- **State Management** TanStack Query caches paginated results; `AuthProvider` ensures all API calls include a valid JWT.
- **Theming** `ThemeProvider` toggles light/dark mode via a context hook that persists the preference in `localStorage`.
These implementation notes detail the concrete file layout, data flow, and core logic that power the UI generated from OpenAPI specifications while maintaining authenticated access. They can be directly adapted for the **lovable** platform to provide a richer UI and better handling of auth and data rendering.

View File

@@ -1,172 +0,0 @@
# Refactor Guide Deep Dive into the KhataUI Codebase
> This document walks through the entire repository, explains the current architecture, and provides a stepbystep refactor plan that will improve maintainability, type safety, and UI/UX while preserving the existing functional behavior.
---
## 1. Repository Layout (highlevel)
```
khata-ui/
├─ react-openapi/ # Core UI generated from OpenAPI configs
│ ├─ components/ # UI pieces: EnhancedTable, FilterBar, etc.
│ ├─ types/ # TypeScript interfaces (config, overrides)
│ └─ utils/ # Helper utilities (options, template resolution)
├─ src/ # Application entry point and pages
│ ├─ auth/ # Authentication context, hooks, and protected routes
│ ├─ pages/ # Dynamic resources (list, form)
│ └─ main.tsx # React root, providers, theming
├─ public/ # Static assets (favicon, index.html)
├─ index.html
├─ package.json
└─ tsconfig.json
```
### Key Concepts
| Area | Responsibility |
|------|-----------------|
| **Auth** | Central JWT handling, `AuthProvider`, `useAuth`, route guarding. |
| **OpenAPIdriven UI** | Describes each resource via `ResourceConfig`/`ResourceField`. Generates tables, filters, and forms automatically. |
| **Data Layer** | TanStack Query (`useQuery`) fetches data; Axios instance carries auth token via interceptor. |
| **Theming** | MUI theme with light/dark mode toggle (future). |
| **Extensibility** | `components` prop on `EnhancedTable` / `FilterBar` lets callers inject custom cell renderers, filter widgets, or action buttons. |
---
## 2. Detailed Module Walkthrough
### 2.1 `react-openapi/types/config.ts`
```ts
export interface ResourceField {
displayFormat: string; // <- single source of truth for rendering
type: FieldType;
label: string;
required?: boolean;
options?: string[];
readOnly?: boolean;
schema?: Record<string, ResourceField>;
formatter?: (value: any) => string;
relation?: string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
}
```
- `displayFormat` replaces the legacy `displayField`. It can be a **template string** (`"{{first}} {{last}}"`) or an **array of keys** for concatenation.
- All UI components now rely exclusively on this field.
### 2.2 `react-openapi/utils/options.ts`
- `resolveTemplate(format: string, item: any)` interpolates `{{key}}` placeholders.
- `getFieldOptions`, `toGridValueOptions` convert enum definitions into MUIcompatible arrays.
- **Refactor idea**: Move the `displayFormat` resolution logic from `EnhancedTable`/`FilterBar` into a dedicated helper (`formatDisplay(item, field)`), reducing duplication.
### 2.3 `react-openapi/components/EnhancedTable.tsx`
- **Core responsibilities**
1. Build column definitions from `config.fields`.
2. Render each cell via `FieldRenderer`.
3. Provide serverside or clientside pagination.
4. Add a static "Actions" column.
- **Key functions**
- `getFormattedDisplayValue(item, displayFormat?, enumValue?)` now uses `resolveTemplate` and falls back to generic fields.
- `FieldRenderer` decides how to render a cell based on `field.type`, `field.relation`, custom renderers, and `displayFormat`.
- **Duplication**: Both `EnhancedTable` and `FilterBar` perform very similar `displayFormat` extraction. Extracting this into a shared utility will shrink the component size and make testing easier.
### 2.4 `react-openapi/components/FilterBar.tsx`
- Generates filter controls for each **filterable** field.
- Uses `extractOptions` to populate autocomplete lists, falling back to `displayFormat` for label generation.
- **Opportunity**: Replace the inline `pull` helper with the shared formatter from `utils/options`.
### 2.5 Authentication (`src/auth`)
- `AuthContext.tsx` provides `user`, `accessToken`, `isAuthenticated` plus actions.
- `useAuth.ts` thin wrapper exposing the context values.
- `ProtectedRoute.tsx` guards routes, redirects to `/login` when unauthenticated.
- `api.ts` thin Axios wrapper (`login`, `logout`, `refresh`).
- **Refactor suggestions**
- Consolidate token storage (localStorage ↔ sessionStorage) behind a small `tokenStore` service.
- Add automatic token refresh using an interceptor that retries the original request.
- Provide a hook (`useAuthorizedQuery`) that injects the auth token into TanStack Query automatically.
### 2.6 Application Core (`src/pages`, `src/main.tsx`)
- `ResourceList.tsx` reads `resource` param, loads the related `ResourceConfig` from a central map, fetches data, and renders `EnhancedTable` + `FilterBar`.
- `ResourceForm.tsx` builds a dynamic form based on `ResourceField` definitions; uses `displayFormat` for default values on relation fields.
- `main.tsx` wraps the app with `AuthProvider`, `QueryClientProvider`, and MUI `ThemeProvider`.
- **Future work**: Extract the “resource loader” into a hook (`useResourceConfig(resourceName)`) that also validates the config at runtime.
---
## 3. Refactor Roadmap StepbyStep
### Phase1 Consolidate Formatting Logic
1. **Create utility** `src/react-openapi/utils/formatDisplay.ts`
```ts
export const formatDisplay = (item: any, field: ResourceField, enumValue?: string) => {
if (enumValue) return resolveTemplate(enumValue, item);
const fmt = field.displayFormat;
if (!fmt) return item.name ?? item.title ?? item.label ?? item.id ?? JSON.stringify(item);
if (Array.isArray(fmt)) {
return fmt.map(k => item[k]).filter(Boolean).join(' ');
}
return resolveTemplate(fmt, item) || item.id || JSON.stringify(item);
};
```
2. Replace *all* inline calls to `getFormattedDisplayValue` in `EnhancedTable` and `FilterBar` with `formatDisplay`.
3. Remove `getFormattedDisplayValue` from `EnhancedTable.tsx` (or keep it as a thin wrapper for backward compatibility).
4. Update imports accordingly.
5. Run TypeScript check no errors.
### Phase2 Decouple UI from Config Loading
- Introduce **`configLoader.ts`** under `src/react-openapi/utils` that reads a JSON file (or fetches a remote spec) and produces a `Record<string, ResourceConfig>`.
- Replace hardcoded imports in `src/pages/ResourceList.tsx` with a call to `useResourceConfig(resourceName)`.
- Add runtime validation (e.g., using `zod`) to ensure required fields (`displayFormat`, `type`, `label`) are present; surface errors via a toast.
### Phase3 Centralize Error & Loading UI
- Create `src/components/LoadingSpinner.tsx` and `src/components/ErrorToast.tsx`.
- Wrap all datafetching hooks (`useResource`, `useAuth` actions) with a HOC that automatically displays these components.
- Migrate the scattered `if (loading) …` checks into the new components.
### Phase4 Theming & Dark Mode
1. Add a `ThemeContext` that stores `mode: 'light' | 'dark'` and persists the preference.
2. Expose a toggle button (e.g., in the topright corner of `App.tsx`).
3. Update component styles to use themeaware colors (via `theme.palette`), ensuring the `Chip` variants already respect the palette.
### Phase5 Testing & CI
- **Unit tests** using `vitest` for:
- `formatDisplay` utility (various template & array cases).
- `AuthProvider` behavior (login, logout, token refresh).
- **Component tests** (`@testing-library/react`) for `EnhancedTable` and `FilterBar` verifying that `displayFormat` rendering matches expectations.
- Add a GitHub Actions workflow that runs `npm run lint && npx tsc --noEmit && vitest run` on each PR.
### Phase6 Documentation (the files you will publish)
- **DESIGN.md** highlevel architecture (already present).
- **IMPLEMENTATION.md** detailed filebyfile breakdown (already present).
- **CONCEPT.md** why the metadatadriven approach works (already present).
- **REFRACTOR_GUIDE.md** the detailed guide you are reading now (this file).
- Keep these files in the repo root; they can be exported to the **lovable** platform directly.
---
## 4. Migration Checklist (what to verify after refactor)
- [ ] All UI components compile with TypeScript (`npx tsc --noEmit`).
- [ ] No runtime references to `displayField` remain (search `\.displayField`).
- [ ] `formatDisplay` correctly resolves:
- Template strings with multiple placeholders.
- Array of keys.
- Fallback to generic fields.
- [ ] Auth flow works (login ➜ token stored ➜ API requests succeed, protected routes guarded).
- [ ] Pagination works both client and serverside.
- [ ] Mobile layout (card view) still renders correctly.
- [ ] Darkmode toggle persists across reloads.
- [ ] Lint passes (`npm run lint` if configured) and tests pass.
---
## 5. Potential Future Enhancements
| Feature | Benefit | Rough Implementation |
|---------|---------|----------------------|
| **Bulk actions** (delete, export) | Improves admin productivity | Add a toolbar with selection model in `EnhancedTable`. |
| **Inline editing** | Faster data tweaks | Replace `onEdit` dialog with celllevel edit mode using MUI `TextField`. |
| **GraphQL fallback** | Flexibility for backends | Abstract data fetching behind an adapter interface (`useDataProvider`). |
| **Internationalisation** | Multilanguage UI | Wrap all static strings with `i18n.t()` and provide locale files. |
| **Performance profiling** | Identify render bottlenecks | Use React Profiler and memoize expensive formatters (`useMemo`). |
---
### Closing Note
The current codebase already demonstrates a powerful pattern: **declare once, render everywhere**. By consolidating the display logic, adding a small utility layer, and strengthening the authentication and theming foundations, the project will become easier to extend, test, and handoff to the **lovable** UI platform while retaining its lowcode advantage.

View File

@@ -9,7 +9,7 @@
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/> />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/svg+xml" href="/favicon.png" />
<title>khata - Aetoskia</title> <title>khata - Aetoskia</title>
</head> </head>
<body> <body>

14
package-lock.json generated
View File

@@ -28,7 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest" "vite": "latest"
} }
}, },
@@ -4104,19 +4103,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

@@ -28,7 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest" "vite": "latest"
} }
} }

View File

@@ -6,7 +6,6 @@ import ResourceView from "./components/ResourceView";
import { getAppConfig } from "./config"; import { getAppConfig } from "./config";
import { initializeApiClients } from "./api/client"; import { initializeApiClients } from "./api/client";
import { AppConfig } from "./types/config"; import { AppConfig } from "./types/config";
import { FieldComponents } from "./types/overrides";
import { Box, Typography, Paper, CircularProgress } from "@mui/material"; import { Box, Typography, Paper, CircularProgress } from "@mui/material";
import { import {
Routes, Routes,
@@ -16,9 +15,8 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { ConfigContext } from "./providers/ConfigContext"; import { ConfigContext } from "./providers/ConfigContext";
import ProfileView from "./components/ProfileView";
function DefaultDashboard({ basePath }: { basePath: string }) { function Dashboard({ basePath }: { basePath: string }) {
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -33,6 +31,7 @@ function DefaultDashboard({ basePath }: { basePath: string }) {
<Typography variant="body1" sx={{ color: 'text.secondary' }}> <Typography variant="body1" sx={{ color: 'text.secondary' }}>
Select a resource from the sidebar to manage data. Select a resource from the sidebar to manage data.
</Typography> </Typography>
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
@@ -62,15 +61,9 @@ function DefaultDashboard({ basePath }: { basePath: string }) {
); );
} }
interface AdminAppProps { import ProfileView from "./components/ProfileView";
basePath: string;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) { function AdminApp({ basePath }: { basePath: string }) {
const { currentUser, login, logout, loading, error } = useAuth(); const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -80,10 +73,10 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
if (!currentUser) { if (!currentUser) {
return ( return (
<LoginPage <AuthPage
mode="login" mode="login"
login={login} login={login}
register={async () => {}} register={async () => {}} // Disable registration for Admin
loading={loading} loading={loading}
error={error} error={error}
onSwitchMode={() => {}} onSwitchMode={() => {}}
@@ -94,7 +87,7 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
} }
return ( return (
<Layout <AdminLayout
username={currentUser.username} username={currentUser.username}
onLogout={logout} onLogout={logout}
onSelectResource={(name) => navigate(`/admin/${name}`)} onSelectResource={(name) => navigate(`/admin/${name}`)}
@@ -103,44 +96,32 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
<Routes> <Routes>
<Route path="/" element={<Dashboard basePath={basePath} />} /> <Route path="/" element={<Dashboard basePath={basePath} />} />
<Route path="/profile" element={<ProfileView />} /> <Route path="/profile" element={<ProfileView />} />
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
</Routes> </Routes>
</Layout> </AdminLayout>
); );
} }
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) { function ResourceRouteWrapper() {
const { resourceName } = useParams(); const { resourceName } = useParams();
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const selectedResource = config?.resources.find((r) => r.name === resourceName); const selectedResource = config?.resources.find((r) => r.name === resourceName);
if (!selectedResource) return <Typography>Resource not found</Typography>; if (!selectedResource) return <Typography>Resource not found</Typography>;
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />; return <ResourceView config={selectedResource} />;
}
interface AdminLayoutProps {
children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void;
onLogout: () => void;
username?: string;
resources: import("./types/config").ResourceConfig[];
} }
interface AdminProps { interface AdminProps {
basePath?: string; basePath?: string;
resourceOverrides?: Record<string, any>; resourceOverrides?: Record<string, any>;
profileConfig?: any; profileConfig?: any;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
} }
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) { export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
const existingConfig = React.useContext(ConfigContext); const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig); const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
@@ -170,14 +151,16 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
const content = ( const content = (
<UploadProvider> <UploadProvider>
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} /> <AdminApp basePath={basePath} />
</UploadProvider> </UploadProvider>
); );
// If we have an existing config, we are already inside a Provider and QueryClient
if (existingConfig) { if (existingConfig) {
return content; return content;
} }
// Fallback for standalone usage
return ( return (
<ConfigContext.Provider value={config}> <ConfigContext.Provider value={config}>
{content} {content}

View File

@@ -1,5 +1,4 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import type { AxiosResponse } from "axios";
import { createApiClient } from "../../react-auth"; import { createApiClient } from "../../react-auth";
/** /**
@@ -31,25 +30,21 @@ function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
} }
export const api = { export const api = {
get: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => { get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.get<T, R>(url, config); return _api.get(...args);
}, },
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => { post: (...args: Parameters<AxiosInstance["post"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.post<T, R>(url, data, config); return _api.post(...args);
}, },
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => { put: (...args: Parameters<AxiosInstance["put"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.put<T, R>(url, data, config); return _api.put(...args);
}, },
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => { delete: (...args: Parameters<AxiosInstance["delete"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.delete<T, R>(url, config); return _api.delete(...args);
},
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.patch<T, R>(url, data, config);
}, },
}; };

View File

@@ -31,8 +31,6 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import { EnhancedTableComponents } from '../types/overrides';
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
interface EnhancedTableProps { interface EnhancedTableProps {
config: ResourceConfig; config: ResourceConfig;
@@ -45,7 +43,6 @@ interface EnhancedTableProps {
onDelete: (id: string) => void; onDelete: (id: string) => void;
onCreate: () => void; onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void; onNavigateToResource?: (resourceName: string, id: string) => void;
components?: EnhancedTableComponents;
} }
export default function EnhancedTable({ export default function EnhancedTable({
@@ -59,7 +56,6 @@ export default function EnhancedTable({
onDelete, onDelete,
onCreate, onCreate,
onNavigateToResource, onNavigateToResource,
components: tableComponents,
}: EnhancedTableProps) { }: EnhancedTableProps) {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -88,7 +84,7 @@ export default function EnhancedTable({
type: muiType, type: muiType,
flex: 1, flex: 1,
minWidth: 150, minWidth: 150,
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} /> renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
}; };
if (muiType === 'date' || muiType === 'dateTime') { if (muiType === 'date' || muiType === 'dateTime') {
@@ -99,8 +95,9 @@ export default function EnhancedTable({
}; };
} }
if (muiType === 'singleSelect') { if (muiType === 'singleSelect' && field.options) {
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field)); // @ts-ignore
col.valueOptions = field.options;
} }
return col; return col;
@@ -161,7 +158,6 @@ export default function EnhancedTable({
onDelete={onDelete} onDelete={onDelete}
onNavigate={onNavigateToResource} onNavigate={onNavigateToResource}
navigate={navigate} navigate={navigate}
components={tableComponents}
/> />
</Box> </Box>
))} ))}
@@ -229,7 +225,7 @@ export default function EnhancedTable({
); );
} }
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) { function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const id = row[config.primaryKey]; const id = row[config.primaryKey];
@@ -265,7 +261,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
{field.label} {field.label}
</Typography> </Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}> <Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} /> <FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
</Typography> </Typography>
</Box> </Box>
))} ))}
@@ -278,27 +274,30 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
); );
} }
function getFormattedDisplayValue(item: any, displayFormat: string) { function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
if (!item) return ""; if (!item) return "";
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
return resolveTemplate(displayFormat, item); if (Array.isArray(displayField)) {
return displayField
.map(key => item[key])
.filter(val => val !== undefined && val !== null)
.join(' ');
}
return item[displayField] || item.id || JSON.stringify(item);
} }
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) { function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
const value = params.value; const value = params.value;
const isPk = fieldKey === config.primaryKey; const isPk = fieldKey === config.primaryKey;
if (field.formatter) return field.formatter(value); if (field.formatter) return field.formatter(value);
const customRenderer = components?.cellRenderers?.[field.type as string];
if (customRenderer) {
return React.createElement(customRenderer, { value, row: params.row, field, fieldKey, config, onNavigate, isMobile });
}
// 1. Single Relation // 1. Single Relation
if (field.relation && value && !Array.isArray(value)) { if (field.relation && value && !Array.isArray(value)) {
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value; const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
const displayValue = getFormattedDisplayValue(value, field.displayFormat); const displayValue = getFormattedDisplayValue(value, field.displayField);
return ( return (
<Chip <Chip
@@ -317,8 +316,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 2. Multi-Select (Array of relations or simple strings) // 2. Multi-Select (Array of relations or simple strings)
if (field.type === 'array' && Array.isArray(value)) { if (field.type === 'array' && Array.isArray(value)) {
const enumValue = field.enumOption?.value; const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayFormat)).join(', ');
return ( return (
<Tooltip title={tooltipTitle} arrow placement="top"> <Tooltip title={tooltipTitle} arrow placement="top">
@@ -326,7 +324,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
{value.map((item, idx) => ( {value.map((item, idx) => (
<Chip <Chip
key={idx} key={idx}
label={getFormattedDisplayValue(item, field.displayFormat)} label={getFormattedDisplayValue(item, field.displayField)}
size="small" size="small"
variant="filled" variant="filled"
sx={{ maxWidth: 120 }} sx={{ maxWidth: 120 }}
@@ -346,7 +344,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 3. Simple Objects // 3. Simple Objects
if (field.type === 'object' && value) { if (field.type === 'object' && value) {
return getFormattedDisplayValue(value, field.displayFormat) || (isMobile ? 'Object' : JSON.stringify(value)); return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
} }
if (field.type === 'number' && typeof value === 'number') { if (field.type === 'number' && typeof value === 'number') {
@@ -379,14 +377,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
); );
} }
if (field.type === 'datetime') return value ? new Date(value).toLocaleString() : ''; if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
if (field.type === 'date') return value ? new Date(value).toLocaleDateString() : '';
if (field.type === 'enum') {
const opt = getFieldOptions(field).find(o => o.key === value);
return opt?.value ?? value;
}
if (isPk && !isMobile) { if (isPk && !isMobile) {
return ( return (

View File

@@ -11,10 +11,8 @@ import {
import DoneIcon from "@mui/icons-material/Done"; import DoneIcon from "@mui/icons-material/Done";
import FilterListIcon from "@mui/icons-material/FilterList"; import FilterListIcon from "@mui/icons-material/FilterList";
import { ResourceField, ResourceMode } from "../types/config"; import { ResourceField, ResourceMode } from "../types/config";
import { FilterBarComponents, FieldComponents } from "../types/overrides";
import { getFieldOptions, resolveTemplate } from "../utils/options";
export function FilterAutocomplete({ function FilterAutocomplete({
options, options,
value, value,
label, label,
@@ -112,9 +110,7 @@ function extractOptions(
): string[] { ): string[] {
const values = new Set<string>(); const values = new Set<string>();
if (field.type === 'enum') { if (field.options) return field.options;
return getFieldOptions(field).map(o => o.value);
}
if (!data) return []; if (!data) return [];
const pull = (item: any): string | null => { const pull = (item: any): string | null => {
@@ -122,13 +118,18 @@ function extractOptions(
if (typeof item === "string") return item; if (typeof item === "string") return item;
if (typeof item !== "object") return String(item); if (typeof item !== "object") return String(item);
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item); const df = field.displayField;
if (!df) { debugger; return null; }
// Use displayFormat if defined if (Array.isArray(df)) {
if (field.displayFormat) { const parts = df.map((k) => item[k]).filter((v) => v != null);
return resolveTemplate(field.displayFormat, item); if (parts.length > 0) return parts.join(" ");
} else {
const v = item[df];
if (v != null) return String(v);
} }
debugger;
return null; return null;
}; };
@@ -156,24 +157,32 @@ function renderFilterInput(
field: ResourceField, field: ResourceField,
options: string[], options: string[],
value: any, value: any,
onChange: (key: string, val: any) => void, onChange: (key: string, val: any) => void
components?: FilterBarComponents,
fieldComponents?: FieldComponents,
) { ) {
const filterType = field.filterType; const filterType = field.filterType;
if (filterType === "number-range") { if (filterType === "number-range") {
const RangeComponent = fieldComponents?.numberRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { min?: string; max?: string }) || {}; const rangeVal = (value as { min?: string; max?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />; return (
<Box sx={{ display: "flex", gap: 1 }}>
<TextField type="number" placeholder="Min" size="small" value={rangeVal.min ?? ""}
onChange={(e) => onChange("min", e.target.value || undefined)} sx={{ width: 100 }} />
<TextField type="number" placeholder="Max" size="small" value={rangeVal.max ?? ""}
onChange={(e) => onChange("max", e.target.value || undefined)} sx={{ width: 100 }} />
</Box>
);
} }
if (filterType === "date-range") { if (filterType === "date-range") {
const RangeComponent = fieldComponents?.dateRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { start?: string; end?: string }) || {}; const rangeVal = (value as { start?: string; end?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />; return (
<Box sx={{ display: "flex", gap: 1 }}>
<TextField type="datetime-local" placeholder="From" size="small" value={rangeVal.start ?? ""}
onChange={(e) => onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
<TextField type="datetime-local" placeholder="To" size="small" value={rangeVal.end ?? ""}
onChange={(e) => onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
</Box>
);
} }
const selected = Array.isArray(value) ? value : []; const selected = Array.isArray(value) ? value : [];
@@ -196,8 +205,6 @@ export interface FilterBarProps {
appliedValues: Record<string, any>; appliedValues: Record<string, any>;
onApply: (values: Record<string, any>) => void; onApply: (values: Record<string, any>) => void;
onClear: () => void; onClear: () => void;
components?: FilterBarComponents;
fieldComponents?: FieldComponents;
} }
export default function FilterBar({ export default function FilterBar({
@@ -207,8 +214,6 @@ export default function FilterBar({
appliedValues, appliedValues,
onApply, onApply,
onClear, onClear,
components: filterComponents,
fieldComponents,
}: FilterBarProps) { }: FilterBarProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues })); const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
@@ -276,7 +281,7 @@ export default function FilterBar({
const field = fields[fieldName]; const field = fields[fieldName];
if (!field) return null; if (!field) return null;
const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect"; const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect";
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : []; const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
const raw = draft[fieldName]; const raw = draft[fieldName];
@@ -286,7 +291,7 @@ export default function FilterBar({
{field.label} {field.label}
</Box> </Box>
{renderFilterInput(fieldName, field, options, raw, (key, val) => {renderFilterInput(fieldName, field, options, raw, (key, val) =>
updateDraft(fieldName, key, val), filterComponents, fieldComponents updateDraft(fieldName, key, val)
)} )}
</Box> </Box>
); );

View File

@@ -7,7 +7,6 @@ import {
CircularProgress, CircularProgress,
} from '@mui/material'; } from '@mui/material';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useUpload } from '../providers/UploadProvider'; import { useUpload } from '../providers/UploadProvider';
import { useQueries } from '@tanstack/react-query'; import { useQueries } from '@tanstack/react-query';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
@@ -22,7 +21,6 @@ interface GenericFormProps {
loading?: boolean; loading?: boolean;
readOnly?: boolean; readOnly?: boolean;
onEditClick?: () => void; onEditClick?: () => void;
fieldComponents: FieldComponents;
} }
export default function GenericForm({ export default function GenericForm({
@@ -33,7 +31,6 @@ export default function GenericForm({
loading: saving, loading: saving,
readOnly = false, readOnly = false,
onEditClick, onEditClick,
fieldComponents,
}: GenericFormProps) { }: GenericFormProps) {
initialData = initialData || {}; initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData); const [formData, setFormData] = React.useState(initialData);
@@ -57,7 +54,7 @@ export default function GenericForm({
queries: allRelations.map(relName => { queries: allRelations.map(relName => {
const relatedRes = appConfig?.resources.find(r => r.name === relName); const relatedRes = appConfig?.resources.find(r => r.name === relName);
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents }); const { getListQueryOptions } = useResource(relatedRes!);
return { return {
...getListQueryOptions(), ...getListQueryOptions(),
enabled: !!relatedRes, enabled: !!relatedRes,
@@ -120,7 +117,6 @@ export default function GenericForm({
uploading={uploading} uploading={uploading}
baseUrl={appConfig?.baseUrl || ""} baseUrl={appConfig?.baseUrl || ""}
relationDataMap={relationDataMap} relationDataMap={relationDataMap}
components={fieldComponents}
/> />
))} ))}

View File

@@ -3,7 +3,6 @@ import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm'; import GenericForm from './GenericForm';
import { ConfigContext } from '../providers/ConfigContext'; import { ConfigContext } from '../providers/ConfigContext';
import { defaultFieldComponents } from './fields/DefaultFieldComponents';
export default function ProfileView() { export default function ProfileView() {
const appConfig = React.useContext(ConfigContext); const appConfig = React.useContext(ConfigContext);
@@ -14,6 +13,7 @@ export default function ProfileView() {
return <Alert severity="error">Profile configuration not found.</Alert>; return <Alert severity="error">Profile configuration not found.</Alert>;
} }
// Create a modified config where only extraFields are editable
const editableConfig = React.useMemo(() => { const editableConfig = React.useMemo(() => {
const newFields = { ...resourceConfig.fields }; const newFields = { ...resourceConfig.fields };
const extraFields = profileConfig.extraFields || []; const extraFields = profileConfig.extraFields || [];
@@ -31,12 +31,13 @@ export default function ProfileView() {
}; };
}, [resourceConfig, profileConfig.extraFields]); }, [resourceConfig, profileConfig.extraFields]);
const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents }); const { useMe, useUpdateMe } = useResource(resourceConfig);
const { data: profile, isLoading, error } = useMe(); const { data: profile, isLoading, error } = useMe();
const updateMutation = useUpdateMe(); const updateMutation = useUpdateMe();
const handleSave = async (formData: any) => { const handleSave = async (formData: any) => {
try { try {
// Only send editable fields to prevent accidental overwrites of read-only data
const extraFields = profileConfig.extraFields || []; const extraFields = profileConfig.extraFields || [];
const dataToSave = Object.keys(formData) const dataToSave = Object.keys(formData)
.filter(key => extraFields.includes(key)) .filter(key => extraFields.includes(key))
@@ -75,7 +76,6 @@ export default function ProfileView() {
onSave={handleSave} onSave={handleSave}
onCancel={() => window.history.back()} onCancel={() => window.history.back()}
loading={updateMutation.isPending} loading={updateMutation.isPending}
fieldComponents={defaultFieldComponents}
/> />
</Paper> </Paper>
</Box> </Box>

View File

@@ -2,9 +2,8 @@ import * as React from 'react';
import { Box, Paper, CircularProgress } from '@mui/material'; import { Box, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import type { ResourceField } from '../types/config'; import type { ResourceField } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import { resolveTemplate } from '../utils/options'; import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable'; import EnhancedTable from './EnhancedTable';
import FilterBar from './FilterBar'; import FilterBar from './FilterBar';
import { useParams, useLocation, useNavigate } from 'react-router-dom'; import { useParams, useLocation, useNavigate } from 'react-router-dom';
@@ -12,16 +11,15 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
onNavigateToResource?: (resourceName: string, id: string) => void; onNavigateToResource?: (resourceName: string, id: string) => void;
fieldComponents: FieldComponents;
} }
import { GridPaginationModel } from '@mui/x-data-grid'; import { GridPaginationModel } from '@mui/x-data-grid';
function getDisplayString(item: any, field: ResourceField): string { function getFilterDisplayFields(field: ResourceField): string[] {
if (item == null || typeof item !== 'object') return String(item ?? ''); if (!field.displayField) return [];
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item); return (Array.isArray(field.displayField) ? field.displayField : [field.displayField]).filter(
if (field.displayFormat) return resolveTemplate(field.displayFormat, item); (df): df is string => !!df
throw new Error('cannot get display string') );
} }
function applyClientFilters( function applyClientFilters(
@@ -61,12 +59,18 @@ function applyClientFilters(
if (Array.isArray(filterValue)) { if (Array.isArray(filterValue)) {
if (field.type === "array" && Array.isArray(itemValue)) { if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) => return itemValue.some((el: any) => {
filterValue.includes(getDisplayString(el, field)) if (el != null && typeof el === "object") {
); const dispFields = getFilterDisplayFields(field);
return dispFields.some((df) => filterValue.includes(String(el[df])));
}
return filterValue.includes(String(el));
});
} }
if (itemValue && typeof itemValue === "object") { if (itemValue && typeof itemValue === "object") {
return filterValue.includes(getDisplayString(itemValue, field)); const dispFields = getFilterDisplayFields(field);
const itemDisplay = dispFields.map((df) => itemValue[df]).filter((v) => v != null).join(" ");
return filterValue.includes(itemDisplay);
} }
return filterValue.includes(String(itemValue)); return filterValue.includes(String(itemValue));
} }
@@ -78,13 +82,18 @@ function applyClientFilters(
} }
if (field.type === "array" && Array.isArray(itemValue)) { if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) => return itemValue.some((el: any) => {
getDisplayString(el, field) === String(filterValue) if (el != null && typeof el === "object") {
); const dispFields = getFilterDisplayFields(field);
return dispFields.some((df) => String(el[df]) === String(filterValue));
}
return String(el) === String(filterValue);
});
} }
if (itemValue && typeof itemValue === "object") { if (itemValue && typeof itemValue === "object") {
return getDisplayString(itemValue, field) === String(filterValue); const dispFields = getFilterDisplayFields(field);
return dispFields.some((df) => String(itemValue[df]) === String(filterValue));
} }
return String(itemValue) === String(filterValue); return String(itemValue) === String(filterValue);
@@ -92,7 +101,7 @@ function applyClientFilters(
); );
} }
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) { export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -111,7 +120,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({}); const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents }); const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
const queryParams = React.useMemo(() => { const queryParams = React.useMemo(() => {
if (!isServer) return { limit: 10000 }; if (!isServer) return { limit: 10000 };
@@ -179,7 +188,6 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
appliedValues={appliedFilters} appliedValues={appliedFilters}
onApply={setAppliedFilters} onApply={setAppliedFilters}
onClear={() => setAppliedFilters({})} onClear={() => setAppliedFilters({})}
fieldComponents={components}
/> />
)} )}
<EnhancedTable <EnhancedTable
@@ -197,7 +205,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
</Box> </Box>
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
{components && <components.GenericForm <GenericForm
config={config} config={config}
initialData={isCreate ? null : itemQuery.data} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
@@ -205,7 +213,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView} readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)} onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
/>} />
</Paper> </Paper>
)} )}
</Box> </Box>

View File

@@ -1,17 +0,0 @@
import { FormControlLabel, Checkbox } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function BooleanField({ field, value, onChange, disabled }: FieldComponentProps) {
return (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={field.label}
/>
);
}

View File

@@ -1,18 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function DateField({ field, value, onChange, disabled }: FieldComponentProps) {
const isDatetime = field.type === 'datetime';
return (
<MuiTextField
fullWidth
label={field.label}
type={isDatetime ? "datetime-local" : "date"}
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, isDatetime ? 16 : 10) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,30 +0,0 @@
import { Box, TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function DateRangeField({ value, onChange, disabled }: FieldComponentProps) {
const rangeVal = (value as { start?: string; end?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="date"
placeholder="From"
size="small"
value={rangeVal.start ?? ""}
onChange={(e) => onChange({ ...rangeVal, start: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
<MuiTextField
type="date"
placeholder="To"
size="small"
value={rangeVal.end ?? ""}
onChange={(e) => onChange({ ...rangeVal, end: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -1,40 +0,0 @@
import * as React from 'react';
import { FieldComponents, FieldComponentProps } from '../../types/overrides';
import TextFieldEntry from './TextField';
import NumberField from './NumberField';
import BooleanField from './BooleanField';
import DateField from './DateField';
import EnumField from './EnumField';
import RelationField from './RelationField';
import ImageUploadField from './ImageUploadField';
import FallbackField from './FallbackField';
import DateRangeField from './DateRangeField';
import NumberRangeField from './NumberRangeField';
const WrappedImageUploadField = (props: FieldComponentProps) =>
React.createElement(ImageUploadField, {
label: props.field.label,
value: props.value || '',
onUpload: async (file: File) => {
const url = await props.uploadFile?.(file);
if (url) props.onChange(url);
},
uploading: props.uploading,
baseUrl: props.baseUrl || '',
disabled: props.disabled,
});
export const defaultFieldComponents: FieldComponents = {
string: TextFieldEntry,
markdown: TextFieldEntry,
number: NumberField,
boolean: BooleanField,
date: DateField,
datetime: DateField,
enum: EnumField,
image: WrappedImageUploadField,
relation: RelationField,
default: FallbackField,
dateRange: DateRangeField,
numberRange: NumberRangeField,
};

View File

@@ -1,24 +0,0 @@
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { getFieldOptions } from '../../utils/options';
import { FieldComponentProps } from '../../types/overrides';
export default function EnumField({ field, value, onChange, disabled }: FieldComponentProps) {
const options = getFieldOptions(field);
return (
<FormControl fullWidth>
<InputLabel>{field.label}</InputLabel>
<Select
value={value || ''}
label={field.label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,13 +0,0 @@
import { TextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function FallbackField({ field, value }: FieldComponentProps) {
return (
<TextField
fullWidth
label={field.label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
}

View File

@@ -1,19 +1,29 @@
import * as React from 'react'; import * as React from 'react';
import {
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Typography,
Box,
Divider,
} from '@mui/material';
import { ResourceField } from '../../types/config'; import { ResourceField } from '../../types/config';
import { FieldComponentProps, FieldComponents } from '../../types/overrides'; import ImageUploadField from './ImageUploadField';
import ObjectField from './ObjectField';
export interface FormFieldProps { interface FormFieldProps {
name: string; name: string;
field: ResourceField; field: ResourceField;
value: any; value: any;
onChange: (val: any) => void; onChange: (val: any) => void;
disabled?: boolean; disabled?: boolean;
uploadFile?: (file: File) => Promise<string | null>; uploadFile: (file: File) => Promise<string | null>;
uploading?: boolean; uploading: boolean;
baseUrl?: string; baseUrl: string;
relationDataMap?: Record<string, any[]>; relationDataMap?: Record<string, any[]>; // Map of relation name to data array
components: FieldComponents;
} }
export default function FormField({ export default function FormField({
@@ -26,60 +36,189 @@ export default function FormField({
uploading, uploading,
baseUrl, baseUrl,
relationDataMap = {}, relationDataMap = {},
components,
}: FormFieldProps) { }: FormFieldProps) {
const fieldProps: FieldComponentProps = { const label = field.label;
name,
field,
value,
onChange,
disabled,
baseUrl,
relationDataMap,
uploadFile,
uploading,
};
const childComponents = components; // 1. Recursive Rendering for Objects (Not Relations)
// 1. Object (recursive) - requires parent FormField for recursion
if (field.type === 'object' && field.schema && !field.relation) { if (field.type === 'object' && field.schema && !field.relation) {
const renderChild = (childProps: FieldComponentProps) => ( return (
<FormField <Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
name={childProps.name} <Typography variant="subtitle2" color="primary" gutterBottom>
field={childProps.field} {label}
value={childProps.value} </Typography>
onChange={childProps.onChange} <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
disabled={childProps.disabled} {Object.entries(field.schema).map(([subKey, subField]) => (
uploadFile={childProps.uploadFile} <FormField
uploading={childProps.uploading} key={subKey}
baseUrl={childProps.baseUrl} name={`${name}.${subKey}`}
relationDataMap={childProps.relationDataMap} field={subField}
components={components} value={value?.[subKey]}
onChange={(newVal) => {
const updated = { ...(value || {}), [subKey]: newVal };
onChange(updated);
}}
disabled={disabled}
uploadFile={uploadFile}
uploading={uploading}
baseUrl={baseUrl}
relationDataMap={relationDataMap}
/>
))}
</Box>
</Box>
);
}
// 2. Relation Handling (Select / Multi-Select)
if (field.relation && relationDataMap[field.relation]) {
const relationData = relationDataMap[field.relation].data;
const isArrayRelation = field.type === 'array';
// Determine how to display the related item
const getOptionLabel = (option: any) => {
if (!option) return "";
if (field.displayField && option[field.displayField]) return option[field.displayField];
// Standard naming fields
return option.name || option.title || option.label || option.id || JSON.stringify(option);
};
const getOptionValue = (option: any) => {
// Return the whole object to maintain identity
return option;
};
return (
<FormControl fullWidth>
<InputLabel shrink>{label}</InputLabel>
<Select
multiple={isArrayRelation}
value={value || (isArrayRelation ? [] : "")}
label={label}
displayEmpty
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
renderValue={(selected: any) => {
if (isArrayRelation) {
return (selected as any[]).map(getOptionLabel).join(', ');
}
return getOptionLabel(selected);
}}
>
{relationData.map((option) => (
<MenuItem key={option.id || JSON.stringify(option)} value={getOptionValue(option)}>
{getOptionLabel(option)}
</MenuItem>
))}
</Select>
</FormControl>
);
}
// 3. Image Handling
if (field.type === 'image') {
return (
<ImageUploadField
label={label}
value={value}
onUpload={async (file: any) => {
const url = await uploadFile(file);
if (url) onChange(url);
}}
uploading={uploading}
baseUrl={baseUrl}
disabled={disabled}
/> />
); );
return <ObjectField {...fieldProps} renderField={renderChild} />;
} }
// 2. Image // 4. Boolean Handling
if (field.type === 'image') { if (field.type === 'boolean') {
const ImageField = components.image; return (
if (!ImageField) return null; <FormControlLabel
return <ImageField {...fieldProps} />; control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={label}
/>
);
} }
// 3. Relation // 5. Enum Handling
if (field.relation && relationDataMap[field.relation]) { if (field.type === 'enum' && field.options) {
const RelationFieldComp = components.relation; return (
if (!RelationFieldComp) return null; <FormControl fullWidth>
return <RelationFieldComp {...fieldProps} />; <InputLabel>{label}</InputLabel>
<Select
value={value || ''}
label={label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{field.options.map((opt: string) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
);
} }
// 4. Lookup by field type // 6. Common Text Fields
const Component = components[field.type] || components.default; if (field.type === 'datetime' || field.type === 'date') {
if (Component) { return (
return <Component {...fieldProps} />; <TextField
fullWidth
label={label}
type={field.type === 'datetime' ? "datetime-local" : "date"}
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
} }
return null; if (field.type === 'markdown' || field.type === 'string') {
return (
<TextField
fullWidth
label={label}
value={value || ''}
multiline={field.type === 'markdown'}
rows={field.type === 'markdown' ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'number') {
return (
<TextField
fullWidth
label={label}
type="number"
value={value === undefined || value === null ? '' : value}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}
return (
<TextField
fullWidth
label={label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
} }

View File

@@ -1,16 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function NumberField({ field, value, onChange, disabled }: FieldComponentProps) {
return (
<MuiTextField
fullWidth
label={field.label}
type="number"
value={value === undefined || value === null ? '' : value}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,28 +0,0 @@
import { Box, TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function NumberRangeField({ value, onChange, disabled }: FieldComponentProps) {
const rangeVal = (value as { min?: string; max?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="number"
placeholder="Min"
size="small"
value={rangeVal.min ?? ""}
onChange={(e) => onChange({ ...rangeVal, min: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
<MuiTextField
type="number"
placeholder="Max"
size="small"
value={rangeVal.max ?? ""}
onChange={(e) => onChange({ ...rangeVal, max: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -1,42 +0,0 @@
import * as React from 'react';
import { Box, Typography } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export interface ObjectFieldProps extends FieldComponentProps {
renderField: (props: FieldComponentProps) => React.ReactNode;
}
export default function ObjectField({ name, field, value, onChange, disabled, baseUrl, uploadFile, uploading, relationDataMap, renderField }: ObjectFieldProps) {
if (!field.schema) return null;
return (
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
{field.label}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(field.schema).map(([subKey, subField]) =>
React.cloneElement(
renderField({
name: `${name}.${subKey}`,
field: subField,
value: value?.[subKey],
onChange: (newVal: any) => {
const updated = { ...(value || {}), [subKey]: newVal };
onChange(updated);
},
disabled,
baseUrl,
uploadFile,
uploading,
relationDataMap,
}) as React.ReactElement,
{ key: subKey }
)
)}
</Box>
</Box>
);
}

View File

@@ -1,50 +0,0 @@
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { getFieldOptions } from '../../utils/options';
import { FieldComponentProps } from '../../types/overrides';
export default function RelationField({ field, value, onChange, disabled, relationDataMap = {} }: FieldComponentProps) {
if (!field.relation || !relationDataMap[field.relation]) {
return null;
}
const relationData = relationDataMap[field.relation];
const isArrayRelation = field.type === 'array';
const options = getFieldOptions(field, relationData);
const keyField = field.enumOption?.key ?? 'id';
const normalizedValue = (() => {
if (isArrayRelation && Array.isArray(value)) {
return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v)));
}
if (value != null && typeof value === 'object') {
return String(value[keyField] ?? '');
}
return value ?? (isArrayRelation ? [] : "");
})();
return (
<FormControl fullWidth>
<InputLabel shrink>{field.label}</InputLabel>
<Select
multiple={isArrayRelation}
value={normalizedValue}
label={field.label}
displayEmpty
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
renderValue={(selected: any) => {
if (isArrayRelation) {
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
}
return options.find(o => o.key === selected)?.value ?? selected;
}}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,18 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function TextField({ field, value, onChange, disabled }: FieldComponentProps) {
const isMarkdown = field.type === 'markdown';
return (
<MuiTextField
fullWidth
label={field.label}
value={value || ''}
multiline={isMarkdown}
rows={isMarkdown ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,14 +0,0 @@
export { default as FormField } from './FormField';
export { default as ImageUploadField } from './ImageUploadField';
export { default as TextField } from './TextField';
export { default as NumberField } from './NumberField';
export { default as BooleanField } from './BooleanField';
export { default as DateField } from './DateField';
export { default as EnumField } from './EnumField';
export { default as RelationField } from './RelationField';
export { default as ObjectField } from './ObjectField';
export { default as FallbackField } from './FallbackField';
export { default as DateRangeField } from './DateRangeField';
export { default as NumberRangeField } from './NumberRangeField';
export { defaultFieldComponents } from './DefaultFieldComponents';
export type { ObjectFieldProps } from './ObjectField';

View File

@@ -1,39 +1,23 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import * as React from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import { ResourceConfig } from "../types/config"; import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext"; import { ConfigContext } from "../providers/ConfigContext";
import { FieldComponents, FieldComponentProps } from "../types/overrides"; import * as React from "react";
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
import FormField from "../components/fields/FormField";
import GenericForm from "../components/GenericForm";
function wrapFormField(merged: FieldComponents) { export function useResource<T = any>(config: ResourceConfig | undefined) {
return (props: Omit<React.ComponentProps<typeof FormField>, 'components'>) =>
React.createElement(FormField, { ...props, components: merged });
}
function wrapGenericForm(merged: FieldComponents) {
return (props: Omit<React.ComponentProps<typeof GenericForm>, 'fieldComponents'>) =>
React.createElement(GenericForm, { ...props, fieldComponents: merged });
}
export function useResource<T = any>(config: ResourceConfig | undefined, options?: { fieldComponents: FieldComponents }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Return empty/disabled hooks if config is missing
const { name = '', endpoint = '', primaryKey = 'id' } = config || {}; const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
const mergedComponents = React.useMemo(
() => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined,
[options?.fieldComponents],
);
// --- READ ALL --- // --- READ ALL ---
const useList = (params?: any) => const useList = (params?: any) =>
useQuery({ useQuery({
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return { data: [], total: 0 }; if (!endpoint) return { data: [], total: 0 };
console.log('params:', params);
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return { return {
@@ -51,6 +35,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "detail", id, params], queryKey: [name, "detail", id, params],
queryFn: async () => { queryFn: async () => {
if (!id || !endpoint) return null; if (!id || !endpoint) return null;
// @ts-ignore
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined); const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined);
return res.data; return res.data;
}, },
@@ -62,6 +47,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.post<T>(endpoint, data); const res = await api.post<T>(endpoint, data);
return res.data; return res.data;
}, },
@@ -75,31 +61,18 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => { mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/${id}`, data); const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data; return res.data;
}, },
onSuccess: (updatedItem: any) => { onSuccess: (updatedItem) => {
// @ts-ignore
const id = updatedItem[primaryKey]; const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] }); queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
}, },
}); });
// --- PATCH ---
const usePatch = () =>
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.patch<T>(`${endpoint}/${id}`, data);
return res.data;
},
onSuccess: (updatedItem: any) => {
const listId = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", listId] });
},
});
// --- DELETE --- // --- DELETE ---
const useDelete = () => const useDelete = () =>
useMutation({ useMutation({
@@ -118,6 +91,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return { data: [], total: 0 }; if (!endpoint) return { data: [], total: 0 };
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return { return {
@@ -134,6 +108,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "me"], queryKey: [name, "me"],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return null; if (!endpoint) return null;
// @ts-ignore
const res = await api.get<T>(`${endpoint}/me`); const res = await api.get<T>(`${endpoint}/me`);
return res.data; return res.data;
}, },
@@ -145,6 +120,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/me`, data); const res = await api.put<T>(`${endpoint}/me`, data);
return res.data; return res.data;
}, },
@@ -154,31 +130,21 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
}, },
}); });
const components = React.useMemo(() => {
if (!mergedComponents) return undefined;
return {
...mergedComponents,
FormField: wrapFormField(mergedComponents),
GenericForm: wrapGenericForm(mergedComponents),
};
}, [mergedComponents]);
return { return {
useList, useList,
useRead, useRead,
useMe, useMe,
useCreate, useCreate,
useUpdate, useUpdate,
usePatch,
useUpdateMe, useUpdateMe,
useDelete, useDelete,
getListQueryOptions, getListQueryOptions,
components,
}; };
} }
export function useResourceByName<T = any>(name: string, options?: { fieldComponents: FieldComponents }) { export function useResourceByName<T = any>(name: string) {
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const resourceConfig = config?.resources.find((r) => r.name === name); const resourceConfig = config?.resources.find((r) => r.name === name);
return useResource<T>(resourceConfig, options); return useResource<T>(resourceConfig);
} }

View File

@@ -2,12 +2,7 @@ export { default as Admin } from "./Admin";
export { api, auth, initializeApiClients } from "./api/client"; export { api, auth, initializeApiClients } from "./api/client";
export { getAppConfig } from "./config"; export { getAppConfig } from "./config";
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config"; export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config";
export type { FieldComponents, FieldComponentProps, FieldComponent, FieldOverride, ResourceOverride, EnhancedTableComponents, FilterBarComponents, CellRendererProps, CellRenderer } from "./types/overrides";
export { AppProvider } from "./providers/AppProvider"; export { AppProvider } from "./providers/AppProvider";
export { ConfigContext, useConfig } from "./providers/ConfigContext"; export { ConfigContext, useConfig } from "./providers/ConfigContext";
export { useResource, useResourceByName } from "./hooks/useResource"; export { useResource, useResourceByName } from "./hooks/useResource";
export { default as FilterBar, FilterAutocomplete } from "./components/FilterBar"; export { default as FilterBar } from "./components/FilterBar";
export { default as EnhancedTable } from "./components/EnhancedTable";
export { default as GenericForm } from "./components/GenericForm";
export { default as ResourceView } from "./components/ResourceView";
export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields";

View File

@@ -10,29 +10,17 @@ export type FieldType =
| 'object' | 'object'
| 'array'; | 'array';
export interface SelectOption {
key: string;
value: string;
}
export interface EnumOption {
key: string;
value: string;
}
export interface ResourceField { export interface ResourceField {
displayFormat: string;
type: FieldType; type: FieldType;
label: string; label: string;
required?: boolean; required?: boolean;
options?: string[]; options?: string[];
readOnly?: boolean; readOnly?: boolean;
schema?: Record<string, ResourceField>; schema?: Record<string, ResourceField>;
displayField?: string | string[];
formatter?: (value: any) => string; formatter?: (value: any) => string;
relation?: string; relation?: string; // Name of the target resource
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
} }
export type ResourceMode = "server" | "client"; export type ResourceMode = "server" | "client";
@@ -50,14 +38,12 @@ export interface ResourceConfig {
mode?: ResourceMode; mode?: ResourceMode;
fields?: string[]; fields?: string[];
}; };
enumOption?: EnumOption;
} }
export interface AppConfig { export interface AppConfig {
baseUrl: string; baseUrl: string;
authBaseUrl: string; authBaseUrl: string;
resources: ResourceConfig[]; resources: ResourceConfig[];
enums: Record<string, string[]>;
profile?: { profile?: {
resource: string; resource: string;
extraFields?: Record<string, any>; extraFields?: Record<string, any>;

View File

@@ -1,22 +1,13 @@
import { ResourceField, FieldType } from './config'; /**
* This file contains application-specific overrides and configuration
export interface EnumOption { * for the generic Admin Panel.
key: string; */
value: string;
}
export interface FieldOverride { export interface FieldOverride {
displayFormat?: string; displayField?: string | string[];
display?: boolean; display?: boolean;
formatter?: (value: any) => string; formatter?: (value: any) => string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumLabels?: Record<string, string>;
// New optional properties to support custom config extensions
path?: string;
refers?: string;
// Added support for overriding the base field type and label
type?: FieldType;
label?: string;
} }
export interface ResourceOverride { export interface ResourceOverride {
@@ -27,63 +18,4 @@ export interface ResourceOverride {
mode?: "server" | "client"; mode?: "server" | "client";
fields?: string[]; fields?: string[];
}; };
enumOption?: EnumOption;
// New optional property for referencetype resources
referenceOptions?: {
enumOption?: EnumOption;
autoComplete?: boolean;
prefetch?: boolean;
};
} }
export interface FieldComponentProps {
name: string;
field: ResourceField;
value: any;
onChange: (val: any) => void;
disabled?: boolean;
error?: string;
baseUrl?: string;
relationDataMap?: Record<string, any[]>;
uploadFile?: (file: File) => Promise<string | null>;
uploading?: boolean;
}
export type FieldComponent = React.ComponentType<FieldComponentProps>;
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
relation?: FieldComponent;
image?: FieldComponent;
default?: FieldComponent;
dateRange?: FieldComponent;
numberRange?: FieldComponent;
FormField?: React.ComponentType<any>;
GenericForm?: React.ComponentType<any>;
};
export interface CellRendererProps {
value: any;
row: any;
field: ResourceField;
fieldKey: string;
config: import('./config').ResourceConfig;
onNavigate?: (resourceName: string, id: string) => void;
isMobile?: boolean;
}
export type CellRenderer = React.ComponentType<CellRendererProps>;
export interface EnhancedTableComponents {
cellRenderers?: Partial<Record<FieldType, CellRenderer>>;
}
export interface FilterBarComponents {
filterInputs?: Record<string, React.ComponentType<{
field: ResourceField;
value: any;
onChange: (val: any) => void;
options: string[];
}>>;
}
export type { FieldType };

View File

@@ -36,26 +36,6 @@ function mapOpenApiType(prop: any): FieldType {
/** /**
* Recursively converts OpenAPI schemas to ResourceField map * Recursively converts OpenAPI schemas to ResourceField map
*/ */
function mergeProperties(schema: any): { properties: Record<string, any>; required: string[] } {
let properties: Record<string, any> = {};
let required: string[] = [];
if (schema.allOf) {
for (const sub of schema.allOf) {
const merged = mergeProperties(sub);
properties = { ...properties, ...merged.properties };
required = [...required, ...merged.required];
}
}
if (schema.properties) {
properties = { ...properties, ...schema.properties };
}
if (schema.required) {
required = [...required, ...schema.required];
}
return { properties, required };
}
function parseSchemaFields( function parseSchemaFields(
schema: any, schema: any,
resourceName: string, resourceName: string,
@@ -63,26 +43,12 @@ function parseSchemaFields(
configuration: Record<string, any> = {} configuration: Record<string, any> = {}
): Record<string, ResourceField> { ): Record<string, ResourceField> {
const fields: Record<string, ResourceField> = {}; const fields: Record<string, ResourceField> = {};
const { properties, required } = mergeProperties(schema); const properties = schema.properties || {};
const required = schema.required || [];
const overrides = configuration[resourceName]?.fields || {}; const overrides = configuration[resourceName]?.fields || {};
console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
for (const [key, prop] of Object.entries(properties) as [string, any]) { for (const [key, prop] of Object.entries(properties) as [string, any]) {
// Resolve oneOf/anyOf by merging all branch properties const type = mapOpenApiType(prop);
let resolvedProp = prop;
if (prop.oneOf || prop.anyOf) {
const branches = prop.oneOf || prop.anyOf;
const merged = mergeProperties({ allOf: branches });
resolvedProp = { ...prop, type: 'object', properties: merged.properties, required: merged.required };
}
const type = mapOpenApiType(resolvedProp);
if (type === 'enum' && (!resolvedProp.enum || resolvedProp.enum.length === 0)) {
throw new Error(
`OpenAPI schema error: field "${resourceName}.${key}" is type "enum" but has no enum values. ` +
`Add an "enum" array with at least one value to the OpenAPI schema definition.`
);
}
const override = overrides[key]; const override = overrides[key];
// Explicitly skip 'id' as it's the primary key and handled elsewhere // Explicitly skip 'id' as it's the primary key and handled elsewhere
@@ -91,12 +57,12 @@ function parseSchemaFields(
fields[key] = { fields[key] = {
type, type,
label: label:
resolvedProp.title || prop.title ||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "), key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
required: required.includes(key), required: required.includes(key),
options: resolvedProp.enum, options: prop.enum,
readOnly: readOnly:
resolvedProp.readOnly || prop.readOnly ||
key === "created_at" || key === "created_at" ||
key === "updated_at", key === "updated_at",
...override, ...override,
@@ -105,36 +71,20 @@ function parseSchemaFields(
// STRICT RELATION DETECTION // STRICT RELATION DETECTION
// A field is a relation ONLY if its schema object (or items schema) // A field is a relation ONLY if its schema object (or items schema)
// exactly matches a schema that is defined as a resource. // exactly matches a schema that is defined as a resource.
let targetSchema = resolvedProp; let targetSchema = prop;
if (type === "array" && resolvedProp.items) { if (type === "array" && prop.items) {
targetSchema = resolvedProp.items; targetSchema = prop.items;
} }
// Check if this schema object is registered as a resource // Check if this schema object is registered as a resource
const relation = schemaToResourceMap.get(targetSchema); const relation = schemaToResourceMap.get(targetSchema);
if (relation) { if (relation) {
fields[key].relation = relation; fields[key].relation = relation;
// Propagate enumOption from target resource config, or derive from target schema
const explicitEnumOption = configuration[relation].referenceOptions.enumOption;
console.log('if relation configuration...', configuration['accounts']['referenceOptions'])
if (explicitEnumOption) {
fields[key].enumOption = explicitEnumOption;
} else {
// No explicit enumOption supplied this is a configuration error.
// We abort loading so the problem is visible immediately.
throw new Error(
`Missing enumOption for relation "${relation}" on field "${key}". ` +
`Define referenceOptions.enumOption in the configuration for resource "${relation}".`
);
}
} }
// Recursively parse nested objects (only if not a relation) // Recursively parse nested objects (only if not a relation)
if (fields[key].type === "object" && resolvedProp.properties && !relation) { if (fields[key].type === "object" && prop.properties && !relation) {
console.log('recursive configuration...', configuration['accounts']['referenceOptions']) fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap, configuration);
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
} }
} }
@@ -145,7 +95,6 @@ function parseSchemaFields(
* Scans paths to identify resources and their basic configuration * Scans paths to identify resources and their basic configuration
*/ */
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> { export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
console.log('init configuration...', configuration['accounts']['referenceOptions'])
// Use SwaggerParser to dereference the spec. // Use SwaggerParser to dereference the spec.
// Dereferencing preserves object identity for $ref targets. // Dereferencing preserves object identity for $ref targets.
const api = await SwaggerParser.dereference( const api = await SwaggerParser.dereference(
@@ -201,7 +150,6 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
const label = name.charAt(0).toUpperCase() + name.slice(1, -1); const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1); const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
console.log('before parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration); const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
const resourceOverride = configuration[name] || {}; const resourceOverride = configuration[name] || {};
@@ -224,17 +172,6 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
}); });
} }
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
const enums: Record<string, string[]> = {};
const apiDoc = api as any;
if (apiDoc.components?.schemas) {
for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) {
if (schema.enum) {
enums[name] = schema.enum;
}
}
}
// @ts-ignore // @ts-ignore
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "") const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
// @ts-ignore // @ts-ignore
@@ -243,7 +180,6 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
baseUrl: serverBaseUrl, baseUrl: serverBaseUrl,
authBaseUrl: authBaseUrl, authBaseUrl: authBaseUrl,
resources, resources,
enums,
profile: profileConfiguration, profile: profileConfiguration,
}; };
} }

View File

@@ -1,39 +0,0 @@
import { ResourceField, SelectOption } from "../types/config";
export function resolveTemplate(template: string, item: any): string {
if (/\{(\w+)\}/.test(template)) {
return template.replace(/\{(\w+)\}/g, (_, field: string) => String(item[field] ?? ''));
}
return String(item[template] ?? '');
}
export function getFieldOptions(field: ResourceField, relationData?: any[]): SelectOption[] {
if (field.type === 'enum') {
return (field.options ?? []).map(opt => ({
key: opt,
value: field.enumLabels?.[opt] ?? opt,
}));
}
if (field.relation) {
const data = Array.isArray(relationData) ? relationData : [];
const enumOption = field.enumOption;
if (!enumOption) {
throw new Error(
`Missing enumOption for relation "${field.relation}" on field "${field}". ` +
`Define referenceOptions.enumOption in the configuration for resource "${field.relation}".`
);
}
return data.map(item => ({
key: String(item[enumOption.key]),
value: resolveTemplate(enumOption.value, item),
}));
}
return [];
}
export function toGridValueOptions(options: SelectOption[]): { value: string; label: string }[] {
return options.map(opt => ({ value: opt.key, label: opt.value }));
}

View File

@@ -1,674 +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 { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
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 = useConfig();
const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
const updateMutation = usePatch();
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?.baseUrl) return;
const url = `${config.baseUrl}/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?.baseUrl]);
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

@@ -4,9 +4,16 @@ import {
Container, Container,
Paper, Paper,
Typography, Typography,
TextField,
Button, Button,
ToggleButtonGroup, ToggleButtonGroup,
ToggleButton, ToggleButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip, Chip,
IconButton, IconButton,
CircularProgress, CircularProgress,
@@ -17,26 +24,15 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogActions, DialogActions,
Tooltip,
TextField,
Select,
MenuItem,
InputLabel,
FormControl,
OutlinedInput,
Autocomplete,
} from "@mui/material"; } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 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 { import {
useFetchRequestsList,
useCreateFetchRequest,
useDeleteFetchRequest,
useUploadFile, useUploadFile,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import type { import type {
@@ -45,50 +41,22 @@ import type {
FileSource, FileSource,
EmailSource, EmailSource,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } from "../react-openapi";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = { const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default", pending: "default",
processing: "info", processing: "info",
paused: "warning",
raw_expenses_done: "primary", raw_expenses_done: "primary",
enriched_done: "warning", enriched_done: "warning",
completed: "success", completed: "success",
failed: "error", 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" }} />,
};
function formatDate(iso: string) { function formatDate(iso: string) {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleString(); 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() { export default function FetchRequests() {
const navigate = useNavigate();
const [sourceType, setSourceType] = React.useState<"file" | "email">("file"); const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
const [accountName, setAccountName] = React.useState(""); const [accountName, setAccountName] = React.useState("");
const [payorUsername, setPayorUsername] = React.useState("aetos"); const [payorUsername, setPayorUsername] = React.useState("aetos");
@@ -103,34 +71,9 @@ export default function FetchRequests() {
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null); const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
const [statusFilter, setStatusFilter] = React.useState<string[]>([]); const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList();
const [accountFilter, setAccountFilter] = React.useState(""); const createMutation = useCreateFetchRequest();
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); const deleteMutation = useDeleteFetchRequest();
const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
const { data: listData, isLoading, isFetching, refetch } = useList({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
});
const { useList: useAccountsList } = useResourceByName("accounts");
const { data: accountsData } = useAccountsList();
const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]);
const config = useConfig();
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
const formatOptions: string[] = formatField?.options ?? [];
const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date;
const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
const createMutation = useCreate();
const updateMutation = usePatch();
const deleteMutation = useDelete();
const uploadMutation = useUploadFile(); const uploadMutation = useUploadFile();
const requests = listData?.data ?? []; const requests = listData?.data ?? [];
@@ -163,7 +106,7 @@ export default function FetchRequests() {
} }
try { try {
const result = await createMutation.mutateAsync({ await createMutation.mutateAsync({
source, source,
account_name: accountName, account_name: accountName,
payor_username: payorUsername, payor_username: payorUsername,
@@ -172,13 +115,8 @@ export default function FetchRequests() {
}); });
setSnackbar({ message: "Fetch request created", severity: "success" }); setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm(); resetForm();
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) { } catch (err: any) {
if (err?.response?.status === 409) { setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" });
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
}
} }
}; };
@@ -194,15 +132,6 @@ export default function FetchRequests() {
setEndDate(""); 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 () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
@@ -214,8 +143,6 @@ export default function FetchRequests() {
setDeleteTarget(null); setDeleteTarget(null);
}; };
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom> <Typography variant="h5" fontWeight="bold" gutterBottom>
@@ -262,112 +189,39 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath} Uploaded as: {uploadedPath}
</Alert> </Alert>
)} )}
{formatField && components?.FormField ? ( <TextField label="Format (csv, pdf, ...)" value={format} onChange={(e) => setFormat(e.target.value)} size="small" />
<components.FormField
name="format"
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 && components?.FormField ? ( <TextField label="Format" value={format} onChange={(e) => setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" />
<components.FormField
name="format"
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="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="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" /> <TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
</> </>
)} )}
<Autocomplete <TextField label="Account Name" value={accountName} onChange={(e) => setAccountName(e.target.value)} size="small" required />
options={accountOptions} <TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
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 && components?.FormField ? (
<components.FormField
name="payor_username"
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 }}> <Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.date ? ( <TextField
<Box sx={{ flex: 1 }}> label="Start Date"
<components.date type="datetime-local"
name="start_date" value={startDate}
field={startDateField} onChange={(e) => setStartDate(e.target.value)}
value={startDate} size="small"
onChange={setStartDate} InputLabelProps={{ shrink: true }}
/> sx={{ flex: 1 }}
</Box> />
) : ( <TextField
<TextField label="End Date"
label="Start Date" type="datetime-local"
type="date" value={endDate}
value={startDate} onChange={(e) => setEndDate(e.target.value)}
onChange={(e) => setStartDate(e.target.value)} size="small"
size="small" InputLabelProps={{ shrink: true }}
InputLabelProps={{ shrink: true }} sx={{ flex: 1 }}
inputProps={{ max: new Date().toISOString().split("T")[0] }} />
sx={{ flex: 1 }}
/>
)}
{endDateField && components?.date ? (
<Box sx={{ flex: 1 }}>
<components.date
name="end_date"
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> </Box>
<Button <Button
@@ -380,98 +234,46 @@ export default function FetchRequests() {
</Box> </Box>
</Paper> </Paper>
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined"> <Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<FormControl size="small" sx={{ minWidth: 200 }}> <Typography variant="subtitle1" fontWeight={600}>
<InputLabel>Status</InputLabel> Fetch Requests
<Select </Typography>
multiple
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as string[])}
input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")}
>
{(config?.enums?.FetchRequestStatus ?? []).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}> <IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon /> <RefreshIcon />
</IconButton> </IconButton>
</Box> </Box>
</Paper>
{isLoading ? ( {isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress /> <CircularProgress />
</Box> </Box>
) : requests.length === 0 ? ( ) : requests.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}> <Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No fetch requests yet No fetch requests yet
</Box> </Box>
) : ( ) : (
<Paper variant="outlined" sx={{ borderRadius: 4 }}> <TableContainer>
<Box sx={{ overflowX: "auto" }}> <Table size="small">
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}> <TableHead>
<Box component="thead"> <TableRow>
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}> <TableCell>Fingerprint</TableCell>
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => ( <TableCell>Source</TableCell>
<Box <TableCell>Account</TableCell>
key={h} <TableCell>Status</TableCell>
component="th" <TableCell>Created</TableCell>
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }} <TableCell align="right">Actions</TableCell>
> </TableRow>
{h} </TableHead>
</Box> <TableBody>
))} {requests.map((req: FetchRequest) => (
</Box> <TableRow key={req.id}>
</Box> <TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
<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 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{shortId(req.fingerprint)} {req.fingerprint}
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { onClick={() => {
e.stopPropagation();
navigator.clipboard.writeText(req.fingerprint); navigator.clipboard.writeText(req.fingerprint);
setSnackbar({ message: "Copied!", severity: "success" }); setSnackbar({ message: "Copied!", severity: "success" });
}} }}
@@ -480,97 +282,31 @@ export default function FetchRequests() {
<ContentCopyIcon sx={{ fontSize: 14 }} /> <ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
</Box> </Box>
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}> <TableCell>
{req.account_name} {"path" in req.source ? "File" : "Email"}
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}> <TableCell>{req.account_name}</TableCell>
<TableCell>
<Chip <Chip
label={"path" in req.source ? "File" : "Email"} label={req.status.replace(/_/g, " ")}
color={statusColors[req.status]}
size="small" size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/> />
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}> <TableCell>{formatDate(req.created_at)}</TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}> <TableCell align="right">
{formatDateRange((req as any).start_date, (req as any).end_date)} <IconButton size="small" onClick={() => setDeleteTarget(req)}>
</Typography> <DeleteIcon fontSize="small" />
</Box> </IconButton>
<Box component="td" sx={{ px: 2, py: 1.5 }}> </TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> </TableRow>
<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> </TableBody>
</Box> </Table>
</Box> </TableContainer>
</Paper> )}
)} </Paper>
<Snackbar <Snackbar
open={!!snackbar} open={!!snackbar}

View File

@@ -4,7 +4,14 @@ import {
Container, Container,
Paper, Paper,
Typography, Typography,
TextField,
Button, Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton, IconButton,
CircularProgress, CircularProgress,
Alert, Alert,
@@ -14,29 +21,20 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogActions, DialogActions,
Switch,
FormControlLabel,
Chip, Chip,
TextField,
} from "@mui/material"; } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle"; import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; import {
import type { ResourceField } from "../react-openapi"; useReportSnapshotsList,
useCreateSnapshot,
interface ReportSnapshotQuery { useDeleteSnapshot,
accounts?: string[]; } from "./features/report-snapshots";
ignore_self?: boolean; import type { ReportSnapshot } from "./features/report-snapshots";
start_date?: string;
end_date?: string;
}
interface ReportSnapshot {
id: string;
snapshot_id: string;
created_at: string;
query?: ReportSnapshotQuery;
}
function formatDate(iso: string) { function formatDate(iso: string) {
const d = new Date(iso); const d = new Date(iso);
@@ -53,32 +51,21 @@ export default function ReportSnapshots() {
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null); const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents }); const { data: listData, isLoading, isFetching, refetch } = useReportSnapshotsList();
const createMutation = useCreateSnapshot();
const deleteMutation = useDeleteSnapshot();
const { data: listData, isLoading, isFetching, refetch } = useList(); const snapshots = listData?.data ?? [];
const createMutation = useCreate();
const deleteMutation = useDelete();
const config = useConfig();
const reportsRes = config?.resources.find((r: any) => r.name === "reports");
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self;
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date;
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date;
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
const snapshots: ReportSnapshot[] = listData?.data ?? [];
const handleCreate = async () => { const handleCreate = async () => {
try { try {
const payload: Record<string, any> = {}; const result = await createMutation.mutateAsync({
if (ignoreSelf) payload.ignore_self = true; ignore_self: ignoreSelf || null,
if (startDate) payload.start_date = new Date(startDate).toISOString(); start_date: startDate ? new Date(startDate).toISOString() : null,
if (endDate) payload.end_date = new Date(endDate).toISOString(); end_date: endDate ? new Date(endDate).toISOString() : null,
if (minAmount) payload.min_amount = parseFloat(minAmount); min_amount: minAmount ? parseFloat(minAmount) : null,
if (maxAmount) payload.max_amount = parseFloat(maxAmount); max_amount: maxAmount ? parseFloat(maxAmount) : null,
});
const result = await createMutation.mutateAsync(payload);
const snapshotId = (result as any)?.snapshot_id; const snapshotId = (result as any)?.snapshot_id;
if (snapshotId) { if (snapshotId) {
setCreatedSnapshotId(snapshotId); setCreatedSnapshotId(snapshotId);
@@ -93,7 +80,7 @@ export default function ReportSnapshots() {
}; };
const resetForm = () => { const resetForm = () => {
setIgnoreSelf(true); setIgnoreSelf(false);
setStartDate(""); setStartDate("");
setEndDate(""); setEndDate("");
setMinAmount(""); setMinAmount("");
@@ -123,61 +110,49 @@ export default function ReportSnapshots() {
</Typography> </Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ignoreSelfField && components?.FormField && ( <FormControlLabel
<components.FormField control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
name="ignore_self" label="Ignore self-transfers"
field={ignoreSelfField} />
value={ignoreSelf}
onChange={(val: boolean) => setIgnoreSelf(val)}
/>
)}
<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={{ display: "flex", gap: 2 }}>
{minAmountField && components?.FormField && ( <TextField
<Box sx={{ flex: 1 }}> label="Start Date"
<components.FormField type="datetime-local"
name="min_amount" value={startDate}
field={minAmountField} onChange={(e) => setStartDate(e.target.value)}
value={minAmount} size="small"
onChange={(val: string) => setMinAmount(val)} InputLabelProps={{ shrink: true }}
/> sx={{ flex: 1 }}
</Box> />
)} <TextField
{maxAmountField && components?.FormField && ( label="End Date"
<Box sx={{ flex: 1 }}> type="datetime-local"
<components.FormField value={endDate}
name="max_amount" onChange={(e) => setEndDate(e.target.value)}
field={maxAmountField} size="small"
value={maxAmount} InputLabelProps={{ shrink: true }}
onChange={(val: string) => setMaxAmount(val)} sx={{ flex: 1 }}
/> />
</Box> </Box>
)}
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
label="Min Amount"
type="number"
value={minAmount}
onChange={(e) => setMinAmount(e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Max Amount"
type="number"
value={maxAmount}
onChange={(e) => setMaxAmount(e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
</Box> </Box>
<Button <Button
@@ -216,29 +191,20 @@ export default function ReportSnapshots() {
No snapshots yet No snapshots yet
</Box> </Box>
) : ( ) : (
<Box sx={{ overflowX: "auto" }}> <TableContainer>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}> <Table size="small">
<Box component="thead"> <TableHead>
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}> <TableRow>
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => ( <TableCell>Snapshot ID</TableCell>
<Box <TableCell>Created</TableCell>
key={h} <TableCell>Query</TableCell>
component="th" <TableCell align="right">Actions</TableCell>
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }} </TableRow>
> </TableHead>
{h} <TableBody>
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{snapshots.map((snap: ReportSnapshot) => ( {snapshots.map((snap: ReportSnapshot) => (
<Box <TableRow key={snap.id}>
key={snap.id} <TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
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 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{snap.snapshot_id} {snap.snapshot_id}
<IconButton <IconButton
@@ -252,11 +218,9 @@ export default function ReportSnapshots() {
<ContentCopyIcon sx={{ fontSize: 14 }} /> <ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
</Box> </Box>
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}> <TableCell>{formatDate(snap.created_at)}</TableCell>
{formatDate(snap.created_at)} <TableCell>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{snap.query ? ( {snap.query ? (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}> <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.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
@@ -265,21 +229,19 @@ export default function ReportSnapshots() {
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />} {snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
</Box> </Box>
) : ( ) : (
<Typography variant="body2" color="text.secondary">\u2014</Typography> <Typography variant="body2" color="text.secondary"></Typography>
)} )}
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}> <TableCell align="right">
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}> <IconButton size="small" onClick={() => setDeleteTarget(snap)}>
<IconButton size="small" onClick={() => setDeleteTarget(snap)}> <DeleteIcon fontSize="small" />
<DeleteIcon fontSize="small" /> </IconButton>
</IconButton> </TableCell>
</Box> </TableRow>
</Box>
</Box>
))} ))}
</Box> </TableBody>
</Box> </Table>
</Box> </TableContainer>
)} )}
</Paper> </Paper>

View File

@@ -1,20 +1,8 @@
export type FetchRequestStatus = export type FetchRequestStatus = "pending" | "processing" | "raw_expenses_done" | "enriched_done" | "completed" | "failed";
| "pending"
| "processing"
| "paused"
| "raw_expenses_done"
| "enriched_done"
| "completed"
| "failed";
export interface FileSource { export interface FileSource {
path: string; path: string;
format: string; format: string;
raw_lines?: string[];
txn_blocks?: Record<string, any>;
txn_dicts?: Record<string, any>[];
txn_dict_count?: number;
txn_dicts_count?: number;
} }
export interface EmailSource { export interface EmailSource {
@@ -22,8 +10,6 @@ export interface EmailSource {
from_email?: string; from_email?: string;
subject?: string; subject?: string;
raw_terms?: string[]; raw_terms?: string[];
txn_dict_count?: number;
txn_dicts_count?: number;
} }
export interface FetchRequestCreate { export interface FetchRequestCreate {
@@ -34,18 +20,12 @@ export interface FetchRequestCreate {
end_date?: string; end_date?: string;
} }
export interface FetchRequestUpdate {
status?: FetchRequestStatus;
error_message?: string | null;
}
export interface FetchRequest extends FetchRequestCreate { export interface FetchRequest extends FetchRequestCreate {
id: string; id: string;
status: FetchRequestStatus; status: FetchRequestStatus;
fingerprint: string; fingerprint: string;
completed_at?: string | null; completed_at?: string | null;
error_message?: string | null; error_message?: string | null;
retry_count?: number;
created_at: string; created_at: string;
} }
@@ -56,78 +36,3 @@ export interface UploadResult {
url: string; url: string;
absolute_path: string; absolute_path: string;
} }
export interface AmbiguityCandidate {
amount: number;
balance: number;
}
export interface PendingAmbiguity {
id: string;
fetch_request: string;
step_index?: number;
line: string;
ocr_amount: number;
ocr_balance: number;
prev_balance: number;
candidates: AmbiguityCandidate[];
chosen?: AmbiguityCandidate | null;
resolved_at?: string | null;
status: "pending" | "resolved";
created_at: string;
}
export interface ResolveAmbiguityPayload {
chosen: {
amount: number;
balance: number;
};
}
export type SSEEventStep =
| "load_content" | "raw_lines" | "txn_blocks" | "txn_dicts"
| "resume_extract" | "extract" | "paused" | "complete" | "enrich"
| "save_expenses" | "pipeline";
export type SSEEventStatus =
| "started" | "completed" | "skipped" | "paused" | "progress" | "failed";
export interface ProgressMessage {
lines?: number;
blocks?: number;
count?: number;
unit?: string;
raw_ocr_line?: string;
error?: string;
}
export interface SSEEvent {
step: SSEEventStep;
status: SSEEventStatus;
message: ProgressMessage;
}
export interface FetchRequestFilters {
status?: FetchRequestStatus[];
account_name?: string;
source_type?: "file" | "email";
}
export function formatApiError(err: any): string {
if (!err?.response) return err?.message || "Request failed";
const data = err.response.data;
const status = err.response.status;
if (status === 422 && Array.isArray(data?.detail)) {
return data.detail.map((d: any) => {
const field = d.loc?.filter((s: string) => s !== "body").pop() || "field";
if (d.type === "value_error.missing") return `Missing: ${field}`;
return `${field}: ${d.msg}`;
}).join("; ");
}
if (typeof data?.detail === "string") return data.detail;
return `Request failed (${status})`;
}
export const RETRY_MAX = 3;

View File

@@ -1,23 +1,15 @@
export type { export type {
FetchRequest, FetchRequest,
FetchRequestCreate, FetchRequestCreate,
FetchRequestUpdate,
FetchRequestStatus, FetchRequestStatus,
FetchRequestFilters,
FileSource, FileSource,
EmailSource, EmailSource,
UploadResult, UploadResult,
PendingAmbiguity,
AmbiguityCandidate,
ResolveAmbiguityPayload,
SSEEvent,
SSEEventStep,
SSEEventStatus,
ProgressMessage,
} from "./fetch-requests.models"; } from "./fetch-requests.models";
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
export { export {
useFetchRequestsList,
useFetchRequest,
useCreateFetchRequest,
useDeleteFetchRequest,
useUploadFile, useUploadFile,
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./useFetchRequests"; } from "./useFetchRequests";

View File

@@ -1,7 +1,6 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
import { api } from "../../../react-openapi/api/client"; import { api } from "../../../react-openapi/api/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
export function useFetchRequestsList(params?: { export function useFetchRequestsList(params?: {
status?: string; status?: string;
@@ -22,11 +21,6 @@ export function useCreateFetchRequest() {
return useCreate(); return useCreate();
} }
export function useUpdateFetchRequest() {
const { usePatch } = useResourceByName("fetch-requests");
return usePatch();
}
export function useDeleteFetchRequest() { export function useDeleteFetchRequest() {
const { useDelete } = useResourceByName("fetch-requests"); const { useDelete } = useResourceByName("fetch-requests");
return useDelete(); return useDelete();
@@ -47,44 +41,3 @@ export function useUploadFile() {
}, },
}); });
} }
export function useFetchRequestAmbiguities(fetchRequestId: string) {
return useQuery({
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => {
const res = await api.get(
`/fetch-requests/${fetchRequestId}/ambiguities`
);
return res.data;
},
enabled: !!fetchRequestId,
});
}
export function useResolveAmbiguity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
ambiguityId,
payload,
}: {
ambiguityId: string;
payload: ResolveAmbiguityPayload;
}) => {
const res = await api.post(
`/ambiguities/${ambiguityId}/resolve`,
payload
);
return res.data;
},
onSuccess: (data: any) => {
queryClient.invalidateQueries({
queryKey: ["fetch-requests", data.fetch_request, "ambiguities"],
});
queryClient.invalidateQueries({
queryKey: ["fetch-requests", "detail", data.fetch_request],
});
},
});
}

View File

@@ -2,3 +2,8 @@ export type {
ReportSnapshot, ReportSnapshot,
ReportQuery, ReportQuery,
} from "./report-snapshots.models"; } from "./report-snapshots.models";
export {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./useReportSnapshots";

View File

@@ -13,9 +13,8 @@ import {
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests'; import FetchRequests from './FetchRequests';
import FetchRequestDetail from './FetchRequestDetail';
import ReportSnapshots from './ReportSnapshots'; import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi'; import { Admin, AppProvider } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config'; import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import process from 'process'; import process from 'process';
@@ -37,7 +36,6 @@ const routerMapping = [
{ path: "/home", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" }, { path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
{ 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: "/reports", component: ReportSnapshots, headerTitle: "Reports" }, { path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
]; ];
@@ -60,7 +58,7 @@ root.render(
path={path} path={path}
element={ element={
path.startsWith("/admin") ? ( path.startsWith("/admin") ? (
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} /> <Component basePath="/admin" />
) : ( ) : (
<Component /> <Component />
) )

View File

@@ -1,4 +1,4 @@
import { ResourceOverride } from "../react-openapi"; import { ResourceOverride } from "../react-openapi/types/overrides";
export const configuration: Record<string, ResourceOverride> = { export const configuration: Record<string, ResourceOverride> = {
expenses: { expenses: {
@@ -8,22 +8,20 @@ export const configuration: Record<string, ResourceOverride> = {
}, },
fields: { fields: {
payee: { payee: {
displayFormat: "{name}", displayField: "name",
filterType: "autocomplete", filterType: "autocomplete",
}, },
payor: { payor: {
display: false, display: false,
displayFormat: "{username}", displayField: "username",
}, },
account: { account: {
displayFormat: "{name}", displayField: "name",
filterType: "multiselect", filterType: "multiselect",
refers: "accounts"
}, },
tags: { tags: {
displayFormat: "{icon} {name}", displayField: ["name", "icon"],
filterType: "autocomplete", filterType: "autocomplete",
refers: "tags"
}, },
occurred_at: { occurred_at: {
filterType: "date-range", filterType: "date-range",
@@ -52,47 +50,6 @@ export const configuration: Record<string, ResourceOverride> = {
} }
}, },
}, },
'fetch-requests': {
fields: {
format: {
path: 'source.format',
},
start_date: {
type: 'date',
label: 'Start Date',
},
end_date: {
type: 'date',
label: 'End Date',
},
// account: {
// refers: 'accounts',
// },
// tags: {
// refers: 'tags',
// },
},
},
accounts: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{name} - XX{number}',
},
autoComplete: true,
prefetch: true,
}
},
tags: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{icon} {name}',
},
autoComplete: true,
prefetch: true,
}
},
}; };
export const profileConfiguration = { export const profileConfiguration = {