20 Commits

Author SHA1 Message Date
38f7416942 refactor period (rolling/calender) to periodType 2026-05-01 17:17:17 +05:30
e82cad4f21 refactor period (rolling/calender) to periodType 2026-05-01 17:15:16 +05:30
1daa90d091 refactor period (rolling/calender) to periodType 2026-05-01 17:13:13 +05:30
2d0b0bc470 fixes for correct comparison period 2026-05-01 17:07:35 +05:30
5f85abdf86 use full for fast loading than weekly for payee report 2026-05-01 16:59:56 +05:30
cc7e6509d2 theme changes 2026-04-25 13:47:42 +05:30
8a3ebdb1be better name 2026-04-25 13:22:49 +05:30
a36d9119bb configurable Dashboard.tsx 2026-04-25 13:21:34 +05:30
67d4c85146 fixes 2026-04-25 12:49:58 +05:30
89ad8e376e Progress Cards for Top Payees 2026-04-25 12:41:05 +05:30
71afc157ff proper use of react-openapi for resource api calls 2026-04-25 12:13:28 +05:30
5acbb7ccdd hiding report resource 2026-04-24 14:55:38 +05:30
3fd20f11ab hidden resource 2026-04-24 14:55:29 +05:30
922d05ae37 dashboard feature to use dashboard data 2026-04-24 14:41:19 +05:30
1fe44abfde removed client data massaging with backend report using feature/report 2026-04-24 14:27:20 +05:30
49bdb85088 refactored ProgressCard to component 2026-04-24 14:04:24 +05:30
b1509fd5ab refactored LatestItems to component 2026-04-24 14:01:49 +05:30
175ca64d1f refactored HistoryChart to component 2026-04-24 13:58:12 +05:30
c9e609fee6 header fixes 2026-04-11 11:23:06 +05:30
82264a5c34 color pallete 2026-04-07 13:47:55 +05:30
96 changed files with 1554 additions and 5797 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

14
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

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

View File

