Compare commits
24 Commits
0.2.1
...
openapi-si
| Author | SHA1 | Date | |
|---|---|---|---|
| f5bc7adc37 | |||
| 9a80a52fd5 | |||
| ac7c3d6313 | |||
| 5892bca44d | |||
| 8c824d5239 | |||
| f345dafb46 | |||
| b0c5332d77 | |||
| 12e5f113b8 | |||
| cbac57dc36 | |||
| 154b15fe51 | |||
| 0a668cf98d | |||
| cd89eb4c88 | |||
| 0907e071b5 | |||
| d58f2a0744 | |||
| 8300e43e14 | |||
| 386297dc1e | |||
| a227c14e0a | |||
| 58df11c623 | |||
| 9771816cf9 | |||
| 7bd946ec7a | |||
| e6ce62a166 | |||
| 2dbe9a5270 | |||
| 5cf2a4c3c4 | |||
| d4a79c785d |
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
||||
# 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
|
||||
140
.drone.yml
Normal file
140
.drone.yml
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
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
|
||||
24
CONCEPT.md
Normal file
24
CONCEPT.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Concept Overview
|
||||
|
||||
The application is a **metadata‑driven 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, number‑range, date‑range) 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** – `react‑auth` 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 hand‑coded tables or forms.
|
||||
- **Template‑based 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 `react‑auth` manages JWTs and user info. Both are provided via React context for easy access.
|
||||
- **Responsive design**: The UI automatically switches to a card‑based 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, dark‑mode 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 re‑using the existing `ResourceConfig` schema and `displayFormat` logic, the Lovable implementation can focus on UI polish and advanced handling while keeping the powerful code‑generation approach intact.
|
||||
34
DESIGN.md
Normal file
34
DESIGN.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Design Overview
|
||||
|
||||
## React‑Auth
|
||||
- **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**: Plug‑in point for additional providers (OAuth, SSO) via a `providers` map.
|
||||
|
||||
## React‑OpenAPI
|
||||
- **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` – Data‑grid component that renders rows according to the config, supports relation rendering, sorting, pagination, and custom cell renderers.
|
||||
- `FilterBar` – Dynamically builds filter controls (autocomplete, number‑range, date‑range) 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 hand‑written 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 per‑resource (`/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.
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# 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"]
|
||||
49
IMPLEMENTATION.md
Normal file
49
IMPLEMENTATION.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Implementation Details
|
||||
|
||||
## React‑Auth
|
||||
- **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` – Full‑screen spinner displayed while session restoration runs on app boot.
|
||||
|
||||
## React‑OpenAPI
|
||||
- **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.
|
||||
172
REFRACTOR_GUIDE.md
Normal file
172
REFRACTOR_GUIDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Refactor Guide – Deep Dive into the Khata‑UI Codebase
|
||||
|
||||
> This document walks through the entire repository, explains the current architecture, and provides a step‑by‑step refactor plan that will improve maintainability, type safety, and UI/UX while preserving the existing functional behavior.
|
||||
|
||||
---
|
||||
|
||||
## 1. Repository Layout (high‑level)
|
||||
```
|
||||
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. |
|
||||
| **OpenAPI‑driven 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 Walk‑through
|
||||
### 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 MUI‑compatible 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 server‑side or client‑side 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 – Step‑by‑Step
|
||||
### Phase 1 – 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.
|
||||
|
||||
### Phase 2 – 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 hard‑coded 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.
|
||||
|
||||
### Phase 3 – Centralize Error & Loading UI
|
||||
- Create `src/components/LoadingSpinner.tsx` and `src/components/ErrorToast.tsx`.
|
||||
- Wrap all data‑fetching hooks (`useResource`, `useAuth` actions) with a HOC that automatically displays these components.
|
||||
- Migrate the scattered `if (loading) …` checks into the new components.
|
||||
|
||||
### Phase 4 – 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 top‑right corner of `App.tsx`).
|
||||
3. Update component styles to use theme‑aware colors (via `theme.palette`), ensuring the `Chip` variants already respect the palette.
|
||||
|
||||
### Phase 5 – 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.
|
||||
|
||||
### Phase 6 – Documentation (the files you will publish)
|
||||
- **DESIGN.md** – high‑level architecture (already present).
|
||||
- **IMPLEMENTATION.md** – detailed file‑by‑file breakdown (already present).
|
||||
- **CONCEPT.md** – why the metadata‑driven 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 server‑side.
|
||||
- [ ] Mobile layout (card view) still renders correctly.
|
||||
- [ ] Dark‑mode 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 cell‑level edit mode using MUI `TextField`. |
|
||||
| **GraphQL fallback** | Flexibility for back‑ends | Abstract data fetching behind an adapter interface (`useDataProvider`). |
|
||||
| **Internationalisation** | Multi‑language 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 hand‑off to the **lovable** UI platform while retaining its low‑code advantage.
|
||||
@@ -9,6 +9,7 @@
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>khata - Aetoskia</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -27,7 +27,9 @@
|
||||
"remark-gfm": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "latest"
|
||||
}
|
||||
},
|
||||
@@ -1631,6 +1633,12 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -4103,6 +4111,19 @@
|
||||
"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": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
"remark-gfm": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,169 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { useAuth, AuthPage } from "../react-auth";
|
||||
import { UploadProvider } from "./providers/UploadProvider";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import ResourceView from "./components/ResourceView";
|
||||
import { getAppConfig } from "./config";
|
||||
import { initializeApiClients } from "./api/client";
|
||||
import { AppConfig } from "./types/config";
|
||||
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { ConfigContext } from "./providers/ConfigContext";
|
||||
|
||||
function Dashboard({ basePath }: { basePath: string }) {
|
||||
const config = React.useContext(ConfigContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const resources = config?.resources || [];
|
||||
const visibleResources = resources.filter((res) => !res.hidden);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Welcome to the Admin Panel
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
|
||||
Select a resource from the sidebar to manage data.
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
|
||||
gap: 3,
|
||||
mt: 4,
|
||||
}}
|
||||
>
|
||||
{visibleResources.map((res) => (
|
||||
<Paper
|
||||
key={res.name}
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: "center",
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
|
||||
}}
|
||||
onClick={() => navigate(`/admin/${res.name}`)}
|
||||
>
|
||||
<Typography variant="h6" color="primary">{res.pluralLabel}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
import ProfileView from "./components/ProfileView";
|
||||
|
||||
function AdminApp({ basePath }: { basePath: string }) {
|
||||
const { currentUser, login, logout, loading, error } = useAuth();
|
||||
const config = React.useContext(ConfigContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const resources = config?.resources || [];
|
||||
const visibleResources = resources.filter((res) => !res.hidden);
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<AuthPage
|
||||
mode="login"
|
||||
login={login}
|
||||
register={async () => {}} // Disable registration for Admin
|
||||
loading={loading}
|
||||
error={error}
|
||||
onSwitchMode={() => {}}
|
||||
onBack={() => {}}
|
||||
currentUser={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
username={currentUser.username}
|
||||
onLogout={logout}
|
||||
onSelectResource={(name) => navigate(`/admin/${name}`)}
|
||||
resources={visibleResources}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard basePath={basePath} />} />
|
||||
<Route path="/profile" element={<ProfileView />} />
|
||||
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceRouteWrapper() {
|
||||
const { resourceName } = useParams();
|
||||
const config = React.useContext(ConfigContext);
|
||||
const selectedResource = config?.resources.find((r) => r.name === resourceName);
|
||||
|
||||
if (!selectedResource) return <Typography>Resource not found</Typography>;
|
||||
|
||||
return <ResourceView config={selectedResource} />;
|
||||
}
|
||||
|
||||
interface AdminProps {
|
||||
basePath?: string;
|
||||
resourceOverrides?: Record<string, any>;
|
||||
profileConfig?: any;
|
||||
}
|
||||
|
||||
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
|
||||
const existingConfig = React.useContext(ConfigContext);
|
||||
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!existingConfig) {
|
||||
getAppConfig(resourceOverrides, profileConfig).then((cfg) => {
|
||||
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
|
||||
setConfig(cfg);
|
||||
});
|
||||
}
|
||||
}, [resourceOverrides, profileConfig, existingConfig]);
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<UploadProvider>
|
||||
<AdminApp basePath={basePath} />
|
||||
</UploadProvider>
|
||||
);
|
||||
|
||||
// If we have an existing config, we are already inside a Provider and QueryClient
|
||||
if (existingConfig) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Fallback for standalone usage
|
||||
return (
|
||||
<ConfigContext.Provider value={config}>
|
||||
{content}
|
||||
</ConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
942
react-openapi/README.md
Normal file
942
react-openapi/README.md
Normal file
@@ -0,0 +1,942 @@
|
||||
# react-openapi
|
||||
|
||||
Auto-generates an admin panel (CRUD, datatable, relationship management) from an OpenAPI 3.1.0 specification at runtime.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. You provide a `specConfiguration` object with the URL to your OpenAPI YAML spec and an auth callback.
|
||||
2. `AppProvider` fetches the spec, validates it against ``react-openapi``'s required extensions, and builds an internal resource configuration.
|
||||
3. The `Admin` component renders routes (list, detail, create, edit) for each resource detected in the spec.
|
||||
4. All API calls go through an Axios client that injects the Bearer token from your callback.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install react-openapi
|
||||
# peer dependencies (install these in your app):
|
||||
npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import { AppProvider, Admin } from "react-openapi";
|
||||
import type { SpecConfiguration } from "react-openapi";
|
||||
|
||||
const specConfig: SpecConfiguration = {
|
||||
specUrl: "/api/openapi.yaml",
|
||||
baseApiUrl: "https://api.example.com/v1",
|
||||
title: "Vet Clinic Admin",
|
||||
getToken: () => localStorage.getItem("token"),
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppProvider specConfiguration={specConfig}>
|
||||
<Admin basePath="/admin" />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Exported API
|
||||
|
||||
### Components
|
||||
|
||||
| Export | Purpose |
|
||||
|------------------------|----------------------------------------------------------------------------------------------------------------|
|
||||
| `AppProvider` | Fetches the spec, validates it, initializes the API client, and provides context. Wrap your app with this. |
|
||||
| `Admin` | Renders the admin layout (sidebar + routes) for all resources. Takes `basePath`. |
|
||||
| `ListCellRenderer` | Renders a cell value in a list/table column. Respects FK display formats, inline refs, enums, booleans. |
|
||||
| `DetailFieldRenderer` | Renders a read-only field value in a detail view (label + value pair). |
|
||||
| `FormFieldRenderer` | Renders an editable form field based on `FieldConfig`. Dispatches to `FkSelectField`, `BooleanField`, etc. |
|
||||
| `SseStreamView` | Renders a live SSE stream card with connection status, event counter, latest event preview, and snackbar. |
|
||||
| `SseConnectionStatus` | Renders a green/red dot with "Connected"/"Disconnected" for an SSE resource. Reactive across all components. |
|
||||
|
||||
### Hooks
|
||||
|
||||
| Export | Purpose |
|
||||
|-------------------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `useAppContext()` | Returns `{ config, resources, schemas, loading, errors, warnings }`. Access all resource configs. |
|
||||
| `useResource(resourceName)` | Returns `{ resource, components, list, get, create, update, remove, stream?, loading, error }`. CRUD + streaming. |
|
||||
|
||||
The `stream` property is only present for SSE resources (`resource.streaming === true`). It accepts `{ onEvent, onError?, onOpen? }` and returns `{ close }`.
|
||||
|
||||
### Utilities
|
||||
|
||||
| Export | Purpose |
|
||||
|---------------------|---------------------------------------------------------------------|
|
||||
| `getApi()` | Returns the configured Axios instance. Useful for custom API calls. |
|
||||
| `applyDisplayFormat`| Formats an object using a template string like `"{name} - #{id}"`. |
|
||||
|
||||
### Types
|
||||
|
||||
| Export | Purpose |
|
||||
|------------------------|----------------------------------------------------------|
|
||||
| `SpecConfiguration` | `{ specUrl, baseApiUrl?, title?, getToken? }` |
|
||||
| `ResourceConfig` | Internal representation of a resource after spec parsing |
|
||||
| `FieldConfig` | Internal representation of a field after spec parsing |
|
||||
| `FKFieldConfig` | `{ resource, prefetch }` |
|
||||
| `ResourceRelationship` | `{ fieldName, config, targetSchemaName }` |
|
||||
| `FilterComponentProps` | `{ value, onChange, data?, labelOverride? }` |
|
||||
|
||||
### `ResourceConfig` Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------------|-----------------------------------|---------------------------------------------------------------------------|
|
||||
| `name` | `string` | URL-friendly resource name (e.g. `"pets"`, `"calls"`) |
|
||||
| `schemaName` | `string` | OpenAPI schema key (e.g. `"Pet"`, `"Call"`) |
|
||||
| `displayName` | `string` | Human-readable name for UI labels (e.g. `"Pets"`, `"Calls"`, `"Two Parts"`) |
|
||||
| `path` | `string` | API base path (e.g. `"/pets"`, `"/calls"`) |
|
||||
| `streaming` | `boolean` (optional) | `true` if this resource uses SSE streaming instead of REST CRUD |
|
||||
|
||||
### SSE Streaming (Server-Sent Events)
|
||||
|
||||
Resources with `x-sse: true` on their OpenAPI spec path object are treated as streaming resources. Instead of CRUD, they expose:
|
||||
|
||||
- **`stream()`** — accepts `{ onEvent, onError?, onOpen? }` and returns `{ close }`.
|
||||
- **Connection status** — reactive across `SseConnectionStatus` and `SseStreamView`.
|
||||
- **Auto-reconnect** — SSE automatically reconnects on connection loss (browser-native).
|
||||
|
||||
**OpenAPI extension:**
|
||||
```yaml
|
||||
/pets/events:
|
||||
x-sse: true
|
||||
get:
|
||||
summary: Subscribe to Pet events
|
||||
responses:
|
||||
200:
|
||||
description: SSE stream of Pet change events
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: object
|
||||
```
|
||||
When `x-sse: true` is set, the resource is excluded from sidebars, list/detail routes, and CRUD hooks. It only appears in the `streaming` resource collection.
|
||||
|
||||
**Custom UI example:**
|
||||
|
||||
```tsx
|
||||
import { useAppContext, SseStreamView } from 'react-openapi';
|
||||
|
||||
function MyStreamPage() {
|
||||
const { resources } = useAppContext();
|
||||
const res = resources.find(r => r.name === 'calls');
|
||||
if (!res) return null;
|
||||
return <SseStreamView resource={res} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Loading Custom Components from Resources
|
||||
|
||||
Pass per-resource overrides via `specConfiguration`:
|
||||
|
||||
```tsx
|
||||
<AppProvider config={config} resourceComponents={{
|
||||
pets: { list: PetList, detail: PetDetail, form: PetForm },
|
||||
calls: { detail: CallStream }, // SSE: only detail is used
|
||||
}} />
|
||||
```
|
||||
|
||||
Within resource components, use `components` from `useResource()`:
|
||||
|
||||
```tsx
|
||||
const { components } = useResource('pets');
|
||||
const row = data.map(item => <components.ListCell field={field} value={item[field.name]} />);
|
||||
```
|
||||
|
||||
## OpenAPI Spec Authoring Guide
|
||||
|
||||
``react-openapi`` uses custom `x-` extensions embedded in your OpenAPI 3.1.0 spec. This section is the **single source of truth** for writing a compatible spec.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```yaml
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: My API
|
||||
version: 1.0.0
|
||||
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
|
||||
components:
|
||||
schemas:
|
||||
|
||||
# --- Error schemas ---
|
||||
ErrorBody:
|
||||
type: object
|
||||
properties:
|
||||
detail:
|
||||
type: string
|
||||
required: [detail]
|
||||
|
||||
HTTPValidationError:
|
||||
type: object
|
||||
properties:
|
||||
detail:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
loc:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
msg:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required: [loc, msg, type]
|
||||
|
||||
# --- Non-resource schema (skipped by validator) ---
|
||||
Metadata:
|
||||
type: object
|
||||
properties:
|
||||
createdOn:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedOn:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
# --- Resource schema ---
|
||||
Pet:
|
||||
type: object
|
||||
x-resource: pets # REQUIRED - maps to /pets path
|
||||
x-primary-key: id # REQUIRED
|
||||
x-display-format: "{name} - #{id}" # REQUIRED
|
||||
x-list-columns: [name, species, age] # REQUIRED
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
readOnly: true
|
||||
x-order: 0 # REQUIRED on every property
|
||||
x-hidden: { form: true, list: true }
|
||||
x-label: "ID" # REQUIRED on every property
|
||||
name:
|
||||
type: string
|
||||
x-order: 1
|
||||
x-label: "Pet Name"
|
||||
x-description: "Name of the pet" # optional
|
||||
x-filterable: true # optional
|
||||
x-sortable: true # optional
|
||||
species:
|
||||
type: string
|
||||
enum: [dog, cat, bird] # renders as select dropdown
|
||||
x-order: 2
|
||||
x-label: "Species"
|
||||
weight:
|
||||
type: number
|
||||
format: float
|
||||
x-order: 3
|
||||
x-label: "Weight"
|
||||
birthDate:
|
||||
type: string
|
||||
format: date # renders as date picker
|
||||
x-order: 4
|
||||
x-label: "Date of Birth"
|
||||
photo:
|
||||
type: string
|
||||
format: binary
|
||||
x-ui-type: image # renders as file upload
|
||||
x-upload-url: /pets/{id}/photo # POST endpoint for upload
|
||||
x-order: 5
|
||||
x-label: "Photo"
|
||||
owner:
|
||||
$ref: '#/components/schemas/Parent'
|
||||
x-fk: # renders as FK dropdown
|
||||
resource: parents
|
||||
prefetch: true # load all options on mount
|
||||
x-order: 6
|
||||
x-label: "Owner"
|
||||
siblings:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
x-fk: # renders as multi-select
|
||||
resource: pets
|
||||
prefetch: false # lazy-load on focus
|
||||
x-order: 7
|
||||
x-label: "Siblings"
|
||||
metadata:
|
||||
$ref: '#/components/schemas/Metadata'
|
||||
# NO x-fk — renders as inline read-only display
|
||||
x-order: 8
|
||||
x-label: "Metadata"
|
||||
required: [id, name, owner]
|
||||
|
||||
# --- Error response schemas (REQUIRED for spec validity) ---
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
Forbidden:
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
ValidationError:
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
InternalServerError:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorBody'
|
||||
|
||||
paths:
|
||||
# --- Collection paths ---
|
||||
/pets:
|
||||
get:
|
||||
summary: List pets (paginated)
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
schema: { type: integer, default: 20 } # default is REQUIRED if pagination params exist
|
||||
- in: query
|
||||
name: offset
|
||||
schema: { type: integer, default: 0 }
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated list of pets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
# --- Error responses (RECOMMENDED on every operation) ---
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
post:
|
||||
summary: Create a pet
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
responses:
|
||||
'201':
|
||||
description: Pet created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
|
||||
# --- Item paths ---
|
||||
/pets/{id}:
|
||||
get:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Single pet
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
put:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
responses:
|
||||
'200':
|
||||
description: Pet updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Pet'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
delete:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'204':
|
||||
description: Pet deleted
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
```
|
||||
|
||||
### Schema-Level Extensions
|
||||
|
||||
These go on the schema object itself and mark it as a **resource** that generates the admin UI.
|
||||
|
||||
#### `x-resource` (REQUIRED)
|
||||
|
||||
```yaml
|
||||
Pet:
|
||||
type: object
|
||||
x-resource: pets # maps to the /pets path in paths
|
||||
```
|
||||
|
||||
- Value must be a **string** matching the collection path segment (e.g., `pets` → `/pets`).
|
||||
- Schemas **without** `x-resource` are skipped entirely — no CRUD routes, no extension validation. Use this for shared schemas like `Metadata`, `ErrorBody`, etc.
|
||||
- **Validator error** if the path `/pets` does not exist in `paths`.
|
||||
|
||||
#### `x-primary-key` (REQUIRED)
|
||||
|
||||
```yaml
|
||||
x-primary-key: id
|
||||
```
|
||||
|
||||
- The name of the property that uniquely identifies each record.
|
||||
- Used as the `:id` path parameter in routes and API calls.
|
||||
- Must reference a property that exists in `properties`.
|
||||
- **Validator error** if missing.
|
||||
|
||||
#### `x-display-format` (REQUIRED)
|
||||
|
||||
```yaml
|
||||
x-display-format: "{name}"
|
||||
x-display-format: "Dr. {name}"
|
||||
x-display-format: "Apt #{id} - {date}"
|
||||
```
|
||||
|
||||
- Template string with `{propertyName}` placeholders.
|
||||
- Used to display a human-readable label for a record (list rows, detail title, FK dropdown options).
|
||||
- Property names in `{braces}` are replaced with the record's property values at render time.
|
||||
- **Validator error** if missing.
|
||||
|
||||
#### `x-list-columns` (REQUIRED)
|
||||
|
||||
```yaml
|
||||
x-list-columns: [name, species, age, owner]
|
||||
```
|
||||
|
||||
- Array of property names to display as columns in the datatable.
|
||||
- Each name must exist in `properties`.
|
||||
- Column order follows the array order.
|
||||
- The display value for each cell is determined by the `x-display-format` of the **property's target resource** (for FK fields) or the **parent resource** (for direct fields).
|
||||
- **Validator error** if missing or if any column name references a non-existent property.
|
||||
|
||||
### Property-Level Extensions
|
||||
|
||||
These go on individual properties inside `properties`.
|
||||
|
||||
#### `x-label` (REQUIRED on every property)
|
||||
|
||||
```yaml
|
||||
x-label: "Pet Name"
|
||||
```
|
||||
|
||||
- Human-readable label used in form fields, table headers, and detail views.
|
||||
- **Validator error** if missing on any property of a resource schema.
|
||||
|
||||
#### `x-order` (REQUIRED on every property)
|
||||
|
||||
```yaml
|
||||
x-order: 1
|
||||
```
|
||||
|
||||
- Integer determining field ordering in forms and detail views.
|
||||
- Lower values appear first. Ties are broken by alphabetical property name.
|
||||
- **Validator error** if missing or null on any property of a resource schema.
|
||||
|
||||
#### `x-description` (optional)
|
||||
|
||||
```yaml
|
||||
x-description: "Name of the pet"
|
||||
```
|
||||
|
||||
- Shown as helper text below form fields and as placeholder text.
|
||||
- Falls back to `x-label` if not provided.
|
||||
|
||||
#### `x-hidden` (optional)
|
||||
|
||||
```yaml
|
||||
x-hidden: { form: true, list: true, detail: true }
|
||||
x-hidden: { form: true } # only hide in form
|
||||
```
|
||||
|
||||
- Controls visibility in each view. Valid keys: `form`, `list`, `detail`.
|
||||
- Any missing key defaults to `false` (visible).
|
||||
- Useful for hiding auto-generated fields like `id` from forms.
|
||||
|
||||
#### `x-filterable` (optional)
|
||||
|
||||
```yaml
|
||||
x-filterable: true
|
||||
```
|
||||
|
||||
- Marks the field as filterable in the datatable (UI integration TBD — reserved for future use).
|
||||
- Default: `false`.
|
||||
|
||||
#### `x-sortable` (optional)
|
||||
|
||||
```yaml
|
||||
x-sortable: true
|
||||
```
|
||||
|
||||
- Adds sort controls to the datatable column header.
|
||||
- When clicked, sends `?sort=fieldName` (ascending) or `?sort=-fieldName` (descending) to the list endpoint.
|
||||
- Default: `false`.
|
||||
|
||||
#### `x-fk` (optional)
|
||||
|
||||
```yaml
|
||||
owner:
|
||||
$ref: '#/components/schemas/Parent'
|
||||
x-fk:
|
||||
resource: parents
|
||||
prefetch: true # load all options on mount
|
||||
```
|
||||
|
||||
Marks a `$ref` property as a foreign key. The form renders a **dropdown** (single) or **multi-select** (if `type: array`, `items: $ref`).
|
||||
|
||||
| Sub-field | Type | Default | Description |
|
||||
|------------|---------|------------|-------------------------------------------------------------------------------------------|
|
||||
| `resource` | string | (required) | The `x-resource` value of the target schema. Uses the resource name, not the schema name. |
|
||||
| `prefetch` | boolean | `false` | If `true`, fetch all FK options on mount. If `false`, fetch on focus (lazy). |
|
||||
|
||||
**How FK options are fetched:**
|
||||
1. The library calls `GET /{targetResource}?limit=0` (for paginated targets — `limit=0` is a server convention meaning "return all") or `GET /{targetResource}` (for non-paginated).
|
||||
2. The response is parsed strictly by the target resource's pagination config.
|
||||
3. Each item is formatted using the target resource's `x-display-format`.
|
||||
|
||||
**Validator rules:**
|
||||
- The `resource` value must match some schema's `x-resource` in the spec.
|
||||
- The target schema must have `x-display-format` and `x-primary-key`.
|
||||
|
||||
#### `$ref` without `x-fk` — Inline Display
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
$ref: '#/components/schemas/Metadata'
|
||||
# no x-fk here
|
||||
```
|
||||
|
||||
A `$ref` property without `x-fk` renders as **read-only inline display**:
|
||||
- In **list** and **detail** views: uses `InlineRefField` which shows key-value chips (or the referenced resource's `x-display-format` if the target is a resource schema).
|
||||
- In **forms**: shows a disabled `TextField` with `JSON.stringify(value, null, 2)`.
|
||||
|
||||
The validator emits an `info` message for these to remind you they won't be editable.
|
||||
|
||||
#### `x-ui-type` (optional)
|
||||
|
||||
```yaml
|
||||
photo:
|
||||
type: string
|
||||
format: binary
|
||||
x-ui-type: image
|
||||
x-upload-url: /pets/{id}/photo
|
||||
```
|
||||
|
||||
Currently, supports only `"image"`. Changes the form field to a file upload component.
|
||||
|
||||
- If the record has an `id` and `x-upload-url` is set: POSTs the file to `x-upload-url` (with `{id}` replaced by the record's ID), then updates the field value with the response URL.
|
||||
- If no `id` or no `uploadUrl`: falls back to base64 data-URL via `FileReader`.
|
||||
|
||||
#### `x-upload-url` (optional, used with `x-ui-type: image`)
|
||||
|
||||
```yaml
|
||||
x-upload-url: /pets/{id}/photo
|
||||
```
|
||||
|
||||
POST endpoint for file upload. The `{id}` placeholder is replaced with the current record ID.
|
||||
|
||||
### Property Types That Trigger Special Renderers
|
||||
|
||||
| Condition | Form Renderer | List/Detail Renderer |
|
||||
|--------------------------------------|-------------------------------------------|----------------------------------------------|
|
||||
| `x-fk` exists, `type` is NOT `array` | `FkSelectField` (dropdown) | `ListCellRenderer` with `applyDisplayFormat` |
|
||||
| `x-fk` exists, `type: array` | `FkMultiSelectField` (Autocomplete multi) | `ListCellRenderer` with chips |
|
||||
| `enum` is defined | `EnumField` (select) | `Chip` |
|
||||
| `type: boolean` | `BooleanField` (Switch) | Chip with "Yes"/"No" |
|
||||
| `type: integer` or `type: number` | `NumberField` | Typography |
|
||||
| `format: date` | `DateField` (date picker) | Typography |
|
||||
| `format: date-time` | `DateField` (datetime-local picker) | Typography |
|
||||
| `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` |
|
||||
| `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) |
|
||||
| None of the above (default) | `StringField` (text input) | Typography |
|
||||
|
||||
### Path Conventions
|
||||
|
||||
#### Collection Path
|
||||
|
||||
Format: `/{x-resource}` (e.g., `/pets`)
|
||||
|
||||
| Operation | Required? | Purpose |
|
||||
|-----------|-----------|-----------------------------------------|
|
||||
| `GET` | Yes | List endpoint — populates the datatable |
|
||||
| `POST` | Yes | Create endpoint |
|
||||
|
||||
#### Item Path
|
||||
|
||||
Format: `/{x-resource}/{id}` (e.g., `/pets/{id}`)
|
||||
|
||||
| Operation | Required? | Purpose |
|
||||
|-----------|-----------|---------------|
|
||||
| `GET` | Yes | Detail view |
|
||||
| `PUT` | Yes | Edit form |
|
||||
| `DELETE` | Yes | Delete action |
|
||||
|
||||
### Response Shapes
|
||||
|
||||
The library expects **strict** response shapes that match the spec. No flexible fallbacks.
|
||||
|
||||
#### Paginated List (GET with limit/offset params)
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 42,
|
||||
"items": [{ "id": 1, "name": "Fido" }, ...]
|
||||
}
|
||||
```
|
||||
|
||||
- `total` is optional; falls back to `items.length` if absent.
|
||||
- `items` is **required** and must be an array.
|
||||
- The library **throws** if the response is not `{ total, items }` with an array `items`.
|
||||
|
||||
#### Non-Paginated List (GET without limit/offset params)
|
||||
|
||||
```json
|
||||
[{ "id": 1, "name": "Vaccination" }, ...]
|
||||
```
|
||||
|
||||
- Response must be a **plain array**.
|
||||
- The library **throws** if the response is not an array.
|
||||
|
||||
#### Single Item (GET /{id})
|
||||
|
||||
```json
|
||||
{ "id": 1, "name": "Fido", ... }
|
||||
```
|
||||
|
||||
- Returns the resource object directly.
|
||||
- No special validation beyond standard API error handling.
|
||||
|
||||
#### Create / Update (POST / PUT)
|
||||
|
||||
```json
|
||||
{ "id": 1, "name": "Fido", ... }
|
||||
```
|
||||
|
||||
- Returns the created/updated resource object directly.
|
||||
|
||||
#### Delete
|
||||
|
||||
- Returns HTTP 204 with no body.
|
||||
|
||||
### Error Response Format
|
||||
|
||||
The library's `parseError()` handles two formats:
|
||||
|
||||
**FastAPI `ValidationError` (422):**
|
||||
```json
|
||||
[
|
||||
{ "loc": ["body", "name"], "msg": "field required", "type": "value_error" }
|
||||
]
|
||||
```
|
||||
→ Rendered as: `"field required"`
|
||||
|
||||
**Generic `ErrorBody`:**
|
||||
```json
|
||||
{ "detail": "Not found" }
|
||||
```
|
||||
→ Rendered as: `"Not found"`
|
||||
|
||||
## Pagination Behavior
|
||||
|
||||
### Detecting Pagination
|
||||
|
||||
A resource is considered paginated if its collection GET path has **both** a `limit` and `offset` query parameter. The `limit` parameter **must** have a `schema.default` value:
|
||||
|
||||
```yaml
|
||||
parameters:
|
||||
- in: query
|
||||
name: limit
|
||||
schema: { type: integer, default: 20 } # default is REQUIRED
|
||||
- in: query
|
||||
name: offset
|
||||
schema: { type: integer, default: 0 }
|
||||
```
|
||||
|
||||
If only one of `limit`/`offset` is present, pagination is **not** detected (the path is treated as non-paginated).
|
||||
|
||||
### Datatable Pagination
|
||||
|
||||
The datatable renders an MUI `TablePagination` component when `resource.pagination` is non-null. It sends `?limit={rowsPerPage}&offset={page * rowsPerPage}` with each list request.
|
||||
|
||||
### FK Options — Paginated Targets
|
||||
|
||||
When fetching FK options for a paginated target resource, the library sends `?limit=0`. This is a **server convention** meaning "return all records" — not documented in the spec. The server must interpret `limit=0` as "no limit."
|
||||
|
||||
## Spec Validation
|
||||
|
||||
The validator (`spec-validator.ts`) runs at mount time and categorizes issues as:
|
||||
|
||||
| Level | Meaning | Impact |
|
||||
|-----------|------------------------------------------|------------------------------------------------------|
|
||||
| `error` | Missing required extension | Admin panel shows error screen — **will not render** |
|
||||
| `warning` | Missing optional configuration | Admin panel renders, warning shown in snackbar |
|
||||
| `info` | Informational note (e.g., non-FK `$ref`) | Shown alongside warnings |
|
||||
|
||||
### Complete Validation Rules
|
||||
|
||||
For each schema with `x-resource`:
|
||||
|
||||
1. **`x-primary-key`** must be present.
|
||||
2. **`x-display-format`** must be present.
|
||||
3. **`x-list-columns`** must be an array, and every entry must reference a real property.
|
||||
4. Every property must have **`x-label`**.
|
||||
5. Every property must have **`x-order`**.
|
||||
6. If a property uses `$ref` without `x-fk` → `info` message.
|
||||
7. If a property uses `x-fk`, the referenced `resource` must exist as another schema's `x-resource`, and that target must have `x-display-format` and `x-primary-key`.
|
||||
8. The `x-resource` path must exist in `paths`.
|
||||
9. The collection path must have **GET** (list) and **POST** (create).
|
||||
10. The item path `/{resource}/{id}` must exist with **GET**, **PUT**, and **DELETE**.
|
||||
11. If the collection GET has pagination params (limit/offset), the `limit` param must have a `schema.default`.
|
||||
|
||||
## Auth
|
||||
|
||||
Authentication is handled via the `getToken` callback in `SpecConfiguration`:
|
||||
|
||||
```ts
|
||||
const specConfig: SpecConfiguration = {
|
||||
specUrl: "/api/openapi.yaml",
|
||||
getToken: () => localStorage.getItem("token"), // called on every request
|
||||
};
|
||||
```
|
||||
|
||||
- The token is injected as `Authorization: Bearer {token}` on every API call.
|
||||
- If a 401 response is received and `getToken` returns a token, the token is cleared from localStorage (for token refresh flows — customize in your wrapper).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Spec loaded at runtime** — not built into the bundle. A single built UI works with any backend serving the spec at a known URL.
|
||||
- **Schema-to-path mapping is explicit** — the `x-resource` value maps directly to the path segment. No magic pluralization or guessing.
|
||||
- **No fallbacks for required extensions** — if the spec is missing `x-label`, `x-order`, `x-primary-key`, `x-display-format`, or `x-list-columns`, the library **throws at transform time** rather than silently defaulting.
|
||||
- **FK options use `?limit=0`** for paginated resources — server convention, not spec-documented.
|
||||
- **Response parsing is strict** — `{ total, items }` for paginated, `[]` for non-paginated. No fallback chains.
|
||||
|
||||
## Example: Complete Minimal Spec
|
||||
|
||||
```yaml
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Minimal API
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
components:
|
||||
schemas:
|
||||
ErrorBody:
|
||||
type: object
|
||||
properties:
|
||||
detail: { type: string }
|
||||
required: [detail]
|
||||
HTTPValidationError:
|
||||
type: object
|
||||
properties:
|
||||
detail:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
loc:
|
||||
type: array
|
||||
items: { type: string }
|
||||
msg: { type: string }
|
||||
type: { type: string }
|
||||
required: [loc, msg, type]
|
||||
Widget:
|
||||
type: object
|
||||
x-resource: widgets
|
||||
x-primary-key: id
|
||||
x-display-format: "{name}"
|
||||
x-list-columns: [name, status]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
readOnly: true
|
||||
x-order: 0
|
||||
x-hidden: { form: true, list: true }
|
||||
x-label: "ID"
|
||||
name:
|
||||
type: string
|
||||
x-order: 1
|
||||
x-label: "Widget Name"
|
||||
status:
|
||||
type: string
|
||||
enum: [active, inactive, archived]
|
||||
x-order: 2
|
||||
x-label: "Status"
|
||||
required: [id, name]
|
||||
responses:
|
||||
Unauthorized:
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorBody' }
|
||||
Forbidden:
|
||||
description: Insufficient permissions
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorBody' }
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorBody' }
|
||||
ValidationError:
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/HTTPValidationError' }
|
||||
InternalServerError:
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorBody' }
|
||||
paths:
|
||||
/widgets:
|
||||
get:
|
||||
summary: List widgets
|
||||
responses:
|
||||
'200':
|
||||
description: List of widgets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
post:
|
||||
summary: Create a widget
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
responses:
|
||||
'201':
|
||||
description: Widget created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
/widgets/{id}:
|
||||
get:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: Single widget
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
put:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
responses:
|
||||
'200':
|
||||
description: Widget updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
delete:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'204':
|
||||
description: Widget deleted
|
||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||
'403': { $ref: '#/components/responses/Forbidden' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'422': { $ref: '#/components/responses/ValidationError' }
|
||||
'500': { $ref: '#/components/responses/InternalServerError' }
|
||||
```
|
||||
@@ -1,65 +0,0 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { createApiClient } from "../../react-auth";
|
||||
|
||||
/**
|
||||
* We expose a singleton-like getter/setter for the API clients
|
||||
*/
|
||||
let _api: AxiosInstance | null = null;
|
||||
let _auth: AxiosInstance | null = null;
|
||||
|
||||
function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
|
||||
instance.defaults.paramsSerializer = {
|
||||
serialize: (params) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => {
|
||||
searchParams.append(key, String(v)); // NO []
|
||||
});
|
||||
} else if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams.toString();
|
||||
},
|
||||
};
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.get(...args);
|
||||
},
|
||||
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.post(...args);
|
||||
},
|
||||
put: (...args: Parameters<AxiosInstance["put"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.put(...args);
|
||||
},
|
||||
delete: (...args: Parameters<AxiosInstance["delete"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.delete(...args);
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||
if (!_auth) throw new Error("Auth client not initialized");
|
||||
return _auth.post(...args);
|
||||
},
|
||||
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||
if (!_auth) throw new Error("Auth client not initialized");
|
||||
return _auth.get(...args);
|
||||
},
|
||||
};
|
||||
|
||||
export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
|
||||
_api = withParamsSerializer(createApiClient(baseUrl));
|
||||
_auth = withParamsSerializer(createApiClient(authBaseUrl));
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
List,
|
||||
Divider,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import TableViewIcon from '@mui/icons-material/TableView';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const collapsedWidth = 64;
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
onSelectResource: (resourceName: string | null) => void;
|
||||
onLogout: () => void;
|
||||
username?: string;
|
||||
resources: ResourceConfig[];
|
||||
}
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
onSelectResource,
|
||||
resources,
|
||||
}: AdminLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const activeResourceName = location.pathname.split('/admin')[1] || null;
|
||||
|
||||
// AUTO-TOGGLE LOGIC (unchanged)
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setIsCollapsed(false);
|
||||
setMobileOpen(false);
|
||||
} else {
|
||||
if (location.pathname === '/admin' || location.pathname === '') {
|
||||
setIsCollapsed(false);
|
||||
} else {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, isMobile]);
|
||||
|
||||
const currentWidth = isMobile
|
||||
? drawerWidth
|
||||
: isCollapsed
|
||||
? collapsedWidth
|
||||
: drawerWidth;
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
setIsCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: isCollapsed ? 'center' : 'flex-end',
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={handleSidebarToggle}>
|
||||
{isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile spacing (replaces Toolbar) */}
|
||||
{isMobile && (
|
||||
<Box sx={{ height: (theme) => theme.spacing(7) }} />
|
||||
)}
|
||||
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<Tooltip
|
||||
title={isCollapsed && !isMobile ? 'Dashboard' : ''}
|
||||
placement="right"
|
||||
>
|
||||
<ListItemButton
|
||||
selected={location.pathname === '/admin'}
|
||||
onClick={() => navigate('/admin')}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent:
|
||||
isCollapsed && !isMobile ? 'center' : 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
mr: isCollapsed && !isMobile ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<DashboardIcon
|
||||
color={
|
||||
location.pathname === '/admin'
|
||||
? 'primary'
|
||||
: 'inherit'
|
||||
}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
{(!isCollapsed || isMobile) && (
|
||||
<ListItemText primary="Dashboard" />
|
||||
)}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
|
||||
<List sx={{ flexGrow: 1 }}>
|
||||
{resources.map((res) => (
|
||||
<ListItem key={res.name} disablePadding>
|
||||
<Tooltip
|
||||
title={
|
||||
isCollapsed && !isMobile ? res.pluralLabel : ''
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<ListItemButton
|
||||
selected={activeResourceName === res.name}
|
||||
onClick={() => onSelectResource(res.name)}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent:
|
||||
isCollapsed && !isMobile
|
||||
? 'center'
|
||||
: 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
mr: isCollapsed && !isMobile ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<TableViewIcon
|
||||
color={
|
||||
activeResourceName === res.name
|
||||
? 'primary'
|
||||
: 'inherit'
|
||||
}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
{(!isCollapsed || isMobile) && (
|
||||
<ListItemText primary={res.pluralLabel} />
|
||||
)}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
{/* NAV */}
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { md: currentWidth }, flexShrink: { md: 0 } }}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
open
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: currentWidth,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: currentWidth,
|
||||
overflowX: 'hidden',
|
||||
transition: theme.transitions.create('width'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* MAIN */}
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: { xs: 2, md: 3 },
|
||||
width: {
|
||||
xs: '100%',
|
||||
md: `calc(100% - ${currentWidth}px)`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Control row (replaces AppBar) */}
|
||||
{isMobile && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2,
|
||||
height: (theme) => theme.spacing(7),
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={handleDrawerToggle}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Grid,
|
||||
Menu,
|
||||
MenuItem,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Divider,
|
||||
Chip,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridActionsCellItem,
|
||||
GridRenderCellParams,
|
||||
GridPaginationModel,
|
||||
} from '@mui/x-data-grid';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
|
||||
interface EnhancedTableProps {
|
||||
config: ResourceConfig;
|
||||
data: any[];
|
||||
total?: number;
|
||||
paginationModel?: GridPaginationModel;
|
||||
onPaginationModelChange?: (model: GridPaginationModel) => void;
|
||||
loading?: boolean;
|
||||
onEdit: (item: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
}
|
||||
|
||||
export default function EnhancedTable({
|
||||
config,
|
||||
data,
|
||||
total,
|
||||
paginationModel,
|
||||
onPaginationModelChange,
|
||||
loading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
onNavigateToResource,
|
||||
}: EnhancedTableProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns: GridColDef[] = React.useMemo(() => {
|
||||
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
|
||||
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
|
||||
if (field.type === 'number') muiType = 'number';
|
||||
if (field.type === 'boolean') muiType = 'boolean';
|
||||
if (field.type === 'date') muiType = 'date';
|
||||
if (field.type === 'datetime') muiType = 'dateTime';
|
||||
if (field.type === 'enum') muiType = 'singleSelect';
|
||||
|
||||
const col: GridColDef = {
|
||||
field: key,
|
||||
headerName: field.label,
|
||||
type: muiType,
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
|
||||
};
|
||||
|
||||
if (muiType === 'date' || muiType === 'dateTime') {
|
||||
col.valueGetter = (value: any) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
}
|
||||
|
||||
if (muiType === 'singleSelect' && field.options) {
|
||||
// @ts-ignore
|
||||
col.valueOptions = field.options;
|
||||
}
|
||||
|
||||
return col;
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 120,
|
||||
getActions: (params) => [
|
||||
<GridActionsCellItem
|
||||
icon={<VisibilityIcon />}
|
||||
label="View"
|
||||
onClick={() => navigate(`/admin/${config.name}/${params.id}`)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<EditIcon />}
|
||||
label="Edit"
|
||||
onClick={() => navigate(`/admin/${config.name}/edit/${params.id}`)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<DeleteIcon />}
|
||||
label="Delete"
|
||||
onClick={() => onDelete(params.id as string)}
|
||||
/>,
|
||||
],
|
||||
});
|
||||
|
||||
return cols;
|
||||
}, [config, onDelete, navigate, onNavigateToResource]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate} size="small">
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{data.map((row) => (
|
||||
<Box key={row[config.primaryKey] || Math.random()}>
|
||||
<MobileCardRow
|
||||
row={row}
|
||||
config={config}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onNavigate={onNavigateToResource}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate}>
|
||||
Add {config.label}
|
||||
</Button>
|
||||
</Box>
|
||||
<DataGrid
|
||||
rows={data || []}
|
||||
columns={columns}
|
||||
autoHeight
|
||||
paginationMode={config.pagination ? 'server' : 'client'}
|
||||
rowCount={(() => {
|
||||
if (!config.pagination) return data.length;
|
||||
if (total !== undefined) return total;
|
||||
|
||||
// Graceful fallback for missing total count
|
||||
const page = paginationModel?.page || 0;
|
||||
const pageSize = paginationModel?.pageSize || 10;
|
||||
if (data.length < pageSize) {
|
||||
return page * pageSize + data.length;
|
||||
}
|
||||
// Enable 'Next' button by pretending there's at least one more page
|
||||
return (page + 2) * pageSize;
|
||||
})()}
|
||||
loading={loading}
|
||||
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
|
||||
onPaginationModelChange={onPaginationModelChange}
|
||||
getRowId={(row) => {
|
||||
const pk = config.primaryKey;
|
||||
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
|
||||
const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
|
||||
for (const key of fallbackKeys) {
|
||||
if (row[key] !== undefined && row[key] !== null) return row[key];
|
||||
}
|
||||
return `temp-id-${data.indexOf(row)}`;
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell:focus': { outline: 'none' },
|
||||
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const id = row[config.primaryKey];
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
#{id}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={handleClick}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
<MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/${id}`); }}>View</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/edit/${id}`); }}>Edit</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
|
||||
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
|
||||
<Box key={key}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
||||
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
|
||||
<Button size="small" onClick={() => navigate(`/admin/${config.name}/${id}`)}>View Details</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
|
||||
if (!item) return "";
|
||||
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(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 }: any) {
|
||||
const value = params.value;
|
||||
const isPk = fieldKey === config.primaryKey;
|
||||
|
||||
if (field.formatter) return field.formatter(value);
|
||||
|
||||
// 1. Single Relation
|
||||
if (field.relation && value && !Array.isArray(value)) {
|
||||
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
||||
const displayValue = getFormattedDisplayValue(value, field.displayField);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={displayValue}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (relationId) onNavigate?.(field.relation!, String(relationId));
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Multi-Select (Array of relations or simple strings)
|
||||
if (field.type === 'array' && Array.isArray(value)) {
|
||||
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipTitle} arrow placement="top">
|
||||
<Stack direction="row" spacing={0.5} sx={{ overflow: 'hidden', flexWrap: 'nowrap' }}>
|
||||
{value.map((item, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={getFormattedDisplayValue(item, field.displayField)}
|
||||
size="small"
|
||||
variant="filled"
|
||||
sx={{ maxWidth: 120 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (field.relation) {
|
||||
const id = typeof item === 'object' ? (item.id || item._id) : item;
|
||||
if (id) onNavigate?.(field.relation!, String(id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Simple Objects
|
||||
if (field.type === 'object' && value) {
|
||||
return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
|
||||
}
|
||||
|
||||
if (field.type === 'number' && typeof value === 'number') {
|
||||
const isNegative = value < 0;
|
||||
const color = isNegative ? 'error' : 'success';
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={value.toLocaleString()}
|
||||
size="small"
|
||||
color={color}
|
||||
variant="filled"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
minWidth: 60,
|
||||
// Soft background with bold text for a premium feel
|
||||
bgcolor: (theme) => alpha(theme.palette[color].main, 0.15),
|
||||
color: (theme) => theme.palette[color].dark,
|
||||
'& .MuiChip-label': { px: 1.5 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return value ? (
|
||||
<Chip label="Yes" size="small" color="success" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
) : (
|
||||
<Chip label="No" size="small" color="default" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
|
||||
|
||||
if (isPk && !isMobile) {
|
||||
return (
|
||||
<Chip
|
||||
label={value}
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/admin/${config.name}/${params.row[config.primaryKey]}`); }}
|
||||
sx={{ cursor: 'pointer', fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { useUpload } from '../providers/UploadProvider';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import FormField from './fields/FormField';
|
||||
import { ConfigContext } from '../providers/ConfigContext';
|
||||
|
||||
interface GenericFormProps {
|
||||
config: ResourceConfig;
|
||||
initialData?: any;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
readOnly?: boolean;
|
||||
onEditClick?: () => void;
|
||||
}
|
||||
|
||||
export default function GenericForm({
|
||||
config,
|
||||
initialData = {},
|
||||
onSave,
|
||||
onCancel,
|
||||
loading: saving,
|
||||
readOnly = false,
|
||||
onEditClick,
|
||||
}: GenericFormProps) {
|
||||
initialData = initialData || {};
|
||||
const [formData, setFormData] = React.useState(initialData);
|
||||
const { uploadFile, uploading } = useUpload();
|
||||
const appConfig = React.useContext(ConfigContext);
|
||||
|
||||
// 1. Identify all unique relations in the schema (including nested ones)
|
||||
const getRelationFields = (fields: Record<string, any>): string[] => {
|
||||
let relations: string[] = [];
|
||||
Object.values(fields).forEach(field => {
|
||||
if (field.relation) relations.push(field.relation);
|
||||
if (field.schema) relations = [...relations, ...getRelationFields(field.schema)];
|
||||
});
|
||||
return Array.from(new Set(relations));
|
||||
};
|
||||
|
||||
const allRelations = React.useMemo(() => getRelationFields(config.fields), [config.fields]);
|
||||
|
||||
// 2. Parallel fetch for all related resource lists
|
||||
const queries = useQueries({
|
||||
queries: allRelations.map(relName => {
|
||||
const relatedRes = appConfig?.resources.find(r => r.name === relName);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { getListQueryOptions } = useResource(relatedRes!);
|
||||
return {
|
||||
...getListQueryOptions(),
|
||||
enabled: !!relatedRes,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const isLoadingRelations = queries.some(q => q.isLoading);
|
||||
|
||||
const relationDataMap = React.useMemo(() => {
|
||||
const map: Record<string, any[]> = {};
|
||||
allRelations.forEach((relName, index) => {
|
||||
// @ts-ignore
|
||||
map[relName] = queries[index].data || [];
|
||||
});
|
||||
return map;
|
||||
}, [allRelations, queries]);
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
if (readOnly) return;
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (readOnly) return;
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (readOnly) return `View ${config.label}`;
|
||||
return initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`;
|
||||
};
|
||||
|
||||
if (isLoadingRelations) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 8, gap: 2 }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">Loading relationships...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Typography variant="h5">
|
||||
{getTitle()}
|
||||
</Typography>
|
||||
<Divider />
|
||||
|
||||
{Object.entries(config.fields).map(([key, field]) => (
|
||||
<FormField
|
||||
key={key}
|
||||
name={key}
|
||||
field={field}
|
||||
value={formData[key]}
|
||||
onChange={(val: any) => handleChange(key, val)}
|
||||
disabled={readOnly || field.readOnly}
|
||||
uploadFile={uploadFile}
|
||||
uploading={uploading}
|
||||
baseUrl={appConfig?.baseUrl || ""}
|
||||
relationDataMap={relationDataMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||
<Button variant="outlined" onClick={onCancel} disabled={saving}>
|
||||
{readOnly ? 'Back to List' : 'Cancel'}
|
||||
</Button>
|
||||
{readOnly ? (
|
||||
<Button variant="contained" color="primary" onClick={onEditClick}>
|
||||
Edit {config.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
|
||||
Save {config.label}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import GenericForm from './GenericForm';
|
||||
import { ConfigContext } from '../providers/ConfigContext';
|
||||
|
||||
export default function ProfileView() {
|
||||
const appConfig = React.useContext(ConfigContext);
|
||||
const profileConfig = appConfig?.profile;
|
||||
const resourceConfig = appConfig?.resources.find(r => r.name === profileConfig?.resource);
|
||||
|
||||
if (!profileConfig || !resourceConfig) {
|
||||
return <Alert severity="error">Profile configuration not found.</Alert>;
|
||||
}
|
||||
|
||||
// Create a modified config where only extraFields are editable
|
||||
const editableConfig = React.useMemo(() => {
|
||||
const newFields = { ...resourceConfig.fields };
|
||||
const extraFields = profileConfig.extraFields || [];
|
||||
|
||||
Object.keys(newFields).forEach(key => {
|
||||
newFields[key] = {
|
||||
...newFields[key],
|
||||
readOnly: !extraFields.includes(key),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...resourceConfig,
|
||||
fields: newFields,
|
||||
};
|
||||
}, [resourceConfig, profileConfig.extraFields]);
|
||||
|
||||
const { useMe, useUpdateMe } = useResource(resourceConfig);
|
||||
const { data: profile, isLoading, error } = useMe();
|
||||
const updateMutation = useUpdateMe();
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
// Only send editable fields to prevent accidental overwrites of read-only data
|
||||
const extraFields = profileConfig.extraFields || [];
|
||||
const dataToSave = Object.keys(formData)
|
||||
.filter(key => extraFields.includes(key))
|
||||
.reduce((obj: any, key) => {
|
||||
obj[key] = formData[key];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
await updateMutation.mutateAsync(dataToSave);
|
||||
} catch (err) {
|
||||
console.error('Profile update failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">Failed to load profile data.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 800, mx: 'auto', mt: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
My Profile
|
||||
</Typography>
|
||||
<Paper sx={{ p: 4, mt: 2 }}>
|
||||
<GenericForm
|
||||
config={editableConfig}
|
||||
initialData={profile}
|
||||
onSave={handleSave}
|
||||
onCancel={() => window.history.back()}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography, Paper, CircularProgress } from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import GenericForm from './GenericForm';
|
||||
import EnhancedTable from './EnhancedTable';
|
||||
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
|
||||
|
||||
interface ResourceViewProps {
|
||||
config: ResourceConfig;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
}
|
||||
|
||||
import { GridPaginationModel } from '@mui/x-data-grid';
|
||||
|
||||
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isCreate = location.pathname.endsWith('/create');
|
||||
const isEdit = location.pathname.includes('/edit/');
|
||||
const isView = !!id && !isEdit;
|
||||
const isList = !id && !isCreate;
|
||||
|
||||
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
||||
|
||||
// Determine query parameters based on pagination config
|
||||
const queryParams = React.useMemo(() => {
|
||||
if (!config.pagination) return {};
|
||||
return {
|
||||
skip: paginationModel.page * paginationModel.pageSize,
|
||||
limit: paginationModel.pageSize,
|
||||
};
|
||||
}, [config.pagination, paginationModel]);
|
||||
|
||||
const listQuery = useList(queryParams);
|
||||
const itemQuery = useRead(id || "");
|
||||
|
||||
const paginatedData = listQuery.data || { data: [], total: undefined };
|
||||
const createMutation = useCreate();
|
||||
const updateMutation = useUpdate();
|
||||
const deleteMutation = useDelete();
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
navigate(`/admin/${config.name}/edit/${item[config.primaryKey]}`);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate(`/admin/${config.name}/create`);
|
||||
};
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateMutation.mutateAsync({ id: id!, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
navigate(`/admin/${config.name}`);
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this item?')) {
|
||||
await deleteMutation.mutateAsync(itemId);
|
||||
}
|
||||
};
|
||||
|
||||
if (isList && listQuery.isLoading) return <CircularProgress />;
|
||||
if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isList ? (
|
||||
<EnhancedTable
|
||||
config={config}
|
||||
data={paginatedData.data || []}
|
||||
total={paginatedData.total}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
loading={listQuery.isFetching}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCreate={handleCreate}
|
||||
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
|
||||
/>
|
||||
) : (
|
||||
<Paper sx={{ p: 4 }}>
|
||||
<GenericForm
|
||||
config={config}
|
||||
initialData={isCreate ? null : itemQuery.data}
|
||||
onSave={handleSave}
|
||||
onCancel={() => navigate(`/admin/${config.name}`)}
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
readOnly={isView}
|
||||
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
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 ImageUploadField from './ImageUploadField';
|
||||
|
||||
interface FormFieldProps {
|
||||
name: string;
|
||||
field: ResourceField;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
disabled?: boolean;
|
||||
uploadFile: (file: File) => Promise<string | null>;
|
||||
uploading: boolean;
|
||||
baseUrl: string;
|
||||
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
name,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
uploadFile,
|
||||
uploading,
|
||||
baseUrl,
|
||||
relationDataMap = {},
|
||||
}: FormFieldProps) {
|
||||
const label = field.label;
|
||||
|
||||
// 1. Recursive Rendering for Objects (Not Relations)
|
||||
if (field.type === 'object' && field.schema && !field.relation) {
|
||||
return (
|
||||
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(field.schema).map(([subKey, subField]) => (
|
||||
<FormField
|
||||
key={subKey}
|
||||
name={`${name}.${subKey}`}
|
||||
field={subField}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Boolean Handling
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Enum Handling
|
||||
if (field.type === 'enum' && field.options) {
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{field.options.map((opt: string) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Common Text Fields
|
||||
if (field.type === 'datetime' || field.type === 'date') {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
uploading?: boolean;
|
||||
onUpload: (file: File) => void;
|
||||
size?: number;
|
||||
baseUrl: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ImageUploadField({
|
||||
label = "Upload Image",
|
||||
value,
|
||||
uploading = false,
|
||||
onUpload,
|
||||
size = 64,
|
||||
baseUrl,
|
||||
disabled = false,
|
||||
}: ImageUploadFieldProps) {
|
||||
|
||||
const imgSrc = value
|
||||
? baseUrl.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
value.replace(/^\/+/, "")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mb: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Avatar
|
||||
src={imgSrc}
|
||||
sx={{ width: size, height: size, borderRadius: 2 }}
|
||||
/>
|
||||
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
disabled={uploading}
|
||||
startIcon={uploading && <CircularProgress size={16} />}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Choose File"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onUpload(file);
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { AppConfig } from "./types/config";
|
||||
import { loadConfigFromOpenApi } from "./utils/openapi_loader";
|
||||
|
||||
export async function getAppConfig(
|
||||
resourceOverrides: Record<string, any> = {},
|
||||
profileConfig: any = {}
|
||||
): Promise<AppConfig> {
|
||||
// @ts-ignore
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
// @ts-ignore
|
||||
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL
|
||||
const config = await loadConfigFromOpenApi(baseUrl, resourceOverrides, profileConfig);
|
||||
|
||||
// You can still apply overrides here
|
||||
return {
|
||||
...config,
|
||||
authBaseUrl: authBaseUrl,
|
||||
baseUrl: baseUrl,
|
||||
};
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { ResourceConfig } from "../types/config";
|
||||
import { ConfigContext } from "../providers/ConfigContext";
|
||||
import * as React from "react";
|
||||
|
||||
export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Return empty/disabled hooks if config is missing
|
||||
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
|
||||
|
||||
// --- READ ALL ---
|
||||
const useList = (params?: any) =>
|
||||
useQuery({
|
||||
queryKey: [name, "list", params],
|
||||
queryFn: async () => {
|
||||
if (!endpoint) return { data: [], total: 0 };
|
||||
console.log('params:', params);
|
||||
// @ts-ignore
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
};
|
||||
},
|
||||
enabled: !!endpoint,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// --- READ ONE ---
|
||||
const useRead = (id: string | null) =>
|
||||
useQuery({
|
||||
queryKey: [name, "detail", id],
|
||||
queryFn: async () => {
|
||||
if (!id || !endpoint) return null;
|
||||
// @ts-ignore
|
||||
const res = await api.get<T>(`${endpoint}/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!id && !!endpoint,
|
||||
});
|
||||
|
||||
// --- CREATE ---
|
||||
const useCreate = () =>
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.post<T>(endpoint, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- UPDATE ---
|
||||
const useUpdate = () =>
|
||||
useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (updatedItem) => {
|
||||
// @ts-ignore
|
||||
const id = updatedItem[primaryKey];
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- DELETE ---
|
||||
const useDelete = () =>
|
||||
useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
await api.delete(`${endpoint}/${id}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- HELPERS FOR useQueries ---
|
||||
const getListQueryOptions = (params?: any) => ({
|
||||
queryKey: [name, "list", params],
|
||||
queryFn: async () => {
|
||||
if (!endpoint) return { data: [], total: 0 };
|
||||
// @ts-ignore
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
};
|
||||
},
|
||||
enabled: !!endpoint,
|
||||
});
|
||||
|
||||
// --- READ ME ---
|
||||
const useMe = () =>
|
||||
useQuery({
|
||||
queryKey: [name, "me"],
|
||||
queryFn: async () => {
|
||||
if (!endpoint) return null;
|
||||
// @ts-ignore
|
||||
const res = await api.get<T>(`${endpoint}/me`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!endpoint,
|
||||
});
|
||||
|
||||
// --- UPDATE ME ---
|
||||
const useUpdateMe = () =>
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.put<T>(`${endpoint}/me`, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "me"] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
useList,
|
||||
useRead,
|
||||
useMe,
|
||||
useCreate,
|
||||
useUpdate,
|
||||
useUpdateMe,
|
||||
useDelete,
|
||||
getListQueryOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export function useResourceByName<T = any>(name: string) {
|
||||
const config = React.useContext(ConfigContext);
|
||||
const resourceConfig = config?.resources.find((r) => r.name === name);
|
||||
return useResource<T>(resourceConfig);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export { default as Admin } from "./Admin";
|
||||
export { api, auth, initializeApiClients } from "./api/client";
|
||||
export { getAppConfig } from "./config";
|
||||
export type { AppConfig, ResourceConfig, ResourceField } from "./types/config";
|
||||
export { AppProvider } from "./providers/AppProvider";
|
||||
export { ConfigContext, useConfig } from "./providers/ConfigContext";
|
||||
export { useResource, useResourceByName } from "./hooks/useResource";
|
||||
export { AppProvider } from "./src/context/AppProvider";
|
||||
export { Admin } from "./src/components/Admin";
|
||||
export { useAppContext } from "./src/context/AppContext";
|
||||
export { useResource } from "./src/context/useResource";
|
||||
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
|
||||
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
|
||||
export { SseStreamView } from "./src/components/SseStreamView";
|
||||
export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
|
||||
export { getApi } from "./src/hooks/useApi";
|
||||
export type { FilterComponentProps } from "./src/context/useResource";
|
||||
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types";
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConfigContext } from "./ConfigContext";
|
||||
import { getAppConfig } from "../config";
|
||||
import { initializeApiClients } from "../api/client";
|
||||
import { AppConfig } from "../types/config";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
|
||||
const defaultQueryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface AppProviderProps {
|
||||
children: React.ReactNode;
|
||||
resourceOverrides?: Record<string, any>;
|
||||
profileConfig?: any;
|
||||
queryClient?: QueryClient;
|
||||
}
|
||||
|
||||
export function AppProvider({
|
||||
children,
|
||||
resourceOverrides = {},
|
||||
profileConfig = {},
|
||||
queryClient = defaultQueryClient,
|
||||
}: AppProviderProps) {
|
||||
const [config, setConfig] = React.useState<AppConfig | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
getAppConfig(resourceOverrides, profileConfig)
|
||||
.then((cfg) => {
|
||||
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
|
||||
setConfig(cfg);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load OpenAPI configuration:", err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [resourceOverrides, profileConfig]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { AppConfig } from "../types/config";
|
||||
|
||||
export const ConfigContext = React.createContext<AppConfig | null>(null);
|
||||
|
||||
export function useConfig() {
|
||||
const context = React.useContext(ConfigContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useConfig must be used within a ConfigProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export interface UploadContextModel {
|
||||
uploadFile: (file: File) => Promise<string | null>;
|
||||
uploading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
|
||||
|
||||
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const uploadFile = async (file: File): Promise<string | null> => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const binary = new Uint8Array(arrayBuffer);
|
||||
|
||||
const res = await api.post("/uploads", binary, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.data.url as string;
|
||||
} catch (err: any) {
|
||||
console.error("File upload failed:", err);
|
||||
setError(err.response?.data?.detail || "Failed to upload file");
|
||||
return null;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UploadContext.Provider value={{ uploadFile, uploading, error }}>
|
||||
{children}
|
||||
</UploadContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpload = (): UploadContextModel => {
|
||||
const ctx = useContext(UploadContext);
|
||||
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
|
||||
return ctx;
|
||||
};
|
||||
60
react-openapi/src/components/Admin.tsx
Normal file
60
react-openapi/src/components/Admin.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { Layout } from "./Layout";
|
||||
import { ResourceList } from "./ResourceList";
|
||||
import { ResourceForm } from "./ResourceForm";
|
||||
import { ResourceDetail } from "./ResourceDetail";
|
||||
import { ValidationAlert } from "./ValidationAlert";
|
||||
|
||||
interface AdminProps {
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export function Admin({ basePath }: AdminProps) {
|
||||
const { resources, loading, errors, warnings } = useAppContext();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return <ValidationAlert errors={errors} warnings={warnings} />;
|
||||
}
|
||||
|
||||
if (resources.length === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
No resources found in the OpenAPI spec with x-resource defined.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
|
||||
<Layout resources={resources} basePath={basePath}>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to={`${basePath}/${resources[0].name}`} replace />} />
|
||||
{resources.map((r) => (
|
||||
<React.Fragment key={r.name}>
|
||||
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
|
||||
{!r.streaming && (
|
||||
<>
|
||||
<Route path={`${r.name}/new`} element={<ResourceForm resource={r} basePath={basePath} mode="create" />} />
|
||||
<Route path={`${r.name}/:id`} element={<ResourceDetail resource={r} basePath={basePath} />} />
|
||||
<Route path={`${r.name}/:id/edit`} element={<ResourceForm resource={r} basePath={basePath} mode="edit" />} />
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Routes>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
react-openapi/src/components/FilterBar.tsx
Normal file
68
react-openapi/src/components/FilterBar.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import { Box, Button } from "@mui/material";
|
||||
import { useResource, FilterComponentProps } from "../context/useResource";
|
||||
|
||||
interface FilterBarProps {
|
||||
resourceName: string;
|
||||
filters: Record<string, string>;
|
||||
onFilterChange: (fieldName: string, value: string) => void;
|
||||
onClear: () => void;
|
||||
data?: any[];
|
||||
}
|
||||
|
||||
export function FilterBar({ resourceName, filters, onFilterChange, onClear, data }: FilterBarProps) {
|
||||
const { resource, components } = useResource(resourceName);
|
||||
const filterable = resource.fields.filter((f) => f.filterable);
|
||||
const hasActiveFilters = Object.values(filters).some((v) => v !== "");
|
||||
|
||||
if (filterable.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2, alignItems: "flex-start" }}>
|
||||
{filterable.map((field) => {
|
||||
const Component = components[field.name] as React.FC<FilterComponentProps>;
|
||||
const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time";
|
||||
|
||||
if (isRange) {
|
||||
return (
|
||||
<Box key={field.name} sx={{ minWidth: 260, display: "flex", gap: 1 }}>
|
||||
<Box sx={{ flex: 1, minWidth: 120 }}>
|
||||
<Component
|
||||
labelOverride={`${field.label} From`}
|
||||
value={filters[field.name + "_from"] ?? ""}
|
||||
onChange={(v) => onFilterChange(field.name + "_from", v)}
|
||||
data={data}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 120 }}>
|
||||
<Component
|
||||
labelOverride={`${field.label} To`}
|
||||
value={filters[field.name + "_to"] ?? ""}
|
||||
onChange={(v) => onFilterChange(field.name + "_to", v)}
|
||||
data={data}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={field.name} sx={{ minWidth: 180 }}>
|
||||
<Component
|
||||
value={filters[field.name] ?? ""}
|
||||
onChange={(v) => onFilterChange(field.name, v)}
|
||||
data={data}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{hasActiveFilters && (
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button size="small" variant="outlined" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
42
react-openapi/src/components/Layout.tsx
Normal file
42
react-openapi/src/components/Layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Box, Toolbar, IconButton, Typography } from "@mui/material";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
import type { ResourceConfig } from "../types";
|
||||
|
||||
interface LayoutProps {
|
||||
resources: ResourceConfig[];
|
||||
basePath: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Layout({ resources, basePath, children }: LayoutProps) {
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "calc(100vh - 128px)" }}>
|
||||
<SideMenu
|
||||
resources={resources}
|
||||
basePath={basePath}
|
||||
mobileOpen={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { md: "none" } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap sx={{ display: { md: "none" } }}>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
97
react-openapi/src/components/ResourceDetail.tsx
Normal file
97
react-openapi/src/components/ResourceDetail.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import type { ResourceConfig } from "../types";
|
||||
import { useResource } from "../context/useResource";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
|
||||
|
||||
interface ResourceDetailProps {
|
||||
resource: ResourceConfig;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const crud = useResource(resource.name);
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
crud
|
||||
.get(id)
|
||||
.then(setData)
|
||||
.catch(() => navigate(`${basePath}/${resource.name}`))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
|
||||
Record not found
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
|
||||
Back
|
||||
</Button>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
|
||||
{applyDisplayFormat(data, resource.displayFormat)}
|
||||
</Typography>
|
||||
{resource.operations.update && (
|
||||
<Button variant="contained" startIcon={<EditIcon />} onClick={() => navigate(`${basePath}/${resource.name}/${id}/edit`)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
{visibleFields.map((field) => {
|
||||
let value = data[field.name];
|
||||
let fmt = resource.displayFormat;
|
||||
if (field.fk && typeof value === "object") {
|
||||
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
|
||||
fmt = targetRes!.displayFormat;
|
||||
} else if (field.refSchema && !field.fk && typeof value === "object") {
|
||||
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
|
||||
}
|
||||
return (
|
||||
<Grid size={12} key={field.name}>
|
||||
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} />
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
306
react-openapi/src/components/ResourceForm.tsx
Normal file
306
react-openapi/src/components/ResourceForm.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import type { ResourceConfig, FieldConfig } from "../types";
|
||||
import { useResource } from "../context/useResource";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { getApi } from "../hooks/useApi";
|
||||
import { FormFieldRenderer } from "./fields";
|
||||
import { extractFields } from "../transformers/field-config";
|
||||
|
||||
interface ResourceFormProps {
|
||||
resource: ResourceConfig;
|
||||
basePath: string;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const crud = useResource(resource.name);
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({
|
||||
open: false,
|
||||
message: "",
|
||||
severity: "success",
|
||||
});
|
||||
|
||||
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
|
||||
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
|
||||
const { schemas } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`[ResourceForm] mounted resource="${resource.name}" mode=${mode} id=${id}`);
|
||||
console.log(`[ResourceForm] relationships:`, resource.relationships.map(r => `{field=${r.fieldName} target=${r.config.resource} prefetch=${r.config.prefetch}}`));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "create") {
|
||||
const initial: Record<string, any> = {};
|
||||
resource.orderedFields.forEach((f) => {
|
||||
if (f.refSchema && !f.fk && formData[f.name] === undefined) {
|
||||
const refSchemaObj = schemas[f.refSchema!];
|
||||
if (refSchemaObj) {
|
||||
const nestedFields = extractFields(f.refSchema!, refSchemaObj, schemas);
|
||||
initial[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas);
|
||||
} else {
|
||||
initial[f.name] = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (Object.keys(initial).length > 0) {
|
||||
setFormData((prev) => ({ ...prev, ...initial }));
|
||||
}
|
||||
}
|
||||
}, [mode, resource.name]);
|
||||
|
||||
const loadFkOptions = useCallback(async (fieldName: string, fk: { resource: string; prefetch: boolean }) => {
|
||||
console.log(`[loadFkOptions] CALLED field="${fieldName}" resource="${fk.resource}" prefetch=${fk.prefetch}`);
|
||||
setFkLoading((prev) => ({ ...prev, [fieldName]: true }));
|
||||
try {
|
||||
const targetRes = allResources.find((r) => r.name === fk.resource);
|
||||
if (!targetRes) {
|
||||
console.log(`[loadFkOptions] targetRes NOT FOUND for "${fk.resource}"`);
|
||||
return;
|
||||
}
|
||||
console.log(`[loadFkOptions] targetRes found: path="${targetRes.path}" pagination=${!!targetRes.pagination}`);
|
||||
|
||||
const api = getApi();
|
||||
const params: Record<string, any> = {};
|
||||
if (targetRes.pagination) {
|
||||
params.limit = 0;
|
||||
}
|
||||
console.log(`[loadFkOptions] fetching GET ${targetRes.path}`, params);
|
||||
const res = await api.get(targetRes.path, { params });
|
||||
console.log(`[loadFkOptions] response status=${res.status} data type=${typeof res.data} isArray=${Array.isArray(res.data)}`);
|
||||
|
||||
let items: any[];
|
||||
if (targetRes.pagination) {
|
||||
if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) {
|
||||
console.log(`[loadFkOptions] paginated parse FAILED: data=`, res.data);
|
||||
throw new Error(`Expected paginated response from ${targetRes.path}`);
|
||||
}
|
||||
items = res.data.items;
|
||||
console.log(`[loadFkOptions] paginated: total=${res.data.total} items.length=${items.length}`);
|
||||
} else {
|
||||
if (!Array.isArray(res.data)) {
|
||||
console.log(`[loadFkOptions] non-paginated parse FAILED: data=`, res.data);
|
||||
throw new Error(`Expected array response from ${targetRes.path}`);
|
||||
}
|
||||
items = res.data;
|
||||
console.log(`[loadFkOptions] non-paginated: items.length=${items.length}`);
|
||||
}
|
||||
|
||||
const opts = items.map((item: any) => ({
|
||||
value: resolvePk(item, targetRes.primaryKey),
|
||||
label: applyFormat(item, targetRes.displayFormat),
|
||||
}));
|
||||
console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3));
|
||||
|
||||
setFkOptions((prev) => ({ ...prev, [fieldName]: opts }));
|
||||
} catch (e) {
|
||||
console.log(`[loadFkOptions] ERROR field="${fieldName}":`, e);
|
||||
} finally {
|
||||
setFkLoading((prev) => ({ ...prev, [fieldName]: false }));
|
||||
}
|
||||
}, [allResources]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`[prefetch effect] ${resource.relationships.length} relationships, checking prefetch...`);
|
||||
resource.relationships.forEach((rel) => {
|
||||
console.log(`[prefetch effect] field="${rel.fieldName}" prefetch=${rel.config.prefetch} -> ${rel.config.prefetch ? "WILL FETCH" : "skipped (onFocus)"}`);
|
||||
if (rel.config.prefetch) {
|
||||
loadFkOptions(rel.fieldName, rel.config);
|
||||
}
|
||||
});
|
||||
}, [resource.relationships, loadFkOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && id) {
|
||||
crud.get(id).then((data) => {
|
||||
const resolved = { ...(data ?? {}) };
|
||||
resource.relationships.forEach((rel) => {
|
||||
const val = resolved[rel.fieldName];
|
||||
if (val != null) {
|
||||
const targetRes = allResources.find((r) => r.name === rel.config.resource);
|
||||
if (targetRes) {
|
||||
if (Array.isArray(val)) {
|
||||
resolved[rel.fieldName] = val.map((item: any) => resolvePk(item, targetRes.primaryKey));
|
||||
} else if (typeof val === "object") {
|
||||
resolved[rel.fieldName] = resolvePk(val, targetRes.primaryKey);
|
||||
}
|
||||
}
|
||||
if (!rel.config.prefetch) {
|
||||
loadFkOptions(rel.fieldName, rel.config);
|
||||
}
|
||||
}
|
||||
});
|
||||
setFormData(resolved);
|
||||
});
|
||||
}
|
||||
}, [mode, id, loadFkOptions, resource.relationships]);
|
||||
|
||||
const loadFkOnFocus = (fieldName: string) => {
|
||||
console.log(`[loadFkOnFocus] CALLED field="${fieldName}"`);
|
||||
const rel = resource.relationships.find((r) => r.fieldName === fieldName);
|
||||
if (rel) {
|
||||
console.log(`[loadFkOnFocus] found rel: prefetch=${rel.config.prefetch} fkOptions[${fieldName}]=${fkOptions[fieldName] ? "exists" : "undefined"}`);
|
||||
} else {
|
||||
console.log(`[loadFkOnFocus] NO RELATIONSHIP found for field="${fieldName}"`);
|
||||
}
|
||||
if (rel && !rel.config.prefetch && !fkOptions[fieldName]) {
|
||||
console.log(`[loadFkOnFocus] conditions met -> calling loadFkOptions`);
|
||||
loadFkOptions(fieldName, rel.config);
|
||||
} else {
|
||||
console.log(`[loadFkOnFocus] NOT calling loadFkOptions: rel=${!!rel} !prefetch=${rel && !rel.config.prefetch} !hasOptions=${!fkOptions[fieldName]}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validationErrors: Record<string, string> = {};
|
||||
resource.orderedFields
|
||||
.filter((f) => f.required && !f.readOnly && f.name !== resource.primaryKey)
|
||||
.forEach((f) => {
|
||||
if (formData[f.name] === undefined || formData[f.name] === null || formData[f.name] === "") {
|
||||
validationErrors[f.name] = `${f.label} is required`;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await crud.create(formData);
|
||||
setSnackbar({ open: true, message: "Created successfully", severity: "success" });
|
||||
navigate(`${basePath}/${resource.name}`);
|
||||
} else {
|
||||
await crud.update(id!, formData);
|
||||
setSnackbar({ open: true, message: "Updated successfully", severity: "success" });
|
||||
navigate(`${basePath}/${resource.name}/${id}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSnackbar({ open: true, message: e.message ?? "Operation failed", severity: "error" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (fieldName: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
if (errors[fieldName]) {
|
||||
setErrors((prev) => {
|
||||
const copy = { ...prev };
|
||||
delete copy[fieldName];
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === "create" ? `Create ${resource.displayName}` : `Edit ${resource.displayName}`;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
|
||||
Back
|
||||
</Button>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Grid container spacing={2}>
|
||||
{resource.orderedFields
|
||||
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
|
||||
.map((field) => (
|
||||
<Grid size={12} key={field.name}>
|
||||
<FormFieldRenderer
|
||||
field={field}
|
||||
value={formData[field.name]}
|
||||
onChange={(val) => handleChange(field.name, val)}
|
||||
error={errors[field.name]}
|
||||
fkOptions={fkOptions[field.name]}
|
||||
fkLoading={fkLoading[field.name]}
|
||||
recordId={id}
|
||||
onFkOpen={loadFkOnFocus}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3, display: "flex", gap: 2 }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={saving ? <CircularProgress size={18} color="inherit" /> : <SaveIcon />}
|
||||
disabled={saving}
|
||||
>
|
||||
{mode === "create" ? "Create" : "Save Changes"}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => navigate(`${basePath}/${resource.name}`)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
|
||||
>
|
||||
<Alert severity={snackbar.severity} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePk(item: any, pk: string): any {
|
||||
const v = item?.[pk];
|
||||
return v != null ? v : item?.[`_${pk}`];
|
||||
}
|
||||
|
||||
function applyFormat(obj: any, format: string): string {
|
||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
||||
}
|
||||
|
||||
function buildInitialShape(fields: FieldConfig[], schemas: Record<string, any>): Record<string, any> {
|
||||
const shape: Record<string, any> = {};
|
||||
for (const f of fields) {
|
||||
if (f.refSchema && !f.fk) {
|
||||
const refSchemaObj = schemas[f.refSchema!];
|
||||
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
|
||||
shape[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas);
|
||||
} else {
|
||||
shape[f.name] = null;
|
||||
}
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
407
react-openapi/src/components/ResourceList.tsx
Normal file
407
react-openapi/src/components/ResourceList.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
Paper,
|
||||
TableSortLabel,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import type { ResourceConfig, FieldConfig } from "../types";
|
||||
import { useResource } from "../context/useResource";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./fields";
|
||||
import { FilterBar } from "./FilterBar";
|
||||
import { readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource";
|
||||
import { SseConnectionStatus } from "./SseConnectionStatus";
|
||||
|
||||
interface ResourceListProps {
|
||||
resource: ResourceConfig;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
function matchRow(row: any, filters: Record<string, string>, fields: FieldConfig[], allResources: ResourceConfig[]): boolean {
|
||||
for (const field of fields) {
|
||||
if (!field.filterable) continue;
|
||||
|
||||
const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time";
|
||||
|
||||
if (isRange) {
|
||||
const from = filters[field.name + "_from"];
|
||||
const to = filters[field.name + "_to"];
|
||||
if (from || to) {
|
||||
const cell = row[field.name];
|
||||
if (cell == null) return false;
|
||||
if (field.type === "integer" || field.type === "number") {
|
||||
if (from && Number(cell) < Number(from)) return false;
|
||||
if (to && Number(cell) > Number(to)) return false;
|
||||
} else {
|
||||
if (from && String(cell) < String(from)) return false;
|
||||
if (to && String(cell) > String(to)) return false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const val = filters[field.name];
|
||||
if (!val) continue;
|
||||
|
||||
const cell = row[field.name];
|
||||
if (cell == null) return false;
|
||||
|
||||
let str: string;
|
||||
if (field.fk && typeof cell === "object" && cell !== null) {
|
||||
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
|
||||
if (targetRes) {
|
||||
const items = Array.isArray(cell) ? cell : [cell];
|
||||
str = items.map((item: any) => applyDisplayFormat(item, targetRes.displayFormat)).join(" ");
|
||||
} else {
|
||||
str = String(cell);
|
||||
}
|
||||
} else {
|
||||
str = String(cell);
|
||||
}
|
||||
const filterParts = val.split(",").filter(Boolean);
|
||||
if (!filterParts.some((part) => str.toLowerCase().includes(part.toLowerCase()))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
const navigate = useNavigate();
|
||||
const { components, ...crud } = useResource(resource.name);
|
||||
const { resources: allResources, config } = useAppContext();
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20);
|
||||
const [sortField, setSortField] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [detailRow, setDetailRow] = useState<any | null>(null);
|
||||
|
||||
const isStreaming = resource.streaming === true;
|
||||
const hasActions = resource.operations.get || resource.operations.update || resource.operations.delete;
|
||||
|
||||
const filterMode = config.resourceConfig?.[resource.name]?.filterOptions?.mode ?? "client";
|
||||
const isClientMode = filterMode === "client" && !isStreaming;
|
||||
|
||||
const visibleColumns = resource.listColumns
|
||||
.map((colName) => resource.fields.find((f) => f.name === colName))
|
||||
.filter((f): f is FieldConfig => !!f && !f.hidden?.list);
|
||||
|
||||
useEffect(() => {
|
||||
setFilters({});
|
||||
}, [resource.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !crud.stream) return;
|
||||
|
||||
setData(readSseCache(resource.name));
|
||||
setSseConnected(resource.name, false);
|
||||
|
||||
const sub = crud.stream({
|
||||
onEvent: (evt) => {
|
||||
const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() };
|
||||
const updated = appendSseCache(resource.name, enriched);
|
||||
setData(updated);
|
||||
},
|
||||
onOpen: () => setSseConnected(resource.name, true),
|
||||
onError: () => setSseConnected(resource.name, false),
|
||||
});
|
||||
|
||||
return () => {
|
||||
setSseConnected(resource.name, false);
|
||||
sub.close();
|
||||
};
|
||||
}, [isStreaming, crud.stream, resource.name]);
|
||||
|
||||
const serverFetchData = useCallback(async () => {
|
||||
const params: Record<string, any> = {};
|
||||
if (resource.pagination) {
|
||||
params[resource.pagination.limitParam] = rowsPerPage;
|
||||
params[resource.pagination.offsetParam] = page * rowsPerPage;
|
||||
}
|
||||
if (sortField) {
|
||||
params.sort = sortDir === "desc" ? `-${sortField}` : sortField;
|
||||
}
|
||||
for (const [key, val] of Object.entries(filters)) {
|
||||
if (val) params[key] = val;
|
||||
}
|
||||
const result = await crud.list(params);
|
||||
setData(result.items ?? []);
|
||||
setTotal(result.total ?? result.items?.length ?? 0);
|
||||
}, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir, filters]);
|
||||
|
||||
const clientFetchAll = useCallback(async () => {
|
||||
const params: Record<string, any> = {};
|
||||
if (resource.pagination) {
|
||||
params[resource.pagination.limitParam] = 0;
|
||||
}
|
||||
const result = await crud.list(params);
|
||||
setData(result.items ?? []);
|
||||
setTotal(result.items?.length ?? 0);
|
||||
}, [crud.list, resource.pagination]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming) return;
|
||||
if (isClientMode) {
|
||||
clientFetchAll();
|
||||
} else {
|
||||
serverFetchData();
|
||||
}
|
||||
}, [isStreaming, isClientMode, clientFetchAll, serverFetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isClientMode) {
|
||||
setPage(0);
|
||||
}
|
||||
}, [filters, isClientMode]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!isClientMode) return data;
|
||||
|
||||
let items = data.filter((row) => matchRow(row, filters, resource.fields, allResources));
|
||||
|
||||
if (sortField) {
|
||||
items = [...items].sort((a, b) => {
|
||||
const aVal = a[sortField];
|
||||
const bVal = b[sortField];
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
|
||||
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
const start = page * rowsPerPage;
|
||||
return items.slice(start, start + rowsPerPage);
|
||||
}, [data, isClientMode, filters, sortField, sortDir, page, rowsPerPage, resource.fields, allResources]);
|
||||
|
||||
const clientTotal = useMemo(() => {
|
||||
if (!isClientMode) return total;
|
||||
return data.filter((row) => matchRow(row, filters, resource.fields, allResources)).length;
|
||||
}, [data, isClientMode, filters, resource.fields, allResources]);
|
||||
|
||||
const displayData = isClientMode ? filteredData : data;
|
||||
const displayTotal = isClientMode ? clientTotal : total;
|
||||
|
||||
const handleDelete = async (id: string | number) => {
|
||||
if (!window.confirm("Are you sure you want to delete this item?")) return;
|
||||
await crud.remove(id);
|
||||
if (isClientMode) {
|
||||
clientFetchAll();
|
||||
} else {
|
||||
serverFetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (fieldName: string, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
{resource.displayName}
|
||||
</Typography>
|
||||
{isStreaming && <SseConnectionStatus resourceName={resource.name} />}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
{isStreaming && data.length > 0 && (
|
||||
<Button variant="outlined" size="small" onClick={() => { setData([]); setTotal(0); clearSseCache(resource.name); }}>
|
||||
Clear ({data.length})
|
||||
</Button>
|
||||
)}
|
||||
{resource.operations.create && !isStreaming && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{!isStreaming && (
|
||||
<FilterBar
|
||||
resourceName={resource.name}
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClear={() => setFilters({})}
|
||||
data={data}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{visibleColumns.map((col) => (
|
||||
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
|
||||
{col.sortable ? (
|
||||
<TableSortLabel
|
||||
active={sortField === col.name}
|
||||
direction={sortField === col.name ? sortDir : "asc"}
|
||||
onClick={() => handleSort(col.name)}
|
||||
>
|
||||
{col.label}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
col.label
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
{hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{displayData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
|
||||
{isStreaming ? "Waiting for events\u2026" : "No records found"}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
displayData.map((row, idx) => {
|
||||
const rowId = isStreaming ? `evt-${row._seq ?? idx}` : row[resource.primaryKey];
|
||||
return (
|
||||
<TableRow
|
||||
key={rowId}
|
||||
hover
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
if (isStreaming) {
|
||||
setDetailRow(row);
|
||||
} else {
|
||||
navigate(`${basePath}/${resource.name}/${rowId}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map((col) => {
|
||||
let value = row[col.name];
|
||||
let fmt = resource.displayFormat;
|
||||
if (col.fk) {
|
||||
const targetRes = allResources.find((r) => r.name === col.fk!.resource);
|
||||
fmt = targetRes!.displayFormat;
|
||||
} else if (col.refSchema && !col.fk && col.inlineDisplayFormat) {
|
||||
fmt = col.inlineDisplayFormat;
|
||||
}
|
||||
return (
|
||||
<TableCell key={col.name}>
|
||||
<ListCellRenderer field={col} value={value} displayFormat={fmt} />
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
{hasActions && (
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
{resource.operations.get && !isStreaming && (
|
||||
<Tooltip title="View">
|
||||
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{resource.operations.update && (
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{resource.operations.delete && (
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{!isStreaming && (resource.pagination || isClientMode) && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={displayTotal}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setRowsPerPage(parseInt(e.target.value, 10));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isStreaming && displayData.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block", textAlign: "right" }}>
|
||||
{displayTotal} record{displayTotal !== 1 ? "s" : ""}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Dialog open={!!detailRow} onClose={() => setDetailRow(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{resource.displayName} Event</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{detailRow && (
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
{visibleColumns.map((col) => (
|
||||
<Grid key={col.name} size={{ xs: 12, sm: 6 }}>
|
||||
<DetailFieldRenderer
|
||||
field={col}
|
||||
value={detailRow[col.name]}
|
||||
displayFormat={resource.displayFormat}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailRow(null)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
110
react-openapi/src/components/SideMenu.tsx
Normal file
110
react-openapi/src/components/SideMenu.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Box,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import CircleIcon from "@mui/icons-material/Circle";
|
||||
import type { ResourceConfig } from "../types";
|
||||
|
||||
interface SideMenuProps {
|
||||
resources: ResourceConfig[];
|
||||
basePath: string;
|
||||
mobileOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const drawerWidth = 260;
|
||||
|
||||
export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const colors = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
|
||||
];
|
||||
|
||||
const content = (
|
||||
<Box>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" fontWeight={700} noWrap>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<List sx={{ px: 1 }}>
|
||||
{resources.map((r, i) => {
|
||||
const listPath = `${basePath}/${r.name}`;
|
||||
const active = location.pathname.startsWith(listPath);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={r.name}
|
||||
selected={active}
|
||||
onClick={() => {
|
||||
navigate(listPath);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
"&.Mui-selected": {
|
||||
bgcolor: `${colors[i % colors.length]}15`,
|
||||
"&:hover": { bgcolor: `${colors[i % colors.length]}20` },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={r.displayName}
|
||||
primaryTypographyProps={{ fontWeight: active ? 700 : 500, fontSize: 14 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={onClose}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
"& .MuiDrawer-paper": { boxSizing: "border-box", width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
"& .MuiDrawer-paper": { width: drawerWidth, boxSizing: "border-box" },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export { drawerWidth };
|
||||
34
react-openapi/src/components/SseConnectionStatus.tsx
Normal file
34
react-openapi/src/components/SseConnectionStatus.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||
import { useSseConnected } from "../context/useResource";
|
||||
|
||||
interface SseConnectionStatusProps {
|
||||
resourceName: string;
|
||||
}
|
||||
|
||||
export function SseConnectionStatus({ resourceName }: SseConnectionStatusProps) {
|
||||
const connected = useSseConnected(resourceName);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: connected ? "#4caf50" : "#f44336",
|
||||
color: connected ? "#4caf50" : "#f44336",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<FiberManualRecordIcon sx={{ fontSize: 10 }} />
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
96
react-openapi/src/components/SseStreamView.tsx
Normal file
96
react-openapi/src/components/SseStreamView.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Box, Typography, Paper, Chip, Snackbar,
|
||||
} from "@mui/material";
|
||||
import type { ResourceConfig } from "../types";
|
||||
import { useResource, readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource";
|
||||
import { applyDisplayFormat } from "./fields";
|
||||
import { SseConnectionStatus } from "./SseConnectionStatus";
|
||||
|
||||
interface SseStreamViewProps {
|
||||
resource: ResourceConfig;
|
||||
}
|
||||
|
||||
export function SseStreamView({ resource }: SseStreamViewProps) {
|
||||
const { stream } = useResource(resource.name);
|
||||
const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name));
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMsg, setSnackbarMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream) return;
|
||||
setSseConnected(resource.name, false);
|
||||
|
||||
const sub = stream({
|
||||
onEvent: (evt) => {
|
||||
const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() };
|
||||
const updated = appendSseCache(resource.name, enriched);
|
||||
setEvents([...updated]);
|
||||
setSnackbarMsg(applyDisplayFormat(evt, resource.displayFormat));
|
||||
setSnackbarOpen(true);
|
||||
},
|
||||
onOpen: () => setSseConnected(resource.name, true),
|
||||
onError: () => setSseConnected(resource.name, false),
|
||||
});
|
||||
|
||||
return () => {
|
||||
setSseConnected(resource.name, false);
|
||||
sub.close();
|
||||
};
|
||||
}, [resource.name]);
|
||||
|
||||
const eventCount = events.length;
|
||||
const latestEvent = events[events.length - 1] ?? null;
|
||||
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
{resource.displayName}
|
||||
</Typography>
|
||||
<SseConnectionStatus resourceName={resource.name} />
|
||||
</Box>
|
||||
<Chip
|
||||
label={eventCount > 0 ? `${eventCount} event${eventCount !== 1 ? "s" : ""}` : "No events"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={eventCount > 0 ? "primary" : "default"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{latestEvent ? (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: "grey.50",
|
||||
borderRadius: 1,
|
||||
p: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: "block" }}>
|
||||
Latest event (#{latestEvent._seq})
|
||||
</Typography>
|
||||
<Typography>
|
||||
{applyDisplayFormat(latestEvent, resource.displayFormat)}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: "center" }}>
|
||||
Waiting for events…
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => setSnackbarOpen(false)}
|
||||
message={snackbarMsg}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
73
react-openapi/src/components/ValidationAlert.tsx
Normal file
73
react-openapi/src/components/ValidationAlert.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Alert, Snackbar, List, ListItem, ListItemIcon, ListItemText } from "@mui/material";
|
||||
import ErrorIcon from "@mui/icons-material/Error";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import type { ValidationMessage } from "../types";
|
||||
|
||||
interface ValidationAlertProps {
|
||||
errors: ValidationMessage[];
|
||||
warnings: ValidationMessage[];
|
||||
}
|
||||
|
||||
export function ValidationAlert({ errors, warnings }: ValidationAlertProps) {
|
||||
const [warningOpen, setWarningOpen] = React.useState(warnings.length > 0);
|
||||
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<Box sx={{ p: 4, maxWidth: 700, mx: "auto", mt: 8 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
OpenAPI Spec Validation Failed
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
The spec has {errors.length} error{errors.length > 1 ? "s" : ""}. Fix them before the admin panel can render.
|
||||
</Typography>
|
||||
</Alert>
|
||||
<List dense>
|
||||
{errors.map((e, i) => (
|
||||
<ListItem key={i}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<ErrorIcon color="error" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={e.message} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{warnings.length > 0 && (
|
||||
<>
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, color: "text.secondary" }}>
|
||||
Warnings ({warnings.length})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{warnings.map((w, i) => (
|
||||
<ListItem key={i}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<WarningIcon color="warning" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={w.message} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={warningOpen}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setWarningOpen(false)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
<Box>
|
||||
{warnings.map((w, i) => (
|
||||
<Alert key={i} severity="warning" sx={{ mb: 1 }} onClose={() => setWarningOpen(false)}>
|
||||
{w.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
23
react-openapi/src/components/fields/DetailFieldRenderer.tsx
Normal file
23
react-openapi/src/components/fields/DetailFieldRenderer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { ListCellRenderer } from "./ListCellRenderer";
|
||||
|
||||
interface DetailFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) {
|
||||
if (field.hidden?.detail) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
127
react-openapi/src/components/fields/FormFieldRenderer.tsx
Normal file
127
react-openapi/src/components/fields/FormFieldRenderer.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { StringField } from "./renderers/StringField";
|
||||
import { NumberField } from "./renderers/NumberField";
|
||||
import { DateField } from "./renderers/DateField";
|
||||
import { BooleanField } from "./renderers/BooleanField";
|
||||
import { EnumField } from "./renderers/EnumField";
|
||||
import { FkSelectField } from "./renderers/FkSelectField";
|
||||
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
|
||||
import { ImageField } from "./renderers/ImageField";
|
||||
import { JsonField } from "./renderers/JsonField";
|
||||
|
||||
interface FormFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
fkLoading?: boolean;
|
||||
recordId?: string | number;
|
||||
onFkOpen?: (fieldName: string) => void;
|
||||
}
|
||||
|
||||
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
|
||||
if (field.hidden?.form) return null;
|
||||
|
||||
if (field.readOnly && field.uiType !== "image") {
|
||||
return (
|
||||
<StringField field={field} value={value} onChange={onChange} error={error} />
|
||||
);
|
||||
}
|
||||
|
||||
if (field.uiType === "image") {
|
||||
return (
|
||||
<ImageField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
id={recordId}
|
||||
uploadUrl={field.uploadUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk) {
|
||||
console.log(`[FormFieldRenderer] FK field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} isArray=${field.isArray}`);
|
||||
if (field.isArray) {
|
||||
return (
|
||||
<FkMultiSelectField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fkOptions={fkOptions}
|
||||
fkLoading={fkLoading}
|
||||
onOpen={() => onFkOpen?.(field.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FkSelectField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
fkOptions={fkOptions}
|
||||
onOpen={() => onFkOpen?.(field.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
return (
|
||||
<EnumField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return <BooleanField field={field} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (field.type === "integer" || field.type === "number") {
|
||||
return (
|
||||
<NumberField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.format === "date" || field.format === "date-time") {
|
||||
return (
|
||||
<DateField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.refSchema && !field.fk) {
|
||||
return (
|
||||
<JsonField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StringField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
70
react-openapi/src/components/fields/ListCellRenderer.tsx
Normal file
70
react-openapi/src/components/fields/ListCellRenderer.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Chip, Avatar } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { applyDisplayFormat } from "./utils";
|
||||
import { InlineRefField } from "./renderers/InlineRefField";
|
||||
|
||||
interface ListCellProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function ListCellRenderer({ field, value, displayFormat }: ListCellProps) {
|
||||
if (value === null || value === undefined) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
|
||||
return <InlineRefField field={field} value={value} displayFormat={displayFormat} />;
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
|
||||
if (value.length === 0) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{value.map((item: any, i: number) => {
|
||||
const label = typeof item === "object"
|
||||
? applyDisplayFormat(item, displayFormat ?? "")
|
||||
: String(item);
|
||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk && typeof value === "object" && !field.isArray) {
|
||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value) && field.fk) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{value.map((item: any, i: number) => {
|
||||
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
|
||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
return <Chip label={value} size="small" />;
|
||||
}
|
||||
|
||||
if (field.uiType === "image" && value) {
|
||||
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return <Chip label={value ? "Yes" : "No"} size="small" color={value ? "success" : "default"} />;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
|
||||
}
|
||||
|
||||
return <Typography variant="body2">{String(value)}</Typography>;
|
||||
}
|
||||
5
react-openapi/src/components/fields/index.ts
Normal file
5
react-openapi/src/components/fields/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { FormFieldRenderer } from "./FormFieldRenderer";
|
||||
export { ListCellRenderer } from "./ListCellRenderer";
|
||||
export { DetailFieldRenderer } from "./DetailFieldRenderer";
|
||||
export { applyDisplayFormat } from "./utils";
|
||||
export { JsonField } from "./renderers/JsonField";
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from "react";
|
||||
import { Box, FormControl, FormControlLabel, Switch, FormHelperText, ToggleButton, ToggleButtonGroup } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import CancelIcon from "@mui/icons-material/Cancel";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
nullable?: boolean;
|
||||
}
|
||||
|
||||
export function BooleanField({ field, value, onChange, nullable }: Props) {
|
||||
if (nullable) {
|
||||
const strValue = String(value ?? "");
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ fontSize: "0.75rem", color: "text.secondary", mb: 0.5, fontWeight: 600 }}>
|
||||
{field.label}
|
||||
</Box>
|
||||
<ToggleButtonGroup
|
||||
value={strValue}
|
||||
exclusive
|
||||
onChange={(_, v) => onChange(v ?? "")}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="" sx={{ color: "text.disabled", borderColor: "divider" }}>
|
||||
<Box sx={{ width: 16, height: 16, borderRadius: "50%", bgcolor: "action.disabledBackground" }} />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="true" sx={{ color: "success.main", borderColor: "success.main" }}>
|
||||
<CheckCircleIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="false" sx={{ color: "error.main", borderColor: "error.main" }}>
|
||||
<CancelIcon fontSize="small" />
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl component="fieldset" fullWidth size="small">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
}
|
||||
label={field.label}
|
||||
/>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
30
react-openapi/src/components/fields/renderers/DateField.tsx
Normal file
30
react-openapi/src/components/fields/renderers/DateField.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DateField({ field, value, onChange, error }: Props) {
|
||||
const inputType = field.format === "date" ? "date" : "datetime-local";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={inputType}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
react-openapi/src/components/fields/renderers/EnumField.tsx
Normal file
34
react-openapi/src/components/fields/renderers/EnumField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function EnumField({ field, value, onChange, error }: Props) {
|
||||
return (
|
||||
<FormControl fullWidth size="small" error={!!error}>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{(field.enumValues ?? []).map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { TextField, Autocomplete } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
fkLoading?: boolean;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) {
|
||||
console.log(`[FkMultiSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} value=${JSON.stringify(value)}`);
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={fkOptions ?? []}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
|
||||
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
|
||||
onOpen={() => onOpen?.()}
|
||||
loading={fkLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={field.label} helperText={field.description} size="small" />
|
||||
)}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export function FkSelectField({ field, value, onChange, error, fkOptions, onOpen }: Props) {
|
||||
console.log(`[FkSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} value=${value}`);
|
||||
return (
|
||||
<FormControl fullWidth size="small" error={!!error}>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onOpen={() => onOpen?.()}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{(fkOptions ?? []).map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
60
react-openapi/src/components/fields/renderers/ImageField.tsx
Normal file
60
react-openapi/src/components/fields/renderers/ImageField.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { getApi } from "../../../hooks/useApi";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
id?: string | number;
|
||||
uploadUrl?: string;
|
||||
}
|
||||
|
||||
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!id || !uploadUrl) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const api = getApi();
|
||||
const url = uploadUrl.replace("{id}", String(id));
|
||||
const res = await api.post(url, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
onChange(res.data.url ?? res.data);
|
||||
} catch {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
{value ? (
|
||||
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
|
||||
) : (
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
Upload {field.label}
|
||||
<input type="file" hidden accept="image/*" onChange={handleUpload} />
|
||||
</Button>
|
||||
)}
|
||||
<FormHelperText>{field.description}</FormHelperText>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Chip } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { applyDisplayFormat } from "../utils";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function InlineRefField({ field, value, displayFormat }: Props) {
|
||||
if (!value || typeof value !== "object") {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
if (displayFormat) {
|
||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{entries.map(([key, v]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={`${key}: ${String(v)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
270
react-openapi/src/components/fields/renderers/JsonField.tsx
Normal file
270
react-openapi/src/components/fields/renderers/JsonField.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Box,
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { useAppContext } from "../../../context/AppContext";
|
||||
import { extractFields } from "../../../transformers/field-config";
|
||||
import { FormFieldRenderer } from "../FormFieldRenderer";
|
||||
|
||||
interface JsonFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
}
|
||||
|
||||
export function JsonField({ field, value, onChange }: JsonFieldProps) {
|
||||
const { schemas } = useAppContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const refSchema = field.refSchema ? schemas[field.refSchema] : null;
|
||||
const subFields = refSchema
|
||||
? extractFields(field.refSchema!, refSchema, schemas)
|
||||
: [];
|
||||
|
||||
const [editValue, setEditValue] = useState<any>(null);
|
||||
|
||||
const handleOpen = () => {
|
||||
setEditValue(initEditValue(value, field, schemas));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(editValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
setEditValue((prev: any[]) => [...(prev || []), buildDefaultShape(subFields, schemas)]);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
setEditValue((prev: any[]) => prev.filter((_: any, i: number) => i !== index));
|
||||
};
|
||||
|
||||
const handleItemFieldChange = (index: number, fieldName: string, val: any) => {
|
||||
setEditValue((prev: any[]) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [fieldName]: val };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, val: any) => {
|
||||
setEditValue((prev: any) => ({ ...prev, [fieldName]: val }));
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
||||
Set {field.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return (
|
||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
||||
Set {field.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
label={`${value.length} item${value.length !== 1 ? "s" : ""}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={handleOpen}
|
||||
onDelete={handleClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const summary = field.inlineDisplayFormat
|
||||
? applyInlineFormat(value, field.inlineDisplayFormat)
|
||||
: Object.entries(value)
|
||||
.filter(([, v]) => v != null)
|
||||
.map(([k, v]) => `${k}: ${String(v)}`)
|
||||
.join(" | ");
|
||||
return (
|
||||
<Chip
|
||||
label={summary || field.label}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={handleOpen}
|
||||
onDelete={handleClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fullScreen open={open} onClose={handleCancel}>
|
||||
<DialogTitle>{field.label}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{field.isArray ? (
|
||||
<ArrayEditor
|
||||
items={editValue ?? []}
|
||||
subFields={subFields}
|
||||
onAddItem={handleAddItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onFieldChange={handleItemFieldChange}
|
||||
schemas={schemas}
|
||||
/>
|
||||
) : (
|
||||
<ObjectEditor
|
||||
value={editValue}
|
||||
subFields={subFields}
|
||||
onFieldChange={handleFieldChange}
|
||||
schemas={schemas}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClear} color="error">
|
||||
Clear
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectEditor({
|
||||
value,
|
||||
subFields,
|
||||
onFieldChange,
|
||||
}: {
|
||||
value: any;
|
||||
subFields: FieldConfig[];
|
||||
onFieldChange: (name: string, val: any) => void;
|
||||
schemas: Record<string, any>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
{subFields.map((subField) => (
|
||||
<Box key={subField.name} sx={{ mb: 2 }}>
|
||||
<FormFieldRenderer
|
||||
field={subField}
|
||||
value={value?.[subField.name]}
|
||||
onChange={(val) => onFieldChange(subField.name, val)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayEditor({
|
||||
items,
|
||||
subFields,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
onFieldChange,
|
||||
schemas,
|
||||
}: {
|
||||
items: any[];
|
||||
subFields: FieldConfig[];
|
||||
onAddItem: () => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
onFieldChange: (index: number, name: string, val: any) => void;
|
||||
schemas: Record<string, any>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
{items.length === 0 && (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
|
||||
No items added yet.
|
||||
</Typography>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
Item {index + 1}
|
||||
</Typography>
|
||||
<IconButton size="small" color="error" onClick={() => onRemoveItem(index)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ pl: 2 }}>
|
||||
{subFields.map((subField) => (
|
||||
<Box key={subField.name} sx={{ mb: 2 }}>
|
||||
<FormFieldRenderer
|
||||
field={subField}
|
||||
value={item?.[subField.name]}
|
||||
onChange={(val) => onFieldChange(index, subField.name, val)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
))}
|
||||
<Button startIcon={<AddIcon />} onClick={onAddItem} variant="outlined" size="small">
|
||||
Add Item
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaultShape(fields: FieldConfig[], schemas: Record<string, any>): Record<string, any> {
|
||||
const shape: Record<string, any> = {};
|
||||
for (const f of fields) {
|
||||
if (f.refSchema && !f.fk) {
|
||||
const refSchemaObj = schemas[f.refSchema!];
|
||||
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
|
||||
shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, schemas);
|
||||
} else {
|
||||
shape[f.name] = null;
|
||||
}
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
|
||||
function initEditValue(value: any, field: FieldConfig, schemas: Record<string, any>): any {
|
||||
if (field.isArray) {
|
||||
return value ? value.map((item: any) => ({ ...item })) : [];
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return { ...value };
|
||||
}
|
||||
return buildDefaultShape(
|
||||
field.refSchema ? extractFields(field.refSchema, schemas[field.refSchema], schemas) : [],
|
||||
schemas
|
||||
);
|
||||
}
|
||||
|
||||
function applyInlineFormat(obj: any, format: string): string {
|
||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function NumberField({ field, value, onChange, error }: Props) {
|
||||
const isFloat = field.type === "number" || field.format === "float";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
onChange("");
|
||||
} else {
|
||||
onChange(isFloat ? parseFloat(raw) : parseInt(raw, 10));
|
||||
}
|
||||
}}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
inputProps={isFloat ? { step: "any" } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function StringField({ field, value, onChange, error }: Props) {
|
||||
const inputType = field.format === "email" ? "email" : "text";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={inputType}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
react-openapi/src/components/fields/utils.ts
Normal file
11
react-openapi/src/components/fields/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
function getNested(obj: any, path: string): any {
|
||||
return path.split(".").reduce((o, k) => o?.[k], obj);
|
||||
}
|
||||
|
||||
export function applyDisplayFormat(item: any, format: string): string {
|
||||
if (!item || typeof item !== "object") return String(item ?? "");
|
||||
return format.replace(/\{([\w.]+)\}/g, (_, key) => {
|
||||
const val = getNested(item, key);
|
||||
return val != null ? String(val) : "";
|
||||
});
|
||||
}
|
||||
21
react-openapi/src/context/AppContext.tsx
Normal file
21
react-openapi/src/context/AppContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ResourceConfig, SpecConfiguration, ValidationMessage } from "../types";
|
||||
|
||||
export interface AppContextValue {
|
||||
config: SpecConfiguration;
|
||||
resources: ResourceConfig[];
|
||||
schemas: Record<string, any>;
|
||||
loading: boolean;
|
||||
errors: ValidationMessage[];
|
||||
warnings: ValidationMessage[];
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
export function useAppContext(): AppContextValue {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAppContext must be used within an AppProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
83
react-openapi/src/context/AppProvider.tsx
Normal file
83
react-openapi/src/context/AppProvider.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import type { SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
|
||||
import { AppContext } from "./AppContext";
|
||||
import { loadSpec } from "../spec-loader";
|
||||
import { validateSpec } from "../spec-validator";
|
||||
import { buildResourceConfigs } from "../transformers/resource-config";
|
||||
import { initApi } from "../hooks/useApi";
|
||||
|
||||
interface AppProviderProps {
|
||||
specConfiguration: SpecConfiguration;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppProvider({ specConfiguration, children }: AppProviderProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resources, setResources] = useState<ResourceConfig[]>([]);
|
||||
const [schemas, setSchemas] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<ValidationMessage[]>([]);
|
||||
const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const spec = await loadSpec(specConfiguration.specUrl);
|
||||
|
||||
const allMessages = validateSpec(spec, specConfiguration);
|
||||
|
||||
const errs = allMessages.filter((m) => m.type === "error");
|
||||
const warns = allMessages.filter((m) => m.type === "warning");
|
||||
|
||||
if (!cancelled) {
|
||||
setErrors(errs);
|
||||
setWarnings(warns);
|
||||
setSchemas(spec.components?.schemas ?? {});
|
||||
}
|
||||
|
||||
if (errs.length === 0) {
|
||||
const configs = buildResourceConfigs(spec);
|
||||
if (!cancelled) {
|
||||
setResources(configs);
|
||||
}
|
||||
|
||||
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";
|
||||
if (baseUrl) {
|
||||
initApi(baseUrl, specConfiguration.getToken);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) {
|
||||
setErrors([{ type: "error", message: e.message ?? "Failed to load spec" }]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [specConfiguration.specUrl]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
config: specConfiguration,
|
||||
resources,
|
||||
schemas,
|
||||
loading,
|
||||
errors,
|
||||
warnings,
|
||||
}),
|
||||
[specConfiguration, resources, schemas, loading, errors, warnings]
|
||||
);
|
||||
|
||||
return React.createElement(AppContext.Provider, { value }, children);
|
||||
}
|
||||
550
react-openapi/src/context/useResource.tsx
Normal file
550
react-openapi/src/context/useResource.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { Autocomplete, TextField } from "@mui/material";
|
||||
import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
|
||||
import { useAppContext } from "./AppContext";
|
||||
import { getApi } from "../hooks/useApi";
|
||||
import { StringField } from "../components/fields/renderers/StringField";
|
||||
import { NumberField } from "../components/fields/renderers/NumberField";
|
||||
import { DateField } from "../components/fields/renderers/DateField";
|
||||
import { BooleanField } from "../components/fields/renderers/BooleanField";
|
||||
import { EnumField } from "../components/fields/renderers/EnumField";
|
||||
import { FkSelectField } from "../components/fields/renderers/FkSelectField";
|
||||
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
|
||||
|
||||
function parseError(e: any): string {
|
||||
if (e.response?.data) {
|
||||
const data = e.response.data;
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((err: any) => err.msg ?? String(err)).join("; ");
|
||||
}
|
||||
if (typeof data.detail === "string") {
|
||||
return data.detail;
|
||||
}
|
||||
}
|
||||
return e.message ?? "An error occurred";
|
||||
}
|
||||
|
||||
interface ResourceState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FilterComponentProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
data?: any[];
|
||||
labelOverride?: string;
|
||||
}
|
||||
|
||||
interface StreamHandlers {
|
||||
onEvent: (data: any) => void;
|
||||
onError?: (evt: Event) => void;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
interface StreamSubscription {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface UseResourceReturn {
|
||||
resource: ResourceConfig;
|
||||
components: Record<string, React.FC<FilterComponentProps>>;
|
||||
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
|
||||
get: (id: string | number, params?: Record<string, any>) => Promise<any>;
|
||||
create: (data: any) => Promise<any>;
|
||||
update: (id: string | number, data: any) => Promise<any>;
|
||||
remove: (id: string | number) => Promise<void>;
|
||||
stream?: (handlers: StreamHandlers) => StreamSubscription;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const _fkOptionsCache = new Map<string, { value: any; label: string }[]>();
|
||||
const _stringOptionsCache = new Map<string, string[]>();
|
||||
const _sseEventCache = new Map<string, any[]>();
|
||||
let _sseSeq = 0;
|
||||
|
||||
export function readSseCache(resourceName: string): any[] {
|
||||
return _sseEventCache.get(resourceName) ?? [];
|
||||
}
|
||||
|
||||
export function appendSseCache(resourceName: string, event: any): any[] {
|
||||
const events = _sseEventCache.get(resourceName) ?? [];
|
||||
events.push(event);
|
||||
if (events.length > 100) events.splice(0, events.length - 100);
|
||||
_sseEventCache.set(resourceName, events);
|
||||
return events;
|
||||
}
|
||||
|
||||
export function clearSseCache(resourceName: string): void {
|
||||
_sseEventCache.delete(resourceName);
|
||||
}
|
||||
|
||||
export function nextSseSeq(): number {
|
||||
return ++_sseSeq;
|
||||
}
|
||||
|
||||
const _sseConnection = new Map<string, boolean>();
|
||||
const _sseListeners = new Map<string, Set<() => void>>();
|
||||
|
||||
export function setSseConnected(resourceName: string, connected: boolean): void {
|
||||
if (_sseConnection.get(resourceName) === connected) return;
|
||||
_sseConnection.set(resourceName, connected);
|
||||
_sseListeners.get(resourceName)?.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function getSseConnected(resourceName: string): boolean {
|
||||
return _sseConnection.get(resourceName) ?? false;
|
||||
}
|
||||
|
||||
export function useSseConnected(resourceName: string): boolean {
|
||||
const [connected, setConnected] = useState(() => getSseConnected(resourceName));
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => setConnected(getSseConnected(resourceName));
|
||||
const listeners = _sseListeners.get(resourceName) ?? new Set();
|
||||
listeners.add(cb);
|
||||
_sseListeners.set(resourceName, listeners);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
if (listeners.size === 0) _sseListeners.delete(resourceName);
|
||||
};
|
||||
}, [resourceName]);
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
function extractDataOptions(data: any[], fieldName: string): string[] {
|
||||
const values = new Set<string>();
|
||||
for (const row of data) {
|
||||
const v = row[fieldName];
|
||||
if (v != null && v !== "") {
|
||||
values.add(String(v));
|
||||
}
|
||||
}
|
||||
return [...values].sort();
|
||||
}
|
||||
|
||||
function applyDisplayFormat(obj: any, format: string): string {
|
||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
||||
}
|
||||
|
||||
function buildFilterComponent(field: FieldConfig, resourceName: string): React.FC<FilterComponentProps> {
|
||||
if (field.type === "boolean") {
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<BooleanField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
nullable
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk) {
|
||||
const FkFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const { resources, config } = useAppContext();
|
||||
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
||||
const targetRes = resources.find((r) => r.name === field.fk!.resource);
|
||||
const [options, setOptions] = useState<{ value: any; label: string }[]>([]);
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode === "client" && data && targetRes) {
|
||||
const seen = new Set<string>();
|
||||
const opts: { value: any; label: string }[] = [];
|
||||
for (const row of data) {
|
||||
const items = Array.isArray(row[field.name]) ? row[field.name] : [row[field.name]];
|
||||
for (const item of items) {
|
||||
if (item == null) continue;
|
||||
const label = applyDisplayFormat(item, targetRes.displayFormat);
|
||||
if (!seen.has(label)) {
|
||||
seen.add(label);
|
||||
opts.push({ value: label, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.sort((a, b) => a.label.localeCompare(b.label));
|
||||
setOptions(opts);
|
||||
fetched.current = true;
|
||||
} else if (filterMode === "server" && targetRes && !fetched.current) {
|
||||
const cacheKey = targetRes.name;
|
||||
if (_fkOptionsCache.has(cacheKey)) {
|
||||
setOptions(_fkOptionsCache.get(cacheKey)!);
|
||||
fetched.current = true;
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const api = getApi();
|
||||
const params: Record<string, any> = {};
|
||||
if (targetRes.pagination) params.limit = 0;
|
||||
const res = await api.get(targetRes.path, { params });
|
||||
let items: any[];
|
||||
if (targetRes.pagination) {
|
||||
items = res.data.items ?? [];
|
||||
} else {
|
||||
items = Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
const opts = items.map((item: any) => {
|
||||
const label = applyDisplayFormat(item, targetRes.displayFormat);
|
||||
return { value: label, label };
|
||||
});
|
||||
_fkOptionsCache.set(cacheKey, opts);
|
||||
setOptions(opts);
|
||||
fetched.current = true;
|
||||
} catch {
|
||||
fetched.current = true;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [filterMode, data, targetRes]);
|
||||
|
||||
if (field.isArray) {
|
||||
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||
return (
|
||||
<FkMultiSelectField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={selected}
|
||||
onChange={(v: any[]) => onChange(v.join(","))}
|
||||
fkOptions={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FkSelectField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v: any) => onChange(v ?? "")}
|
||||
fkOptions={options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return FkFilter;
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
const EnumFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const dataOptions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return extractDataOptions(data, field.name);
|
||||
}, [data]);
|
||||
const merged = useMemo(
|
||||
() => [...new Set([...(field.enumValues ?? []), ...dataOptions])],
|
||||
[dataOptions]
|
||||
);
|
||||
return (
|
||||
<EnumField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label, enumValues: merged }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return EnumFilter;
|
||||
}
|
||||
|
||||
if (field.type === "integer" || field.type === "number") {
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<NumberField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v === "" ? "" : String(v))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.format === "date" || field.format === "date-time") {
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<DateField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAutocompleteFilter(getDisplayValue: (row: any) => string) {
|
||||
const StringAutocompleteFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const { resources, config } = useAppContext();
|
||||
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode === "client" && data) {
|
||||
const vals = new Set<string>();
|
||||
for (const row of data) {
|
||||
const v = getDisplayValue(row);
|
||||
if (v && v !== "") vals.add(v);
|
||||
}
|
||||
setOptions([...vals].sort());
|
||||
fetched.current = true;
|
||||
} else if (filterMode === "server" && !fetched.current) {
|
||||
const cacheKey = resourceName + ":" + field.name;
|
||||
if (_stringOptionsCache.has(cacheKey)) {
|
||||
setOptions(_stringOptionsCache.get(cacheKey)!);
|
||||
fetched.current = true;
|
||||
} else {
|
||||
(async () => {
|
||||
try {
|
||||
const api = getApi();
|
||||
const selfRes = resources.find((r) => r.name === resourceName);
|
||||
if (!selfRes) { fetched.current = true; return; }
|
||||
const params: Record<string, any> = {};
|
||||
if (selfRes.pagination) params.limit = 0;
|
||||
const res = await api.get(selfRes.path, { params });
|
||||
let items: any[];
|
||||
if (selfRes.pagination) {
|
||||
items = Array.isArray(res.data) ? res.data : (res.data.items ?? []);
|
||||
} else {
|
||||
items = Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
const values = [...new Set(items.map((r: any) => getDisplayValue(r)).filter(Boolean))].sort();
|
||||
_stringOptionsCache.set(cacheKey, values);
|
||||
setOptions(values);
|
||||
fetched.current = true;
|
||||
} catch {
|
||||
fetched.current = true;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
size="small"
|
||||
options={options}
|
||||
value={value || null}
|
||||
onInputChange={(_, newVal) => onChange(newVal ?? "")}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={labelOverride ?? field.label}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return StringAutocompleteFilter;
|
||||
}
|
||||
|
||||
const isSimpleField =
|
||||
!field.fk && !field.enumValues &&
|
||||
field.type !== "boolean" && field.type !== "integer" && field.type !== "number" &&
|
||||
field.format !== "date" && field.format !== "date-time";
|
||||
|
||||
if (isSimpleField && !field.refSchema) {
|
||||
return buildAutocompleteFilter((row) => String(row[field.name] ?? ""));
|
||||
}
|
||||
|
||||
if (field.refSchema && field.inlineDisplayFormat) {
|
||||
return buildAutocompleteFilter((row) => {
|
||||
const val = row[field.name];
|
||||
if (val == null || typeof val !== "object") return "";
|
||||
return field.inlineDisplayFormat!.replace(/\{(\w+)\}/g, (_, key) => String(val[key] ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
return ({ value, onChange, labelOverride }) => (
|
||||
<StringField
|
||||
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function useResource(resourceName: string): UseResourceReturn {
|
||||
const { resources } = useAppContext();
|
||||
const resource = resources.find((r) => r.name === resourceName);
|
||||
|
||||
const [state, setState] = useState<ResourceState>({ loading: false, error: null });
|
||||
|
||||
const rPath = resource?.path;
|
||||
const rPagination = resource?.pagination;
|
||||
const rUpdateMethod = resource?.updateMethod;
|
||||
const rStreaming = resource?.streaming;
|
||||
const rFields = resource?.fields;
|
||||
|
||||
const setLoading = useCallback((loading: boolean) => {
|
||||
setState((s) => ({ ...s, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState((s) => ({ ...s, error }));
|
||||
}, []);
|
||||
|
||||
const list = useCallback(
|
||||
async (params?: Record<string, any>): Promise<ParsedListResponse> => {
|
||||
if (!rPath) return { items: [] };
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(rPath, { params });
|
||||
const data = res.data;
|
||||
|
||||
if (rPagination) {
|
||||
if (Array.isArray(data)) {
|
||||
return { items: data };
|
||||
}
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
|
||||
throw new Error(`Expected paginated response { total, items } from ${rPath}`);
|
||||
}
|
||||
return { items: data.items, total: data.total ?? data.items.length };
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`Expected array response from ${rPath}`);
|
||||
}
|
||||
return { items: data };
|
||||
} catch (e: any) {
|
||||
const msg = parseError(e);
|
||||
setError(msg);
|
||||
return { items: [] };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[rPath, rPagination, setLoading, setError]
|
||||
);
|
||||
|
||||
const get = useCallback(
|
||||
async (id: string | number, params?: Record<string, any>): Promise<any> => {
|
||||
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(`${rPath}/${id}`, { params });
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[rPath, setLoading, setError]
|
||||
);
|
||||
|
||||
const create = useCallback(
|
||||
async (data: any): Promise<any> => {
|
||||
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.post(rPath, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[rPath, setLoading, setError]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string | number, data: any): Promise<any> => {
|
||||
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const method = rUpdateMethod ?? "put";
|
||||
const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[rPath, rUpdateMethod, setLoading, setError]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string | number): Promise<void> => {
|
||||
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
await api.delete(`${rPath}/${id}`);
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[rPath, setLoading, setError]
|
||||
);
|
||||
|
||||
const stream = useCallback(
|
||||
(handlers: StreamHandlers): StreamSubscription => {
|
||||
if (!rPath || !rStreaming) {
|
||||
throw new Error(`Resource "${resourceName}" does not support streaming`);
|
||||
}
|
||||
const api = getApi();
|
||||
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
const url = baseUrl + rPath;
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.onopen = () => handlers.onOpen?.();
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
handlers.onEvent(data);
|
||||
} catch {
|
||||
// ignore malformed JSON payloads
|
||||
}
|
||||
};
|
||||
es.onerror = (e) => {
|
||||
handlers.onError?.(e);
|
||||
};
|
||||
|
||||
return { close: () => es.close() };
|
||||
},
|
||||
[rPath, rStreaming, resourceName]
|
||||
);
|
||||
|
||||
const components = useMemo(
|
||||
() => {
|
||||
const map: Record<string, React.FC<FilterComponentProps>> = {};
|
||||
if (!rFields) return map;
|
||||
for (const field of rFields) {
|
||||
map[field.name] = buildFilterComponent(field, resourceName);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
[rFields, resourceName]
|
||||
);
|
||||
|
||||
if (!resource) {
|
||||
return {
|
||||
resource: null as unknown as ResourceConfig,
|
||||
components,
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
stream: undefined,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { resource, components, list, get, create, update, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
|
||||
}
|
||||
45
react-openapi/src/hooks/useApi.ts
Normal file
45
react-openapi/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { tokenStore } from "../../../react-auth/token";
|
||||
|
||||
let apiClient: AxiosInstance | null = null;
|
||||
|
||||
export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance {
|
||||
if (apiClient && apiClient.defaults.baseURL === baseUrl) {
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
apiClient = axios.create({
|
||||
baseURL: baseUrl,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getToken?.();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && getToken) {
|
||||
const currentToken = getToken();
|
||||
if (currentToken) {
|
||||
tokenStore.clear();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
export function getApi(): AxiosInstance {
|
||||
if (!apiClient) {
|
||||
throw new Error("API client not initialized. Make sure AppProvider is mounted.");
|
||||
}
|
||||
return apiClient;
|
||||
}
|
||||
17
react-openapi/src/spec-loader.ts
Normal file
17
react-openapi/src/spec-loader.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as yaml from "js-yaml";
|
||||
import type { OpenApiSpec } from "./types";
|
||||
|
||||
export async function loadSpec(url: string): Promise<OpenApiSpec> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch spec: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
|
||||
const parsed = yaml.load(text);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Spec is empty or not an object");
|
||||
}
|
||||
|
||||
return parsed as OpenApiSpec;
|
||||
}
|
||||
132
react-openapi/src/spec-validator.ts
Normal file
132
react-openapi/src/spec-validator.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types";
|
||||
|
||||
export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
|
||||
const messages: ValidationMessage[] = [];
|
||||
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
|
||||
const paths = spec.paths ?? {};
|
||||
|
||||
if (!spec.openapi) {
|
||||
messages.push({ type: "error", message: "Missing 'openapi' version field" });
|
||||
}
|
||||
|
||||
if (!spec.info?.title) {
|
||||
messages.push({ type: "error", message: "Missing 'info.title'" });
|
||||
}
|
||||
|
||||
if (!spec.servers?.[0]?.url && !specConfig?.baseApiUrl) {
|
||||
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
|
||||
}
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (!schema || typeof schema !== "object") continue;
|
||||
|
||||
const isResource = typeof schema["x-resource"] === "string";
|
||||
|
||||
if (!isResource) continue;
|
||||
|
||||
const resourcePath = `/${schema["x-resource"]}`;
|
||||
|
||||
if (!schema["x-primary-key"]) {
|
||||
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
|
||||
}
|
||||
|
||||
if (!schema["x-display-format"]) {
|
||||
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-display-format'` });
|
||||
}
|
||||
|
||||
if (!schema["x-list-columns"]) {
|
||||
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-list-columns'` });
|
||||
}
|
||||
|
||||
if (Array.isArray(schema["x-list-columns"])) {
|
||||
const props = schema.properties ?? {};
|
||||
for (const col of schema["x-list-columns"]) {
|
||||
if (!props[col]) {
|
||||
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = schema.properties ?? {};
|
||||
for (const [propName, _raw] of Object.entries(props)) {
|
||||
const prop = _raw as any;
|
||||
if (!prop || typeof prop !== "object") continue;
|
||||
if (!prop["x-label"]) {
|
||||
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
|
||||
}
|
||||
if (prop["x-order"] === undefined || prop["x-order"] === null) {
|
||||
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
|
||||
}
|
||||
|
||||
if (prop["$ref"] && !prop["x-fk"]) {
|
||||
const refName = (prop["$ref"] as string).split("/").pop();
|
||||
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
|
||||
}
|
||||
|
||||
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
|
||||
const refName = (prop.items.$ref as string).split("/").pop();
|
||||
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
|
||||
}
|
||||
|
||||
if (prop["x-fk"]) {
|
||||
const fkResource = prop["x-fk"].resource as string;
|
||||
const targetSchema = Object.entries(schemas as Record<string, any>).find(([, s]) => s?.["x-resource"] === fkResource);
|
||||
if (!targetSchema) {
|
||||
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no schema has x-resource="${fkResource}"` });
|
||||
} else {
|
||||
const [, target] = targetSchema;
|
||||
if (!target["x-display-format"]) {
|
||||
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-display-format'` });
|
||||
}
|
||||
if (!target["x-primary-key"]) {
|
||||
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-primary-key'` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!paths[resourcePath]) {
|
||||
messages.push({ type: "error", message: `x-resource "${schema["x-resource"]}" points to path "${resourcePath}" but no such path exists` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const collectionPath = paths[resourcePath] as any;
|
||||
|
||||
if (!collectionPath?.get) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
|
||||
}
|
||||
|
||||
const isSSE = collectionPath?.get?.["x-sse"] === true;
|
||||
if (isSSE) continue;
|
||||
|
||||
const listParams = collectionPath?.get?.parameters ?? [];
|
||||
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
|
||||
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
|
||||
if (limitParam || offsetParam) {
|
||||
if (!limitParam?.schema?.default) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` });
|
||||
}
|
||||
}
|
||||
|
||||
if (!collectionPath?.post) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` });
|
||||
}
|
||||
|
||||
const itemPath = paths[`${resourcePath}/{id}`] as any;
|
||||
if (!itemPath) {
|
||||
messages.push({ type: "error", message: `No path "${resourcePath}/{id}" found — detail/update/delete not possible` });
|
||||
} else {
|
||||
if (!itemPath?.get) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` });
|
||||
}
|
||||
if (!itemPath?.put) {
|
||||
messages.push({ type: "info", message: `"${resourcePath}/{id}" has no PUT endpoint — update not available` });
|
||||
}
|
||||
if (!itemPath?.delete) {
|
||||
messages.push({ type: "info", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not available` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
56
react-openapi/src/transformers/field-config.ts
Normal file
56
react-openapi/src/transformers/field-config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { FieldConfig } from "../types";
|
||||
|
||||
function resolveRef(ref: string): string | undefined {
|
||||
return ref.split("/").pop();
|
||||
}
|
||||
|
||||
export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
|
||||
const props = schema.properties ?? {};
|
||||
const requiredFields: string[] = schema.required ?? [];
|
||||
|
||||
return Object.entries(props)
|
||||
.filter(([, prop]: [string, any]) => prop && typeof prop === "object")
|
||||
.map(([name, prop]: [string, any]) => {
|
||||
const isDirectRef = !!prop.$ref;
|
||||
const isItemsRef = prop.type === "array" && !!prop.items?.$ref;
|
||||
const isOneOf = !!prop.oneOf;
|
||||
const isRef = isDirectRef || isItemsRef || isOneOf;
|
||||
|
||||
const refSchemaName = isDirectRef
|
||||
? resolveRef(prop.$ref)
|
||||
: isItemsRef
|
||||
? resolveRef(prop.items.$ref)
|
||||
: isOneOf && prop.oneOf[0]?.$ref
|
||||
? resolveRef(prop.oneOf[0].$ref)
|
||||
: undefined;
|
||||
|
||||
const refSchema = refSchemaName ? schemas[refSchemaName] : undefined;
|
||||
|
||||
const inlineDisplayFormat = isRef && refSchema && !prop["x-fk"]
|
||||
? refSchema["x-display-format"]
|
||||
: undefined;
|
||||
|
||||
const field: FieldConfig = {
|
||||
name,
|
||||
label: prop["x-label"],
|
||||
description: prop["x-description"] ?? prop["x-label"] ?? name,
|
||||
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
|
||||
format: prop.format,
|
||||
order: prop["x-order"],
|
||||
hidden: prop["x-hidden"] ?? {},
|
||||
filterable: prop["x-filterable"] ?? false,
|
||||
sortable: prop["x-sortable"] ?? false,
|
||||
readOnly: prop.readOnly ?? false,
|
||||
required: requiredFields.includes(name),
|
||||
enumValues: prop.enum,
|
||||
fk: prop["x-fk"],
|
||||
uiType: prop["x-ui-type"],
|
||||
uploadUrl: prop["x-upload-url"],
|
||||
refSchema: refSchemaName,
|
||||
inlineDisplayFormat,
|
||||
isArray: prop.type === "array",
|
||||
};
|
||||
|
||||
return field;
|
||||
});
|
||||
}
|
||||
32
react-openapi/src/transformers/relationship-config.ts
Normal file
32
react-openapi/src/transformers/relationship-config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FKFieldConfig, ResourceRelationship } from "../types";
|
||||
|
||||
export function extractRelationships(schema: any, schemas: Record<string, any>): ResourceRelationship[] {
|
||||
const props = schema.properties ?? {};
|
||||
const rels: ResourceRelationship[] = [];
|
||||
|
||||
for (const [name, _raw] of Object.entries(props)) {
|
||||
const prop = _raw as any;
|
||||
if (!prop || typeof prop !== "object") continue;
|
||||
|
||||
if (!prop["x-fk"]) continue;
|
||||
|
||||
const fkResource = prop["x-fk"].resource as string;
|
||||
const targetEntry = Object.entries(schemas).find(([, s]) => s?.["x-resource"] === fkResource);
|
||||
const targetSchemaName = targetEntry ? targetEntry[0] : fkResource;
|
||||
|
||||
const prefetch = prop["x-fk"].prefetch ?? false;
|
||||
console.log(`[FK] extracted relationship: field="${name}" target="${fkResource}" prefetch=${prefetch} rawPrefetch=${prop["x-fk"].prefetch}`);
|
||||
|
||||
rels.push({
|
||||
fieldName: name,
|
||||
config: {
|
||||
resource: fkResource,
|
||||
prefetch,
|
||||
},
|
||||
targetSchemaName,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[FK] total relationships extracted: ${rels.length}`);
|
||||
return rels;
|
||||
}
|
||||
108
react-openapi/src/transformers/resource-config.ts
Normal file
108
react-openapi/src/transformers/resource-config.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
|
||||
import { extractFields } from "./field-config";
|
||||
import { extractRelationships } from "./relationship-config";
|
||||
|
||||
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
|
||||
const params = pathObj?.get?.parameters ?? [];
|
||||
const limit = params.find((p: any) => p.in === "query" && p.name === "limit");
|
||||
const offset = params.find((p: any) => p.in === "query" && p.name === "offset");
|
||||
if (limit && offset) {
|
||||
return {
|
||||
limitParam: "limit",
|
||||
offsetParam: "offset",
|
||||
defaultLimit: limit.schema.default,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasOperation(pathObj: any, method: string): boolean {
|
||||
return !!pathObj?.[method];
|
||||
}
|
||||
|
||||
function sortFields(fields: FieldConfig[]): FieldConfig[] {
|
||||
return [...fields].sort((a, b) => {
|
||||
const orderDiff = a.order - b.order;
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function formatDisplayName(name: string): string {
|
||||
return name.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
const SSE_RECEIVED_FIELD: FieldConfig = {
|
||||
name: "_received_at",
|
||||
label: "Received",
|
||||
description: "Timestamp when the event was received",
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
order: 0,
|
||||
hidden: {},
|
||||
filterable: false,
|
||||
sortable: true,
|
||||
readOnly: true,
|
||||
required: false,
|
||||
isArray: false,
|
||||
};
|
||||
|
||||
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
||||
const schemas = spec.components?.schemas ?? {};
|
||||
const paths = spec.paths ?? {};
|
||||
const configs: ResourceConfig[] = [];
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (!schema || typeof schema !== "object") continue;
|
||||
|
||||
const resourceName = schema["x-resource"];
|
||||
if (!resourceName || typeof resourceName !== "string") continue;
|
||||
|
||||
const resourcePath = `/${resourceName}`;
|
||||
const itemPath = `${resourcePath}/{id}`;
|
||||
const collectionPathObj = paths[resourcePath];
|
||||
const itemPathObj = paths[itemPath];
|
||||
|
||||
const fields = extractFields(schemaName, schema, schemas);
|
||||
const relationships = extractRelationships(schema, schemas);
|
||||
const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
|
||||
|
||||
const resource: ResourceConfig = {
|
||||
name: resourceName,
|
||||
schemaName,
|
||||
displayName: formatDisplayName(resourceName),
|
||||
path: resourcePath,
|
||||
primaryKey: schema["x-primary-key"],
|
||||
displayFormat: schema["x-display-format"],
|
||||
listColumns: schema["x-list-columns"],
|
||||
fields,
|
||||
orderedFields: sortFields(fields),
|
||||
operations: {
|
||||
list: hasOperation(collectionPathObj, "get"),
|
||||
get: hasOperation(itemPathObj, "get"),
|
||||
create: hasOperation(collectionPathObj, "post"),
|
||||
update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"),
|
||||
delete: hasOperation(itemPathObj, "delete"),
|
||||
},
|
||||
updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
|
||||
pagination: detectPagination(collectionPathObj),
|
||||
relationships,
|
||||
streaming: hasSSE || undefined,
|
||||
};
|
||||
|
||||
if (hasSSE) {
|
||||
resource.operations = { list: true, get: false, create: false, update: false, delete: false };
|
||||
resource.updateMethod = "put";
|
||||
resource.pagination = null;
|
||||
resource.relationships = [];
|
||||
resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];
|
||||
resource.orderedFields = sortFields(resource.fields);
|
||||
resource.listColumns = ["_received_at", ...resource.listColumns];
|
||||
resource.primaryKey = "_received_at";
|
||||
}
|
||||
|
||||
configs.push(resource);
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
97
react-openapi/src/types.ts
Normal file
97
react-openapi/src/types.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
export type FilterMode = "client" | "server";
|
||||
|
||||
export interface ResourceConfiguration {
|
||||
filterOptions?: {
|
||||
mode?: FilterMode;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpecConfiguration {
|
||||
specUrl: string;
|
||||
baseApiUrl?: string;
|
||||
title?: string;
|
||||
getToken?: () => string | null;
|
||||
resourceConfig?: Record<string, ResourceConfiguration>;
|
||||
}
|
||||
|
||||
export interface ValidationMessage {
|
||||
type: "error" | "warning" | "info";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ResourceRelationship {
|
||||
fieldName: string;
|
||||
config: FKFieldConfig;
|
||||
targetSchemaName: string;
|
||||
}
|
||||
|
||||
export interface ResourceConfig {
|
||||
name: string;
|
||||
schemaName: string;
|
||||
displayName: string;
|
||||
path: string;
|
||||
primaryKey: string;
|
||||
displayFormat: string;
|
||||
listColumns: string[];
|
||||
fields: FieldConfig[];
|
||||
orderedFields: FieldConfig[];
|
||||
operations: {
|
||||
list: boolean;
|
||||
get: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
updateMethod: "put" | "patch";
|
||||
pagination: {
|
||||
limitParam: string;
|
||||
offsetParam: string;
|
||||
defaultLimit: number;
|
||||
} | null;
|
||||
relationships: ResourceRelationship[];
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: string;
|
||||
format?: string;
|
||||
order: number;
|
||||
hidden: { form?: boolean; list?: boolean; detail?: boolean };
|
||||
filterable: boolean;
|
||||
sortable: boolean;
|
||||
readOnly: boolean;
|
||||
required: boolean;
|
||||
enumValues?: string[];
|
||||
fk?: FKFieldConfig;
|
||||
uiType?: string;
|
||||
uploadUrl?: string;
|
||||
refSchema?: string;
|
||||
inlineDisplayFormat?: string;
|
||||
isArray: boolean;
|
||||
}
|
||||
|
||||
export interface FKFieldConfig {
|
||||
resource: string;
|
||||
prefetch: boolean;
|
||||
}
|
||||
|
||||
export interface OpenApiSpec {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
version: string;
|
||||
};
|
||||
servers?: { url: string }[];
|
||||
components?: {
|
||||
schemas?: Record<string, any>;
|
||||
};
|
||||
paths?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ParsedListResponse {
|
||||
total?: number;
|
||||
items?: any[];
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
export type FieldType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'markdown'
|
||||
| 'enum'
|
||||
| 'image'
|
||||
| 'object'
|
||||
| 'array';
|
||||
|
||||
export interface ResourceField {
|
||||
type: FieldType;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
readOnly?: boolean;
|
||||
schema?: Record<string, ResourceField>;
|
||||
displayField?: string | string[];
|
||||
formatter?: (value: any) => string;
|
||||
relation?: string; // Name of the target resource
|
||||
}
|
||||
|
||||
export interface ResourceConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
pluralLabel: string;
|
||||
endpoint: string;
|
||||
primaryKey: string;
|
||||
fields: Record<string, ResourceField>;
|
||||
pagination?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
baseUrl: string;
|
||||
authBaseUrl: string;
|
||||
resources: ResourceConfig[];
|
||||
profile?: {
|
||||
resource: string;
|
||||
extraFields?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* This file contains application-specific overrides and configuration
|
||||
* for the generic Admin Panel.
|
||||
*/
|
||||
|
||||
export interface FieldOverride {
|
||||
displayField?: string | string[];
|
||||
display?: boolean;
|
||||
formatter?: (value: any) => string;
|
||||
}
|
||||
|
||||
export interface ResourceOverride {
|
||||
fields?: Record<string, FieldOverride>;
|
||||
pagination?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||||
|
||||
/**
|
||||
* Maps OpenAPI property types to our internal FieldType
|
||||
*/
|
||||
function mapOpenApiType(prop: any): FieldType {
|
||||
const type = prop.type;
|
||||
const format = prop.format;
|
||||
|
||||
if (format === "date-time") return "datetime";
|
||||
if (format === "date") return "date";
|
||||
if (prop.enum) return "enum";
|
||||
if (
|
||||
type === "string" &&
|
||||
(prop.description?.toLowerCase().includes("image") ||
|
||||
prop.name?.toLowerCase().includes("icon"))
|
||||
)
|
||||
return "image";
|
||||
|
||||
switch (type) {
|
||||
case "integer":
|
||||
case "number":
|
||||
return "number";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "object":
|
||||
return "object";
|
||||
case "array":
|
||||
return "array";
|
||||
default:
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively converts OpenAPI schemas to ResourceField map
|
||||
*/
|
||||
function parseSchemaFields(
|
||||
schema: any,
|
||||
resourceName: string,
|
||||
schemaToResourceMap: Map<any, string>,
|
||||
configuration: Record<string, any> = {}
|
||||
): Record<string, ResourceField> {
|
||||
const fields: Record<string, ResourceField> = {};
|
||||
const properties = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
const overrides = configuration[resourceName]?.fields || {};
|
||||
|
||||
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
||||
const type = mapOpenApiType(prop);
|
||||
const override = overrides[key];
|
||||
|
||||
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
||||
if (key === "id" || override?.display === false) continue;
|
||||
|
||||
fields[key] = {
|
||||
type,
|
||||
label:
|
||||
prop.title ||
|
||||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
||||
required: required.includes(key),
|
||||
options: prop.enum,
|
||||
readOnly:
|
||||
prop.readOnly ||
|
||||
key === "created_at" ||
|
||||
key === "updated_at",
|
||||
...override,
|
||||
};
|
||||
|
||||
// STRICT RELATION DETECTION
|
||||
// A field is a relation ONLY if its schema object (or items schema)
|
||||
// exactly matches a schema that is defined as a resource.
|
||||
let targetSchema = prop;
|
||||
if (type === "array" && prop.items) {
|
||||
targetSchema = prop.items;
|
||||
}
|
||||
|
||||
// Check if this schema object is registered as a resource
|
||||
const relation = schemaToResourceMap.get(targetSchema);
|
||||
if (relation) {
|
||||
fields[key].relation = relation;
|
||||
}
|
||||
|
||||
// Recursively parse nested objects (only if not a relation)
|
||||
if (fields[key].type === "object" && prop.properties && !relation) {
|
||||
fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans paths to identify resources and their basic configuration
|
||||
*/
|
||||
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
|
||||
// Use SwaggerParser to dereference the spec.
|
||||
// Dereferencing preserves object identity for $ref targets.
|
||||
const api = await SwaggerParser.dereference(
|
||||
new URL("/openapi.json", baseUrl).href
|
||||
);
|
||||
|
||||
const resources: ResourceConfig[] = [];
|
||||
const paths = api.paths || {};
|
||||
|
||||
// Group paths by base resource name
|
||||
const resourcePaths: Record<string, any> = {};
|
||||
for (const path of Object.keys(paths)) {
|
||||
const base = path.split("/")[1];
|
||||
if (!base) continue;
|
||||
|
||||
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
|
||||
const methods = Object.keys(paths[path] || {});
|
||||
resourcePaths[base].methods.push(...methods);
|
||||
|
||||
// Identify the list endpoint for this resource
|
||||
if (!resourcePaths[base].listPath && !path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
||||
resourcePaths[base].listPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Identify which schema objects correspond to which resources
|
||||
const schemaToResourceMap = new Map<any, string>();
|
||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||
const listPath = info.listPath || `/${name}`;
|
||||
const listOp = paths[listPath]?.get;
|
||||
if (!listOp) continue;
|
||||
|
||||
// @ts-ignore
|
||||
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||
let schemaObj = responseSchema;
|
||||
if (responseSchema?.type === "array" && responseSchema.items) {
|
||||
schemaObj = responseSchema.items;
|
||||
}
|
||||
|
||||
if (schemaObj) {
|
||||
schemaToResourceMap.set(schemaObj, name);
|
||||
resourcePaths[name].schemaObj = schemaObj;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Generate ResourceConfig for each identified resource
|
||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||
const listPath = info.listPath || `/${name}`;
|
||||
const listOp = paths[listPath]?.get;
|
||||
if (!listOp || !info.schemaObj) continue;
|
||||
|
||||
const schema = info.schemaObj;
|
||||
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
||||
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
|
||||
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
|
||||
|
||||
const resourceOverride = configuration[name] || {};
|
||||
|
||||
resources.push({
|
||||
name,
|
||||
label: schema.title || label,
|
||||
pluralLabel: pluralLabel,
|
||||
endpoint: listPath,
|
||||
primaryKey: "id", // Strict default, no heuristics
|
||||
fields,
|
||||
pagination: resourceOverride.pagination,
|
||||
hidden: resourceOverride.hidden,
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
|
||||
// @ts-ignore
|
||||
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL || ""
|
||||
return {
|
||||
baseUrl: serverBaseUrl,
|
||||
authBaseUrl: authBaseUrl,
|
||||
resources,
|
||||
profile: profileConfiguration,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,18 @@ import {
|
||||
useReport,
|
||||
prepareReport,
|
||||
} from "./features/report";
|
||||
import { useReportSnapshotsList } from "./features/report-snapshots";
|
||||
|
||||
function formatSnapshotDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [state, setState] = React.useState<DashboardState>({
|
||||
@@ -42,7 +54,28 @@ export default function Dashboard() {
|
||||
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
|
||||
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
|
||||
|
||||
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
|
||||
|
||||
const { data: snapshotsData } = useReportSnapshotsList();
|
||||
const snapshotOptions = React.useMemo(() => {
|
||||
const options: { label: string; value: string | null }[] = [
|
||||
{ label: "Latest (auto)", value: null },
|
||||
];
|
||||
if (snapshotsData?.items) {
|
||||
for (const snap of snapshotsData.items) {
|
||||
options.push({
|
||||
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
|
||||
value: snap.snapshot_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [snapshotsData]);
|
||||
|
||||
const selectedSnapshotOption = snapshotOptions.find((o) => o.value === selectedSnapshotId) ?? snapshotOptions[0];
|
||||
|
||||
const report = useReport({
|
||||
snapshot_id: selectedSnapshotId ?? undefined,
|
||||
periods: ["daily", "weekly", "monthly", "all"],
|
||||
flow: state.flow,
|
||||
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
|
||||
@@ -50,10 +83,10 @@ export default function Dashboard() {
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (report.data?.data) {
|
||||
if (report.data) {
|
||||
setLoadedPayees(prev => {
|
||||
const pSet = new Set<string>(prev);
|
||||
report.data.data.buckets.forEach((b: any) => {
|
||||
report.data.buckets.forEach((b: any) => {
|
||||
Object.values(b.periods).forEach((periodArray: any) => {
|
||||
periodArray?.forEach((p: any) => {
|
||||
p.metric?.transactions?.forEach((t: any) => {
|
||||
@@ -67,7 +100,7 @@ export default function Dashboard() {
|
||||
|
||||
setLoadedTags(prev => {
|
||||
const tSet = new Set<string>(prev);
|
||||
report.data.data.buckets.forEach((b: any) => {
|
||||
report.data.buckets.forEach((b: any) => {
|
||||
Object.values(b.periods).forEach((periodArray: any) => {
|
||||
periodArray?.forEach((p: any) => {
|
||||
p.metric?.transactions?.forEach((t: any) => {
|
||||
@@ -79,7 +112,7 @@ export default function Dashboard() {
|
||||
return Array.from(tSet).sort();
|
||||
});
|
||||
}
|
||||
}, [report.data?.data]);
|
||||
}, [report.data]);
|
||||
|
||||
const toggleFlow =
|
||||
React.useCallback(() => {
|
||||
@@ -219,7 +252,7 @@ export default function Dashboard() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = prepareReport(report.data.data);
|
||||
const data = prepareReport(report.data);
|
||||
return (
|
||||
<Box>
|
||||
<Container>
|
||||
@@ -265,6 +298,21 @@ export default function Dashboard() {
|
||||
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: { sm: 220 } }}>
|
||||
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
|
||||
Snapshot
|
||||
</Box>
|
||||
<Autocomplete
|
||||
options={snapshotOptions}
|
||||
value={selectedSnapshotOption}
|
||||
onChange={(_, option) => setSelectedSnapshotId(option?.value ?? null)}
|
||||
getOptionLabel={(o) => o.label}
|
||||
isOptionEqualToValue={(o, v) => o.value === v.value}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
|
||||
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
|
||||
681
src/FetchRequestDetail.tsx
Normal file
681
src/FetchRequestDetail.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
import * as React from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
StepIcon,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Snackbar,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import ErrorIcon from "@mui/icons-material/Error";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
|
||||
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||
import {
|
||||
useFetchRequestAmbiguities,
|
||||
useResolveAmbiguity,
|
||||
} from "./features/fetch-requests";
|
||||
import type {
|
||||
FetchRequestStatus,
|
||||
SSEEvent,
|
||||
ProgressMessage,
|
||||
} from "./features/fetch-requests";
|
||||
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
||||
import { useAppContext, useResource } from "../react-openapi";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
|
||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||
pending: "default",
|
||||
processing: "info",
|
||||
paused: "warning",
|
||||
raw_expenses_done: "primary",
|
||||
enriched_done: "warning",
|
||||
completed: "success",
|
||||
failed: "error",
|
||||
};
|
||||
|
||||
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
|
||||
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
|
||||
processing: <CircularProgress size={14} />,
|
||||
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
|
||||
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
||||
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
||||
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
||||
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
|
||||
};
|
||||
|
||||
function computeProgressPercent(
|
||||
status: FetchRequestStatus,
|
||||
liveCount: number,
|
||||
seenSteps: Set<string>,
|
||||
stepStats: Record<string, number>,
|
||||
txnBlockCount: number,
|
||||
txnDictCount: number,
|
||||
): number {
|
||||
if (status === "pending") return 0;
|
||||
if (status === "completed") return 100;
|
||||
|
||||
let pct = 0;
|
||||
|
||||
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
|
||||
|
||||
if (txnBlockCount > 0) {
|
||||
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);
|
||||
pct += Math.min(1, current / txnBlockCount) * 20;
|
||||
}
|
||||
|
||||
if (txnDictCount > 0) {
|
||||
pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50;
|
||||
pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20;
|
||||
}
|
||||
|
||||
return Math.round(Math.min(100, pct));
|
||||
}
|
||||
|
||||
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
|
||||
|
||||
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
|
||||
if (status === "completed") return stepLabels.length;
|
||||
|
||||
if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return stepLabels.length;
|
||||
if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3;
|
||||
|
||||
if (seenSteps.has("enrich/completed")) return 3;
|
||||
if (seenSteps.has("enrich")) return 2;
|
||||
|
||||
if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2;
|
||||
if (seenSteps.has("txn_dicts")) return 1;
|
||||
|
||||
if (seenSteps.has("txn_blocks/completed")) return 1;
|
||||
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0;
|
||||
|
||||
if (status === "processing" || status === "paused") return 0;
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function formatProgressMessage(msg: ProgressMessage): string {
|
||||
if (msg.lines !== undefined) return `${msg.lines} lines`;
|
||||
if (msg.blocks !== undefined) return `${msg.blocks} blocks`;
|
||||
if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`;
|
||||
if (msg.count !== undefined) return `${msg.count} items`;
|
||||
if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`;
|
||||
if (msg.error) return msg.error.slice(0, 80);
|
||||
return "";
|
||||
}
|
||||
|
||||
function sseIcon(status: SSEEvent["status"]) {
|
||||
switch (status) {
|
||||
case "started": return <CircularProgress size={14} />;
|
||||
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
|
||||
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
|
||||
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
|
||||
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
|
||||
case "progress": return (
|
||||
<FiberManualRecordIcon
|
||||
sx={{ fontSize: 14, color: "info.main" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isMathValid(candidate: { amount: number; balance: number }, prevBalance: number) {
|
||||
return (
|
||||
candidate.balance === prevBalance + candidate.amount ||
|
||||
candidate.balance === prevBalance - candidate.amount ||
|
||||
Math.abs(candidate.balance - (prevBalance + candidate.amount)) < 0.01 ||
|
||||
Math.abs(candidate.balance - (prevBalance - candidate.amount)) < 0.01
|
||||
);
|
||||
}
|
||||
|
||||
export default function FetchRequestDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useAppContext();
|
||||
const { get, update } = useResource("fetch-requests");
|
||||
|
||||
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
|
||||
queryKey: ["fetch-requests", "detail", id],
|
||||
queryFn: () => get(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
|
||||
});
|
||||
const resolveMutation = useResolveAmbiguity();
|
||||
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
||||
|
||||
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
|
||||
const [sseConnected, setSseConnected] = React.useState(false);
|
||||
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
|
||||
const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
|
||||
const [failNotif, setFailNotif] = React.useState<string | null>(null);
|
||||
const sseRef = React.useRef<EventSource | null>(null);
|
||||
const feedRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const txnBlockCount = React.useMemo(() => {
|
||||
const blocks = (fetchRequest as any)?.source?.txn_blocks;
|
||||
if (!blocks) return 0;
|
||||
return Object.values(blocks).reduce(
|
||||
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
|
||||
0,
|
||||
);
|
||||
}, [fetchRequest]);
|
||||
|
||||
const stepMessages = React.useMemo(() => {
|
||||
const msgs: Record<number, string> = {};
|
||||
const source = (fetchRequest as any)?.source;
|
||||
|
||||
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
|
||||
if (rawLineCount) msgs[0] = `${rawLineCount}`;
|
||||
|
||||
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
|
||||
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
|
||||
const dictCurrent = Math.max(dictLive, sourceDictCount);
|
||||
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
|
||||
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
|
||||
|
||||
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
|
||||
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
|
||||
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
|
||||
|
||||
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
|
||||
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
|
||||
|
||||
return msgs;
|
||||
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!id || !config?.baseApiUrl) return;
|
||||
const url = `${config.baseApiUrl}/fetch-requests/${id}/events`;
|
||||
const es = new EventSource(url);
|
||||
sseRef.current = es;
|
||||
|
||||
es.onopen = () => setSseConnected(true);
|
||||
es.onerror = () => setSseConnected(false);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const parsed: SSEEvent = JSON.parse(event.data);
|
||||
setSseEvents((prev) => [...prev, parsed]);
|
||||
|
||||
if (parsed.status === "progress" && parsed.message.count !== undefined) {
|
||||
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
|
||||
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
|
||||
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
|
||||
}
|
||||
|
||||
if (parsed.status === "completed" && parsed.message.count !== undefined) {
|
||||
const stats: Record<string, number> = {};
|
||||
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
|
||||
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
|
||||
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
|
||||
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
|
||||
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
|
||||
if (Object.keys(stats).length) {
|
||||
setStepStats((prev) => ({ ...prev, ...stats }));
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.status === "paused") {
|
||||
refetchRequest();
|
||||
refetchAmbiguities();
|
||||
}
|
||||
if (parsed.status === "failed") {
|
||||
setFailNotif(parsed.message.error || "Fetch request failed");
|
||||
refetchRequest();
|
||||
}
|
||||
if (parsed.status === "completed" || parsed.step === "resume_extract") {
|
||||
refetchRequest();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
sseRef.current = null;
|
||||
};
|
||||
}, [id, config?.baseApiUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (feedRef.current) {
|
||||
feedRef.current.scrollTop = feedRef.current.scrollHeight;
|
||||
}
|
||||
}, [sseEvents]);
|
||||
|
||||
const displayEvents = React.useMemo(() => {
|
||||
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
|
||||
const lastProgressIdx: Record<string, number> = {};
|
||||
for (let i = sseEvents.length - 1; i >= 0; i--) {
|
||||
const e = sseEvents[i];
|
||||
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
|
||||
lastProgressIdx[e.step] = i;
|
||||
}
|
||||
}
|
||||
|
||||
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
|
||||
return sseEvents.filter((e, i) => {
|
||||
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
|
||||
if (e.status === "started") {
|
||||
return !sseEvents.slice(i + 1).some(
|
||||
(later) => later.step === e.step && terminalStatuses.has(later.status),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [sseEvents]);
|
||||
|
||||
const seenSteps = React.useMemo(() => {
|
||||
const steps = new Set<string>();
|
||||
for (const evt of sseEvents) {
|
||||
steps.add(evt.step);
|
||||
if (evt.status === "completed") steps.add(`${evt.step}/completed`);
|
||||
if (evt.status === "failed") steps.add(`${evt.step}/failed`);
|
||||
if (evt.status === "started") steps.add(`${evt.step}/started`);
|
||||
if (evt.status === "progress") steps.add(`${evt.step}/progress`);
|
||||
}
|
||||
return steps;
|
||||
}, [sseEvents]);
|
||||
|
||||
const displayParsedCount = React.useMemo(() => {
|
||||
if (liveParsedCount && liveParsedCount > 0) return liveParsedCount;
|
||||
const source = (fetchRequest as any)?.source;
|
||||
const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
|
||||
if (persistedCount > 0) return persistedCount;
|
||||
const dicts = source?.txn_dicts;
|
||||
if (Array.isArray(dicts) && dicts.length > 0) return dicts.length;
|
||||
return 0;
|
||||
}, [liveParsedCount, fetchRequest]);
|
||||
|
||||
const txnDictCount = React.useMemo(() => {
|
||||
const source = (fetchRequest as any)?.source;
|
||||
if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts;
|
||||
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
|
||||
}, [fetchRequest, stepStats]);
|
||||
|
||||
const progressPercent = React.useMemo(
|
||||
() => computeProgressPercent(
|
||||
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
|
||||
displayParsedCount,
|
||||
seenSteps,
|
||||
stepStats,
|
||||
txnBlockCount,
|
||||
txnDictCount,
|
||||
),
|
||||
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
|
||||
);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id, data: { status: "pending" } });
|
||||
} catch (err: any) {
|
||||
setFailNotif(formatApiError(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
|
||||
await resolveMutation.mutateAsync({
|
||||
ambiguityId: ambiguity.id,
|
||||
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
|
||||
});
|
||||
refetchAmbiguities();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !fetchRequest) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
|
||||
Back
|
||||
</Button>
|
||||
<Alert severity="error">Failed to load fetch request</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const req = fetchRequest as any;
|
||||
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
|
||||
const retryCount = req.retry_count ?? 0;
|
||||
const isRetryExhausted = retryCount >= RETRY_MAX;
|
||||
const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? [];
|
||||
const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? [];
|
||||
const hasAmbiguities = ambiguities && ambiguities.length > 0;
|
||||
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0;
|
||||
const ambiguitiesLoading = !ambiguities;
|
||||
|
||||
return (
|
||||
<Container sx={{ mt: 4, mb: 4 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
|
||||
Back to Fetch Requests
|
||||
</Button>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
|
||||
<Chip
|
||||
icon={statusIcons[req.status as FetchRequestStatus] as any}
|
||||
label={req.status.replace(/_/g, " ")}
|
||||
color={statusColors[req.status as FetchRequestStatus]}
|
||||
/>
|
||||
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
|
||||
<Chip
|
||||
label={"path" in req.source ? "File" : "Email"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={"path" in req.source ? "primary" : "secondary"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Date Range</Typography>
|
||||
<Typography variant="body2">
|
||||
{(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} → {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Created</Typography>
|
||||
<Typography variant="body2">{new Date(req.created_at).toLocaleString()}</Typography>
|
||||
</Box>
|
||||
{req.completed_at && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Completed</Typography>
|
||||
<Typography variant="body2">{new Date(req.completed_at).toLocaleString()}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Overall Progress
|
||||
</Typography>
|
||||
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
|
||||
<Typography variant="caption" fontWeight={600} color="info.main">
|
||||
Validated: {displayParsedCount} transactions
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progressPercent}
|
||||
color={req.status === "failed" ? "error" : req.status === "completed" ? "success" : "primary"}
|
||||
sx={{ borderRadius: 1, height: 8, transition: "width 0.3s ease" }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: "block" }}>
|
||||
{progressPercent}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box sx={{ flex: 1, maxWidth: 300 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Retries: {retryCount}/{RETRY_MAX}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(retryCount / RETRY_MAX) * 100}
|
||||
color={isRetryExhausted ? "error" : "primary"}
|
||||
sx={{ mt: 0.5, borderRadius: 1, height: 6 }}
|
||||
/>
|
||||
</Box>
|
||||
{req.status === "failed" && !isRetryExhausted && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ReplayIcon />}
|
||||
onClick={handleRetry}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{req.status === "failed" && req.error_message && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{req.error_message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isRetryExhausted && req.status === "failed" && (
|
||||
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
Max retries reached — no further retry attempts will be made.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||
Pipeline Progress
|
||||
</Typography>
|
||||
<Stepper activeStep={activeStep} alternativeLabel>
|
||||
{stepLabels.map((label, index) => {
|
||||
const isCompleted = index < activeStep;
|
||||
const isActive = index === activeStep;
|
||||
const isPaused = req.status === "paused" && isActive;
|
||||
const isFailed = req.status === "failed" && isActive;
|
||||
|
||||
let icon: React.ReactNode;
|
||||
if (isCompleted) {
|
||||
icon = <CheckCircleIcon sx={{ color: "success.main" }} />;
|
||||
} else if (isFailed) {
|
||||
icon = <ErrorIcon sx={{ color: "error.main" }} />;
|
||||
} else if (isPaused) {
|
||||
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
|
||||
} else if (isActive) {
|
||||
icon = <CircularProgress size={20} />;
|
||||
} else {
|
||||
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
|
||||
}
|
||||
|
||||
const stepMsg = stepMessages[index];
|
||||
|
||||
return (
|
||||
<Step key={label}>
|
||||
<StepLabel
|
||||
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={600}>{label}</Typography>
|
||||
{stepMsg && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
|
||||
{stepMsg}
|
||||
</Typography>
|
||||
)}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
|
||||
Progress Events
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
bgcolor: sseConnected ? "success.main" : "error.main",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{sseConnected ? "Connected" : "Disconnected"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
ref={feedRef}
|
||||
sx={{
|
||||
maxHeight: 300,
|
||||
overflowY: "auto",
|
||||
p: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{displayEvents.length === 0 ? (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
|
||||
Waiting for events...
|
||||
</Typography>
|
||||
) : (
|
||||
displayEvents.map((evt, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
{sseIcon(evt.status)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{evt.step.replace(/_/g, " ")}
|
||||
</Typography>
|
||||
{evt.message && formatProgressMessage(evt.message) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatProgressMessage(evt.message)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{hasAmbiguities && (
|
||||
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||
Ambiguity Resolution
|
||||
</Typography>
|
||||
|
||||
{allResolved ? (
|
||||
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
All ambiguities resolved — pipeline will resume on next poll cycle
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
Pipeline paused — resolve ambiguities to continue
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{ambiguities.map((ambiguity: any) => {
|
||||
const isResolved = ambiguity.status === "resolved";
|
||||
return (
|
||||
<Paper
|
||||
key={ambiguity.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
border: 1,
|
||||
borderColor: isResolved ? "success.main" : "divider",
|
||||
opacity: isResolved ? 0.8 : 1,
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<Box sx={{ fontFamily: "monospace", fontSize: "0.85rem", mb: 1.5, p: 1, bgcolor: "grey.900", borderRadius: 1, color: "grey.100" }}>
|
||||
{ambiguity.line}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 3, mb: 1.5, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">OCR Amount</Typography>
|
||||
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
|
||||
₹{ambiguity.ocr_amount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">OCR Balance</Typography>
|
||||
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
|
||||
₹{ambiguity.ocr_balance}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Previous Balance</Typography>
|
||||
<Typography variant="body2">₹{ambiguity.prev_balance}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isResolved ? (
|
||||
<Alert severity="success" sx={{ py: 0.5, borderRadius: 2 }} icon={<CheckCircleIcon />}>
|
||||
Resolved: ₹{ambiguity.chosen?.amount} / ₹{ambiguity.chosen?.balance}
|
||||
</Alert>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{ambiguity.candidates.map((candidate: any, ci: number) => {
|
||||
const isCredit = candidate.amount > 0;
|
||||
const isDebit = candidate.amount < 0;
|
||||
const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined;
|
||||
return (
|
||||
<Button
|
||||
key={ci}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => handleResolve(ambiguity, candidate)}
|
||||
disabled={resolveMutation.isPending}
|
||||
sx={{
|
||||
borderColor: cColor,
|
||||
color: cColor,
|
||||
"&:hover": cColor ? { borderColor: cColor } : undefined,
|
||||
}}
|
||||
>
|
||||
₹{candidate.amount} / ₹{candidate.balance}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
<Snackbar
|
||||
open={!!failNotif}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setFailNotif(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
|
||||
{failNotif}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
619
src/FetchRequests.tsx
Normal file
619
src/FetchRequests.tsx
Normal file
@@ -0,0 +1,619 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
Chip,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Tooltip,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
OutlinedInput,
|
||||
Autocomplete,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import ErrorIcon from "@mui/icons-material/Error";
|
||||
import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import {
|
||||
useUploadFile,
|
||||
} from "./features/fetch-requests";
|
||||
import type {
|
||||
FetchRequest,
|
||||
FetchRequestStatus,
|
||||
FileSource,
|
||||
EmailSource,
|
||||
} from "./features/fetch-requests";
|
||||
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useResource, FormFieldRenderer } from "../react-openapi";
|
||||
import type { FieldConfig } from "../react-openapi";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||
pending: "default",
|
||||
processing: "info",
|
||||
paused: "warning",
|
||||
raw_expenses_done: "primary",
|
||||
enriched_done: "warning",
|
||||
completed: "success",
|
||||
failed: "error",
|
||||
};
|
||||
|
||||
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
|
||||
pending: <ScheduleIcon sx={{ fontSize: 16 }} />,
|
||||
processing: <CircularProgress size={14} sx={{ mr: 0.5 }} />,
|
||||
paused: <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />,
|
||||
raw_expenses_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
|
||||
enriched_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
|
||||
completed: <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />,
|
||||
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: FetchRequestStatus[] = [
|
||||
"pending",
|
||||
"processing",
|
||||
"paused",
|
||||
"raw_expenses_done",
|
||||
"enriched_done",
|
||||
"completed",
|
||||
"failed",
|
||||
];
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function formatDateRange(start?: string, end?: string) {
|
||||
if (!start && !end) return "\u2014";
|
||||
const s = start ? new Date(start).toLocaleDateString() : "?";
|
||||
const e = end ? new Date(end).toLocaleDateString() : "?";
|
||||
return `${s} \u2192 ${e}`;
|
||||
}
|
||||
|
||||
function shortId(fp: string) {
|
||||
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
|
||||
}
|
||||
|
||||
export default function FetchRequests() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
|
||||
const [accountName, setAccountName] = React.useState("");
|
||||
const [payorUsername, setPayorUsername] = React.useState("aetos");
|
||||
const [format, setFormat] = React.useState("");
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [uploadedPath, setUploadedPath] = React.useState<string | null>(null);
|
||||
const [fromEmail, setFromEmail] = React.useState("");
|
||||
const [subject, setSubject] = React.useState("");
|
||||
const [rawTerms, setRawTerms] = React.useState("");
|
||||
const [startDate, setStartDate] = React.useState("");
|
||||
const [endDate, setEndDate] = React.useState("");
|
||||
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
|
||||
|
||||
const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
|
||||
const [accountFilter, setAccountFilter] = React.useState("");
|
||||
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
|
||||
|
||||
const fr = useResource("fetch-requests");
|
||||
const { list, create, update, remove, resource: fetchRes } = fr;
|
||||
|
||||
const { data: listData, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
|
||||
queryFn: () => list({
|
||||
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
|
||||
...(accountFilter ? { account_name: accountFilter } : {}),
|
||||
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { list: listAccounts } = useResource("accounts");
|
||||
const { data: accountsData } = useQuery({
|
||||
queryKey: ["accounts", "list"],
|
||||
queryFn: () => listAccounts(),
|
||||
});
|
||||
const accountOptions: string[] = React.useMemo(() => {
|
||||
return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean);
|
||||
}, [accountsData]);
|
||||
|
||||
const fields = fetchRes?.orderedFields ?? [];
|
||||
const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
|
||||
const formatOptions: string[] = formatField?.enumValues ?? [];
|
||||
const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
|
||||
const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
|
||||
const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => create(data),
|
||||
});
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => remove(id),
|
||||
});
|
||||
const uploadMutation = useUploadFile();
|
||||
|
||||
const requests = listData?.items ?? [];
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
const result = await uploadMutation.mutateAsync(file);
|
||||
if (result?.saved_as) {
|
||||
setUploadedPath(result.saved_as);
|
||||
if (!format) setFormat(file.name.split(".").pop() || "");
|
||||
setSnackbar({ message: `File uploaded: ${result.saved_as}`, severity: "success" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!accountName) return;
|
||||
|
||||
let source: FileSource | EmailSource;
|
||||
|
||||
if (sourceType === "file") {
|
||||
if (!uploadedPath || !format) return;
|
||||
source = { path: uploadedPath, format } as FileSource;
|
||||
} else {
|
||||
if (!format) return;
|
||||
const emailSource: EmailSource = { format };
|
||||
if (fromEmail) emailSource.from_email = fromEmail;
|
||||
if (subject) emailSource.subject = subject;
|
||||
if (rawTerms.trim()) emailSource.raw_terms = rawTerms.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
source = emailSource;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createMutation.mutateAsync({
|
||||
source,
|
||||
account_name: accountName,
|
||||
payor_username: payorUsername,
|
||||
...(startDate ? { start_date: new Date(startDate).toISOString() } : {}),
|
||||
...(endDate ? { end_date: new Date(endDate).toISOString() } : {}),
|
||||
});
|
||||
setSnackbar({ message: "Fetch request created", severity: "success" });
|
||||
resetForm();
|
||||
navigate(`/fetch-requests/${result.id}`);
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
|
||||
} else {
|
||||
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setAccountName("");
|
||||
setFormat("");
|
||||
setFile(null);
|
||||
setUploadedPath(null);
|
||||
setFromEmail("");
|
||||
setSubject("");
|
||||
setRawTerms("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
};
|
||||
|
||||
const handleRetry = async (req: FetchRequest) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: req.id, data: { status: "pending" } });
|
||||
setSnackbar({ message: "Retrying fetch request", severity: "success" });
|
||||
} catch {
|
||||
setSnackbar({ message: "Failed to retry", severity: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||
setSnackbar({ message: "Fetch request deleted", severity: "success" });
|
||||
} catch {
|
||||
setSnackbar({ message: "Failed to delete", severity: "error" });
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
|
||||
|
||||
return (
|
||||
<Container sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
||||
Fetch Request Pipeline
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||
New Fetch Request
|
||||
</Typography>
|
||||
|
||||
<ToggleButtonGroup
|
||||
value={sourceType}
|
||||
exclusive
|
||||
onChange={(_, val) => val && setSourceType(val)}
|
||||
sx={{ mb: 3 }}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="file">File Upload</ToggleButton>
|
||||
<ToggleButton value="email">Email Fetch</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{sourceType === "file" ? (
|
||||
<>
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
|
||||
<Button variant="outlined" component="label" startIcon={<CloudUploadIcon />}>
|
||||
Choose File
|
||||
<input type="file" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
</Button>
|
||||
<Typography variant="body2" sx={{ flex: 1, color: "text.secondary" }}>
|
||||
{file ? file.name : "No file selected"}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
>
|
||||
{uploadMutation.isPending ? "Uploading..." : "Upload"}
|
||||
</Button>
|
||||
</Box>
|
||||
{uploadedPath && (
|
||||
<Alert severity="success" sx={{ py: 0 }}>
|
||||
Uploaded as: {uploadedPath}
|
||||
</Alert>
|
||||
)}
|
||||
{formatField ? (
|
||||
<FormFieldRenderer
|
||||
field={formatField}
|
||||
value={format}
|
||||
onChange={setFormat}
|
||||
/>
|
||||
) : (
|
||||
<FormControl size="small">
|
||||
<InputLabel>Format</InputLabel>
|
||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||
{formatOptions.map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatField ? (
|
||||
<FormFieldRenderer
|
||||
field={formatField}
|
||||
value={format}
|
||||
onChange={setFormat}
|
||||
/>
|
||||
) : (
|
||||
<FormControl size="small">
|
||||
<InputLabel>Format</InputLabel>
|
||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||
{formatOptions.map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
|
||||
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
|
||||
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Autocomplete
|
||||
options={accountOptions}
|
||||
value={accountName || null}
|
||||
onChange={(_, val) => setAccountName(val ?? "")}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Account Name" size="small" required />
|
||||
)}
|
||||
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||
/>
|
||||
{payorUsernameField ? (
|
||||
<FormFieldRenderer
|
||||
field={payorUsernameField}
|
||||
value={payorUsername}
|
||||
onChange={setPayorUsername}
|
||||
/>
|
||||
) : (
|
||||
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{startDateField ? (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<FormFieldRenderer
|
||||
field={startDateField}
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
{endDateField ? (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<FormFieldRenderer
|
||||
field={endDateField}
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !accountName || (sourceType === "file" && (!uploadedPath || !format)) || (sourceType === "email" && !format)}
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create Fetch Request"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as string[])}
|
||||
input={<OutlinedInput label="Status" />}
|
||||
renderValue={(selected) => (selected as string[]).join(", ")}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s: string) => (
|
||||
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Autocomplete
|
||||
options={accountOptions}
|
||||
value={accountFilter || null}
|
||||
onChange={(_, val) => setAccountFilter(val ?? "")}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
|
||||
)}
|
||||
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
value={sourceFilter}
|
||||
exclusive
|
||||
onChange={(_, val) => val && setSourceFilter(val)}
|
||||
size="small"
|
||||
>
|
||||
{sourceTypeOptions.map((opt) => (
|
||||
<ToggleButton key={opt} value={opt}>
|
||||
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : requests.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
|
||||
No fetch requests yet
|
||||
</Box>
|
||||
) : (
|
||||
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<Box component="thead">
|
||||
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
|
||||
<Box
|
||||
key={h}
|
||||
component="th"
|
||||
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{h}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{[...requests]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((req: FetchRequest) => (
|
||||
<Box
|
||||
key={req.id}
|
||||
component="tr"
|
||||
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
"&:last-child": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{shortId(req.fingerprint)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(req.fingerprint);
|
||||
setSnackbar({ message: "Copied!", severity: "success" });
|
||||
}}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
||||
{req.account_name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Chip
|
||||
label={"path" in req.source ? "File" : "Email"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={"path" in req.source ? "primary" : "secondary"}
|
||||
/>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
||||
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
||||
<Chip
|
||||
icon={statusIcons[req.status] as any}
|
||||
label={req.status.replace(/_/g, " ")}
|
||||
color={statusColors[req.status]}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
{(req.retry_count ?? 0) > 0 ? (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||
{req.retry_count}/{RETRY_MAX}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
||||
\u2014
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
||||
{formatDate(req.created_at)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
{req.status === "paused" && (
|
||||
<Tooltip title="Resolve ambiguities">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/fetch-requests/${req.id}`);
|
||||
}}
|
||||
>
|
||||
<WarningAmberIcon fontSize="small" color="warning" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
|
||||
<Tooltip title="Retry">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry(req);
|
||||
}}
|
||||
>
|
||||
<ReplayIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(req);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={!!snackbar}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
|
||||
</Snackbar>
|
||||
|
||||
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Fetch Request?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently delete the fetch request and all associated data.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,32 @@ export default function Header({
|
||||
|
||||
<span style={{ flexGrow: 1 }} />
|
||||
|
||||
{/* NAV LINKS */}
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "none", md: "flex" },
|
||||
alignItems: "center",
|
||||
mr: 2,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: "Dashboard", path: "/dashboard" },
|
||||
{ label: "Fetch", path: "/fetch-requests" },
|
||||
{ label: "Reports", path: "/reports" },
|
||||
].map(({ label, path }) => (
|
||||
<Button
|
||||
key={path}
|
||||
color="inherit"
|
||||
onClick={() => navigate(path)}
|
||||
sx={{ textTransform: "none", fontWeight: 500, px: 1.5 }}
|
||||
size="small"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* AUTH SECTION */}
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
|
||||
226
src/Home.tsx
226
src/Home.tsx
@@ -1,71 +1,180 @@
|
||||
import * as React from "react";
|
||||
import { Box, Typography, Button, Container, Stack } from "@mui/material";
|
||||
import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material";
|
||||
import { useTheme, alpha } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
import SyncIcon from "@mui/icons-material/Sync";
|
||||
import BarChartIcon from "@mui/icons-material/BarChart";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
|
||||
import { useAuth } from "../react-auth";
|
||||
|
||||
export default function Home() {
|
||||
interface FeatureCardProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
accent: string;
|
||||
}
|
||||
|
||||
function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
onClick={() => navigate(path)}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 3,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
cursor: "pointer",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.25s ease",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
background: accent,
|
||||
opacity: 0,
|
||||
transition: "opacity 0.25s ease",
|
||||
},
|
||||
"&:hover": {
|
||||
transform: "translateY(-4px)",
|
||||
boxShadow: `0 12px 32px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.3 : 0.08)}`,
|
||||
borderColor: "transparent",
|
||||
"&::before": { opacity: 1 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: alpha(accent, 0.12),
|
||||
color: accent,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.6 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
{label && (
|
||||
<Chip
|
||||
label={label}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mt: 2, alignSelf: "flex-start", textTransform: "capitalize" }}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <DashboardIcon />,
|
||||
title: "Dashboard",
|
||||
description: "Visualise inflows and outflows with interactive charts, drill into categories, and track trends over daily, weekly, and monthly periods.",
|
||||
path: "/dashboard",
|
||||
accent: theme.palette.mode === "dark" ? "#818cf8" : "#6366f1",
|
||||
},
|
||||
{
|
||||
icon: <SyncIcon />,
|
||||
title: "Fetch Requests",
|
||||
description: "Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.",
|
||||
path: "/fetch-requests",
|
||||
accent: theme.palette.mode === "dark" ? "#34d399" : "#10b981",
|
||||
},
|
||||
{
|
||||
icon: <BarChartIcon />,
|
||||
title: "Report Snapshots",
|
||||
description: "Generate cached report snapshots with custom filters — accounts, date ranges, amount bounds — then pin a snapshot on the dashboard for consistent comparisons.",
|
||||
path: "/reports",
|
||||
accent: theme.palette.mode === "dark" ? "#fbbf24" : "#f59e0b",
|
||||
},
|
||||
{
|
||||
icon: <SettingsIcon />,
|
||||
title: "Admin",
|
||||
description: "Full CRUD over accounts, expenses, tags, and payors. Manage your data programmatically through the OpenAPI-driven admin panel.",
|
||||
path: "/admin",
|
||||
accent: theme.palette.mode === "dark" ? "#e879f9" : "#d946ef",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
minHeight: "calc(100vh - 64px)", // accounting for header
|
||||
minHeight: "calc(100vh - 64px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: "-20%",
|
||||
left: "-10%",
|
||||
width: "50%",
|
||||
height: "60%",
|
||||
background: "radial-gradient(circle, rgba(99,102,241,0.15) 0%, rgba(0,0,0,0) 70%)",
|
||||
top: "-15%",
|
||||
left: "-8%",
|
||||
width: "45%",
|
||||
height: "55%",
|
||||
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)",
|
||||
zIndex: 0,
|
||||
},
|
||||
"&::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
bottom: "-20%",
|
||||
right: "-10%",
|
||||
width: "50%",
|
||||
height: "60%",
|
||||
background: "radial-gradient(circle, rgba(236,72,153,0.15) 0%, rgba(0,0,0,0) 70%)",
|
||||
bottom: "-15%",
|
||||
right: "-8%",
|
||||
width: "45%",
|
||||
height: "55%",
|
||||
background: "radial-gradient(circle, rgba(236,72,153,0.1) 0%, transparent 70%)",
|
||||
zIndex: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1 }}>
|
||||
<Stack
|
||||
spacing={4}
|
||||
alignItems="center"
|
||||
textAlign="center"
|
||||
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: { xs: 4, md: 8 },
|
||||
backdropFilter: "blur(20px)",
|
||||
backgroundColor: (t) => alpha(t.palette.common.white, t.palette.mode === "dark" ? 0.04 : 0.6),
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: 4,
|
||||
boxShadow: (t) =>
|
||||
t.palette.mode === "dark"
|
||||
? "0 8px 32px 0 rgba(0, 0, 0, 0.5)"
|
||||
: "0 8px 32px 0 rgba(31, 38, 135, 0.07)",
|
||||
textAlign: "center",
|
||||
mb: 6,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h1"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: "3rem", md: "5rem" },
|
||||
background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
|
||||
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" },
|
||||
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
letterSpacing: "-0.03em",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
@@ -73,14 +182,20 @@ export default function Home() {
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h5"
|
||||
variant="h6"
|
||||
color="text.secondary"
|
||||
sx={{ maxWidth: "600px", lineHeight: 1.6 }}
|
||||
sx={{
|
||||
maxWidth: 580,
|
||||
mx: "auto",
|
||||
lineHeight: 1.7,
|
||||
fontWeight: 400,
|
||||
fontSize: { xs: "1rem", md: "1.15rem" },
|
||||
}}
|
||||
>
|
||||
Your intelligent, extensible financial ledger. Control accounts, manage transactions, and track your data dynamically with our OpenAPI-driven architecture.
|
||||
Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow.
|
||||
</Typography>
|
||||
|
||||
<Box mt={4}>
|
||||
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
@@ -88,21 +203,44 @@ export default function Home() {
|
||||
onClick={() => navigate("/dashboard")}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
py: 1.4,
|
||||
borderRadius: "50px",
|
||||
fontWeight: "bold",
|
||||
background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
|
||||
transition: "transform 0.2s ease-in-out, box-shadow 0.2s",
|
||||
fontWeight: 700,
|
||||
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)",
|
||||
transition: "transform 0.2s ease, box-shadow 0.2s",
|
||||
"&:hover": {
|
||||
transform: "translateY(-3px)",
|
||||
boxShadow: (t) => `0 8px 20px ${alpha(t.palette.primary.main, 0.4)}`,
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Enter Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={() => navigate("/fetch-requests")}
|
||||
sx={{
|
||||
px: 4,
|
||||
py: 1.4,
|
||||
borderRadius: "50px",
|
||||
fontWeight: 600,
|
||||
borderWidth: 2,
|
||||
"&:hover": { borderWidth: 2 },
|
||||
}}
|
||||
>
|
||||
Import Data
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{features.map((f) => (
|
||||
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<FeatureCard {...f} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
|
||||
303
src/ReportSnapshots.tsx
Normal file
303
src/ReportSnapshots.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Chip,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import { useResource } from "../react-openapi";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface ReportSnapshotQuery {
|
||||
accounts?: string[];
|
||||
ignore_self?: boolean;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
interface ReportSnapshot {
|
||||
id: string;
|
||||
snapshot_id: string;
|
||||
created_at: string;
|
||||
query?: ReportSnapshotQuery;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export default function ReportSnapshots() {
|
||||
const [ignoreSelf, setIgnoreSelf] = React.useState(true);
|
||||
const [startDate, setStartDate] = React.useState("");
|
||||
const [endDate, setEndDate] = React.useState("");
|
||||
const [minAmount, setMinAmount] = React.useState("");
|
||||
const [maxAmount, setMaxAmount] = React.useState("");
|
||||
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
|
||||
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
|
||||
|
||||
const { list, create, remove } = useResource("reports");
|
||||
|
||||
const { data: listData, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["reports", "list"],
|
||||
queryFn: () => list(),
|
||||
});
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => create(data),
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
const snapshots: ReportSnapshot[] = listData?.items ?? [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const payload: Record<string, any> = {};
|
||||
if (ignoreSelf) payload.ignore_self = true;
|
||||
if (startDate) payload.start_date = new Date(startDate).toISOString();
|
||||
if (endDate) payload.end_date = new Date(endDate).toISOString();
|
||||
if (minAmount) payload.min_amount = parseFloat(minAmount);
|
||||
if (maxAmount) payload.max_amount = parseFloat(maxAmount);
|
||||
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
const snapshotId = (result as any)?.snapshot_id;
|
||||
if (snapshotId) {
|
||||
setCreatedSnapshotId(snapshotId);
|
||||
setSnackbar({ message: `Snapshot created: ${snapshotId}`, severity: "success" });
|
||||
} else {
|
||||
setSnackbar({ message: "Snapshot created", severity: "success" });
|
||||
}
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
setSnackbar({ message: err?.response?.data?.detail || "Failed to create snapshot", severity: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setIgnoreSelf(true);
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setMinAmount("");
|
||||
setMaxAmount("");
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.snapshot_id);
|
||||
setSnackbar({ message: "Snapshot deleted", severity: "success" });
|
||||
} catch {
|
||||
setSnackbar({ message: "Failed to delete snapshot", severity: "error" });
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
||||
Report Snapshots
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||
Generate New Snapshot
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="Min Amount"
|
||||
type="number"
|
||||
value={minAmount}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMinAmount(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="Max Amount"
|
||||
type="number"
|
||||
value={maxAmount}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMaxAmount(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddCircleIcon />}
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
|
||||
</Button>
|
||||
|
||||
{createdSnapshotId && (
|
||||
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
|
||||
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ borderRadius: 4 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Existing Snapshots
|
||||
</Typography>
|
||||
<IconButton onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : snapshots.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
|
||||
No snapshots yet
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<Box component="thead">
|
||||
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
|
||||
<Box
|
||||
key={h}
|
||||
component="th"
|
||||
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{h}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{snapshots.map((snap: ReportSnapshot) => (
|
||||
<Box
|
||||
key={snap.id}
|
||||
component="tr"
|
||||
sx={{ borderBottom: 1, borderColor: "divider", "&:last-child": { borderBottom: 0 }, "&:hover": { bgcolor: "action.hover" } }}
|
||||
>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{snap.snapshot_id}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(snap.snapshot_id);
|
||||
setSnackbar({ message: "Copied!", severity: "success" });
|
||||
}}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
||||
{formatDate(snap.created_at)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
{snap.query ? (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
|
||||
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
|
||||
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
|
||||
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">\u2014</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={!!snackbar}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
|
||||
</Snackbar>
|
||||
|
||||
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Delete Snapshot?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently delete the report snapshot.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
26
src/RequireAuth.tsx
Normal file
26
src/RequireAuth.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth, AuthPage } from "../react-auth";
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const { currentUser, loading, error, login, register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = React.useState<"login" | "register">("login");
|
||||
|
||||
if (currentUser) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPage
|
||||
mode={mode}
|
||||
onBack={() => navigate("/")}
|
||||
onSwitchMode={() => setMode(mode === "login" ? "register" : "login")}
|
||||
login={login}
|
||||
register={register}
|
||||
loading={loading}
|
||||
error={error}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
133
src/features/fetch-requests/fetch-requests.models.ts
Normal file
133
src/features/fetch-requests/fetch-requests.models.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export type FetchRequestStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "paused"
|
||||
| "raw_expenses_done"
|
||||
| "enriched_done"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface FileSource {
|
||||
path: 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 {
|
||||
format: string;
|
||||
from_email?: string;
|
||||
subject?: string;
|
||||
raw_terms?: string[];
|
||||
txn_dict_count?: number;
|
||||
txn_dicts_count?: number;
|
||||
}
|
||||
|
||||
export interface FetchRequestCreate {
|
||||
source: FileSource | EmailSource;
|
||||
account_name: string;
|
||||
payor_username?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
export interface FetchRequestUpdate {
|
||||
status?: FetchRequestStatus;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface FetchRequest extends FetchRequestCreate {
|
||||
id: string;
|
||||
status: FetchRequestStatus;
|
||||
fingerprint: string;
|
||||
completed_at?: string | null;
|
||||
error_message?: string | null;
|
||||
retry_count?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
original_filename: string;
|
||||
saved_as: string;
|
||||
content_type: string;
|
||||
url: 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;
|
||||
23
src/features/fetch-requests/index.ts
Normal file
23
src/features/fetch-requests/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type {
|
||||
FetchRequest,
|
||||
FetchRequestCreate,
|
||||
FetchRequestUpdate,
|
||||
FetchRequestStatus,
|
||||
FetchRequestFilters,
|
||||
FileSource,
|
||||
EmailSource,
|
||||
UploadResult,
|
||||
PendingAmbiguity,
|
||||
AmbiguityCandidate,
|
||||
ResolveAmbiguityPayload,
|
||||
SSEEvent,
|
||||
SSEEventStep,
|
||||
SSEEventStatus,
|
||||
ProgressMessage,
|
||||
} from "./fetch-requests.models";
|
||||
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
|
||||
export {
|
||||
useUploadFile,
|
||||
useFetchRequestAmbiguities,
|
||||
useResolveAmbiguity,
|
||||
} from "./useFetchRequests";
|
||||
105
src/features/fetch-requests/useFetchRequests.ts
Normal file
105
src/features/fetch-requests/useFetchRequests.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useResource, getApi } from "../../../react-openapi";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
|
||||
|
||||
export function useFetchRequestsList(params?: {
|
||||
status?: string;
|
||||
account_name?: string;
|
||||
source_type?: string;
|
||||
}) {
|
||||
const { list } = useResource("fetch-requests");
|
||||
return useQuery({
|
||||
queryKey: ["fetch-requests", "list", params],
|
||||
queryFn: () => list(params),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFetchRequest(id: string) {
|
||||
const { get } = useResource("fetch-requests");
|
||||
return useQuery({
|
||||
queryKey: ["fetch-requests", "detail", id],
|
||||
queryFn: () => get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFetchRequest() {
|
||||
const { create } = useResource("fetch-requests");
|
||||
return useMutation({
|
||||
mutationFn: (data: any) => create(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFetchRequest() {
|
||||
const { update } = useResource("fetch-requests");
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFetchRequest() {
|
||||
const { remove } = useResource("fetch-requests");
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => remove(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadFile() {
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const binary = new Uint8Array(arrayBuffer);
|
||||
const api = getApi();
|
||||
const res = await api.post("/uploads", binary, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFetchRequestAmbiguities(fetchRequestId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
|
||||
queryFn: async () => {
|
||||
const api = getApi();
|
||||
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 api = getApi();
|
||||
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],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
9
src/features/report-snapshots/index.ts
Normal file
9
src/features/report-snapshots/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
ReportSnapshot,
|
||||
ReportQuery,
|
||||
} from "./report-snapshots.models";
|
||||
export {
|
||||
useReportSnapshotsList,
|
||||
useCreateSnapshot,
|
||||
useDeleteSnapshot,
|
||||
} from "./useReportSnapshots";
|
||||
15
src/features/report-snapshots/report-snapshots.models.ts
Normal file
15
src/features/report-snapshots/report-snapshots.models.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface ReportQuery {
|
||||
accounts?: string[] | null;
|
||||
ignore_self?: boolean | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
min_amount?: number | null;
|
||||
max_amount?: number | null;
|
||||
}
|
||||
|
||||
export interface ReportSnapshot {
|
||||
id: string;
|
||||
snapshot_id: string;
|
||||
created_at: string;
|
||||
query?: ReportQuery;
|
||||
}
|
||||
28
src/features/report-snapshots/useReportSnapshots.ts
Normal file
28
src/features/report-snapshots/useReportSnapshots.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useResource } from "../../../react-openapi";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function useReportSnapshotsList() {
|
||||
const { list } = useResource("reports");
|
||||
return useQuery({
|
||||
queryKey: ["reports", "list"],
|
||||
queryFn: () => list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSnapshot() {
|
||||
const { create } = useResource("reports");
|
||||
return useMutation({
|
||||
mutationFn: (data: any) => create(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSnapshot() {
|
||||
const queryClient = useQueryClient();
|
||||
const { remove } = useResource("reports");
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useResourceByName } from "../../../react-openapi";
|
||||
import { useResource } from "../../../react-openapi";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface ReportParams {
|
||||
snapshot_id?: string;
|
||||
@@ -9,10 +10,13 @@ export interface ReportParams {
|
||||
}
|
||||
|
||||
export function useReport(params: ReportParams) {
|
||||
const { useList } = useResourceByName("reports");
|
||||
const { get } = useResource("reports");
|
||||
|
||||
return useList({
|
||||
...params,
|
||||
periods: params.periods,
|
||||
const { snapshot_id, ...queryParams } = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["reports", "read", params],
|
||||
queryFn: () =>
|
||||
get(snapshot_id ?? "latest", queryParams),
|
||||
});
|
||||
}
|
||||
|
||||
20
src/main.jsx
20
src/main.jsx
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
@@ -12,14 +13,20 @@ import {
|
||||
} from "@mui/material";
|
||||
import Home from './Home';
|
||||
import Dashboard from './Dashboard';
|
||||
import { Admin, AppProvider } from '../react-openapi';
|
||||
import { configuration, profileConfiguration } from './openapi-config';
|
||||
import FetchRequests from './FetchRequests';
|
||||
import FetchRequestDetail from './FetchRequestDetail';
|
||||
import ReportSnapshots from './ReportSnapshots';
|
||||
import { RequireAuth } from './RequireAuth';
|
||||
import { AppProvider, Admin } from '../react-openapi';
|
||||
import { Buffer } from 'buffer';
|
||||
import process from 'process';
|
||||
import { AuthProvider } from "../react-auth";
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import AppTheme from './shared-theme/AppTheme';
|
||||
import { specConfiguration } from './openapi-config';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
window.Buffer = Buffer;
|
||||
window.process = process;
|
||||
@@ -33,11 +40,15 @@ const routerMapping = [
|
||||
{ path: "/", component: Home, headerTitle: "Home" },
|
||||
{ path: "/home", component: Home, headerTitle: "Home" },
|
||||
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
|
||||
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
|
||||
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
|
||||
{ path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
|
||||
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
|
||||
];
|
||||
|
||||
root.render(
|
||||
<AppProvider resourceOverrides={configuration} profileConfig={profileConfiguration}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider specConfiguration={specConfiguration}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider authBaseUrl={AUTH_BASE}>
|
||||
<AppTheme>
|
||||
@@ -54,7 +65,7 @@ root.render(
|
||||
path={path}
|
||||
element={
|
||||
path.startsWith("/admin") ? (
|
||||
<Component basePath="/admin" />
|
||||
<RequireAuth><Component basePath="/admin" /></RequireAuth>
|
||||
) : (
|
||||
<Component />
|
||||
)
|
||||
@@ -69,4 +80,5 @@ root.render(
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,16 @@
|
||||
import { ResourceOverride } from "../react-openapi/types/overrides";
|
||||
import type { SpecConfiguration } from "../react-openapi";
|
||||
// import { tokenStore } from "../react-auth";
|
||||
|
||||
export const configuration: Record<string, ResourceOverride> = {
|
||||
expenses: {
|
||||
fields: {
|
||||
payee: {
|
||||
displayField: "name",
|
||||
},
|
||||
payor: {
|
||||
display: false,
|
||||
displayField: "username",
|
||||
},
|
||||
account: {
|
||||
displayField: "name",
|
||||
},
|
||||
tags: {
|
||||
displayField: ["name", "icon"],
|
||||
},
|
||||
occurred_at: {
|
||||
formatter: (val: string) => {
|
||||
const date = new Date(val);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'long' });
|
||||
const year = date.getFullYear();
|
||||
const suffix = (day: number) => {
|
||||
if (day > 3 && day < 21) return 'th';
|
||||
switch (day % 10) {
|
||||
case 1: return "st";
|
||||
case 2: return "nd";
|
||||
case 3: return "rd";
|
||||
default: return "th";
|
||||
}
|
||||
};
|
||||
return `${day}${suffix(day)} ${month} ${year}`;
|
||||
}
|
||||
},
|
||||
created_at: {
|
||||
display: false
|
||||
}
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
export const specConfiguration: SpecConfiguration = {
|
||||
specUrl: `${apiBase}/openapi.json`,
|
||||
baseApiUrl: apiBase,
|
||||
title: "Khata",
|
||||
resourceConfig: {
|
||||
expenses: {
|
||||
filterOptions: { mode: "client" },
|
||||
},
|
||||
pagination: true,
|
||||
},
|
||||
reports: {
|
||||
hidden: true
|
||||
}
|
||||
};
|
||||
|
||||
export const profileConfiguration = {
|
||||
"extraFields": ['name'],
|
||||
"resource": "payors",
|
||||
// not in use
|
||||
"hidden": true,
|
||||
// getToken: () => tokenStore.get(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user