@@ -1,5 +1,4 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import type { AxiosResponse } from "axios";
import { createApiClient } from "../../react-auth"; import { createApiClient } from "../../react-auth";
/** /**
@@ -8,48 +7,22 @@ import { createApiClient } from "../../react-auth";
let _api: AxiosInstance | null = null; let _api: AxiosInstance | null = null;
let _auth: 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 = { export const api = {
get: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => { get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.get<T, R>(url, config); return _api.get(...args);
}, },
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => { post: (...args: Parameters<AxiosInstance["post"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.post<T, R>(url, data, config); return _api.post(...args);
}, },
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => { put: (...args: Parameters<AxiosInstance["put"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.put<T, R>(url, data, config); return _api.put(...args);
}, },
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => { delete: (...args: Parameters<AxiosInstance["delete"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.delete<T, R>(url, config); return _api.delete(...args);
},
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.patch<T, R>(url, data, config);
}, },
}; };
@@ -65,6 +38,6 @@ export const auth = {
}; };
export function initializeApiClients(baseUrl: string, authBaseUrl: string) { export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
_api = withParamsSerializer(createApiClient(baseUrl)); _api = createApiClient(baseUrl);
_auth = withParamsSerializer(createApiClient(authBaseUrl)); _auth = createApiClient(authBaseUrl);
} }

View File

@@ -31,8 +31,6 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import { EnhancedTableComponents } from '../types/overrides';
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
interface EnhancedTableProps { interface EnhancedTableProps {
config: ResourceConfig; config: ResourceConfig;
@@ -45,34 +43,24 @@ interface EnhancedTableProps {
onDelete: (id: string) => void; onDelete: (id: string) => void;
onCreate: () => void; onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void; onNavigateToResource?: (resourceName: string, id: string) => void;
components?: EnhancedTableComponents;
} }
export default function EnhancedTable({ export default function EnhancedTable({
config, config,
data, data,
total, total,
paginationModel: externalPaginationModel, paginationModel,
onPaginationModelChange: externalOnPaginationModelChange, onPaginationModelChange,
loading = false, loading = false,
onEdit, onEdit,
onDelete, onDelete,
onCreate, onCreate,
onNavigateToResource, onNavigateToResource,
components: tableComponents,
}: EnhancedTableProps) { }: EnhancedTableProps) {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const isServer = config.filterOptions?.mode !== "client";
const [internalPaginationModel, setInternalPaginationModel] = React.useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const paginationModel = isServer ? externalPaginationModel : internalPaginationModel;
const onPaginationModelChange = isServer ? externalOnPaginationModelChange : setInternalPaginationModel;
const columns: GridColDef[] = React.useMemo(() => { const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => { const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string'; let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
@@ -88,7 +76,7 @@ export default function EnhancedTable({
type: muiType, type: muiType,
flex: 1, flex: 1,
minWidth: 150, minWidth: 150,
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} /> renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
}; };
if (muiType === 'date' || muiType === 'dateTime') { if (muiType === 'date' || muiType === 'dateTime') {
@@ -99,8 +87,9 @@ export default function EnhancedTable({
}; };
} }
if (muiType === 'singleSelect') { if (muiType === 'singleSelect' && field.options) {
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field)); // @ts-ignore
col.valueOptions = field.options;
} }
return col; return col;
@@ -133,15 +122,6 @@ export default function EnhancedTable({
return cols; return cols;
}, [config, onDelete, navigate, onNavigateToResource]); }, [config, onDelete, navigate, onNavigateToResource]);
const mobilePageSize = 10;
const [mobilePage, setMobilePage] = React.useState(0);
const mobileTotalPages = Math.ceil(data.length / mobilePageSize) || 1;
const mobileData = data.slice(mobilePage * mobilePageSize, (mobilePage + 1) * mobilePageSize);
React.useEffect(() => {
if (mobilePage >= mobileTotalPages) setMobilePage(0);
}, [data.length, mobilePage, mobileTotalPages]);
if (isMobile) { if (isMobile) {
return ( return (
<Box> <Box>
@@ -152,7 +132,7 @@ export default function EnhancedTable({
</Button> </Button>
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{mobileData.map((row) => ( {data.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}> <Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow <MobileCardRow
row={row} row={row}
@@ -161,22 +141,10 @@ export default function EnhancedTable({
onDelete={onDelete} onDelete={onDelete}
onNavigate={onNavigateToResource} onNavigate={onNavigateToResource}
navigate={navigate} navigate={navigate}
components={tableComponents}
/> />
</Box> </Box>
))} ))}
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2, flexWrap: 'wrap' }}>
<Button size="small" disabled={mobilePage === 0} onClick={() => setMobilePage(mobilePage - 1)}>
Previous
</Button>
<Typography variant="body2" sx={{ alignSelf: 'center', px: 1 }}>
Page {mobilePage + 1} of {mobileTotalPages}
</Typography>
<Button size="small" disabled={mobilePage >= mobileTotalPages - 1} onClick={() => setMobilePage(mobilePage + 1)}>
Next
</Button>
</Box>
</Box> </Box>
); );
} }
@@ -193,18 +161,20 @@ export default function EnhancedTable({
rows={data || []} rows={data || []}
columns={columns} columns={columns}
autoHeight autoHeight
paginationMode={isServer ? 'server' : 'client'} paginationMode={config.pagination ? 'server' : 'client'}
{...(isServer ? { rowCount={(() => {
rowCount: (() => { if (!config.pagination) return data.length;
if (total !== undefined) return total; if (total !== undefined) return total;
// Graceful fallback for missing total count
const page = paginationModel?.page || 0; const page = paginationModel?.page || 0;
const pageSize = paginationModel?.pageSize || 10; const pageSize = paginationModel?.pageSize || 10;
if (data.length < pageSize) { if (data.length < pageSize) {
return page * pageSize + data.length; return page * pageSize + data.length;
} }
// Enable 'Next' button by pretending there's at least one more page
return (page + 2) * pageSize; return (page + 2) * pageSize;
})(), })()}
} : {})}
loading={loading} loading={loading}
paginationModel={paginationModel || { page: 0, pageSize: 10 }} paginationModel={paginationModel || { page: 0, pageSize: 10 }}
onPaginationModelChange={onPaginationModelChange} onPaginationModelChange={onPaginationModelChange}
@@ -229,7 +199,7 @@ export default function EnhancedTable({
); );
} }
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) { function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const id = row[config.primaryKey]; const id = row[config.primaryKey];
@@ -264,8 +234,8 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label} {field.label}
</Typography> </Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}> <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 components={components} /> <FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
</Typography> </Typography>
</Box> </Box>
))} ))}
@@ -278,27 +248,30 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
); );
} }
function getFormattedDisplayValue(item: any, displayFormat: string) { function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
if (!item) return ""; if (!item) return "";
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
return resolveTemplate(displayFormat, item); if (Array.isArray(displayField)) {
return displayField
.map(key => item[key])
.filter(val => val !== undefined && val !== null)
.join(' ');
} }
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) { return item[displayField] || item.id || JSON.stringify(item);
}
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
const value = params.value; const value = params.value;
const isPk = fieldKey === config.primaryKey; const isPk = fieldKey === config.primaryKey;
if (field.formatter) return field.formatter(value); if (field.formatter) return field.formatter(value);
const customRenderer = components?.cellRenderers?.[field.type as string];
if (customRenderer) {
return React.createElement(customRenderer, { value, row: params.row, field, fieldKey, config, onNavigate, isMobile });
}
// 1. Single Relation // 1. Single Relation
if (field.relation && value && !Array.isArray(value)) { if (field.relation && value && !Array.isArray(value)) {
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value; const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
const displayValue = getFormattedDisplayValue(value, field.displayFormat); const displayValue = getFormattedDisplayValue(value, field.displayField);
return ( return (
<Chip <Chip
@@ -317,8 +290,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 2. Multi-Select (Array of relations or simple strings) // 2. Multi-Select (Array of relations or simple strings)
if (field.type === 'array' && Array.isArray(value)) { if (field.type === 'array' && Array.isArray(value)) {
const enumValue = field.enumOption?.value; const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayFormat)).join(', ');
return ( return (
<Tooltip title={tooltipTitle} arrow placement="top"> <Tooltip title={tooltipTitle} arrow placement="top">
@@ -326,7 +298,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
{value.map((item, idx) => ( {value.map((item, idx) => (
<Chip <Chip
key={idx} key={idx}
label={getFormattedDisplayValue(item, field.displayFormat)} label={getFormattedDisplayValue(item, field.displayField)}
size="small" size="small"
variant="filled" variant="filled"
sx={{ maxWidth: 120 }} sx={{ maxWidth: 120 }}
@@ -346,7 +318,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 3. Simple Objects // 3. Simple Objects
if (field.type === 'object' && value) { if (field.type === 'object' && value) {
return getFormattedDisplayValue(value, field.displayFormat) || (isMobile ? 'Object' : JSON.stringify(value)); return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
} }
if (field.type === 'number' && typeof value === 'number') { if (field.type === 'number' && typeof value === 'number') {
@@ -379,14 +351,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
); );
} }
if (field.type === 'datetime') return value ? new Date(value).toLocaleString() : ''; if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
if (field.type === 'date') return value ? new Date(value).toLocaleDateString() : '';
if (field.type === 'enum') {
const opt = getFieldOptions(field).find(o => o.key === value);
return opt?.value ?? value;
}
if (isPk && !isMobile) { if (isPk && !isMobile) {
return ( return (

View File

@@ -1,308 +0,0 @@
import * as React from "react";
import {
Box,
Button,
Chip,
Paper,
TextField,
Autocomplete,
Typography,
} from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import FilterListIcon from "@mui/icons-material/FilterList";
import { ResourceField, ResourceMode } from "../types/config";
import { FilterBarComponents, FieldComponents } from "../types/overrides";
import { getFieldOptions, resolveTemplate } from "../utils/options";
export function FilterAutocomplete({
options,
value,
label,
onChange,
}: {
options: string[];
value: string[];
label: string;
onChange: (val: string[]) => void;
}) {
const listboxRef = React.useRef<HTMLUListElement>(null);
const scrollPosRef = React.useRef(0);
const [open, setOpen] = React.useState(false);
const [frozenValue, setFrozenValue] = React.useState<string[]>(value);
const toggleDropdown = () => {
setOpen(prev => {
const next = !prev;
setFrozenValue(value);
return next;
});
};
const sortedOptions = React.useMemo(() => {
const sel = new Set(frozenValue);
const picked: string[] = [];
const rest: string[] = [];
for (const o of options) {
if (sel.has(o)) picked.push(o);
else rest.push(o);
}
return [...picked, ...rest];
}, [options, frozenValue]);
return (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
open={open}
onOpen={toggleDropdown}
onClose={toggleDropdown}
options={sortedOptions}
value={value}
getOptionKey={(option) => option}
onChange={(_, val) => onChange(val.length > 0 ? val : [])}
ListboxProps={{
ref: listboxRef,
onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; },
}}
renderOption={(props, option, { selected }) => {
const { key, ...rest } = props;
return (
<li key={key} {...rest}>
{selected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: 'primary.main' }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip
key={key}
{...tagProps}
label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>;
})}
{tagValue.length > maxChips && (
<Chip
label={`+${tagValue.length - maxChips}`}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>
)}
</>
);
}}
renderInput={(params) => <TextField {...params} placeholder={`Add ${label}...`} />}
sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }}
/>
);
}
function extractOptions(
fieldName: string,
field: ResourceField,
data: any[]
): string[] {
const values = new Set<string>();
if (field.type === 'enum') {
return getFieldOptions(field).map(o => o.value);
}
if (!data) return [];
const pull = (item: any): string | null => {
if (item == null) return null;
if (typeof item === "string") return item;
if (typeof item !== "object") return String(item);
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
// Use displayFormat if defined
if (field.displayFormat) {
return resolveTemplate(field.displayFormat, item);
}
return null;
};
for (const row of data) {
const v = row[fieldName];
if (v == null) continue;
if (Array.isArray(v)) {
for (const el of v) {
const label = pull(el);
if (label) values.add(label);
}
} else {
const label = pull(v);
if (label) values.add(label);
}
}
// console.log('extracted', fieldName, Array.from(values).sort())
return Array.from(values).sort();
}
function renderFilterInput(
fieldName: string,
field: ResourceField,
options: string[],
value: any,
onChange: (key: string, val: any) => void,
components?: FilterBarComponents,
fieldComponents?: FieldComponents,
) {
const filterType = field.filterType;
if (filterType === "number-range") {
const RangeComponent = fieldComponents?.numberRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { min?: string; max?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
}
if (filterType === "date-range") {
const RangeComponent = fieldComponents?.dateRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { start?: string; end?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
}
const selected = Array.isArray(value) ? value : [];
return (
<FilterAutocomplete
options={options}
value={selected}
label={field.label}
onChange={(val) => onChange("value", val.length > 0 ? val : undefined)}
/>
);
}
export interface FilterBarProps {
fields: Record<string, ResourceField>;
filterableFields: string[];
mode: ResourceMode;
data?: any[];
appliedValues: Record<string, any>;
onApply: (values: Record<string, any>) => void;
onClear: () => void;
components?: FilterBarComponents;
fieldComponents?: FieldComponents;
}
export default function FilterBar({
fields,
filterableFields,
data,
appliedValues,
onApply,
onClear,
components: filterComponents,
fieldComponents,
}: FilterBarProps) {
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
React.useEffect(() => {
if (!open) setDraft({ ...appliedValues });
}, [appliedValues, open]);
if (!filterableFields || filterableFields.length === 0) return null;
const activeCount = Object.keys(appliedValues).filter((k) => {
const v = appliedValues[k];
if (v == null || v === "") return false;
if (typeof v === "object" && Object.values(v).every((x) => x == null || x === "")) return false;
return true;
}).length;
const handleApply = () => onApply({ ...draft });
const handleClear = () => {
setDraft({});
onClear();
};
const updateDraft = (fieldName: string, key: string, val: any) => {
setDraft((prev) => {
if (key === "value") {
return { ...prev, [fieldName]: val };
}
const existing = prev[fieldName] || {};
return { ...prev, [fieldName]: { ...existing, [key]: val } };
});
};
return (
<Paper variant="outlined" sx={{ mb: 2, borderRadius: 2, overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
onClick={() => setOpen((o) => !o)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FilterListIcon fontSize="small" color="action" />
<Typography variant="subtitle2" fontWeight={600}>
{open ? "Hide Filters" : "Show Filters"}
</Typography>
</Box>
{activeCount > 0 && (
<Typography variant="caption" color="primary" fontWeight={600}>
{activeCount} active
</Typography>
)}
</Box>
{open && (
<Box sx={{ px: 2, pb: 2, borderTop: "1px solid", borderColor: "divider", pt: 2 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, alignItems: "flex-end" }}>
{filterableFields.map((fieldName) => {
const field = fields[fieldName];
if (!field) return null;
const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect";
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
const raw = draft[fieldName];
return (
<Box key={fieldName} sx={{ display: "flex", flexDirection: "column", flex: { xs: '0 0 100%', sm: 1 }, minWidth: { sm: 200 } }}>
<Box sx={{ typography: "caption", mb: 0.5, color: "text.secondary" }}>
{field.label}
</Box>
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
updateDraft(fieldName, key, val), filterComponents, fieldComponents
)}
</Box>
);
})}
</Box>
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<Button variant="contained" onClick={handleApply}>
Apply
</Button>
<Button variant="outlined" onClick={handleClear}>
Clear
</Button>
</Box>
</Box>
)}
</Paper>
);
}

View File

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

View File

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

View File

@@ -1,98 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Paper, CircularProgress } from '@mui/material'; import { Box, Typography, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import type { ResourceField } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import { resolveTemplate } from '../utils/options'; import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable'; import EnhancedTable from './EnhancedTable';
import FilterBar from './FilterBar'; import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
onNavigateToResource?: (resourceName: string, id: string) => void; onNavigateToResource?: (resourceName: string, id: string) => void;
fieldComponents: FieldComponents;
} }
import { GridPaginationModel } from '@mui/x-data-grid'; import { GridPaginationModel } from '@mui/x-data-grid';
function getDisplayString(item: any, field: ResourceField): string { export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
if (item == null || typeof item !== 'object') return String(item ?? '');
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
if (field.displayFormat) return resolveTemplate(field.displayFormat, item);
throw new Error('cannot get display string')
}
function applyClientFilters(
data: any[],
filters: Record<string, any>,
fields: Record<string, ResourceField>
): any[] {
const entries = Object.entries(filters).filter(([_, v]) => {
if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) return false;
if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false;
return true;
});
if (entries.length === 0) return data;
return data.filter((item) =>
entries.every(([fieldName, filterValue]) => {
const field = fields[fieldName];
if (!field) return true;
const itemValue = item[fieldName];
if (typeof filterValue === "object" && !Array.isArray(filterValue)) {
if (field.type === "number") {
if (filterValue.min != null && filterValue.min !== "" && Number(itemValue) < Number(filterValue.min)) return false;
if (filterValue.max != null && filterValue.max !== "" && Number(itemValue) > Number(filterValue.max)) return false;
return true;
}
if (field.type === "datetime" || field.type === "date") {
const itemTime = new Date(itemValue).getTime();
if (filterValue.start && new Date(filterValue.start).getTime() > itemTime) return false;
if (filterValue.end && new Date(filterValue.end).getTime() < itemTime) return false;
return true;
}
return true;
}
if (Array.isArray(filterValue)) {
if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) =>
filterValue.includes(getDisplayString(el, field))
);
}
if (itemValue && typeof itemValue === "object") {
return filterValue.includes(getDisplayString(itemValue, field));
}
return filterValue.includes(String(itemValue));
}
if (!filterValue) return true;
if (field.type === "boolean") {
return String(itemValue) === filterValue;
}
if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) =>
getDisplayString(el, field) === String(filterValue)
);
}
if (itemValue && typeof itemValue === "object") {
return getDisplayString(itemValue, field) === String(filterValue);
}
return String(itemValue) === String(filterValue);
})
);
}
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -102,36 +23,26 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
const isView = !!id && !isEdit; const isView = !!id && !isEdit;
const isList = !id && !isCreate; const isList = !id && !isCreate;
const isServer = config.filterOptions?.mode !== "client";
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({ const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
page: 0, page: 0,
pageSize: 10, pageSize: 10,
}); });
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({}); const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
// Determine query parameters based on pagination config
const queryParams = React.useMemo(() => { const queryParams = React.useMemo(() => {
if (!isServer) return { limit: 10000 }; if (!config.pagination) return {};
return { return {
skip: paginationModel.page * paginationModel.pageSize, skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize, limit: paginationModel.pageSize,
}; };
}, [isServer, paginationModel]); }, [config.pagination, paginationModel]);
const listQuery = useList(queryParams); const listQuery = useList(queryParams);
const itemQuery = useRead(id || ""); const itemQuery = useRead(id || "");
const rawData = listQuery.data?.data || []; const paginatedData = listQuery.data || { data: [], total: undefined };
const totalCount = listQuery.data?.total;
const filteredData = React.useMemo(
() => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)),
[isServer, rawData, appliedFilters, config.fields]
);
const createMutation = useCreate(); const createMutation = useCreate();
const updateMutation = useUpdate(); const updateMutation = useUpdate();
const deleteMutation = useDelete(); const deleteMutation = useDelete();
@@ -169,35 +80,21 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
return ( return (
<Box> <Box>
{isList ? ( {isList ? (
<Box>
{!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
<FilterBar
fields={config.fields}
filterableFields={config.filterOptions.fields}
mode={config.filterOptions?.mode || "server"}
data={rawData}
appliedValues={appliedFilters}
onApply={setAppliedFilters}
onClear={() => setAppliedFilters({})}
fieldComponents={components}
/>
)}
<EnhancedTable <EnhancedTable
config={config} config={config}
data={filteredData} data={paginatedData.data || []}
total={isServer ? totalCount : filteredData.length} total={paginatedData.total}
paginationModel={isServer ? paginationModel : undefined} paginationModel={paginationModel}
onPaginationModelChange={isServer ? setPaginationModel : undefined} onPaginationModelChange={setPaginationModel}
loading={listQuery.isFetching} loading={listQuery.isFetching}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onCreate={handleCreate} onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)} onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/> />
</Box>
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
{components && <components.GenericForm <GenericForm
config={config} config={config}
initialData={isCreate ? null : itemQuery.data} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
@@ -205,7 +102,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView} readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)} onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
/>} />
</Paper> </Paper>
)} )}
</Box> </Box>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

48
src/AppTheme.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { getDesignTokens } from "./shared-theme/themePrimitives";
import { inputsCustomizations } from "./shared-theme/customizations/inputs";
import { dataDisplayCustomizations } from "./shared-theme/customizations/dataDisplay";
import { feedbackCustomizations } from "./shared-theme/customizations/feedback";
import { navigationCustomizations } from "./shared-theme/customizations/navigation";
import { surfacesCustomizations } from "./shared-theme/customizations/surfaces";
export const ColorModeContext = React.createContext({
toggleColorMode: () => {},
mode: "light" as "light" | "dark",
});
export default function AppTheme({ children }: { children: React.ReactNode }) {
const [mode, setMode] = React.useState<"light" | "dark">("light");
const colorMode = React.useMemo(
() => ({
toggleColorMode: () => {
setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
},
mode,
}),
[mode]
);
const theme = React.useMemo(
() =>
createTheme({
...getDesignTokens(mode),
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
},
}),
[mode]
);
return (
<ColorModeContext.Provider value={colorMode}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ColorModeContext.Provider>
);
}

View File

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

View File

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

View File

@@ -1,600 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
ToggleButtonGroup,
ToggleButton,
Chip,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Tooltip,
TextField,
Select,
MenuItem,
InputLabel,
FormControl,
OutlinedInput,
Autocomplete,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import {
useUploadFile,
} from "./features/fetch-requests";
import type {
FetchRequest,
FetchRequestStatus,
FileSource,
EmailSource,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } from "../react-openapi";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <ScheduleIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} sx={{ mr: 0.5 }} />,
paused: <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />,
raw_expenses_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
enriched_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />,
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
};
function formatDate(iso: string) {
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 { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
const { data: listData, isLoading, isFetching, refetch } = useList({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
});
const { useList: useAccountsList } = useResourceByName("accounts");
const { data: accountsData } = useAccountsList();
const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]);
const config = useConfig();
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
const formatOptions: string[] = formatField?.options ?? [];
const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date;
const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
const createMutation = useCreate();
const updateMutation = usePatch();
const deleteMutation = useDelete();
const uploadMutation = useUploadFile();
const requests = listData?.data ?? [];
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 && components?.FormField ? (
<components.FormField
name="format"
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)}
</>
) : (
<>
{formatField && components?.FormField ? (
<components.FormField
name="format"
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)}
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
<TextField label="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 && components?.FormField ? (
<components.FormField
name="payor_username"
field={payorUsernameField}
value={payorUsername}
onChange={setPayorUsername}
/>
) : (
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.date ? (
<Box sx={{ flex: 1 }}>
<components.date
name="start_date"
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 && components?.date ? (
<Box sx={{ flex: 1 }}>
<components.date
name="end_date"
field={endDateField}
value={endDate}
onChange={setEndDate}
/>
</Box>
) : (
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)}
</Box>
<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(", ")}
>
{(config?.enums?.FetchRequestStatus ?? []).map((s: string) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
options={accountOptions}
value={accountFilter || null}
onChange={(_, val) => setAccountFilter(val ?? "")}
renderInput={(params) => (
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
)}
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
<ToggleButtonGroup
value={sourceFilter}
exclusive
onChange={(_, val) => val && setSourceFilter(val)}
size="small"
>
{sourceTypeOptions.map((opt) => (
<ToggleButton key={opt} value={opt}>
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"}
</ToggleButton>
))}
</ToggleButtonGroup>
<Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : requests.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No fetch requests yet
</Box>
) : (
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<Box
key={req.id}
component="tr"
onClick={() => navigate(`/fetch-requests/${req.id}`)}
sx={{
cursor: "pointer",
borderBottom: 1,
borderColor: "divider",
"&:hover": { bgcolor: "action.hover" },
"&:last-child": { borderBottom: 0 },
}}
>
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{shortId(req.fingerprint)}
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(req.fingerprint);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{req.account_name}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{formatDateRange((req as any).start_date, (req as any).end_date)}
</Typography>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
<Chip
icon={statusIcons[req.status] as any}
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status]}
size="small"
/>
</Tooltip>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{(req.retry_count ?? 0) > 0 ? (
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{req.retry_count}/{RETRY_MAX}
</Typography>
) : (
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
\u2014
</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
{formatDate(req.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && (
<Tooltip title="Resolve ambiguities">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/fetch-requests/${req.id}`);
}}
>
<WarningAmberIcon fontSize="small" color="warning" />
</IconButton>
</Tooltip>
)}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRetry(req);
}}
>
<ReplayIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(req);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</Paper>
)}
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Fetch Request?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the fetch request and all associated data.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -20,7 +20,7 @@ import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode"; import LightModeIcon from "@mui/icons-material/LightMode";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../react-auth"; import { useAuth } from "../react-auth";
import { ColorModeContext } from "./shared-theme/AppTheme"; import { ColorModeContext } from "./AppTheme";
interface HeaderProps { interface HeaderProps {
routerMapping: { routerMapping: {
@@ -91,32 +91,6 @@ export default function Header({
<span style={{ flexGrow: 1 }} /> <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 */} {/* AUTH SECTION */}
{isAuthenticated ? ( {isAuthenticated ? (
<> <>

View File

@@ -1,180 +1,70 @@
import * as React from "react"; import * as React from "react";
import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material"; import { Box, Typography, Button, Container, Stack } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; 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 ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { useAuth } from "../react-auth";
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() { export default function Home() {
const navigate = useNavigate(); 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 ( return (
<Box <Box
sx={{ sx={{
minHeight: "calc(100vh - 64px)", width: "100%",
minHeight: "calc(100vh - 64px)", // accounting for header
display: "flex", display: "flex",
flexDirection: "column", alignItems: "center",
justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
"&::before": { "&::before": {
content: '""', content: '""',
position: "absolute", position: "absolute",
top: "-15%", top: "-20%",
left: "-8%", left: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(99,102,241,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
"&::after": { "&::after": {
content: '""', content: '""',
position: "absolute", position: "absolute",
bottom: "-15%", bottom: "-20%",
right: "-8%", right: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(236,72,153,0.1) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(236,72,153,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
}} }}
> >
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}> <Container maxWidth="lg" sx={{ position: "relative", zIndex: 1 }}>
<Box <Stack
spacing={4}
alignItems="center"
textAlign="center"
sx={{ sx={{
textAlign: "center", p: { xs: 4, md: 8 },
mb: 6, backdropFilter: "blur(20px)",
backgroundColor: (theme) =>
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.03)" : "rgba(255, 255, 255, 0.6)",
border: "1px solid",
borderColor: "divider",
borderRadius: 4,
boxShadow: (theme) =>
theme.palette.mode === "dark"
? "0 8px 32px 0 rgba(0, 0, 0, 0.37)"
: "0 8px 32px 0 rgba(31, 38, 135, 0.07)",
}} }}
> >
<Typography <Typography
variant="h1" variant="h1"
sx={{ sx={{
fontWeight: 800, fontWeight: 800,
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" }, fontSize: { xs: "3rem", md: "5rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent", WebkitTextFillColor: "transparent",
letterSpacing: "-0.03em",
mb: 2, mb: 2,
}} }}
> >
@@ -182,20 +72,14 @@ export default function Home() {
</Typography> </Typography>
<Typography <Typography
variant="h6" variant="h5"
color="text.secondary" color="text.secondary"
sx={{ sx={{ maxWidth: "600px", lineHeight: 1.6 }}
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
> >
Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow. Your intelligent, extensible financial ledger. Control accounts, manage transactions, and track your data dynamically with our OpenAPI-driven architecture.
</Typography> </Typography>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}> <Box mt={4}>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
@@ -203,44 +87,21 @@ export default function Home() {
onClick={() => navigate("/dashboard")} onClick={() => navigate("/dashboard")}
sx={{ sx={{
px: 4, px: 4,
py: 1.4, py: 1.5,
borderRadius: "50px", borderRadius: "50px",
fontWeight: 700, fontWeight: "bold",
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
transition: "transform 0.2s ease, box-shadow 0.2s", transition: "transform 0.2s ease-in-out, box-shadow 0.2s",
"&:hover": { "&:hover": {
transform: "translateY(-2px)", transform: "translateY(-3px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`, boxShadow: "0 8px 20px rgba(236,72,153,0.4)",
}, },
}} }}
> >
Enter Dashboard Enter Dashboard
</Button> </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> </Box>
</Box> </Stack>
<Grid container spacing={3}>
{features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
<FeatureCard {...f} />
</Grid>
))}
</Grid>
</Container> </Container>
</Box> </Box>
); );

View File

@@ -1,311 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Chip,
TextField,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } from "../react-openapi";
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 { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
const { data: listData, isLoading, isFetching, refetch } = useList();
const createMutation = useCreate();
const deleteMutation = useDelete();
const config = useConfig();
const reportsRes = config?.resources.find((r: any) => r.name === "reports");
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self;
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date;
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date;
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
const snapshots: ReportSnapshot[] = listData?.data ?? [];
const handleCreate = async () => {
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 }}>
{ignoreSelfField && components?.FormField && (
<components.FormField
name="ignore_self"
field={ignoreSelfField}
value={ignoreSelf}
onChange={(val: boolean) => setIgnoreSelf(val)}
/>
)}
<Box sx={{ display: "flex", gap: 2 }}>
<Box sx={{ flex: 1 }}>
<TextField
label="Start Date"
type="date"
value={startDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
/>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
/>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
{minAmountField && components?.FormField && (
<Box sx={{ flex: 1 }}>
<components.FormField
name="min_amount"
field={minAmountField}
value={minAmount}
onChange={(val: string) => setMinAmount(val)}
/>
</Box>
)}
{maxAmountField && components?.FormField && (
<Box sx={{ flex: 1 }}>
<components.FormField
name="max_amount"
field={maxAmountField}
value={maxAmount}
onChange={(val: string) => setMaxAmount(val)}
/>
</Box>
)}
</Box>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
</Button>
{createdSnapshotId && (
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
</Alert>
)}
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Existing Snapshots
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : snapshots.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No snapshots yet
</Box>
) : (
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{snapshots.map((snap: ReportSnapshot) => (
<Box
key={snap.id}
component="tr"
sx={{ borderBottom: 1, borderColor: "divider", "&:last-child": { borderBottom: 0 }, "&:hover": { bgcolor: "action.hover" } }}
>
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{snap.snapshot_id}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(snap.snapshot_id);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{formatDate(snap.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{snap.query ? (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
</Box>
) : (
<Typography variant="body2" color="text.secondary">\u2014</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</Paper>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Snapshot?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the report snapshot.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -1,61 +1,49 @@
import * as React from "react"; import * as React from "react";
import {
ReportData,
GroupKey,
} from "../../features/report";
export type DashboardFlow = "outflows" | "inflows"; export type DashboardMode = "expense" | "income";
export type DashboardPeriodType = "rolling" | "calendar"; export type DashboardPeriodType = "rolling" | "calendar";
export type DashboardSelectedPeriodId = string | null;
export interface DashboardState { export interface DashboardState {
flow: DashboardFlow; mode: DashboardMode;
periodType: DashboardPeriodType; periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean; comparison: boolean;
} }
export interface DashboardStateSetters {
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleFlow: () => void;
togglePeriodType: () => void;
toggleComparison: () => void;
}
export interface DashboardSection { export interface DashboardSection {
id: string; id: string;
title: string; title?: string;
component: React.ComponentType<any>;
summary?: string; summary?: string;
component: React.ComponentType<any>;
dataKey?: string;
settings?: Record<string, any>; settings?: Record<string, any>;
isList?: boolean;
style?: {
size?: number;
[key: string]: any;
};
}
export interface ColorDefinition {
primary: string;
background?: string;
text?: string;
}
export interface ThemeAwarePalette {
light: ColorDefinition;
dark: ColorDefinition;
} }
export interface DashboardConfig { export interface DashboardConfig {
sections: DashboardSection[]; sections: DashboardSection[];
style?: {
palette?: Record<DashboardMode, ThemeAwarePalette>;
};
} }
export interface DashboardViewProps { export interface DashboardProps {
config: DashboardConfig; config: DashboardConfig;
data: ReportData; data: any; // Aggregated data from features
state: DashboardState; latest: any[]; // Latest items from features
stateSetters: DashboardStateSetters; onModeChange?: (mode: DashboardMode) => void;
isFetching: boolean;
}
export interface ColorScheme {
primary: string;
surface: string;
text: string;
}
export interface ComponentProps extends DashboardSection {
reportData: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
colorScheme: ColorScheme;
} }

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import DashboardView from "./Dashboard.view";
import { DashboardProps, DashboardState } from "./Dashboard.models";
export default function Dashboard(props: DashboardProps) {
const [state, setState] = React.useState<DashboardState>({
mode: "expense",
periodType: "rolling",
comparison: false,
});
return (
<DashboardView
{...props}
state={state}
setState={setState}
/>
);
}

View File

@@ -3,80 +3,93 @@ import {
Box, Box,
Container, Container,
Grid, Grid,
Typography,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup
Button
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { DashboardViewProps } from "./Dashboard.models"; import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps {
state: DashboardState;
setState: React.Dispatch<React.SetStateAction<DashboardState>>;
}
export default function DashboardView({ export default function DashboardView({
config, config,
data, data,
latest,
state, state,
stateSetters, setState,
isFetching, onModeChange
}: DashboardViewProps) { }: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const themeMode = theme.palette.mode;
const { mode, periodType, comparison } = state;
const { // Resolve colors with fallbacks
flow, const colors = React.useMemo(() => {
selectedGroupKey, const palette = config.style?.palette?.[mode];
} = state; const modeColors = palette ? palette[themeMode] : null;
const colorScheme = flow === "outflows" ? theme.palette.flows.outflows : theme.palette.flows.inflows; if (modeColors) {
return {
primary: modeColors.primary,
light: modeColors.background || alpha(modeColors.primary, 0.1),
text: modeColors.text || (themeMode === 'light' ? theme.palette.text.primary : '#fff')
};
}
// Fallback to standard theme colors
const themeColor = mode === 'expense' ? theme.palette.error : theme.palette.success;
return {
primary: themeColor.main,
light: alpha(themeColor.main, themeMode === 'light' ? 0.08 : 0.15),
text: themeColor.main
};
}, [config.style?.palette, mode, themeMode, theme.palette]);
const handleModeChange = (_: any, newMode: any) => {
if (newMode && onModeChange) {
onModeChange(newMode);
setState(prev => ({ ...prev, mode: newMode }));
}
};
return ( return (
<Container <Container
sx={{ sx={{
mt: 4, mt: 4,
mb: 4, mb: 4,
background: `linear-gradient(180deg, ${alpha(colorScheme.primary, theme.palette.mode === "dark" ? 0.06 : 0.04)} 0%, transparent 100%)`, background: `linear-gradient(180deg, ${colors.light} 0%, transparent 100%)`,
borderRadius: 4, borderRadius: 4,
p: 2, p: 2,
transition: "background 0.3s ease", transition: 'background 0.3s ease'
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
mb: 3,
}} }}
> >
<Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
<ToggleButtonGroup <ToggleButtonGroup
value={flow} value={mode}
exclusive exclusive
onChange={stateSetters.toggleFlow} onChange={handleModeChange}
sx={{ sx={{
borderRadius: 3, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
"& .MuiToggleButton-root": { "& .MuiToggleButton-root": {
px: 3, px: 3,
textTransform: "none", textTransform: "none",
color: "text.secondary", color: "text.secondary"
}, },
"&.Mui-selected": { "&.Mui-selected": {
bgcolor: colorScheme.primary, bgcolor: colors.primary,
color: "white", color: "white",
borderColor: colorScheme.primary, borderColor: colors.primary
}, },
}} }}
> >
<ToggleButton value="outflows">Outflows</ToggleButton> <ToggleButton value="expense">Expenses</ToggleButton>
<ToggleButton value="inflows">Inflows</ToggleButton> <ToggleButton value="income">Income</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
<Button
size="small"
sx={{ mt: 1, textTransform: "none" }}
onClick={() => stateSetters.setSelectedGroupKey(null)}
>
Clear Drill-down
</Button>
)}
</Box> </Box>
<Grid container spacing={4}> <Grid container spacing={4}>
@@ -84,19 +97,55 @@ export default function DashboardView({
const Component = section.component; const Component = section.component;
return ( return (
<Grid key={section.id} size={12}> <Grid key={section.id} size={section.style?.size || 12 as any}>
{section.title && !section.isList && (
<Box sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
{section.title}
</Typography>
</Box>
)}
{section.isList ? (
<Box>
{section.title && (
<Box sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
{section.title}
</Typography>
</Box>
)}
<Grid container spacing={2}>
{(data[section.dataKey || ""] || []).map((item: any, idx: number) => (
<Grid key={idx} size={{ xs: 12, sm: 6, md: 2.4 }}>
<Component <Component
{...section} {...section.settings}
header={item.payeeName || item.name}
reportData={data} progressAmount={item.amount}
totalAmount={data.totalAmount}
state={state} colorTheme={mode === "expense" ? "error" : "success"}
stateSetters={stateSetters}
isFetching={isFetching}
colorScheme={colorScheme}
/> />
</Grid> </Grid>
))}
</Grid>
</Box>
) : (
<Component
{...section.settings}
header={section.title}
summary={section.summary}
data={section.dataKey ? data[section.dataKey] : data.chartData}
items={section.dataKey === 'latest' ? latest : (data[section.dataKey || ''] || [])}
title={section.title}
accentColor={colors.primary}
colorScheme={colors}
periodType={periodType}
onPeriodTypeChange={(p: any) => setState(prev => ({ ...prev, periodType: p }))}
comparison={comparison}
setComparison={(c: any) => setState(prev => ({ ...prev, comparison: c }))}
/>
)}
</Grid>
); );
})} })}
</Grid> </Grid>

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
export interface _ChartDataPoint { export interface _ChartDataPoint {
id: string; id: string;
label: string;
amount: number; amount: number;
highlighted?: boolean; highlighted?: boolean;
} }
@@ -8,3 +7,31 @@ export interface _ChartDataPoint {
export interface ChartDataPoint extends _ChartDataPoint { export interface ChartDataPoint extends _ChartDataPoint {
compare?: _ChartDataPoint; compare?: _ChartDataPoint;
} }
export interface ChartData {
daily?: ChartDataPoint[];
weekly?: Record<string, ChartDataPoint[]>;
monthly?: Record<string, ChartDataPoint[]>;
}
export interface AggregatedDashboardData {
chartData: ChartData;
totalAmount: number;
topPayees: Array<{ payeeName: string; amount: number }>;
}
export interface HistoryChartProps {
header: string;
summary?: string;
tabs: string[];
data: ChartData;
periodType: "rolling" | "calendar";
onPeriodTypeChange: (p: "rolling" | "calendar") => void;
comparison: boolean;
setComparison: (v: boolean) => void;
colorScheme: {
primary: string;
light: string;
text: string;
};
}

View File

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

View File

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

View File

@@ -25,3 +25,19 @@ export const formatDisplay = (
return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`; return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`;
}; };
export const formatLabel = (label: string, type: string) => {
if (type === "monthly") return label;
if (type === "weekly") {
const parts = label.split(" - ");
if (parts.length === 2) {
const [start, end] = parts;
const startDay = start.split(" ")[0];
const [endDay, month] = end.split(" ");
return `${startDay}${endDay} ${month}`;
}
}
return label;
};

View File

@@ -11,21 +11,33 @@ import IconButton from "@mui/material/IconButton";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { import {
HistoryChartViewProps, ChartDataPoint,
} from "./HistoryChart.props"; HistoryChartProps,
import { formatDisplay } from "./HistoryChart.utils"; } from "./HistoryChart.models";
import { formatDisplay, formatLabel } from "./HistoryChart.utils";
export default function HistoryChartView({ interface ViewProps extends HistoryChartProps {
title, activeTab: string;
setActiveTab: (v: string) => void;
currentData: ChartDataPoint[];
visibleData: ChartDataPoint[];
maxAmount: number;
visibleCount: number;
startIndex: number;
setStartIndex: React.Dispatch<React.SetStateAction<number>>;
activeDataKey: string;
}
export default function HistoryChartView(props: ViewProps) {
const {
header,
summary, summary,
settings, tabs,
periodType,
state, onPeriodTypeChange,
stateSetters, comparison,
isFetching, setComparison,
colorScheme, colorScheme,
activeTab, activeTab,
setActiveTab, setActiveTab,
currentData, currentData,
@@ -35,36 +47,24 @@ export default function HistoryChartView({
startIndex, startIndex,
setStartIndex, setStartIndex,
activeDataKey, activeDataKey,
}: HistoryChartViewProps) { } = props;
const { flow, periodType, selectedPeriodId, comparison } = state;
const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters;
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
const total = currentData.length;
const maxStartIndex = Math.max(total - visibleCount, 0);
const clampedStartIndex = Math.min(startIndex, maxStartIndex);
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => { const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) setActiveTab(newTab); if (newTab !== null) setActiveTab(newTab);
}; };
const canGoLeft = clampedStartIndex > 0; const canGoLeft = startIndex > 0;
const canGoRight = clampedStartIndex < maxStartIndex; const canGoRight = startIndex + visibleCount < currentData.length;
const handlePrev = () => { const handlePrev = () => {
if (!canGoLeft) return; if (canGoLeft) setStartIndex((prev) => prev - visibleCount);
setStartIndex((prev) => Math.max(prev - visibleCount, 0));
}; };
const handleNext = () => { const handleNext = () => {
if (!canGoRight) return; if (canGoRight) setStartIndex((prev) => prev + visibleCount);
setStartIndex((prev) => {
const next = prev + visibleCount;
return Math.min(next, maxStartIndex);
});
}; };
return ( return (
@@ -76,14 +76,12 @@ export default function HistoryChartView({
boxShadow: "none", boxShadow: "none",
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
bgcolor: isDark ? "background.paper" : colorScheme.surface, bgcolor: isDark ? "background.paper" : colorScheme.light,
opacity: isFetching ? 0.6 : 1, transition: 'background-color 0.3s ease, border-color 0.3s ease'
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}} }}
> >
<Typography variant="h6" fontWeight={700} gutterBottom> <Typography variant="h6" fontWeight={700} gutterBottom sx={{ color: isDark ? 'text.primary' : colorScheme.text }}>
{title} {header}
</Typography> </Typography>
{summary && ( {summary && (
@@ -93,24 +91,42 @@ export default function HistoryChartView({
)} )}
<ToggleButtonGroup value={activeTab} exclusive onChange={handleTabChange} fullWidth sx={{ mb: 4 }}> <ToggleButtonGroup value={activeTab} exclusive onChange={handleTabChange} fullWidth sx={{ mb: 4 }}>
{settings.tabs.map((tab) => ( {tabs.map((tab) => (
<ToggleButton key={tab} value={tab}> <ToggleButton key={tab} value={tab}>
{tab} {tab}
</ToggleButton> </ToggleButton>
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
<ToggleButtonGroup value={periodType} exclusive onChange={togglePeriodType} size="small"> <ToggleButtonGroup value={periodType} exclusive onChange={(_, v) => v && onPeriodTypeChange(v)} size="small">
<ToggleButton value="rolling">Rolling</ToggleButton> <ToggleButton value="rolling">Rolling</ToggleButton>
<ToggleButton value="calendar">Calendar</ToggleButton> <ToggleButton value="calendar" disabled={activeDataKey === "daily"}>
Calendar
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
<ToggleButton <ToggleButton
value="compare" value="compare"
selected={comparison} selected={comparison}
onChange={toggleComparison} onChange={() => setComparison(!comparison)}
size="small" size="small"
sx={{
textTransform: "none",
borderRadius: 2,
px: 2,
color: "text.secondary",
border: "1px solid",
borderColor: "divider",
"&.Mui-selected": {
color: "white",
bgcolor: "success.main",
borderColor: "success.main"
},
"&.Mui-selected:hover": {
bgcolor: "success.dark"
}
}}
> >
Compare Compare
</ToggleButton> </ToggleButton>
@@ -119,7 +135,19 @@ export default function HistoryChartView({
{currentData.length > 0 ? ( {currentData.length > 0 ? (
<Box sx={{ position: "relative", mt: 4 }}> <Box sx={{ position: "relative", mt: 4 }}>
{canGoLeft && ( {canGoLeft && (
<IconButton onClick={handlePrev} size="small" sx={{ position: "absolute", left: 0, top: "50%" }}> <IconButton
onClick={handlePrev}
size="small"
sx={{
position: "absolute",
left: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronLeftIcon fontSize="small" /> <ChevronLeftIcon fontSize="small" />
</IconButton> </IconButton>
)} )}
@@ -130,67 +158,78 @@ export default function HistoryChartView({
const compareHeight = comparison const compareHeight = comparison
? ((point.compare?.amount ?? 0) / maxAmount) * 100 ? ((point.compare?.amount ?? 0) / maxAmount) * 100
: 0; : 0;
const labelHeight = Math.max(currentHeight, compareHeight);
const isSelected = selectedPeriodId === point.id;
const display = formatDisplay(point, activeDataKey, comparison);
return ( return (
<Box <Box key={point.id} sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end", height: "100%" }}>
key={point.id} <Box sx={{ display: "flex", alignItems: "flex-end", gap: comparison ? 1 : 0.5, height: "100%", position: "relative" }}>
onClick={() => <Typography
setSelectedPeriodId(isSelected ? null : point.id) variant="caption"
}
sx={{ sx={{
flex: 1, position: "absolute",
display: "flex", bottom: `${labelHeight}%`,
flexDirection: "column", left: "50%",
alignItems: "center", transform: "translate(-50%, -6px)",
cursor: "pointer", fontSize: "0.65rem",
height: "100%" whiteSpace: "nowrap",
pointerEvents: "none",
color: 'text.secondary',
fontWeight: 600
}} }}
> >
<Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, height: "100%" }}> {formatDisplay(point, activeTab.toLowerCase(), comparison)}
</Typography>
{comparison && ( {comparison && (
<Box <Box sx={{ width: 8, height: `${compareHeight}%`, bgcolor: isDark ? alpha(colorScheme.primary, 0.3) : alpha(colorScheme.primary, 0.4), borderRadius: '4px 4px 0 0' }} />
sx={{
width: 8,
height: `${compareHeight}%`,
bgcolor: alpha(colorScheme.primary, 0.4),
borderRadius: "4px 4px 0 0"
}}
/>
)} )}
<Box <Box
sx={{ sx={{
width: 12, width: comparison ? 10 : 16,
height: `${currentHeight}%`, height: `${currentHeight}%`,
bgcolor: isSelected ? "warning.main" : colorScheme.primary, bgcolor: point.highlighted ? colorScheme.primary : isDark ? alpha(colorScheme.primary, 0.8) : alpha(colorScheme.primary, 0.9),
borderRadius: "4px 4px 0 0" borderRadius: '4px 4px 0 0',
boxShadow: point.highlighted ? `0 0 10px ${alpha(colorScheme.primary, 0.5)}` : 'none'
}} }}
/> />
</Box> </Box>
<Typography variant="caption"> <Box sx={{ mt: 1.5, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", lineHeight: 1.1 }}>
{point.label} <Typography variant="caption" sx={{ fontSize: "0.7rem", opacity: 0.8, color: 'text.primary', fontWeight: 500 }}>
{formatLabel(point.id, activeDataKey)}
</Typography> </Typography>
{comparison && point.compare && ( <Typography
<Typography variant="caption" color="text.secondary"> variant="caption"
{point.compare.label} sx={{
</Typography> fontSize: "0.65rem",
)} color: "text.disabled",
visibility: comparison && point.compare && activeDataKey !== "daily" ? "visible" : "hidden"
<Typography variant="caption"> }}
{display} >
{point.compare ? formatLabel(point.compare.id, activeDataKey) : "placeholder"}
</Typography> </Typography>
</Box> </Box>
</Box>
); );
})} })}
</Box> </Box>
{canGoRight && ( {canGoRight && (
<IconButton onClick={handleNext} size="small" sx={{ position: "absolute", right: 0, top: "50%" }}> <IconButton
onClick={handleNext}
size="small"
sx={{
position: "absolute",
right: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronRightIcon fontSize="small" /> <ChevronRightIcon fontSize="small" />
</IconButton> </IconButton>
)} )}

View File

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

View File

@@ -1,7 +1,18 @@
import * as React from "react";
export interface LatestItem { export interface LatestItem {
id: string | number; id: string | number;
icon: React.ReactNode;
iconBgColor?: string;
title: string; title: string;
subtitle: string; subtitle: string;
amount: string; amount: string;
timeAgo: string; timeAgo: string;
} }
export interface LatestItemsListProps {
title?: string;
items: LatestItem[];
onViewAll?: () => void;
accentColor: string;
}

View File

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

View File

@@ -1,40 +1,112 @@
import * as React from "react"; import * as React from "react";
import { buildLatestItems } from "./LatestItems.adapter"; import {
import LatestItemsView from "./LatestItems.view"; List,
import { LatestItemsProps } from "./LatestItems.props"; ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
Button,
} from "@mui/material";
export default function LatestItems(props: LatestItemsProps) { export interface LatestItem {
const { id: string | number;
reportData, icon: React.ReactNode;
state, iconBgColor?: string;
stateSetters, title: string;
isFetching, subtitle: string;
} = props; amount: string;
timeAgo: string;
}
const { flow, selectedPeriodId, selectedGroupKey } = state; export interface LatestItemsListProps {
const [visibleCount, setVisibleCount] = React.useState(5); title?: string;
items: LatestItem[];
// Reset count when flow changes to start clean onViewAll?: () => void;
React.useEffect(() => { accentColor: any;
setVisibleCount(5); }
}, [flow]);
const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow);
}, [reportData, selectedPeriodId, selectedGroupKey, flow]);
const visibleItems = React.useMemo(() => {
return allItems.slice(0, visibleCount);
}, [allItems, visibleCount]);
const canExpand = visibleCount < allItems.length;
export default function LatestItems({
title = "Recent Transactions",
items,
onViewAll,
accentColor,
}: LatestItemsListProps) {
return ( return (
<LatestItemsView <Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
{...props} {/* Header */}
items={visibleItems} <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, px: 2 }}>
canExpand={canExpand} <Typography variant="h6" fontWeight="bold">
onExpand={() => setVisibleCount((prev) => prev + 5)} {title}
</Typography>
{onViewAll && (
<Button
variant="text"
color="inherit"
size="small"
sx={{ textTransform: "none", color: "text.secondary", fontWeight: "medium" }}
onClick={onViewAll}
>
view all
</Button>
)}
</Box>
{/* List */}
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
transition: "background-color 0.2s ease",
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: `${accentColor}22`,
color: "inherit",
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
>
{item.icon}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600} color="text.primary">
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/> />
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
</List>
</Box>
); );
} }

View File

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

View File

@@ -0,0 +1,8 @@
export interface ProgressCardProps {
header: string;
summary?: string;
progressAmount: number;
totalAmount: number;
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
compact?: boolean;
}

View File

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

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import ProgressCardView from "./ProgressCard.view";
import { ProgressCardProps } from "./ProgressCard.models";
import { getPercentage, formatCurrency } from "./ProgressCard.utils";
export default function ProgressCard(props: ProgressCardProps) {
const { progressAmount, totalAmount, compact = false } = props;
const percentage = getPercentage(progressAmount, totalAmount);
const formattedProgress = formatCurrency(progressAmount);
const formattedTotal = formatCurrency(totalAmount);
return (
<ProgressCardView
{...props}
percentage={percentage}
formattedProgress={formattedProgress}
formattedTotal={formattedTotal}
compact={compact}
/>
);
}

View File

@@ -0,0 +1,15 @@
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};

View File

@@ -8,79 +8,77 @@ import {
linearProgressClasses linearProgressClasses
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { getPercentage, formatCurrency } from "../report.helpers"; import { ProgressCardProps } from "./ProgressCard.models";
import { ProgressCardViewProps } from "./ProgressCard.props";
interface ViewProps extends ProgressCardProps {
percentage: number;
formattedProgress: string;
formattedTotal: string;
}
export default function ProgressCardView({ export default function ProgressCardView({
title, header,
settings, colorTheme = "info",
percentage,
isFetching, formattedProgress,
formattedTotal,
colorScheme, compact = false,
}: ViewProps) {
progressAmount,
totalAmount,
selected,
onClick,
}: ProgressCardViewProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const percentage = getPercentage(progressAmount, totalAmount);
const formattedProgress = formatCurrency(progressAmount);
const formattedTotal = formatCurrency(totalAmount);
return ( return (
<Paper <Paper
elevation={settings.compact ? 2 : 4} elevation={compact ? 2 : 4}
onClick={onClick}
sx={{ sx={{
width: "100%", width: "100%",
p: settings.compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 }, p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: settings.compact ? 3 : 4, borderRadius: compact ? 3 : 4,
transform: selected ? "scale(1.02)" : "scale(1)", background: (theme) => {
transition: "transform 0.2s ease, box-shadow 0.2s ease", const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
bgcolor: colorScheme.surface, const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
color: colorScheme.text, return isDark
? `linear-gradient(135deg, ${alpha(baseColor, 0.9)} 0%, ${alpha(baseColor, 0.3)} 100%)`
: `linear-gradient(135deg, ${baseColor} 0%, ${lightColor} 100%)`;
},
color: "#fff",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: settings.compact ? "flex-start" : "center", alignItems: compact ? "flex-start" : "center",
justifyContent: "center", justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
border: selected border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
? `2px solid ${colorScheme.primary}` boxShadow: (theme) =>
: "1px solid", `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
borderColor: selected ? colorScheme.primary : "divider", isDark
boxShadow: "none", ? "rgba(0,0,0,0.5)"
opacity: isFetching ? 0.6 : 1, : theme.palette[colorTheme]?.main || theme.palette.primary.main
pointerEvents: isFetching ? "none" : "auto", }`,
}} }}
> >
<Typography <Typography
variant={settings.compact ? "body2" : "subtitle1"} variant={compact ? "body2" : "subtitle1"}
fontWeight={700} fontWeight={700}
sx={{ sx={{
opacity: 0.95, opacity: 0.95,
mb: settings.compact ? 1.5 : 2, mb: compact ? 1.5 : 2,
width: "100%", width: '100%',
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
letterSpacing: 0.5, letterSpacing: 0.5,
textShadow: isDark ? '0 1px 2px rgba(0,0,0,0.3)' : 'none'
}} }}
> >
{title} {header}
</Typography> </Typography>
<Box sx={{ mb: settings.compact ? 2 : 3, width: "100%" }}> <Box sx={{ mb: compact ? 2 : 3, width: '100%' }}>
<Typography <Typography
variant={settings.compact ? "h5" : "h3"} variant={compact ? "h5" : "h3"}
fontWeight={900} fontWeight={900}
sx={{ sx={{ mb: 0.5, lineHeight: 1.2, textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none' }}
mb: 0.5,
lineHeight: 1.2,
}}
> >
{formattedProgress} {formattedProgress}
</Typography> </Typography>
@@ -88,38 +86,38 @@ export default function ProgressCardView({
<Divider <Divider
sx={{ sx={{
my: 1, my: 1,
borderColor: "divider", borderColor: "rgba(255,255,255,0.25)",
width: "100%", width: "100%",
}} }}
/> />
<Typography <Typography
variant={settings.compact ? "caption" : "body2"} variant={compact ? "caption" : "body2"}
sx={{ sx={{
opacity: 0.85, opacity: 0.85,
fontWeight: 500, fontWeight: 500,
display: "block", display: "block",
color: alpha(colorScheme.text, 0.85), color: "rgba(255,255,255,0.9)"
}} }}
> >
of {formattedTotal} of {formattedTotal}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ width: "100%", mt: "auto" }}> <Box sx={{ width: "100%", mt: 'auto' }}>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={percentage} value={percentage}
sx={{ sx={{
height: settings.compact ? 6 : 10, height: compact ? 6 : 10,
borderRadius: 5, borderRadius: 5,
[`&.${linearProgressClasses.colorPrimary}`]: { [`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: alpha(theme.palette.divider, 0.5), backgroundColor: "rgba(0, 0, 0, 0.25)",
}, },
[`& .${linearProgressClasses.bar}`]: { [`& .${linearProgressClasses.bar}`]: {
borderRadius: 5, borderRadius: 5,
backgroundColor: colorScheme.primary, backgroundColor: "#fff",
boxShadow: `0 0 8px ${alpha(colorScheme.primary, 0.4)}`, boxShadow: '0 0 8px rgba(255,255,255,0.4)'
}, },
}} }}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import HistoryChart from "./components/HistoryChart"; import HistoryChart from "./components/HistoryChart";
import ProgressCard from "./components/ProgressCard";
import LatestItems from "./components/LatestItems"; import LatestItems from "./components/LatestItems";
import { DashboardConfig } from "./components/Dashboard"; import { DashboardConfig } from "./components/Dashboard";
import TopTags from "./components/ProgressCard/TopTags";
import TopPayees from "./components/ProgressCard/TopPayees";
export const configuration: DashboardConfig = { export const configuration: DashboardConfig = {
sections: [ sections: [
@@ -11,30 +10,63 @@ export const configuration: DashboardConfig = {
title: "Breakdown", title: "Breakdown",
summary: "Interactive chronological tracking", summary: "Interactive chronological tracking",
component: HistoryChart, component: HistoryChart,
dataKey: "chartData",
settings: { settings: {
tabs: ["Weekly", "Monthly"], tabs: ["Daily", "Weekly", "Monthly"],
}, },
}, style: {
{ size: 12,
id: "top-categories",
title: 'Top Categories',
component: TopTags,
settings: {
compact: true,
}, },
}, },
{ {
id: "top-payees", id: "top-payees",
title: 'Top Payees', title: 'Top Payees',
component: TopPayees, component: ProgressCard,
dataKey: "topPayees",
isList: true,
settings: { settings: {
compact: true, compact: true,
}, },
style: {
size: 12,
},
}, },
{ {
id: "items", id: "latest",
title: 'Recent Transactions', title: 'Recent Transactions',
component: LatestItems, component: LatestItems,
dataKey: "latest",
style: {
size: 12,
},
}, },
], ],
style: {
palette: {
expense: {
light: {
primary: "#d32f2f",
background: "#fdecea",
text: "#b71c1c"
},
dark: {
primary: "#f44336",
background: "rgba(244, 67, 54, 0.15)",
text: "#ffcdd2"
}
},
income: {
light: {
primary: "#2e7d32",
background: "#e8f5e9",
text: "#1b5e20"
},
dark: {
primary: "#4caf50",
background: "rgba(76, 175, 80, 0.15)",
text: "#c8e6c9"
}
}
}
}
}; };

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
import { LatestItem } from "../../components/LatestItems";
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" }
});
export function mapToLatestItems(
items: any[],
type: "expense" | "income"
): LatestItem[] {
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
return items
.filter((item: any) => isValid(Number(item.amount) || 0))
.slice(0, 5)
.map((exp: any, index: number) => {
const time = new Date(
exp.occurred_at || exp.created_at || Date.now()
).getTime();
const diffDays = Math.floor(
Math.abs(Date.now() - time) / (1000 * 60 * 60 * 24)
);
return {
id: exp.id || index,
icon: DEFAULT_ICON,
iconBgColor:
type === "expense" ? "#ffebee" : "#e8f5e9",
title: exp.payee?.name || exp.payee || "Unknown Payee",
subtitle:
exp.category?.name || exp.account?.name || "Transaction",
amount: `Rs ${Math.abs(exp.amount || 0)}`,
timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago`
};
});
}

View File

@@ -0,0 +1,3 @@
export {
useDashboardData
} from './useDashboardData'

View File

@@ -0,0 +1,52 @@
import { useResourceByName } from "../../../react-openapi";
import { mapToLatestItems } from "./dashboard.mapper";
import { mapReportToDashboard } from "../report/report.mapper";
export function useDashboardData(type: "expense" | "income") {
const { useList: useExpenseList } = useResourceByName("expenses");
const { useList: useReportList } = useResourceByName("reports");
// Fetch latest transactions
const latestQuery = useExpenseList({
limit: 100,
sort: "-occurred_at"
});
// Fetch reports for aggregation
const weeklyReport = useReportList({ period: "weekly", rolling: true });
const monthlyReport = useReportList({ period: "monthly", rolling: true });
const payeeReport = useReportList({ period: "full", rolling: true, group_by: "payee" });
const isLoading =
latestQuery.isLoading ||
weeklyReport.isLoading ||
monthlyReport.isLoading ||
payeeReport.isLoading;
const error =
latestQuery.error ||
weeklyReport.error ||
monthlyReport.error ||
payeeReport.error;
const latest = latestQuery.data?.data
? mapToLatestItems(latestQuery.data.data, type)
: [];
const aggregatedData =
weeklyReport.data?.data && monthlyReport.data?.data && payeeReport.data?.data
? mapReportToDashboard(
(weeklyReport.data.data as any).buckets,
(monthlyReport.data.data as any).buckets,
(payeeReport.data.data as any).buckets,
type
)
: null;
return {
data: aggregatedData,
latest: latest,
isLoading,
error,
};
}

View File

@@ -1,133 +0,0 @@
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;

View File

@@ -1,23 +0,0 @@
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";

View File

@@ -1,90 +0,0 @@
import { useResourceByName } from "../../../react-openapi";
import { api } from "../../../react-openapi/api/client";
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 { useList } = useResourceByName("fetch-requests");
return useList(params);
}
export function useFetchRequest(id: string) {
const { useRead } = useResourceByName("fetch-requests");
return useRead(id);
}
export function useCreateFetchRequest() {
const { useCreate } = useResourceByName("fetch-requests");
return useCreate();
}
export function useUpdateFetchRequest() {
const { usePatch } = useResourceByName("fetch-requests");
return usePatch();
}
export function useDeleteFetchRequest() {
const { useDelete } = useResourceByName("fetch-requests");
return useDelete();
}
export function useUploadFile() {
return useMutation({
mutationFn: async (file: File) => {
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;
},
});
}
export function useFetchRequestAmbiguities(fetchRequestId: string) {
return useQuery({
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => {
const res = await api.get(
`/fetch-requests/${fetchRequestId}/ambiguities`
);
return res.data;
},
enabled: !!fetchRequestId,
});
}
export function useResolveAmbiguity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
ambiguityId,
payload,
}: {
ambiguityId: string;
payload: ResolveAmbiguityPayload;
}) => {
const res = await api.post(
`/ambiguities/${ambiguityId}/resolve`,
payload
);
return res.data;
},
onSuccess: (data: any) => {
queryClient.invalidateQueries({
queryKey: ["fetch-requests", data.fetch_request, "ambiguities"],
});
queryClient.invalidateQueries({
queryKey: ["fetch-requests", "detail", data.fetch_request],
});
},
});
}

View File

@@ -1,4 +0,0 @@
export type {
ReportSnapshot,
ReportQuery,
} from "./report-snapshots.models";

View File

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

View File

@@ -1,16 +0,0 @@
import { useResourceByName } from "../../../react-openapi";
export function useReportSnapshotsList() {
const { useList } = useResourceByName("reports");
return useList();
}
export function useCreateSnapshot() {
const { useCreate } = useResourceByName("reports");
return useCreate();
}
export function useDeleteSnapshot() {
const { useDelete } = useResourceByName("reports");
return useDelete();
}

View File

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

View File

@@ -0,0 +1,143 @@
import {
AggregatedDashboardData,
ChartData,
ChartDataPoint,
} from "../../components/HistoryChart";
type ReportBucket = any;
const sumBucket = (bucket: ReportBucket, flow: "expenses" | "incomes") =>
bucket.groups.reduce(
(acc: number, g: any) => acc + (g?.[flow]?.sum || 0),
0
);
const toLabel = (start: string, end: string, type: "weekly" | "monthly") => {
const s = new Date(start);
const e = new Date(end);
if (type === "monthly") {
return s.toLocaleString("default", { month: "short" });
}
return `${s.getDate()}${e.getDate()} ${e.toLocaleString("default", {
month: "short",
})}`;
};
const getWeekOfMonth = (date: Date) => {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
return Math.ceil((date.getDate() + firstDay.getDay()) / 7);
};
const findCompareBucket = (
current: ReportBucket,
buckets: ReportBucket[],
type: "weekly" | "monthly"
): ReportBucket | undefined => {
const start = new Date(current.start);
if (type === "monthly") {
const targetYear = start.getFullYear() - 1;
const targetMonth = start.getMonth();
return buckets.find(b => {
const d = new Date(b.start);
return (
d.getFullYear() === targetYear &&
d.getMonth() === targetMonth
);
});
}
if (type === "weekly") {
const weekIndex = getWeekOfMonth(start); // you must define this
const target = new Date(start);
target.setMonth(target.getMonth() - 1);
return buckets.find(b => {
const d = new Date(b.start);
return (
d.getFullYear() === target.getFullYear() &&
d.getMonth() === target.getMonth() &&
getWeekOfMonth(d) === weekIndex
);
});
}
return undefined;
};
const toPoints = (
buckets: ReportBucket[],
type: "weekly" | "monthly",
flow: "expenses" | "incomes"
): ChartDataPoint[] => {
return buckets.map((b) => {
const amount = sumBucket(b, flow);
const prev = findCompareBucket(b, buckets, type);
return {
id: toLabel(b.start, b.end, type),
amount,
compare: prev
? {
id: toLabel(prev.start, prev.end, type),
amount: sumBucket(prev, flow),
}
: undefined,
};
});
};
export function mapReportToDashboard(
weekly: ReportBucket[],
monthly: ReportBucket[],
payeeBuckets: ReportBucket[],
type: "expense" | "income"
): AggregatedDashboardData {
const flow = type === "expense" ? "expenses" : "incomes";
const chartData: ChartData = {
daily: [],
weekly: {
rolling: toPoints(weekly, "weekly", flow),
calendar: toPoints(weekly, "weekly", flow),
},
monthly: {
rolling: toPoints(monthly, "monthly", flow),
calendar: toPoints(monthly, "monthly", flow),
},
};
const totalAmount = weekly.reduce(
(acc, b) => acc + sumBucket(b, flow),
0
);
const payeeMap: Record<string, number> = {};
const sourceForPayees = (payeeBuckets && payeeBuckets.length > 0) ? payeeBuckets : weekly;
for (const b of sourceForPayees) {
for (const g of b.groups) {
const key = g.group_key || "Unknown";
const amt = g?.[flow]?.sum || 0;
payeeMap[key] = (payeeMap[key] || 0) + amt;
}
}
const topPayees = Object.entries(payeeMap)
// .filter(([name]) => name !== "Unknown")
.map(([payeeName, amount]) => ({ payeeName, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return {
chartData,
totalAmount,
topPayees,
};
}

View File

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

View File

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

View File

@@ -1,21 +1,14 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
export interface ReportParams { export interface ReportParams {
snapshot_id?: string; period: "weekly" | "monthly" | "yearly" | "fyly";
periods?: ("daily" | "weekly" | "monthly" | "all")[]; rolling?: boolean;
flow?: "inflows" | "outflows"; report_date?: string;
payee?: string[]; group_by?: ("flow" | "payee" | "tags")[];
tags?: string[]; ignore_self?: boolean;
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
const { useRead } = useResourceByName("reports"); const { useList } = useResourceByName("reports");
return useList(params);
return useRead(
params.snapshot_id ? params.snapshot_id : "latest",
{
...params,
periods: params.periods,
}
);
} }

View File

@@ -12,17 +12,14 @@ import {
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests'; import { Admin, AppProvider } from '../react-openapi';
import FetchRequestDetail from './FetchRequestDetail';
import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config'; import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import process from 'process'; import process from 'process';
import { AuthProvider } from "../react-auth"; import { AuthProvider } from "../react-auth";
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme'; import AppTheme from './AppTheme';
window.Buffer = Buffer; window.Buffer = Buffer;
window.process = process; window.process = process;
@@ -36,9 +33,6 @@ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" }, { path: "/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" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
]; ];
@@ -60,7 +54,7 @@ root.render(
path={path} path={path}
element={ element={
path.startsWith("/admin") ? ( path.startsWith("/admin") ? (
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} /> <Component basePath="/admin" />
) : ( ) : (
<Component /> <Component />
) )

View File

@@ -1,32 +1,22 @@
import { ResourceOverride } from "../react-openapi"; import { ResourceOverride } from "../react-openapi/types/overrides";
export const configuration: Record<string, ResourceOverride> = { export const configuration: Record<string, ResourceOverride> = {
expenses: { expenses: {
filterOptions: {
mode: "client",
fields: ["account", "payee", "tags", "occurred_at", "amount"],
},
fields: { fields: {
payee: { payee: {
displayFormat: "{name}", displayField: "name",
filterType: "autocomplete",
}, },
payor: { payor: {
display: false, display: false,
displayFormat: "{username}", displayField: "username",
}, },
account: { account: {
displayFormat: "{name}", displayField: "name",
filterType: "multiselect",
refers: "accounts"
}, },
tags: { tags: {
displayFormat: "{icon} {name}", displayField: ["name", "icon"],
filterType: "autocomplete",
refers: "tags"
}, },
occurred_at: { occurred_at: {
filterType: "date-range",
formatter: (val: string) => { formatter: (val: string) => {
const date = new Date(val); const date = new Date(val);
const day = date.getDate(); const day = date.getDate();
@@ -44,55 +34,15 @@ export const configuration: Record<string, ResourceOverride> = {
return `${day}${suffix(day)} ${month} ${year}`; return `${day}${suffix(day)} ${month} ${year}`;
} }
}, },
amount: {
filterType: "number-range",
},
created_at: { created_at: {
display: false display: false
} }
}, },
pagination: true,
}, },
'fetch-requests': { reports: {
fields: { hidden: true
format: {
path: 'source.format',
},
start_date: {
type: 'date',
label: 'Start Date',
},
end_date: {
type: 'date',
label: 'End Date',
},
// account: {
// refers: 'accounts',
// },
// tags: {
// refers: 'tags',
// },
},
},
accounts: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{name} - XX{number}',
},
autoComplete: true,
prefetch: true,
} }
},
tags: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{icon} {name}',
},
autoComplete: true,
prefetch: true,
}
},
}; };
export const profileConfiguration = { export const profileConfiguration = {

View File

@@ -1,103 +1,53 @@
import * as React from "react"; import * as React from 'react';
import { import { ThemeProvider, createTheme } from '@mui/material/styles';
ThemeProvider, import type { ThemeOptions } from '@mui/material/styles';
createTheme, import { inputsCustomizations } from './customizations/inputs';
CssBaseline, import { dataDisplayCustomizations } from './customizations/dataDisplay';
Box, import { feedbackCustomizations } from './customizations/feedback';
} from "@mui/material"; import { navigationCustomizations } from './customizations/navigation';
import { surfacesCustomizations } from './customizations/surfaces';
import { colorSchemes, typography, shadows, shape } from './themePrimitives';
import { getDesignTokens } from "./themePrimitives"; interface AppThemeProps {
import { getSemanticColors } from "./themeConfig";
import { inputsCustomizations } from "./customizations/inputs";
import { dataDisplayCustomizations } from "./customizations/dataDisplay";
import { feedbackCustomizations } from "./customizations/feedback";
import { navigationCustomizations } from "./customizations/navigation";
import { surfacesCustomizations } from "./customizations/surfaces";
export type ColorMode = "light" | "dark";
type ColorModeContextValue = {
mode: ColorMode;
setMode: (mode: ColorMode) => void;
toggleColorMode: () => void;
};
export const ColorModeContext =
React.createContext<ColorModeContextValue>({
mode: "light",
setMode: () => {},
toggleColorMode: () => {},
});
type AppThemeProps = {
children: React.ReactNode; children: React.ReactNode;
defaultMode?: ColorMode; /**
}; * This is for the docs site. You can ignore it or remove it.
*/
export default function AppTheme({ disableCustomTheme?: boolean;
children, themeComponents?: ThemeOptions['components'];
defaultMode = "light", }
}: AppThemeProps) {
const [mode, setMode] =
React.useState<ColorMode>(defaultMode);
const toggleColorMode = React.useCallback(() => {
setMode((prev) =>
prev === "light" ? "dark" : "light"
);
}, []);
const contextValue = React.useMemo(
() => ({
mode,
setMode,
toggleColorMode,
}),
[mode, toggleColorMode]
);
const semantic = React.useMemo(
() => getSemanticColors(mode),
[mode]
);
const theme = React.useMemo(
() =>
createTheme({
...getDesignTokens(mode),
semantic,
export default function AppTheme(props: AppThemeProps) {
const { children, disableCustomTheme, themeComponents } = props;
const theme = React.useMemo(() => {
return disableCustomTheme
? {}
: createTheme({
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme',
cssVarPrefix: 'template',
},
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
typography,
shadows,
shape,
components: { components: {
...inputsCustomizations, ...inputsCustomizations,
...dataDisplayCustomizations, ...dataDisplayCustomizations,
...feedbackCustomizations, ...feedbackCustomizations,
...navigationCustomizations, ...navigationCustomizations,
...surfacesCustomizations, ...surfacesCustomizations,
...themeComponents,
}, },
}), });
[mode, semantic] }, [disableCustomTheme, themeComponents]);
); if (disableCustomTheme) {
return <React.Fragment>{children}</React.Fragment>;
}
return ( return (
<ColorModeContext.Provider value={contextValue}> <ThemeProvider theme={theme} disableTransitionOnChange>
<ThemeProvider theme={theme}>
<CssBaseline />
<Box
sx={{
"--bg-page": semantic.surface.page,
"--bg-card": semantic.surface.card,
"--bg-elevated": semantic.surface.elevated,
"--border-default": semantic.border.default,
"--border-subtle": semantic.border.subtle,
"--text-primary": semantic.text.primary,
"--text-secondary": semantic.text.secondary,
"--text-muted": semantic.text.muted,
}}
>
{children} {children}
</Box>
</ThemeProvider> </ThemeProvider>
</ColorModeContext.Provider>
); );
} }

View File

@@ -0,0 +1,89 @@
import * as React from 'react';
import DarkModeIcon from '@mui/icons-material/DarkModeRounded';
import LightModeIcon from '@mui/icons-material/LightModeRounded';
import Box from '@mui/material/Box';
import IconButton, { IconButtonOwnProps } from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useColorScheme } from '@mui/material/styles';
export default function ColorModeIconDropdown(props: IconButtonOwnProps) {
const { mode, systemMode, setMode } = useColorScheme();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => {
setMode(targetMode);
handleClose();
};
if (!mode) {
return (
<Box
data-screenshot="toggle-mode"
sx={(theme) => ({
verticalAlign: 'bottom',
display: 'inline-flex',
width: '2.25rem',
height: '2.25rem',
borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
})}
/>
);
}
const resolvedMode = (systemMode || mode) as 'light' | 'dark';
const icon = {
light: <LightModeIcon />,
dark: <DarkModeIcon />,
}[resolvedMode];
return (
<React.Fragment>
<IconButton
data-screenshot="toggle-mode"
onClick={handleClick}
disableRipple
size="small"
aria-controls={open ? 'color-scheme-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
{...props}
>
{icon}
</IconButton>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
onClick={handleClose}
slotProps={{
paper: {
variant: 'outlined',
elevation: 0,
sx: {
my: '4px',
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem selected={mode === 'system'} onClick={handleMode('system')}>
System
</MenuItem>
<MenuItem selected={mode === 'light'} onClick={handleMode('light')}>
Light
</MenuItem>
<MenuItem selected={mode === 'dark'} onClick={handleMode('dark')}>
Dark
</MenuItem>
</Menu>
</React.Fragment>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { useColorScheme } from '@mui/material/styles';
import MenuItem from '@mui/material/MenuItem';
import Select, { SelectProps } from '@mui/material/Select';
export default function ColorModeSelect(props: SelectProps) {
const { mode, setMode } = useColorScheme();
if (!mode) {
return null;
}
return (
<Select
value={mode}
onChange={(event) =>
setMode(event.target.value as 'system' | 'light' | 'dark')
}
SelectDisplayProps={{
// @ts-ignore
'data-screenshot': 'toggle-mode',
}}
{...props}
>
<MenuItem value="system">System</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
);
}

View File

@@ -14,8 +14,8 @@ export const feedbackCustomizations: Components<Theme> = {
color: orange[500], color: orange[500],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: alpha(orange[900], 0.35), backgroundColor: `${alpha(orange[900], 0.5)}`,
border: `1px solid ${alpha(orange[800], 0.3)}`, border: `1px solid ${alpha(orange[800], 0.5)}`,
}), }),
}), }),
}, },

View File

@@ -125,15 +125,15 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200], backgroundColor: gray[200],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: 'hsla(0, 0%, 100%, 0.06)', backgroundColor: gray[800],
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
'&:hover': { '&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
borderColor: 'hsla(0, 0%, 100%, 0.15)', borderColor: gray[600],
}, },
'&:active': { '&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
}, },
}), }),
}, },
@@ -183,12 +183,12 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200], backgroundColor: gray[200],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: 'hsl(0, 0%, 92%)', color: gray[50],
'&:hover': { '&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.08)', backgroundColor: gray[700],
}, },
'&:active': { '&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.12)', backgroundColor: alpha(gray[700], 0.7),
}, },
}), }),
}, },
@@ -241,14 +241,14 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200], backgroundColor: gray[200],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: 'hsla(0, 0%, 100%, 0.06)', backgroundColor: gray[800],
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
'&:hover': { '&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
borderColor: 'hsla(0, 0%, 100%, 0.15)', borderColor: gray[600],
}, },
'&:active': { '&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
}, },
}), }),
variants: [ variants: [
@@ -288,7 +288,7 @@ export const inputsCustomizations: Components<Theme> = {
[`& .${toggleButtonGroupClasses.selected}`]: { [`& .${toggleButtonGroupClasses.selected}`]: {
color: '#fff', color: '#fff',
}, },
boxShadow: `0 2px 8px ${alpha(brand[700], 0.3)}`, boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
}), }),
}), }),
}, },
@@ -302,7 +302,7 @@ export const inputsCustomizations: Components<Theme> = {
fontWeight: 500, fontWeight: 500,
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: gray[400], color: gray[400],
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
[`&.${toggleButtonClasses.selected}`]: { [`&.${toggleButtonClasses.selected}`]: {
color: brand[300], color: brand[300],
}, },

View File

@@ -49,8 +49,9 @@ export const navigationCustomizations: Components<Theme> = {
}, },
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
background: (theme.vars || theme).palette.background.paper, background: gray[900],
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)', boxShadow:
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
}), }),
}), }),
}, },
@@ -83,17 +84,17 @@ export const navigationCustomizations: Components<Theme> = {
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
borderRadius: (theme.vars || theme).shape.borderRadius, borderRadius: (theme.vars || theme).shape.borderRadius,
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'inset 0 1px 0 hsla(0, 0%, 100%, 0.05)', boxShadow: `inset 0 1px 0 1px ${alpha(gray[700], 0.15)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
'&:hover': { '&:hover': {
borderColor: 'hsla(0, 0%, 100%, 0.15)', borderColor: alpha(gray[700], 0.7),
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none', boxShadow: 'none',
}, },
[`&.${selectClasses.focused}`]: { [`&.${selectClasses.focused}`]: {
outlineOffset: 0, outlineOffset: 0,
borderColor: 'hsl(210, 55%, 55%)', borderColor: gray[900],
}, },
'&:before, &:after': { '&:before, &:after': {
display: 'none', display: 'none',
@@ -107,7 +108,7 @@ export const navigationCustomizations: Components<Theme> = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
'&:focus-visible': { '&:focus-visible': {
backgroundColor: (theme.vars || theme).palette.background.default, backgroundColor: gray[900],
}, },
}), }),
}), }),
@@ -150,7 +151,6 @@ export const navigationCustomizations: Components<Theme> = {
styleOverrides: { styleOverrides: {
paper: ({ theme }) => ({ paper: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.background.default, backgroundColor: (theme.vars || theme).palette.background.default,
borderRight: `1px solid ${(theme.vars || theme).palette.divider}`,
}), }),
}, },
}, },
@@ -204,8 +204,8 @@ export const navigationCustomizations: Components<Theme> = {
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
':hover': { ':hover': {
color: (theme.vars || theme).palette.text.primary, color: (theme.vars || theme).palette.text.primary,
backgroundColor: alpha((theme.vars || theme).palette.common.white, 0.08), backgroundColor: gray[800],
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
}, },
[`&.${tabClasses.selected}`]: { [`&.${tabClasses.selected}`]: {
color: '#fff', color: '#fff',

View File

@@ -40,7 +40,7 @@ export const surfacesCustomizations: Components<Theme> = {
'&:hover': { backgroundColor: gray[50] }, '&:hover': { backgroundColor: gray[50] },
'&:focus-visible': { backgroundColor: 'transparent' }, '&:focus-visible': { backgroundColor: 'transparent' },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
'&:hover': { backgroundColor: alpha(theme.palette.common.white, 0.06) }, '&:hover': { backgroundColor: gray[800] },
}), }),
}), }),
}, },
@@ -67,7 +67,7 @@ export const surfacesCustomizations: Components<Theme> = {
border: `1px solid ${(theme.vars || theme).palette.divider}`, border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: 'none', boxShadow: 'none',
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: gray[800],
}), }),
variants: [ variants: [
{ {
@@ -79,7 +79,7 @@ export const surfacesCustomizations: Components<Theme> = {
boxShadow: 'none', boxShadow: 'none',
background: 'hsl(0, 0%, 100%)', background: 'hsl(0, 0%, 100%)',
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
background: alpha((theme.vars || theme).palette.background.paper, 0.6), background: alpha(gray[900], 0.4),
}), }),
}, },
}, },

View File

@@ -1,72 +0,0 @@
import { gray } from "./themePrimitives";
import { alpha } from "@mui/material/styles";
declare module "@mui/material/styles" {
interface Theme {
semantic: SemanticColors;
}
interface ThemeOptions {
semantic?: SemanticColors;
}
}
export type SemanticColorMode = "light" | "dark";
export interface SemanticColors {
surface: {
page: string;
card: string;
elevated: string;
};
border: {
default: string;
subtle: string;
};
text: {
primary: string;
secondary: string;
muted: string;
};
}
const darkBg = 'hsl(0, 0%, 9%)';
const darkPaper = 'hsl(0, 0%, 14%)';
const darkElevated = 'hsl(0, 0%, 19%)';
export function getSemanticColors(mode: SemanticColorMode): SemanticColors {
if (mode === "dark") {
return {
surface: {
page: darkBg,
card: darkPaper,
elevated: darkElevated,
},
border: {
default: 'hsla(0, 0%, 100%, 0.08)',
subtle: 'hsla(0, 0%, 100%, 0.04)',
},
text: {
primary: 'hsl(0, 0%, 92%)',
secondary: 'hsl(0, 0%, 60%)',
muted: 'hsl(0, 0%, 45%)',
},
};
}
return {
surface: {
page: "hsl(0, 0%, 99%)",
card: "hsl(220, 35%, 97%)",
elevated: gray[100],
},
border: {
default: alpha(gray[300], 0.4),
subtle: alpha(gray[200], 0.3),
},
text: {
primary: gray[800],
secondary: gray[600],
muted: gray[500],
},
};
}

View File

@@ -23,10 +23,6 @@ declare module '@mui/material/styles' {
interface Palette { interface Palette {
baseShadow: string; baseShadow: string;
flows: {
outflows: { primary: string; surface: string; text: string };
inflows: { primary: string; surface: string; text: string };
};
} }
} }
@@ -56,9 +52,7 @@ export const gray = {
500: 'hsl(220, 20%, 42%)', 500: 'hsl(220, 20%, 42%)',
600: 'hsl(220, 20%, 35%)', 600: 'hsl(220, 20%, 35%)',
700: 'hsl(220, 20%, 25%)', 700: 'hsl(220, 20%, 25%)',
750: 'hsl(220, 20%, 18%)',
800: 'hsl(220, 30%, 6%)', 800: 'hsl(220, 30%, 6%)',
850: 'hsl(220, 22%, 11%)',
900: 'hsl(220, 35%, 3%)', 900: 'hsl(220, 35%, 3%)',
}; };
@@ -101,14 +95,10 @@ export const red = {
900: 'hsl(0, 93%, 6%)', 900: 'hsl(0, 93%, 6%)',
}; };
const darkBg = 'hsl(0, 0%, 9%)';
const darkPaper = 'hsl(0, 0%, 14%)';
const darkElevated = 'hsl(0, 0%, 19%)';
export const getDesignTokens = (mode: PaletteMode) => { export const getDesignTokens = (mode: PaletteMode) => {
customShadows[1] = customShadows[1] =
mode === 'dark' mode === 'dark'
? '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)' ? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px'
: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px'; : 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
return { return {
@@ -121,9 +111,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
contrastText: brand[50], contrastText: brand[50],
...(mode === 'dark' && { ...(mode === 'dark' && {
contrastText: brand[50], contrastText: brand[50],
light: 'hsl(210, 50%, 65%)', light: brand[300],
main: 'hsl(210, 55%, 55%)', main: brand[400],
dark: 'hsl(210, 50%, 35%)', dark: brand[700],
}), }),
}, },
info: { info: {
@@ -132,10 +122,10 @@ export const getDesignTokens = (mode: PaletteMode) => {
dark: brand[600], dark: brand[600],
contrastText: gray[50], contrastText: gray[50],
...(mode === 'dark' && { ...(mode === 'dark' && {
contrastText: 'hsl(210, 30%, 80%)', contrastText: brand[300],
light: 'hsl(210, 40%, 50%)', light: brand[500],
main: 'hsl(210, 35%, 40%)', main: brand[700],
dark: 'hsl(210, 30%, 25%)', dark: brand[900],
}), }),
}, },
warning: { warning: {
@@ -143,9 +133,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: orange[400], main: orange[400],
dark: orange[800], dark: orange[800],
...(mode === 'dark' && { ...(mode === 'dark' && {
light: 'hsl(45, 60%, 55%)', light: orange[400],
main: 'hsl(45, 55%, 45%)', main: orange[500],
dark: 'hsl(45, 50%, 30%)', dark: orange[700],
}), }),
}, },
error: { error: {
@@ -153,9 +143,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: red[400], main: red[400],
dark: red[800], dark: red[800],
...(mode === 'dark' && { ...(mode === 'dark' && {
light: 'hsl(0, 55%, 60%)', light: red[400],
main: 'hsl(0, 55%, 50%)', main: red[500],
dark: 'hsl(0, 50%, 35%)', dark: red[700],
}), }),
}, },
success: { success: {
@@ -163,46 +153,34 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: green[400], main: green[400],
dark: green[800], dark: green[800],
...(mode === 'dark' && { ...(mode === 'dark' && {
light: 'hsl(120, 40%, 55%)', light: green[400],
main: 'hsl(120, 40%, 45%)', main: green[500],
dark: 'hsl(120, 35%, 30%)', dark: green[700],
}), }),
}, },
grey: { grey: {
...gray, ...gray,
}, },
divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4), divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
background: { background: {
default: 'hsl(0, 0%, 99%)', default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)', paper: 'hsl(220, 35%, 97%)',
...(mode === 'dark' && { default: darkBg, paper: darkPaper }), ...(mode === 'dark' && { default: gray[900], paper: 'hsl(220, 30%, 7%)' }),
}, },
text: { text: {
primary: gray[800], primary: gray[800],
secondary: gray[600], secondary: gray[600],
warning: orange[400], warning: orange[400],
...(mode === 'dark' && { primary: 'hsl(0, 0%, 92%)', secondary: 'hsl(0, 0%, 60%)' }), ...(mode === 'dark' && { primary: 'hsl(0, 0%, 100%)', secondary: gray[400] }),
}, },
action: { action: {
hover: alpha(gray[200], 0.2), hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`, selected: `${alpha(gray[200], 0.3)}`,
...(mode === 'dark' && { ...(mode === 'dark' && {
hover: 'hsla(0, 0%, 100%, 0.06)', hover: alpha(gray[600], 0.2),
selected: 'hsla(0, 0%, 100%, 0.1)', selected: alpha(gray[600], 0.3),
}), }),
}, },
flows: {
outflows: {
primary: mode === 'dark' ? 'hsl(0, 55%, 60%)' : '#d32f2f',
surface: mode === 'dark' ? 'hsla(0, 35%, 25%, 0.6)' : '#fdecea',
text: mode === 'dark' ? 'hsl(0, 60%, 80%)' : '#b71c1c',
},
inflows: {
primary: mode === 'dark' ? 'hsl(120, 40%, 55%)' : '#2e7d32',
surface: mode === 'dark' ? 'hsla(120, 25%, 22%, 0.6)' : '#e8f5e9',
text: mode === 'dark' ? 'hsl(120, 40%, 78%)' : '#1b5e20',
},
},
}, },
typography: { typography: {
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
@@ -307,18 +285,6 @@ export const colorSchemes = {
hover: alpha(gray[200], 0.2), hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`, selected: `${alpha(gray[200], 0.3)}`,
}, },
flows: {
outflows: {
primary: '#d32f2f',
surface: '#fdecea',
text: '#b71c1c',
},
inflows: {
primary: '#2e7d32',
surface: '#e8f5e9',
text: '#1b5e20',
},
},
baseShadow: baseShadow:
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px', 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
}, },
@@ -327,60 +293,49 @@ export const colorSchemes = {
palette: { palette: {
primary: { primary: {
contrastText: brand[50], contrastText: brand[50],
light: 'hsl(210, 50%, 65%)', light: brand[300],
main: 'hsl(210, 55%, 55%)', main: brand[400],
dark: 'hsl(210, 50%, 35%)', dark: brand[700],
}, },
info: { info: {
contrastText: 'hsl(210, 30%, 80%)', contrastText: brand[300],
light: 'hsl(210, 40%, 50%)', light: brand[500],
main: 'hsl(210, 35%, 40%)', main: brand[700],
dark: 'hsl(210, 30%, 25%)', dark: brand[900],
}, },
warning: { warning: {
light: 'hsl(45, 60%, 55%)', light: orange[400],
main: 'hsl(45, 55%, 45%)', main: orange[500],
dark: 'hsl(45, 50%, 30%)', dark: orange[700],
}, },
error: { error: {
light: 'hsl(0, 55%, 60%)', light: red[400],
main: 'hsl(0, 55%, 50%)', main: red[500],
dark: 'hsl(0, 50%, 35%)', dark: red[700],
}, },
success: { success: {
light: 'hsl(120, 40%, 55%)', light: green[400],
main: 'hsl(120, 40%, 45%)', main: green[500],
dark: 'hsl(120, 35%, 30%)', dark: green[700],
}, },
grey: { grey: {
...gray, ...gray,
}, },
divider: 'hsla(0, 0%, 100%, 0.08)', divider: alpha(gray[700], 0.6),
background: { background: {
default: darkBg, default: gray[900],
paper: darkPaper, paper: 'hsl(220, 30%, 7%)',
}, },
text: { text: {
primary: 'hsl(0, 0%, 92%)', primary: 'hsl(0, 0%, 100%)',
secondary: 'hsl(0, 0%, 60%)', secondary: gray[400],
}, },
action: { action: {
hover: 'hsla(0, 0%, 100%, 0.06)', hover: alpha(gray[600], 0.2),
selected: 'hsla(0, 0%, 100%, 0.1)', selected: alpha(gray[600], 0.3),
}, },
flows: { baseShadow:
outflows: { 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
primary: 'hsl(0, 55%, 60%)',
surface: 'hsla(0, 35%, 25%, 0.6)',
text: 'hsl(0, 60%, 80%)',
},
inflows: {
primary: 'hsl(120, 40%, 55%)',
surface: 'hsla(120, 25%, 22%, 0.6)',
text: 'hsl(120, 40%, 78%)',
},
},
baseShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)',
}, },
}, },
}; };