24 Commits

Author SHA1 Message Date
9377460e79 refactor-for-llama-parse (#17)
# MR: LlamaParse pipeline support, fetch request deletion, and UX improvements

**Source branch:** `refactor-for-llama-parse`
**Target branch:** `main`

## Summary

This MR adds support for the `llama_parse` pipeline type across fetch requests, introduces delete functionality for fetch requests (from both the list and detail pages), and navigates to the detail page after a successful create.

## Changes

### Features

- **LlamaParse pipeline support** (`22e6603`)
  - Renamed pipeline type `llama_parser` → `llama_parse` in `fetch-requests.models.ts` to match the backend enum.
  - Updated pipeline progress computation in `PipelineStepper` for the new step naming.

- **Delete fetch requests** (`8694537`)
  - List page: added a delete icon button on each row with confirmation dialog; disabled while the request is processing.
  - Detail page: added a Delete action in the header section with confirmation; redirects back to the list on success.
  - Deletion warns that all associated expenses and ambiguities will be removed.
  - Success/error feedback via toast notifications using `formatApiError`.

- **Navigate after create** (`c6622d4`)
  - After successfully creating a fetch request, the user is now redirected to its detail page instead of staying on the form.

### Refactoring / Cleanup

- **Drop dead llama_extract SSE step** (`c585b62`)
  - Removed unused SSE handling for the obsolete `llama_extract` step.

### Chores

- Added `tsconfig.tsbuildinfo` to `.gitignore` (`1c5d8ed`).

## Files changed

| File | Change |
| --- | --- |
| `.gitignore` | Ignore `tsconfig.tsbuildinfo` |
| `src/FetchRequest/FetchRequestCreate.tsx` | Row-level delete in list; navigate to detail after create |
| `src/FetchRequest/FetchRequestDetail.tsx` | Delete action with confirm + redirect |
| `src/FetchRequest/components/PipelineStepper.tsx` | Progress calc adjustments |
| `src/features/fetch-requests/fetch-requests.models.ts` | `PipelineType`: `llama_parser` → `llama_parse` |

Reviewed-on: #17
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-08-23 16:58:32 +00:00
2add23efe7 Reports frontend — spec-driven generate form, sliceable viewer, dimension bars (#16)
# MR: Reports frontend — spec-driven generate form, sliceable viewer, dimension bars

**Branch:** `cached-reporting` → **Target:** `main`

## Summary

Reports UI rebuilt to treat the backend OpenAPI spec as the source of truth: the
generate-report form renders generically from `ReportQuery`, and the viewer displays
API-computed metrics verbatim instead of recomputing them client-side.

## Changes

### react-openapi (library)
- `MultiEnumField` renderer for array-of-enum fields; dispatch branch in
  `FormFieldRenderer`
- `useFkFieldOptions` hook + `FKFieldConfig.value` (FK options keyed by a non-PK
  field — accounts resolve by `name`)
- `FieldConfig.defaultValue` seeding in `extractFields`; enum lift for
  `items.enum` arrays; sanitize-payload bypasses FK resolution when `fk.value` is set

### Reports page
- **GenerateReportPanel**: rewritten (~275 → ~110 lines) — fields extracted from the
  served `ReportQuery` schema (labels, order, defaults, multi-enum, account picker);
  server-side validation only
- **ReportViewer**:
  - Period groups built from cube buckets keyed by canonical `period_id`; buckets
    merged per period (disjoint slices), txn ids deduped
  - Cards/bars/metric strips read `metrics` verbatim (outflows/inflows/sum/count/
    avg/min/max/cadence/frequency); Net = one subtraction at render
  - Dimension-switchable bar strip (Period / Payees / Tags): segmented pill control
    with sliding indicator; pills disabled when the report has ≤1 distinct value;
    top-10 bars in a fixed view paged by chevrons (hidden scrollbar)
  - Active dim requests the full distribution via `payee=*` / `tags=*` when its own
    filter is unselected; explicit selections always slice normally
- **TransactionList**: shared component with optional `groups` prop (API periods +
  metrics) used by the viewer; Expense page keeps the client-side fallback path;
  group strip shows Sum · Avg · Min · Max · Cadence (or Frequency /day when
  cadence < 1)

### Fixes
- Strict DD-MM-YYYY parsing (`parseOccurredAt`) in period merges and date ordering —
  `new Date()` mis-read dates as MM-DD-YYYY and corrupted merged cadence
  (Aug Zomato: 12.89 days displayed vs 0.76 actual)
- Bar widths magnitude-based so all-outflow slices render at proper width

Reviewed-on: #16
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-08-21 14:16:35 +00:00
25ec534597 Stripe-style UI overhaul + Expenses feed with month grouping (#15)
## Title
Stripe-style UI overhaul + Expenses feed with month grouping

## Summary
Rework the khata-ui frontend to a Stripe-grade design system and add a
rich, monthly-grouped Expenses experience driven by react-openapi's
shared, spec-configured field renderers.

## Highlights

### Design overhaul
- New design system: single indigo (#635BFF), 6px radius, Inter type
  scale, streamlined surfaces/inputs/feedback tokens
- Sticky app bar with brand-left header + inline footer, route titles
- Split-panel auth pages, Stripe-style Home hero + feature cards
- Toast provider + shared PageHeader/EmptyState UI primitives
- Admin polish: right-aligned numerics, row-hover actions, skeletons,
  richer empty states, breadcrumbed fetch-request flows

### Expenses page
- Monthly grouped feed with stat cards (total spent / this month / income)
- Month cards as single-open accordions with red-spent / green-income totals
- Flat transaction rows: logo, name/date, account, amount (no accordion)
- Floating month–year selector pill (scroll-spy) that expands + scrolls
  to the selected month
- Strict DD-MM-YYYY date parsing (day-first), no format guessing

### Shared react-openapi fields
- New `CurrencyField` + `formatCurrency` (cached Intl, sign-colored,
  sign-less amounts; form editing reuses NumberField)
- `resourceConfig.fieldTypes` override mechanism (amount → currency)
- Expenses page rendered via ListCellRenderer / DetailFieldRenderer /
  applyDisplayFormat — zero custom field renderers
- `resolveMediaUrl` resolves relative `/uploads/...` against the API
  base for every image field (Admin + Expenses logos)

### Fixes
- Perf: memoized rows, formatter caches, single-open accordions —
  cuts slow-click INP and DOM-nesting warnings
- Removed unused @mui/x-data-grid@7, which hoisted @mui/system@7 and
  crashed Box/createTheme at runtime (v5/v7 mix)

## Commits (10)
- 9808a1f design overhaul + expenses page
- d6856a5 fix expenses list performance + dom nesting
- 3dd833a shared currency field + expenses rendered from react-openapi fields
- e8c585b fix expenses grouping to parse occurred_at as DD-MM-YYYY strictly
- cc940ee expense list: split month totals, sticky headers + month pill
- 47d799f expense list: clickable month pill + fix scroll-to-month
- 537aef9 resolve relative media URLs against the API base in react-openapi
- 8e9a13d expense list: replace accordion with flat Stripe-style rows
- 62dd06c expense list: month accordions + month-year selector; drop unused x-data-grid
- 806bd42 dropped amount sign from CurrencyField.tsx

Reviewed-on: #15
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-08-18 13:51:28 +00:00
51762f8d18 loader fixes 2026-08-01 17:19:58 +05:30
002b22ff0e entity-wise-enrichment (#14)
## What's changed

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

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

## Files changed

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

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

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

## Changes

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

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

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

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

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

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

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

## Changes

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

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

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

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

Reviewed-on: #12
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-07-19 14:47:30 +00:00
6c720c390c fixes 2026-07-17 14:31:39 +05:30
83ecdcb500 upload functionality 2026-07-13 20:09:31 +05:30
f3135b8247 new react-openapi 2026-07-13 16:15:26 +05:30
72e7e843a4 new react-openapi 2026-07-13 04:35:58 +05:30
617f6bea6c new FetchRequest 2026-07-12 00:01:53 +05:30
e1ae4f0ebe removed unused pages 2026-07-11 17:23:42 +05:30
04aa16b564 fetch request fixes 2026-07-11 17:18:42 +05:30
99cc5afb7c fetch request fixes 2026-07-11 17:11:27 +05:30
afddb3cfbc removed dashboard and reports 2026-07-11 17:10:26 +05:30
b75505de25 added useItemSse 2026-07-11 17:09:50 +05:30
46e666c6a5 fix for nested x-display-format 2026-07-11 03:38:25 +05:30
f5bc7adc37 MULTI schema handling 2026-06-19 20:03:23 +05:30
9a80a52fd5 form single line per field 2026-06-19 19:18:41 +05:30
ac7c3d6313 fixes 2026-06-19 16:53:12 +05:30
5892bca44d require auth 2026-06-19 00:28:23 +05:30
8c824d5239 passing specConfiguration to validator 2026-06-18 23:57:32 +05:30
124 changed files with 7450 additions and 5602 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ dist
dist-ssr
*.local
.idea
tsconfig.tsbuildinfo

View File

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

View File

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

View File

@@ -3,17 +3,19 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta name="description" content="Khata — your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow." />
<meta name="theme-color" content="#635BFF" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/>
<link rel="icon" type="image/png" href="/favicon.png" />
<title>khata - Aetoskia</title>
<title>Khata — Financial Ledger</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
</html>

1445
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,20 @@
import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
import {
Box,
TextField,
Button,
Typography,
IconButton,
CircularProgress,
Link,
InputAdornment,
alpha,
} from '@mui/material';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useTheme } from '@mui/material/styles';
export type AuthMode = "login" | "register";
@@ -15,6 +29,12 @@ export interface AuthPageProps {
currentUser: any;
}
const FEATURES = [
"OpenAPI-driven admin for accounts, expenses, tags & payors",
"Import bank statements with live pipeline progress",
"Entity & tag enrichment for clean, comparable data",
];
export function AuthPage({
mode,
onBack,
@@ -25,114 +45,200 @@ export function AuthPage({
error,
currentUser,
}: AuthPageProps) {
const theme = useTheme();
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [fieldErrors, setFieldErrors] = React.useState<{ username?: string; password?: string }>({});
const isLogin = mode === "login";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isLogin) {
await login(username, password);
} else {
await register(username, password);
}
};
// ✅ Auto-return if already logged in
React.useEffect(() => {
if (currentUser) onBack();
}, [currentUser, onBack]);
return (
<Box
sx={{
maxWidth: 400,
mx: 'auto',
mt: 8,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: 'background.paper',
}}
>
<IconButton onClick={onBack} sx={{ mb: 2 }}>
<ArrowBackRoundedIcon />
const validate = (): boolean => {
const next: { username?: string; password?: string } = {};
if (!username.trim()) next.username = "Username is required";
else if (username.trim().length < 3) next.username = "Username must be at least 3 characters";
if (!password) next.password = "Password is required";
else if (password.length < 6) next.password = "Password must be at least 6 characters";
setFieldErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
if (isLogin) {
await login(username.trim(), password);
} else {
await register(username.trim(), password);
}
};
const handleSwitch = () => {
setFieldErrors({});
onSwitchMode();
};
const form = (
<Box sx={{ width: '100%', maxWidth: 400 }}>
<IconButton onClick={onBack} sx={{ mb: 2, border: '1px solid', borderColor: 'divider' }} aria-label="Go back">
<ArrowBackRoundedIcon fontSize="small" />
</IconButton>
<Typography variant="h4" fontWeight="bold" gutterBottom>
{isLogin ? "Sign In" : "Create Account"}
<Typography variant="h4" fontWeight={700} gutterBottom sx={{ letterSpacing: '-0.02em' }}>
{isLogin ? "Sign in to Khata" : "Create your account"}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{isLogin
? "Please log in to continue"
: "Create an account to get started"}
? "Welcome back. Enter your credentials to continue."
: "Start managing your ledger in a few seconds."}
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
type="username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<form onSubmit={handleSubmit} noValidate>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
fullWidth
label="Username"
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
if (fieldErrors.username) setFieldErrors((p) => ({ ...p, username: undefined }));
}}
error={!!fieldErrors.username}
helperText={fieldErrors.username}
autoComplete="username"
autoFocus
/>
<TextField
fullWidth
label="Password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (fieldErrors.password) setFieldErrors((p) => ({ ...p, password: undefined }));
}}
error={!!fieldErrors.password}
helperText={fieldErrors.password}
autoComplete={isLogin ? 'current-password' : 'new-password'}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={showPassword ? 'Hide password' : 'Show password'}
onClick={() => setShowPassword((s) => !s)}
edge="end"
size="small"
sx={{ border: 'none' }}
>
{showPassword ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
}}
/>
{error && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Button
fullWidth
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={loading}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : isLogin ? (
"Login"
) : (
"Register"
{error && (
<Typography color="error" variant="body2" sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Button>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
sx={{ mt: 1, fontWeight: 600 }}
disabled={loading}
>
{loading ? (
<CircularProgress size={20} color="inherit" />
) : isLogin ? (
"Sign In"
) : (
"Create Account"
)}
</Button>
</Box>
</form>
<Typography
variant="body2"
color="text.secondary"
align="center"
sx={{ mt: 3 }}
>
{isLogin ? "Don’t have an account?" : "Already have an account?"}{' '}
<Link
component="button"
underline="hover"
color="primary"
onClick={onSwitchMode}
sx={{ fontWeight: 500 }}
>
{isLogin ? "Register" : "Login"}
<Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 3 }}>
{isLogin ? "Don't have an account?" : "Already have an account?"}{' '}
<Link component="button" underline="hover" color="primary" onClick={handleSwitch} sx={{ fontWeight: 600 }}>
{isLogin ? "Register" : "Sign In"}
</Link>
</Typography>
</Box>
);
return (
<Box
sx={{
minHeight: 'calc(100vh - 64px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: { xs: 2, md: 4 },
}}
>
<Box
sx={{
width: '100%',
maxWidth: 960,
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '5fr 6fr' },
borderRadius: 3,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
backgroundColor: 'background.paper',
boxShadow: (t) =>
`0 12px 40px ${alpha(t.palette.common.black, t.palette.mode === 'dark' ? 0.4 : 0.08)}`,
}}
>
{/* LEFT BRAND PANEL */}
<Box
sx={{
display: { xs: 'none', md: 'flex' },
flexDirection: 'column',
justifyContent: 'space-between',
p: 4,
background: 'linear-gradient(150deg, #635BFF 0%, #4F46E5 55%, #3B82F6 100%)',
color: '#fff',
}}
>
<Box>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: '-0.02em' }}>
Khata
</Typography>
<Typography sx={{ mt: 3, fontSize: '1.25rem', fontWeight: 600, lineHeight: 1.4, maxWidth: 320 }}>
The intelligent, extensible financial ledger for your cashflow.
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{FEATURES.map((f) => (
<Box key={f} sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<CheckRoundedIcon sx={{ fontSize: 18, mt: 0.25, color: alpha('#fff', 0.9) }} />
<Typography variant="body2" sx={{ color: alpha('#fff', 0.92), lineHeight: 1.5 }}>
{f}
</Typography>
</Box>
))}
</Box>
</Box>
{/* RIGHT FORM PANEL */}
<Box sx={{ p: { xs: 3, md: 5 }, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{form}
</Box>
</Box>
</Box>
);
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ Auto-generates an admin panel (CRUD, datatable, relationship management) from an
```bash
npm install react-openapi
# peer dependencies (install these in your app):
npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml
npm install react react-dom react-router-dom @mui/material @mui/icons-material @emotion/react @emotion/styled axios js-yaml
```
## Quick Start
@@ -590,6 +590,28 @@ x-upload-url: /pets/{id}/photo
POST endpoint for file upload. The `{id}` placeholder is replaced with the current record ID.
#### `x-upload` (optional)
```yaml
path:
type: string
x-upload:
type: file # one of: image, pdf, html, csv, file
url: /uploads # POST endpoint that accepts raw binary + Content-Disposition header
```
Changes the form field to a file upload component. The file is POSTed as raw binary (`application/octet-stream`) to the specified `url` with a `Content-Disposition: attachment; filename="..."` header. The response must contain a `saved_as` field, which is stored as the field value.
| `type` | Display |
|------------|--------------------------------------------------|
| `image` | `<Avatar src="/uploads/{value}" />` |
| `pdf` | Clickable chip linking to `/uploads/{value}` |
| `html` | Clickable chip linking to `/uploads/{value}` |
| `csv` | Clickable chip linking to `/uploads/{value}` |
| `file` | Clickable chip linking to `/uploads/{value}` |
The chip includes a delete (X) button to replace the file.
### Property Types That Trigger Special Renderers
| Condition | Form Renderer | List/Detail Renderer |
@@ -602,6 +624,7 @@ POST endpoint for file upload. The `{id}` placeholder is replaced with the curre
| `format: date` | `DateField` (date picker) | Typography |
| `format: date-time` | `DateField` (datetime-local picker) | Typography |
| `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` |
| `x-upload` exists | `FileUploadField` (upload button + chip/Avatar) | Chip or Avatar |
| `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) |
| None of the above (default) | `StringField` (text input) | Typography |

View File

@@ -1,11 +1,21 @@
export { AppProvider } from "./src/context/AppProvider";
export { Admin } from "./src/components/Admin";
export { Admin, ProfileRoutes } from "./src/components/Admin";
export type { AdminProps, ProfileRoute } from "./src/components/Admin";
export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
export { MultiEnumField } from "./src/components/fields/renderers/MultiEnumField";
export { extractFields } from "./src/transformers/field-config";
export { useFkFieldOptions } from "./src/hooks/useFkFieldOptions";
export type { FkOption } from "./src/hooks/useFkFieldOptions";
export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField";
export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField";
export { SseStreamView } from "./src/components/SseStreamView";
export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { getApi } from "./src/hooks/useApi";
export { useItemSse } from "./src/hooks/useItemSse";
export { sanitizePayload } from "./src/utils/sanitize-payload";
export type { FkResolver } from "./src/utils/sanitize-payload";
export type { FilterComponentProps } from "./src/context/useResource";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents, AuthConfig } from "./src/types";

View File

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

View File

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

View File

@@ -39,4 +39,4 @@ export function Layout({ resources, basePath, children }: LayoutProps) {
</Box>
</Box>
);
}
}

View File

@@ -7,6 +7,8 @@ import {
Paper,
Grid,
CircularProgress,
Tabs,
Tab,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import EditIcon from "@mui/icons-material/Edit";
@@ -14,12 +16,18 @@ import type { ResourceConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
import { SseStreamView } from "./SseStreamView";
interface ResourceDetailProps {
resource: ResourceConfig;
basePath: string;
}
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate();
const { id } = useParams();
@@ -27,6 +35,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const { resources: allResources } = useAppContext();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [tabIndex, setTabIndex] = useState(0);
useEffect(() => {
if (id) {
@@ -49,22 +58,49 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
if (!data) {
return (
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
Record not found
</Typography>
<Box sx={{ py: 6, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
Record not found
</Typography>
<Button sx={{ mt: 1.5 }} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back to list
</Button>
</Box>
);
}
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
const tabs = [{ label: "Details", key: "details" }];
if (resource.subResources) {
for (const subName of resource.subResources) {
const sub = allResources.find((r) => r.name === subName);
if (sub) {
tabs.push({ label: sub.displayName, key: subName });
}
}
}
return (
<Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back
</Button>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
{applyDisplayFormat(data, resource.displayFormat)}
<Typography variant="h5" fontWeight={700} sx={{ flex: 1, letterSpacing: "-0.02em" }}>
{applyDisplayFormat(
Object.fromEntries(
resource.orderedFields.map((field) => {
const value = data[field.name];
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) return [field.name, applyDisplayFormat(value, target.displayFormat)];
}
return [field.name, value];
})
),
resource.displayFormat
)}
</Typography>
{resource.operations.update && (
<Button variant="contained" startIcon={<EditIcon />} onClick={() => navigate(`${basePath}/${resource.name}/${id}/edit`)}>
@@ -73,25 +109,47 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
)}
</Box>
<Paper variant="outlined" sx={{ p: 3 }}>
<Grid container spacing={2}>
{visibleFields.map((field) => {
let value = data[field.name];
let fmt = resource.displayFormat;
if (field.fk && typeof value === "object") {
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
fmt = targetRes!.displayFormat;
} else if (field.refSchema && !field.fk && typeof value === "object") {
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
}
return (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} />
</Grid>
);
})}
</Grid>
</Paper>
{tabs.length > 1 && (
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)} sx={{ mb: 1 }}>
{tabs.map((t) => (
<Tab key={t.key} label={t.label} />
))}
</Tabs>
)}
<TabPanel value={tabIndex} index={0}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Grid container spacing={2}>
{visibleFields.map((field) => {
let value = data[field.name];
let fmt = resource.displayFormat;
if (field.fk && typeof value === "object") {
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
fmt = targetRes!.displayFormat;
} else if (field.refSchema && !field.fk && typeof value === "object") {
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
}
return (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} basePath={basePath} />
</Grid>
);
})}
</Grid>
</Paper>
</TabPanel>
{tabs.slice(1).map((t, i) => {
const sub = allResources.find((r) => r.name === t.key)!;
const pathParam = sub.parent?.pathParam ?? "id";
return (
<TabPanel key={t.key} value={tabIndex} index={i + 1}>
{sub.streaming ? (
<SseStreamView resource={sub} pathParams={{ [pathParam]: id! }} />
) : null}
</TabPanel>
);
})}
</Box>
);
}

View File

@@ -236,6 +236,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
<Grid container spacing={2}>
{resource.orderedFields
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
.filter((f) => !f.hidden?.form)
.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<FormFieldRenderer

View File

@@ -20,11 +20,13 @@ import {
DialogContent,
DialogActions,
Grid,
Skeleton,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility";
import StorageIcon from "@mui/icons-material/Storage";
import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
@@ -38,6 +40,10 @@ interface ResourceListProps {
basePath: string;
}
function isNumericColumn(col: FieldConfig): boolean {
return col.type === "integer" || col.type === "number";
}
function matchRow(row: any, filters: Record<string, string>, fields: FieldConfig[], allResources: ResourceConfig[]): boolean {
for (const field of fields) {
if (!field.filterable) continue;
@@ -265,12 +271,16 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
/>
)}
<TableContainer component={Paper} variant="outlined">
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 3 }}>
<Table size="small">
<TableHead>
<TableRow>
{visibleColumns.map((col) => (
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
<TableCell
key={col.name}
align={isNumericColumn(col) ? "right" : "left"}
sx={{ fontWeight: 700, fontSize: "0.75rem", py: 1, whiteSpace: "nowrap" }}
>
{col.sortable ? (
<TableSortLabel
active={sortField === col.name}
@@ -284,16 +294,45 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
)}
</TableCell>
))}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700, fontSize: "0.75rem", py: 1 }}>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 ? (
{crud.loading && displayData.length === 0 ? (
Array.from({ length: 6 }).map((_, i) => (
<TableRow key={`skeleton-${i}`}>
{visibleColumns.map((col) => (
<TableCell key={col.name} align={isNumericColumn(col) ? "right" : "left"} sx={{ py: 1 }}>
<Skeleton variant="text" width={col.type === "integer" || col.type === "number" ? 56 : "80%"} />
</TableCell>
))}
{hasActions && (
<TableCell align="right" sx={{ py: 1 }}>
<Skeleton variant="rectangular" width={72} height={24} sx={{ ml: "auto", borderRadius: 1 }} />
</TableCell>
)}
</TableRow>
))
) : displayData.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
{isStreaming ? "Waiting for events\u2026" : "No records found"}
</Typography>
<Box sx={{ py: 5, display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<StorageIcon sx={{ fontSize: 32, color: "text.disabled" }} />
<Typography variant="body2" color="text.secondary">
{isStreaming ? "Waiting for events…" : "No records found"}
</Typography>
{!isStreaming && resource.operations.create && (
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
sx={{ mt: 1 }}
>
Create
</Button>
)}
</Box>
</TableCell>
</TableRow>
) : (
@@ -303,7 +342,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
<TableRow
key={rowId}
hover
sx={{ cursor: "pointer" }}
sx={{ cursor: "pointer", "&:hover .row-actions": { opacity: 1 } }}
onClick={() => {
if (isStreaming) {
setDetailRow(row);
@@ -322,34 +361,56 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
fmt = col.inlineDisplayFormat;
}
return (
<TableCell key={col.name}>
<ListCellRenderer field={col} value={value} displayFormat={fmt} />
<TableCell
key={col.name}
align={isNumericColumn(col) ? "right" : "left"}
sx={{
py: 1,
fontSize: "0.8125rem",
...(isNumericColumn(col) && { fontVariantNumeric: "tabular-nums" }),
}}
>
<ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} />
</TableCell>
);
})}
{hasActions && (
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{resource.operations.get && !isStreaming && (
<Tooltip title="View">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.update && (
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<TableCell
align="right"
onClick={(e) => e.stopPropagation()}
>
<Box
className="row-actions"
sx={{
opacity: { xs: 1, md: 0 },
display: "flex",
justifyContent: "flex-end",
gap: 0.5,
transition: "opacity 160ms ease",
}}
>
{resource.operations.get && !isStreaming && (
<Tooltip title="View">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.update && (
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
)}
</TableRow>
@@ -388,11 +449,12 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{visibleColumns.map((col) => (
<Grid key={col.name} item xs={12} sm={6}>
<DetailFieldRenderer
field={col}
value={detailRow[col.name]}
displayFormat={resource.displayFormat}
/>
<DetailFieldRenderer
field={col}
value={detailRow[col.name]}
displayFormat={resource.displayFormat}
basePath={basePath}
/>
</Grid>
))}
</Grid>

View File

@@ -30,11 +30,6 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const colors = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
];
const content = (
<Box>
<Toolbar>
@@ -43,7 +38,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
</Typography>
</Toolbar>
<List sx={{ px: 1 }}>
{resources.map((r, i) => {
{resources.map((r) => {
const listPath = `${basePath}/${r.name}`;
const active = location.pathname.startsWith(listPath);
return (
@@ -58,13 +53,15 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
borderRadius: 2,
mb: 0.5,
"&.Mui-selected": {
bgcolor: `${colors[i % colors.length]}15`,
"&:hover": { bgcolor: `${colors[i % colors.length]}20` },
bgcolor: "primary.main",
color: "primary.contrastText",
"&:hover": { bgcolor: "primary.main" },
"& .MuiListItemIcon-root": { color: "primary.contrastText" },
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
<CircleIcon sx={{ fontSize: 12 }} />
</ListItemIcon>
<ListItemText
primary={r.displayName}
@@ -107,4 +104,4 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
);
}
export { drawerWidth };
export { drawerWidth };

View File

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

View File

@@ -1,15 +1,20 @@
import React from "react";
import { Box, Typography } from "@mui/material";
import { Box, Typography, Avatar } from "@mui/material";
import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer";
import { CurrencyField } from "./renderers/CurrencyField";
import { resolveMediaUrl } from "./utils";
import { useAppContext } from "../../context/AppContext";
interface DetailFieldProps {
field: FieldConfig;
value: any;
displayFormat?: string;
basePath?: string;
}
export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) {
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
const { config } = useAppContext();
if (field.hidden?.detail) return null;
return (
@@ -17,7 +22,13 @@ export function DetailFieldRenderer({ field, value, displayFormat }: DetailField
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label}
</Typography>
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} />
{field.uiType === "currency" ? (
<CurrencyField value={Number(value)} large />
) : field.uiType === "image" ? (
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
)}
</Box>
);
}

View File

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

View File

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

View File

@@ -3,3 +3,5 @@ export { ListCellRenderer } from "./ListCellRenderer";
export { DetailFieldRenderer } from "./DetailFieldRenderer";
export { applyDisplayFormat } from "./utils";
export { JsonField } from "./renderers/JsonField";
export { MultiEnumField } from "./renderers/MultiEnumField";
export { CurrencyField, formatCurrency } from "./renderers/CurrencyField";

View File

@@ -0,0 +1,42 @@
import React from "react";
import { Typography } from "@mui/material";
const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"];
const _currencyFormatters = new Map<string, Intl.NumberFormat>();
export function formatCurrency(amount: number, currency?: string): string {
const code = currency && CURRENCIES.includes(currency) ? currency : "INR";
let formatter = _currencyFormatters.get(code);
if (!formatter) {
formatter = new Intl.NumberFormat("en-IN", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
});
_currencyFormatters.set(code, formatter);
}
return formatter.format(Math.abs(amount));
}
interface CurrencyFieldProps {
value: number;
currency?: string;
large?: boolean;
}
export function CurrencyField({ value, currency, large }: CurrencyFieldProps) {
const negative = value < 0;
return (
<Typography
component="span"
sx={{
fontWeight: 700,
fontSize: large ? "0.9375rem" : "0.8125rem",
fontVariantNumeric: "tabular-nums",
color: negative ? "error.main" : "success.main",
}}
>
{formatCurrency(value, currency)}
</Typography>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
import Button from "@mui/material/Button";
import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi";
import { useAppContext } from "../../../context/AppContext";
import { resolveMediaUrl } from "../utils";
interface Props {
field: FieldConfig;
@@ -13,6 +15,7 @@ interface Props {
}
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
const { config } = useAppContext();
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -47,14 +50,14 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
{field.label}
</Typography>
{value ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<Button variant="outlined" component="label" size="small">
Upload {field.label}
<input type="file" hidden accept="image/*" onChange={handleUpload} />
</Button>
)}
<FormHelperText>{field.description}</FormHelperText>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</Box>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
import React from "react";
import { Autocomplete, TextField, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any[]) => void;
error?: string;
}
export function MultiEnumField({ field, value, onChange, error }: Props) {
const selected: string[] = Array.isArray(value) ? value : [];
return (
<Autocomplete
multiple
disableCloseOnSelect
size="small"
options={field.enumValues ?? []}
value={selected}
onChange={(_, newVal) => onChange(newVal)}
renderOption={(props, option, { selected: isSelected }) => {
const { key, ...rest } = props as any;
return (
<li key={key} {...rest}>
{isSelected ? (
<DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} />
) : (
<Box sx={{ width: 22, mr: 1 }} />
)}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) =>
tagValue.map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag} size="small" />;
})
}
renderInput={(params) => (
<TextField
{...params}
label={field.label}
placeholder={field.description || undefined}
error={!!error}
helperText={error || field.description || undefined}
size="small"
/>
)}
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
disabled={field.readOnly}
/>
);
}

View File

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

View File

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

View File

@@ -9,3 +9,15 @@ export function applyDisplayFormat(item: any, format: string): string {
return val != null ? String(val) : "";
});
}
/**
* Resolve a media/image URL against the API base so relative paths
* like `/uploads/entity_logos/x.svg` point at the backend origin.
* Absolute (http/data/blob) URLs are returned untouched.
*/
export function resolveMediaUrl(value: string | null | undefined, baseUrl?: string): string | undefined {
if (value == null || value === "") return undefined;
if (value.startsWith("http") || value.startsWith("data:") || value.startsWith("blob:")) return value;
if (value.startsWith("/") && baseUrl) return `${baseUrl.replace(/\/+$/, "")}${value}`;
return value;
}

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from "react";
import type { SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
import type { AuthConfig, ProfileOperation, SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
import { AppContext } from "./AppContext";
import { loadSpec } from "../spec-loader";
import { validateSpec } from "../spec-validator";
@@ -11,13 +11,72 @@ interface AppProviderProps {
children: React.ReactNode;
}
function extractAuthConfig(spec: any): AuthConfig {
const bearer: Record<string, any> =
spec?.components?.securitySchemes?.bearerAuth ?? {};
return {
serverUrl: bearer["x-server-url"] ?? "",
loginPath: bearer["x-login-path"] ?? "/login",
registerPath: bearer["x-register-path"] ?? "/register",
logoutPath: bearer["x-logout-path"] ?? "/logout",
mePath: bearer["x-me-path"] ?? "/me",
introspectPath: bearer["x-introspect-path"] ?? "/introspect",
};
}
function extractProfileOperations(spec: any): ProfileOperation[] {
const ops: ProfileOperation[] = [];
const paths = spec.paths ?? {};
for (const [path, methods] of Object.entries<Record<string, any>>(paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (method.startsWith("x-")) continue;
if (operation["x-profile"] === true) {
ops.push({
path,
method: method.toUpperCase(),
operationId: operation.operationId ?? "",
summary: operation.summary,
});
}
}
}
return ops;
}
function applyResourceOverrides(configs: ResourceConfig[], specConfiguration: SpecConfiguration): ResourceConfig[] {
for (const resource of configs) {
const fieldTypes = specConfiguration.resourceConfig?.[resource.name]?.fieldTypes;
if (!fieldTypes) continue;
// fields and orderedFields share the same FieldConfig object references,
// so an in-place mutation updates both.
for (const field of resource.fields) {
const override = fieldTypes[field.name];
if (override) field.uiType = override;
}
}
return configs;
}
const DEFAULT_AUTH_CONFIG: AuthConfig = {
serverUrl: "",
loginPath: "/login",
registerPath: "/register",
logoutPath: "/logout",
mePath: "/me",
introspectPath: "/introspect",
};
export function AppProvider({ specConfiguration, children }: AppProviderProps) {
const [loading, setLoading] = useState(true);
const [resources, setResources] = useState<ResourceConfig[]>([]);
const [profileOperations, setProfileOperations] = useState<ProfileOperation[]>([]);
const [authConfig, setAuthConfig] = useState<AuthConfig>(DEFAULT_AUTH_CONFIG);
const [schemas, setSchemas] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<ValidationMessage[]>([]);
const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
const profileComponents = specConfiguration.profileComponents ?? {};
useEffect(() => {
let cancelled = false;
@@ -27,7 +86,7 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
const spec = await loadSpec(specConfiguration.specUrl);
const allMessages = validateSpec(spec);
const allMessages = validateSpec(spec, specConfiguration);
const errs = allMessages.filter((m) => m.type === "error");
const warns = allMessages.filter((m) => m.type === "warning");
@@ -36,12 +95,14 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
setErrors(errs);
setWarnings(warns);
setSchemas(spec.components?.schemas ?? {});
setProfileOperations(extractProfileOperations(spec));
setAuthConfig(extractAuthConfig(spec));
}
if (errs.length === 0) {
const configs = buildResourceConfigs(spec);
if (!cancelled) {
setResources(configs);
setResources(applyResourceOverrides(configs, specConfiguration));
}
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";
@@ -51,7 +112,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
}
} catch (e: any) {
if (!cancelled) {
setErrors([{ type: "error", message: e.message ?? "Failed to load spec" }]);
const lines = (e.message ?? "Failed to load spec").split("\n");
setErrors(lines.map((msg: string) => ({ type: "error" as const, message: msg })));
}
} finally {
if (!cancelled) {
@@ -71,12 +133,15 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
() => ({
config: specConfiguration,
resources,
profileOperations,
profileComponents,
authConfig,
schemas,
loading,
errors,
warnings,
}),
[specConfiguration, resources, schemas, loading, errors, warnings]
[specConfiguration, resources, profileOperations, profileComponents, authConfig, schemas, loading, errors, warnings]
);
return React.createElement(AppContext.Provider, { value }, children);

View File

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

View File

@@ -1,15 +1,38 @@
import axios, { AxiosInstance } from "axios";
let apiClient: AxiosInstance | null = null;
let _onUnauthorized: (() => void) | undefined;
export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance {
function serializeParams(params: Record<string, any>): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params ?? {})) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const item of value) searchParams.append(key, String(item));
} else if (typeof value === "object") {
for (const [nestedKey, nestedValue] of Object.entries(value)) {
if (nestedValue === undefined || nestedValue === null) continue;
searchParams.append(`${key}[${nestedKey}]`, String(nestedValue));
}
} else {
searchParams.append(key, String(value));
}
}
return searchParams.toString();
}
export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance {
if (apiClient && apiClient.defaults.baseURL === baseUrl) {
_onUnauthorized = onUnauthorized;
return apiClient;
}
_onUnauthorized = onUnauthorized;
apiClient = axios.create({
baseURL: baseUrl,
headers: { "Content-Type": "application/json" },
paramsSerializer: serializeParams,
});
apiClient.interceptors.request.use((config) => {
@@ -23,12 +46,12 @@ export function initApi(baseUrl: string, getToken?: () => string | null): AxiosI
apiClient.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401 && getToken) {
const currentToken = getToken();
if (currentToken) {
const tokenStore = { clear: () => localStorage.removeItem("token") };
tokenStore.clear();
}
if (error.response?.status === 401) {
console.log("[useApi] 401 from %s %s - dispatching auth:unauthorized", error.config?.method, error.config?.url);
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
_onUnauthorized?.();
} else {
console.log("[useApi] non-401 error %s from %s %s", error.response?.status, error.config?.method, error.config?.url);
}
return Promise.reject(error);
}

View File

@@ -0,0 +1,82 @@
import { useCallback, useEffect, useState } from "react";
import type { FieldConfig, FKFieldConfig } from "../types";
import { useAppContext } from "../context/AppContext";
import { getApi } from "./useApi";
export interface FkOption {
value: any;
label: string;
}
function applyFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
}
/**
* Loads select options for FK-annotated fields of a field set.
* Option values bind to `fk.value` when set (e.g. account names),
* otherwise to the target resource's primary key.
*/
export function useFkFieldOptions(fields: FieldConfig[]) {
const { resources } = useAppContext();
const [fkOptions, setFkOptions] = useState<Record<string, FkOption[]>>({});
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
const loadFkOptions = useCallback(
async (fieldName: string, fk: FKFieldConfig) => {
setFkLoading((prev) => ({ ...prev, [fieldName]: true }));
try {
const targetRes = resources.find((r) => r.name === fk.resource);
if (!targetRes) return;
const api = getApi();
const params: Record<string, any> = {};
if (targetRes.pagination) params.limit = 0;
const res = await api.get(targetRes.path, { params });
let items: any[];
if (targetRes.pagination) {
if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) {
throw new Error(`Expected paginated response from ${targetRes.path}`);
}
items = res.data.items;
} else {
if (!Array.isArray(res.data)) {
throw new Error(`Expected array response from ${targetRes.path}`);
}
items = res.data;
}
const opts: FkOption[] = items.map((item: any) => ({
value: item[fk.value ?? targetRes.primaryKey],
label: applyFormat(item, targetRes.displayFormat),
}));
setFkOptions((prev) => ({ ...prev, [fieldName]: opts }));
} catch {
// leave options empty; the field renders without suggestions
} finally {
setFkLoading((prev) => ({ ...prev, [fieldName]: false }));
}
},
[resources]
);
useEffect(() => {
fields.forEach((f) => {
if (f.fk?.prefetch) loadFkOptions(f.name, f.fk);
});
}, [fields, loadFkOptions]);
const loadFkOnOpen = useCallback(
(fieldName: string) => {
const field = fields.find((f) => f.name === fieldName);
if (field?.fk && !field.fk.prefetch && !fkOptions[fieldName]) {
loadFkOptions(fieldName, field.fk);
}
},
[fields, fkOptions, loadFkOptions]
);
return { fkOptions, fkLoading, loadFkOnOpen };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,14 @@ export interface ResourceConfiguration {
filterOptions?: {
mode?: FilterMode;
};
/** Map of field name → uiType override (e.g. { amount: "currency" }). */
fieldTypes?: Record<string, string>;
}
export interface ProfileComponents {
create?: React.ComponentType<any>;
edit?: React.ComponentType<any>;
view?: React.ComponentType<any>;
}
export interface SpecConfiguration {
@@ -12,6 +20,25 @@ export interface SpecConfiguration {
title?: string;
getToken?: () => string | null;
resourceConfig?: Record<string, ResourceConfiguration>;
profileComponents?: ProfileComponents;
}
/** Auth server config extracted from securitySchemes extensions. */
export interface AuthConfig {
serverUrl: string;
loginPath: string;
registerPath: string;
logoutPath: string;
mePath: string;
introspectPath: string;
}
/** Represents a single operation marked with `x-profile: true` in the spec. */
export interface ProfileOperation {
path: string;
method: string;
operationId: string;
summary?: string;
}
export interface ValidationMessage {
@@ -40,6 +67,7 @@ export interface ResourceConfig {
get: boolean;
create: boolean;
update: boolean;
patch: boolean;
delete: boolean;
};
updateMethod: "put" | "patch";
@@ -50,6 +78,21 @@ export interface ResourceConfig {
} | null;
relationships: ResourceRelationship[];
streaming?: boolean;
parent?: { resource: string; pathParam: string };
subResources?: string[];
}
export interface FKFieldConfig {
resource: string;
prefetch: boolean;
/** Target property used as the wire value (defaults to the target's primary key). */
value?: string;
}
export interface OneOfOption {
value: string;
label: string;
fields: FieldConfig[];
}
export interface FieldConfig {
@@ -65,17 +108,18 @@ export interface FieldConfig {
readOnly: boolean;
required: boolean;
enumValues?: string[];
/** Schema-level `default` used to seed create-form state. */
defaultValue?: any;
fk?: FKFieldConfig;
uiType?: string;
uploadUrl?: string;
upload?: { type: string; url: string };
refSchema?: string;
inlineDisplayFormat?: string;
isArray: boolean;
}
export interface FKFieldConfig {
resource: string;
prefetch: boolean;
oneOfOptions?: OneOfOption[];
discriminatorProperty?: string;
autocomplete?: "text" | "token" | "email" | "phone";
}
export interface OpenApiSpec {

View File

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

View File

@@ -0,0 +1,69 @@
import type { FieldConfig } from "../types";
import { extractFields } from "../transformers/field-config";
export type FkResolver = (resourceName: string, fkValue: any) => Promise<any>;
export async function sanitizePayload(
data: any,
fields: FieldConfig[],
schemas: Record<string, any>,
resolveFk?: FkResolver,
): Promise<any> {
if (data == null || typeof data !== "object") return data;
if (Array.isArray(data)) {
return Promise.all(data.map((item) => {
if (item != null && typeof item === "object") {
return sanitizePayload(item, fields, schemas, resolveFk);
}
return item;
}));
}
const result: Record<string, any> = { ...data };
const fieldMap = new Map(fields.map((f) => [f.name, f]));
for (const key of Object.keys(result)) {
const field = fieldMap.get(key);
if (!field) continue;
const val = result[key];
// FK fields whose wire value is a plain target property (fk.value, e.g.
// account names) are already in wire format — no id→object resolution.
if (field.fk && !field.fk.value) {
if (val == null || val === "") {
result[key] = null;
} else if (resolveFk) {
if (field.isArray && Array.isArray(val)) {
result[key] = await Promise.all(val.map((v: any) => resolveFk(field.fk!.resource, v)));
} else {
result[key] = await resolveFk(field.fk!.resource, val);
}
}
continue;
}
if (field.refSchema && !field.fk) {
if (val == null || val === "") {
result[key] = field.isArray ? [] : {};
} else if (typeof val === "object") {
const refSchemaObj = schemas[field.refSchema!];
const nestedFields = refSchemaObj
? extractFields(field.refSchema!, refSchemaObj, schemas)
: [];
result[key] = await sanitizePayload(val, nestedFields, schemas, resolveFk);
}
} else if (field.isArray) {
if (val == null || val === "") {
result[key] = [];
}
} else if (field.type === "object") {
if (val == null || val === "") {
result[key] = {};
}
}
}
return result;
}

View File

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

View File

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

126
src/Expense.tsx Normal file
View File

@@ -0,0 +1,126 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Container,
Box,
Paper,
Typography,
Alert,
Skeleton,
Button,
} from "@mui/material";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import { useNavigate } from "react-router-dom";
import { useResource, useAppContext, formatCurrency } from "../react-openapi";
import { PageHeader } from "./ui/PageHeader";
import { EmptyState } from "./ui/EmptyState";
import { StatCard } from "./common/components/StatCard";
import { TransactionList } from "./common/components/TransactionList";
import { buildTxnFieldConfigs } from "./common/utils/fieldConfigs";
import { isExpense } from "./common/utils/transactions";
import { currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./common/utils/dates";
import type { ExpenseItem } from "./common/types";
export default function Expense() {
const navigate = useNavigate();
const { list, loading, error, resource } = useResource("expenses");
const { resources } = useAppContext();
const [items, setItems] = useState<ExpenseItem[] | null>(null);
const fieldConfigs = useMemo(() => buildTxnFieldConfigs(resources), [resources]);
useEffect(() => {
let mounted = true;
list({ limit: 0 }).then((res) => {
if (!mounted) return;
setItems((res.items ?? []) as ExpenseItem[]);
});
return () => {
mounted = false;
};
}, [list]);
const sorted = useMemo(
() =>
[...(items ?? [])].sort(
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
),
[items],
);
const summary = useMemo(() => {
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const totalSpent = sorted.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0);
const totalIncome = sorted.filter((it) => !isExpense(it)).reduce((s, it) => s + (it.amount ?? 0), 0);
const thisMonth = currentMonthKey();
const monthItems = sorted.filter((it) => monthKey(it.occurred_at) === thisMonth);
const monthTotal = monthItems.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0);
return { currency, totalSpent, totalIncome, thisMonth: monthLabel(thisMonth), monthItems, monthTotal };
}, [sorted]);
const loadingUI = (
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
{[0, 1, 2].map((i) => (
<Box key={i}>
<Skeleton variant="text" width={160} height={32} sx={{ mb: 1.5 }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{[0, 1, 2].map((j) => (
<Skeleton key={j} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
))}
</Box>
</Box>
))}
</Box>
);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Expenses" }]}
title="Expenses"
subtitle="Every transaction imported from your accounts, grouped by month."
actions={
<Button
variant="contained"
onClick={() => navigate("/admin/expenses/new")}
>
New Expense
</Button>
}
/>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
Failed to load expenses: {error}
</Alert>
)}
{items === null && !error ? (
loadingUI
) : items && items.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3, mt: 2 }}>
<EmptyState
icon={<ReceiptLongIcon />}
title="No expenses yet"
description="Once you run a fetch request, imported transactions will show up here grouped by month."
actionLabel="Go to Fetch Requests"
onAction={() => navigate("/fetch-requests")}
/>
</Paper>
) : (
<>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
<StatCard
label="Outflows"
value={formatCurrency(summary.totalSpent, summary.currency)}
hint={`${summary.monthItems.length} transaction${summary.monthItems.length === 1 ? "" : "s"} this month`}
/>
<StatCard label="This month" value={formatCurrency(summary.monthTotal, summary.currency)} hint={summary.thisMonth} />
<StatCard label="Inflows" value={formatCurrency(summary.totalIncome, summary.currency)} />
</Box>
{fieldConfigs && <TransactionList items={sorted} fields={fieldConfigs} />}
</>
)}
</Container>
);
}

View File

@@ -0,0 +1,265 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box, Container, Typography, Paper, Button, Alert,
CircularProgress, IconButton, Tooltip,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { formatApiError } from "../features/fetch-requests";
import { useToast } from "../ui/Toast";
const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
function FetchRequestList() {
const navigate = useNavigate();
const { list, remove, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [rows, setRows] = useState<any[] | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { showToast } = useToast();
const reload = () => {
if (!resource) return;
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
};
const columns = useMemo(() => {
if (!resource) return [];
return resource.listColumns
.map((n) => resource.fields.find((f) => f.name === n))
.filter(Boolean) as FieldConfig[];
}, [resource]);
useEffect(() => {
if (!resource) return;
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
}, [resource?.name]);
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeletingId(id);
try {
await remove(id);
showToast("Fetch request deleted");
reload();
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeletingId(null);
}
};
if (!rows) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress size={28} />
</Box>
);
}
if (rows.length === 0) {
return (
<EmptyState
icon={<ReceiptLongIcon />}
title="No fetch requests yet"
description="Upload a bank statement or configure email ingestion to import your first transactions."
actionLabel="New Fetch Request"
onAction={() => document.getElementById("new-fetch-request")?.scrollIntoView({ behavior: "smooth", block: "center" })}
/>
);
}
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{rows.map((row, i) => {
const displayFormat = resource?.displayFormat ?? "";
return (
<Paper
key={row.id ?? i}
variant="outlined"
sx={{
p: 2,
cursor: "pointer",
borderRadius: 2,
transition: "border-color 160ms ease, box-shadow 160ms ease",
"&:hover": { borderColor: "primary.main", boxShadow: 1 },
}}
onClick={() => navigate(`/fetch-requests/${row.id}`)}
>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{columns.map((col) => (
<Box key={col.name} sx={{ minWidth: 120 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{col.label}
</Typography>
<ListCellRenderer
field={col}
value={row[col.name]}
displayFormat={col.fk
? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? displayFormat)
: displayFormat}
/>
</Box>
))}
<Box sx={{ ml: "auto", flexShrink: 0 }}>
<Tooltip title="Delete fetch request">
<IconButton
size="small"
color="error"
disabled={deletingId === row.id || row.status === "processing"}
onClick={(e) => handleDelete(e, row.id)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Paper>
);
})}
</Box>
);
}
export default function FetchRequestCreate() {
const { resources: allResources } = useAppContext();
const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]);
const { create } = useResource("fetch-requests");
const navigate = useNavigate();
const [formData, setFormData] = useState<Record<string, any>>({});
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ severity: "success" | "error"; message: string } | null>(null);
useEffect(() => {
if (!resource) return;
resource.relationships.forEach((rel) => {
const targetRes = allResources.find((r) => r.name === rel.config.resource);
if (!targetRes) return;
(async () => {
try {
const api = getApi();
const params: Record<string, any> = {};
if (targetRes.pagination) params.limit = 0;
const res = await api.get(targetRes.path, { params });
const items = targetRes.pagination
? (res.data.items ?? [])
: (Array.isArray(res.data) ? res.data : []);
const opts = items.map((item: any) => ({
value: item[targetRes.primaryKey],
label: applyDisplayFormat(item, targetRes.displayFormat),
}));
setFkOptions((prev) => ({ ...prev, [rel.fieldName]: opts }));
} catch (e) {
console.warn(`Failed to load FK options for ${rel.fieldName}:`, e);
}
})();
});
}, [resource]);
const formFields = useMemo(() => {
if (!resource) return [];
return resource.orderedFields.filter((f) => CREATE_FIELDS.includes(f.name));
}, [resource]);
const handleChange = (fieldName: string, value: any) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
setResult(null);
};
const handleSubmit = async () => {
setLoading(true);
setResult(null);
try {
const created = await create(formData);
const display = applyDisplayFormat(created, resource!.displayFormat);
setResult({ severity: "success", message: `Created: ${display}` });
setFormData({});
const newId = (created as any)?.id;
if (newId) {
navigate(`/fetch-requests/${newId}`);
}
} catch (e: any) {
const detail = e?.response?.data?.detail;
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error");
setResult({ severity: "error", message: `Failed: ${msg}` });
} finally {
setLoading(false);
}
};
if (!resource) {
return (
<Alert severity="info">
The <strong>fetch-requests</strong> resource was not found in this spec.
</Alert>
);
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests" }]}
title="Fetch Requests"
subtitle="Import transactions from bank statements or email and track pipeline progress."
/>
<Paper
id="new-fetch-request"
variant="outlined"
sx={{ p: 3, mb: 4, borderRadius: 3 }}
>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
New Fetch Request
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, maxWidth: 520, lineHeight: 1.6 }}>
Choose an account, pipeline, and a file or email source to kick off an import.
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, maxWidth: 520 }}>
{formFields.map((field) => (
<FormFieldRenderer
key={field.name}
field={field}
value={formData[field.name] ?? ""}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
/>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3, gap: 1.5 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading} sx={{ minWidth: 180 }}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request
</Button>
</Box>
{result && (
<Alert severity={result.severity} sx={{ mt: 2 }} onClose={() => setResult(null)}>
{result.message}
</Alert>
)}
</Paper>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Recent Fetch Requests
</Typography>
<FetchRequestList />
</Container>
);
}

View File

@@ -0,0 +1,413 @@
import React, { useMemo, useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Container,
Paper,
Typography,
Button,
Chip,
CircularProgress,
Alert,
Divider,
Tooltip,
} from "@mui/material";
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 DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests";
import { PipelineStepper } from "./components/PipelineStepper";
import { AmbiguityResolver } from "./components/AmbiguityResolver";
import { PageHeader } from "../ui/PageHeader";
import { useToast } from "../ui/Toast";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} />,
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
};
function formatProgressMessage(msg: ProgressMessage): string {
if (msg.lines !== undefined) return `${msg.lines} lines`;
if (msg.blocks !== undefined) return `${msg.blocks} blocks`;
if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`;
if (msg.count !== undefined) return `${msg.count} items`;
if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`;
if (msg.error) return msg.error.slice(0, 80);
return "";
}
function sseIcon(status: SSEEvent["status"]) {
switch (status) {
case "started": return <CircularProgress size={14} />;
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return <FiberManualRecordIcon sx={{ fontSize: 14, color: "info.main" }} />;
}
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<Paper sx={{ p: 3, borderRadius: 3, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={700} sx={{ flex: 1 }}>
{title}
</Typography>
{action}
</Box>
{children}
</Paper>
);
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { get, patch, remove, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false);
const [deleting, setDeleting] = useState(false);
const { showToast } = useToast();
const feedRef = useRef<HTMLDivElement>(null);
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id!),
enabled: !!id,
});
const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
onEvent: (parsed: SSEEvent) => {
if (parsed.status === "progress" && parsed.message.count !== undefined) {
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
}
if (parsed.status === "completed" && parsed.message.count !== undefined) {
const stats: Record<string, number> = {};
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
if (Object.keys(stats).length) {
setStepStats((prev) => ({ ...prev, ...stats }));
}
}
if (parsed.status === "paused") {
refetchRequest();
}
if (parsed.status === "failed") {
showToast(parsed.message.error || "Fetch request failed", "error");
refetchRequest();
}
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
},
});
useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
const displayEvents = useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
for (let i = sseEvents.length - 1; i >= 0; i--) {
const e = sseEvents[i];
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
lastProgressIdx[e.step] = i;
}
}
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
return sseEvents.filter((e, i) => {
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
if (e.status === "started") {
return !sseEvents.slice(i + 1).some(
(later) => later.step === e.step && terminalStatuses.has(later.status),
);
}
return true;
});
}, [sseEvents]);
const handleRetry = async () => {
if (!id || !patch || retrying) return;
setRetrying(true);
try {
await patch(id, { status: "pending" });
refetchRequest();
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setRetrying(false);
}
};
const handleDelete = async () => {
if (!id || !remove || deleting) return;
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeleting(true);
try {
await remove(id);
showToast("Fetch request deleted");
navigate("/fetch-requests");
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeleting(false);
}
};
const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = (req?.status ?? "pending") as FetchRequestStatus;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
const displayTitle = useMemo(() => {
if (!resource || !req) return "";
const resolved = Object.fromEntries(
resource.orderedFields.map((field) => {
const value = req[field.name];
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) return [field.name, applyDisplayFormat(value, target.displayFormat)];
}
return [field.name, value];
}),
);
return applyDisplayFormat(resolved, resource.displayFormat);
}, [resource, req, allResources]);
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
<CircularProgress />
</Box>
);
}
if (fetchError || !fetchRequest) {
return (
<Container sx={{ mt: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: "Details" }]}
title="Fetch Request"
/>
<Alert severity="error">Failed to load fetch request</Alert>
</Container>
);
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: displayTitle }]}
title={displayTitle}
subtitle="Live pipeline status, progress events, and ambiguity resolution for this import."
/>
<Section
title="Overview"
action={
<Chip
icon={statusIcons[status] as any}
label={status.replace(/_/g, " ")}
color={statusColors[status]}
sx={{ textTransform: "capitalize" }}
/>
}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip
label={"path" in (req.source ?? {}) ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in (req.source ?? {}) ? "primary" : "secondary"}
/>
<Typography variant="caption" color="text.secondary">
ID: {req.id}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
{detailFields.map((field) => {
const value = req[field.name];
let fmt = resource?.displayFormat;
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) fmt = target.displayFormat;
}
return (
<DetailFieldRenderer
key={field.name}
field={field}
value={value}
displayFormat={fmt}
/>
);
})}
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary">
Retries: {retryCount}/{RETRY_MAX}
</Typography>
</Box>
{status === "failed" && !isRetryExhausted && (
<Button
variant="outlined"
size="small"
startIcon={<ReplayIcon />}
onClick={handleRetry}
disabled={retrying}
>
Retry
</Button>
)}
<Tooltip title={status === "processing" ? "Cannot delete while processing" : "Delete fetch request"}>
<span>
<Button
variant="outlined"
size="small"
color="error"
startIcon={<DeleteOutlineIcon />}
onClick={handleDelete}
disabled={deleting || status === "processing"}
>
Delete
</Button>
</span>
</Tooltip>
</Box>
</Section>
{status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message}
</Alert>
)}
{isRetryExhausted && status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached — no further retry attempts will be made.
</Alert>
)}
<PipelineStepper
fetchRequest={req}
sseEvents={sseEvents}
stepStats={stepStats}
liveParsedCount={liveParsedCount ?? 0}
/>
<Section
title="Progress Events"
action={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
}
>
<Box
ref={feedRef}
sx={{
maxHeight: 300,
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 1,
pr: 0.5,
}}
>
{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.25,
borderRadius: 2,
bgcolor: "action.hover",
}}
>
{sseIcon(evt.status)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{evt.step.replace(/_/g, " ")}
</Typography>
{evt.message && formatProgressMessage(evt.message) && (
<Typography variant="caption" color="text.secondary">
{formatProgressMessage(evt.message)}
</Typography>
)}
</Box>
</Box>
))
)}
</Box>
</Section>
<AmbiguityResolver fetchRequestId={id!} />
</Container>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,52 +1,36 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import {AppBar, Box, Button, IconButton, Toolbar, Tooltip} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import LogoutIcon from "@mui/icons-material/Logout";
function Copyright() {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Link color="text.secondary" href="https://www.aetoskia.com/">
{'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
</Link>
&nbsp;
{new Date().getFullYear()}
</Typography>
);
}
export default function Footer() {
return (
<AppBar
position="fixed"
color="default"
<Box
component="footer"
sx={{
top: 'auto',
bottom: 0,
backdropFilter: 'blur(8px)',
boxShadow: 'none',
borderTop: '1px solid',
borderColor: 'divider',
py: 3,
mt: 'auto',
}}
>
<Toolbar>
<React.Fragment>
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: { xs: 2, sm: 4 },
textAlign: { sm: 'center', md: 'left' },
}}
>
<Copyright />
</Container>
</React.Fragment>
</Toolbar>
</AppBar>
<Container
maxWidth="lg"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Typography variant="body2" color="text.secondary" fontWeight={500}>
Khata
</Typography>
<Typography variant="caption" color="text.secondary">
© {new Date().getFullYear()} Aetoskia Internal Infrastructure — All rights reserved.
</Typography>
</Container>
</Box>
);
}
}

View File

@@ -1,8 +1,5 @@
import * as React from "react";
import {
useLocation,
matchPath
} from "react-router-dom";
import { useLocation, matchPath, useNavigate } from "react-router-dom";
import {
AppBar,
Toolbar,
@@ -11,6 +8,11 @@ import {
Tooltip,
Button,
Box,
Drawer,
List,
ListItemButton,
ListItemText,
Divider,
useMediaQuery,
useTheme,
} from "@mui/material";
@@ -18,7 +20,6 @@ import MenuIcon from "@mui/icons-material/Menu";
import LogoutIcon from "@mui/icons-material/Logout";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../react-auth";
import { ColorModeContext } from "./shared-theme/AppTheme";
@@ -30,136 +31,261 @@ interface HeaderProps {
onDrawerToggle?: () => void;
}
export default function Header({
routerMapping,
onDrawerToggle,
}: HeaderProps) {
const location = useLocation();
const matchedRoute = routerMapping.find((route) =>
matchPath({ path: route.path, end: false }, location.pathname)
);
const headerTitle = matchedRoute?.headerTitle ?? "Khata";
const NAV_LINKS = [
{ label: "Home", path: "/" },
{ label: "Expenses", path: "/expenses" },
{ label: "Reports", path: "/reports" },
{ label: "Fetch Requests", path: "/fetch-requests" },
];
function isActive(path: string, locationPath: string): boolean {
if (path === "/") return locationPath === "/" || locationPath === "/home";
return matchPath({ path, end: false }, locationPath) !== null;
}
export default function Header({ routerMapping, onDrawerToggle }: HeaderProps) {
const location = useLocation();
const navigate = useNavigate();
const { currentUser, logout } = useAuth();
const { mode, toggleColorMode } = React.useContext(ColorModeContext);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [mobileOpen, setMobileOpen] = React.useState(false);
const isAuthenticated = !!currentUser;
return (
<AppBar
position="fixed"
color="default"
const navContent = (
<Box
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
backdropFilter: "blur(8px)",
boxShadow: "none",
borderBottom: "1px solid",
borderColor: "divider",
display: { xs: "none", md: "flex" },
alignItems: "center",
gap: 0.5,
ml: 3,
}}
>
<Toolbar>
{/* MOBILE MENU BUTTON */}
{isMobile && onDrawerToggle && (
<IconButton
{NAV_LINKS.map((link) => {
const active = isActive(link.path, location.pathname);
return (
<Button
key={link.path}
color="inherit"
edge="start"
onClick={onDrawerToggle}
sx={{ mr: 2 }}
onClick={() => navigate(link.path)}
sx={{
textTransform: "none",
fontWeight: active ? 600 : 500,
color: active ? "text.primary" : "text.secondary",
px: 1.5,
borderRadius: "6px",
position: "relative",
"&::after":
active
? {
content: '""',
position: "absolute",
left: 12,
right: 12,
bottom: 4,
height: 2,
borderRadius: 1,
backgroundColor: "primary.main",
}
: {},
"&:hover": { backgroundColor: "action.hover", color: "text.primary" },
}}
>
<MenuIcon />
</IconButton>
)}
{link.label}
</Button>
);
})}
</Box>
);
{/* THEME TOGGLE */}
<IconButton onClick={toggleColorMode} color="inherit" sx={{ mr: 2 }}>
{mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
{/* TITLE */}
<Typography
variant="h6"
noWrap
sx={{ fontWeight: "bold", cursor: "pointer" }}
onClick={() => navigate("/")}
>
{headerTitle}
</Typography>
<span style={{ flexGrow: 1 }} />
{/* NAV LINKS */}
<Box
sx={{
display: { xs: "none", md: "flex" },
alignItems: "center",
mr: 2,
gap: 1,
}}
>
{[
{ label: "Dashboard", path: "/dashboard" },
{ label: "Fetch", path: "/fetch-requests" },
{ label: "Reports", path: "/reports" },
].map(({ label, path }) => (
<Button
key={path}
color="inherit"
onClick={() => navigate(path)}
sx={{ textTransform: "none", fontWeight: 500, px: 1.5 }}
size="small"
>
{label}
</Button>
))}
</Box>
{/* AUTH SECTION */}
{isAuthenticated ? (
<>
<Box
sx={{
display: { xs: "none", sm: "flex" },
alignItems: "center",
mr: 2,
}}
>
<Button
color="inherit"
onClick={() => navigate("/admin")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
Admin
</Button>
<Button
color="inherit"
onClick={() => navigate("/admin/profile")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
{currentUser.username}
</Button>
</Box>
<Tooltip title="Logout">
<IconButton color="inherit" onClick={logout}>
<LogoutIcon />
</IconButton>
</Tooltip>
</>
) : (
const authSection = (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{isAuthenticated ? (
<>
<Button
color="inherit"
variant="outlined"
onClick={() => navigate("/admin")}
sx={{ textTransform: "none" }}
sx={{
textTransform: "none",
fontWeight: isActive("/admin", location.pathname) ? 600 : 500,
color: isActive("/admin", location.pathname) ? "text.primary" : "text.secondary",
display: { xs: "none", sm: "inline-flex" },
}}
>
Login
Admin
</Button>
)}
</Toolbar>
</AppBar>
<Button
color="inherit"
onClick={() => navigate("/profile/me")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
{currentUser.username}
</Button>
<Tooltip title="Logout">
<IconButton color="inherit" onClick={logout} aria-label="Logout">
<LogoutIcon />
</IconButton>
</Tooltip>
</>
) : (
<Button
color="inherit"
variant="outlined"
onClick={() => navigate("/login")}
sx={{ textTransform: "none" }}
>
Login
</Button>
)}
</Box>
);
}
const drawerContent = (
<Box sx={{ width: 260 }} role="presentation">
<Box sx={{ p: 2 }}>
<Brand />
</Box>
<Divider />
<List>
{routerMapping
.filter((r) => !r.path.startsWith("/login") && !r.path.startsWith("/register") && !r.path.startsWith("/profile"))
.map((route) => {
const active = isActive(route.path, location.pathname);
return (
<ListItemButton
key={route.path}
selected={active}
onClick={() => {
navigate(route.path);
setMobileOpen(false);
}}
sx={{
borderRadius: "6px",
mx: 1,
mb: 0.5,
"&.Mui-selected": {
backgroundColor: "action.selected",
},
}}
>
<ListItemText
primary={route.headerTitle}
primaryTypographyProps={{ fontWeight: active ? 600 : 500, fontSize: 14 }}
/>
</ListItemButton>
);
})}
<Divider sx={{ my: 1 }} />
{isAuthenticated ? (
<>
<ListItemButton onClick={() => navigate("/profile/me")} sx={{ borderRadius: "6px", mx: 1 }}>
<ListItemText primary="Profile" primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
<ListItemButton onClick={logout} sx={{ borderRadius: "6px", mx: 1 }}>
<ListItemText primary="Logout" primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
</>
) : (
<ListItemButton onClick={() => navigate("/login")} sx={{ borderRadius: "6px", mx: 1 }}>
<ListItemText primary="Login" primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
)}
</List>
</Box>
);
return (
<>
<AppBar
position="sticky"
color="default"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
backdropFilter: "blur(8px)",
boxShadow: "none",
borderBottom: "1px solid",
borderColor: "divider",
backgroundColor: "background.default",
}}
>
<Toolbar>
{/* MOBILE MENU */}
{isMobile && (
<IconButton
color="inherit"
edge="start"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
sx={{ mr: 1 }}
>
<MenuIcon />
</IconButton>
)}
{/* BRAND — always leftmost */}
<Brand onClick={() => navigate("/")} />
{navContent}
<Box sx={{ flexGrow: 1 }} />
{/* THEME TOGGLE — right side */}
<IconButton onClick={toggleColorMode} color="inherit" aria-label="Toggle color mode" sx={{ mr: 1 }}>
{mode === "dark" ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
{authSection}
</Toolbar>
</AppBar>
<Drawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
>
{drawerContent}
</Drawer>
</>
);
}
function Brand({ onClick }: { onClick?: () => void }) {
return (
<Box
onClick={onClick}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
cursor: onClick ? "pointer" : "default",
userSelect: "none",
}}
>
<Box
sx={{
width: 28,
height: 28,
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #635BFF 0%, #8B5CF6 100%)",
color: "#fff",
fontWeight: 700,
fontSize: 15,
}}
>
K
</Box>
<Typography
variant="h6"
sx={{ fontWeight: 700, letterSpacing: "-0.02em", color: "text.primary" }}
>
Khata
</Typography>
</Box>
);
}

View File

@@ -1,11 +1,11 @@
import * as React from "react";
import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { Box, Typography, Button, Container, Grid, Paper, alpha } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/Dashboard";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import SyncIcon from "@mui/icons-material/Sync";
import BarChartIcon from "@mui/icons-material/BarChart";
import SettingsIcon from "@mui/icons-material/Settings";
import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { useAuth } from "../react-auth";
@@ -14,11 +14,9 @@ interface FeatureCardProps {
title: string;
description: string;
path: string;
label?: string;
accent: string;
}
function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) {
function FeatureCard({ icon, title, description, path }: FeatureCardProps) {
const navigate = useNavigate();
const theme = useTheme();
@@ -35,208 +33,156 @@ function FeatureCard({ icon, title, description, path, label, accent }: FeatureC
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",
},
transition: "border-color 180ms ease, box-shadow 180ms ease, transform 180ms 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 },
transform: "translateY(-2px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.35 : 0.08)}`,
"& .feature-arrow": {
opacity: 1,
transform: "translateX(0)",
},
},
}}
>
<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
sx={{
width: 40,
height: 40,
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "action.hover",
color: "primary.main",
mb: 2,
}}
>
{icon}
</Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 0.75 }}>
{title}
</Typography>
<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" }}
/>
)}
<Box
className="feature-arrow"
sx={{
mt: 2,
display: "flex",
alignItems: "center",
gap: 0.5,
color: "primary.main",
fontWeight: 600,
opacity: 0,
transform: "translateX(-4px)",
transition: "opacity 180ms ease, transform 180ms ease",
}}
>
<Typography variant="caption" fontWeight={600} color="primary">
Open
</Typography>
<ArrowForwardIcon sx={{ fontSize: 14 }} />
</Box>
</Paper>
);
}
export default function Home() {
const navigate = useNavigate();
const theme = useTheme();
const { currentUser } = useAuth();
const features = [
{
icon: <DashboardIcon />,
title: "Dashboard",
description: "Visualise inflows and outflows with interactive charts, drill into categories, and track trends over daily, weekly, and monthly periods.",
path: "/dashboard",
accent: theme.palette.mode === "dark" ? "#818cf8" : "#6366f1",
icon: <ReceiptLongIcon />,
title: "Expenses",
description:
"Browse every transaction imported from your accounts — grouped by month, with entity, account, tags, and amount at a glance.",
path: "/expenses",
},
{
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.",
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.",
icon: <AccountBalanceWalletIcon />,
title: "Accounts & Entities",
description:
"Manage your accounts, expenses, tags, and payors through the OpenAPI-driven admin panel with full CRUD and server-side filters.",
path: "/admin",
accent: theme.palette.mode === "dark" ? "#e879f9" : "#d946ef",
},
{
icon: <AccountCircleIcon />,
title: "Profile",
description:
"View and edit your user profile — username, email, and account preferences — from the profile page.",
path: "/profile/me",
},
];
return (
<Box
sx={{
minHeight: "calc(100vh - 64px)",
display: "flex",
flexDirection: "column",
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
top: "-15%",
left: "-8%",
width: "45%",
height: "55%",
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)",
zIndex: 0,
},
"&::after": {
content: '""',
position: "absolute",
bottom: "-15%",
right: "-8%",
width: "45%",
height: "55%",
background: "radial-gradient(circle, rgba(236,72,153,0.1) 0%, transparent 70%)",
zIndex: 0,
},
}}
>
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}>
<Box
sx={{
textAlign: "center",
mb: 6,
}}
>
<Box sx={{ width: "100%" }}>
<Container maxWidth="lg" sx={{ py: { xs: 6, md: 10 } }}>
<Box sx={{ maxWidth: 680, mx: { xs: "auto", md: 0 }, textAlign: { xs: "center", md: "left" } }}>
<Typography
variant="overline"
sx={{ color: "primary.main", mb: 1.5, display: "block", fontWeight: 700 }}
>
Khata · Financial Ledger
</Typography>
<Typography
variant="h1"
sx={{
fontWeight: 800,
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
color: "text.primary",
fontSize: { xs: "2.25rem", md: "3.25rem" },
letterSpacing: "-0.03em",
mb: 2,
}}
>
Welcome to Khata
Your intelligent, extensible financial ledger.
</Typography>
<Typography
variant="h6"
variant="body1"
color="text.secondary"
sx={{
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
sx={{ maxWidth: 540, lineHeight: 1.7, fontSize: "1.0625rem" }}
>
Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow.
Import transactions from bank statements, enrich them with entity and tag intelligence,
and stay on top of your cashflow — all in one place.
</Typography>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: { xs: "center", md: "flex-start" }, flexWrap: "wrap" }}>
<Button
variant="contained"
size="large"
endIcon={<ArrowForwardIcon />}
onClick={() => navigate("/dashboard")}
sx={{
px: 4,
py: 1.4,
borderRadius: "50px",
fontWeight: 700,
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)",
transition: "transform 0.2s ease, box-shadow 0.2s",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`,
},
}}
onClick={() => navigate(currentUser ? "/fetch-requests" : "/login")}
sx={{ px: 3.5, py: 1.4, fontWeight: 600 }}
>
Enter Dashboard
{currentUser ? "Enter App" : "Get Started"}
</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 },
}}
sx={{ px: 3.5, py: 1.4, fontWeight: 600 }}
>
Import Data
</Button>
</Box>
</Box>
<Grid container spacing={3}>
<Grid container spacing={3} sx={{ mt: { xs: 4, md: 8 } }}>
{features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
<Grid key={f.title} item xs={12} sm={6} md={3}>
<FeatureCard {...f} />
</Grid>
))}

View File

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

View File

@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Paper,
Typography,
Button,
Grid,
Alert,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import {
useResource,
useAppContext,
FormFieldRenderer,
extractFields,
useFkFieldOptions,
} from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { useToast } from "../ui/Toast";
import { apiErrorMessage } from "./types";
interface GenerateReportPanelProps {
onGenerated: (report: any) => void;
}
export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
const { schemas } = useAppContext();
const { create, loading: submitting } = useResource("reports");
const { showToast } = useToast();
const [formData, setFormData] = useState<Record<string, any>>({});
const [error, setError] = useState<string | null>(null);
const fields: FieldConfig[] = useMemo(() => {
const schema = schemas?.ReportQuery;
if (!schema) return [];
return extractFields("ReportQuery", schema, schemas).sort(
(a, b) => a.order - b.order || a.name.localeCompare(b.name)
);
}, [schemas]);
const { fkOptions, fkLoading, loadFkOnOpen } = useFkFieldOptions(fields);
useEffect(() => {
setFormData((prev) => {
const next = { ...prev };
let changed = false;
for (const f of fields) {
if (next[f.name] === undefined && f.defaultValue !== undefined) {
next[f.name] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue;
changed = true;
}
}
return changed ? next : prev;
});
}, [fields]);
const handleChange = useCallback((name: string, value: any) => {
setFormData((prev) => ({ ...prev, [name]: value }));
setError(null);
}, []);
const buildPayload = (): Record<string, any> => {
const payload: Record<string, any> = {};
for (const f of fields) {
const v = formData[f.name];
if (v === undefined || v === null || v === "") continue;
if (Array.isArray(v) && v.length === 0) continue;
payload[f.name] = v;
}
return payload;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
const created = await create(buildPayload());
showToast(`Generated snapshot ${created?.name ? `“${created.name}”` : ""}`.trim() || "Generated snapshot");
onGenerated(created);
} catch (e: any) {
setError(apiErrorMessage(e));
}
};
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3, mb: 4 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
Generate report
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>
Define the snapshot's scope. Granularity, payee and tag are sliced at view time — the cube is built once and
every combination stays cheap to read.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={2}>
{fields.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<FormFieldRenderer
field={field}
value={formData[field.name]}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
fkLoading={fkLoading[field.name]}
onFkOpen={loadFkOnOpen}
/>
</Grid>
))}
</Grid>
<Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" startIcon={<AddIcon />} disabled={submitting}>
{submitting ? "Generating…" : "Generate"}
</Button>
</Box>
</Box>
</Paper>
);
}

184
src/Reports/Report.tsx Normal file
View File

@@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Container,
Box,
Paper,
Typography,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import AssessmentIcon from "@mui/icons-material/Assessment";
import { useResource, useAppContext } from "../../react-openapi";
import { useToast } from "../ui/Toast";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { StatCard } from "../common/components/StatCard";
import { buildTxnFieldConfigs } from "../common/utils/fieldConfigs";
import { GenerateReportPanel } from "./GenerateReportPanel";
import { ReportList } from "./ReportList";
import { ReportViewer } from "./ReportViewer";
import { apiErrorMessage, buildReportFieldConfigs } from "./types";
export default function Report() {
const { resources } = useAppContext();
const { showToast } = useToast();
const { list, loading, error } = useResource("reports");
const { create, remove } = useResource("reports");
const [reports, setReports] = useState<any[] | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [viewerVersion, setViewerVersion] = useState(0);
const reportFields = useMemo(() => buildReportFieldConfigs(resources), [resources]);
const txnFields = useMemo(() => buildTxnFieldConfigs(resources), [resources]);
const load = useCallback(async () => {
const res = await list({ limit: 200 });
setReports(res.items ?? []);
}, [list]);
useEffect(() => {
load();
}, [load]);
const handleGenerated = useCallback(
(created: any) => {
load();
if (created?.id) setSelectedId(created.id);
},
[load],
);
const handleRegenerate = useCallback(
async (report: any) => {
try {
const created = await create(report.query ?? {});
showToast("Report regenerated");
setSelectedId(created?.id ?? report.id);
setViewerVersion((v) => v + 1);
load();
} catch (e: any) {
showToast(apiErrorMessage(e), "error");
}
},
[create, load, showToast],
);
const handleDelete = useCallback(
async (id: string) => {
try {
await remove(id);
showToast("Report deleted");
if (selectedId === id) setSelectedId(null);
load();
} catch (e: any) {
showToast(apiErrorMessage(e), "error");
}
},
[remove, load, selectedId, showToast],
);
const summary = useMemo(() => {
const rows = reports ?? [];
const granularities = new Set<string>();
for (const r of rows) for (const g of r.query?.granularities ?? []) granularities.add(g);
return { count: rows.length, granularities: [...granularities].join(", ") };
}, [reports]);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Reports" }]}
title="Reports"
subtitle="Build an immutable snapshot cube once, then slice by granularity, period, payee and tag at view time."
/>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
Failed to load reports: {error}
</Alert>
)}
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
<StatCard label="Reports" value={summary.count.toLocaleString("en-IN")} />
<StatCard label="Granularities covered" value={summary.granularities || "—"} />
</Box>
<GenerateReportPanel onGenerated={handleGenerated} />
<Accordion
disableGutters
defaultExpanded
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
backgroundColor: "background.paper",
"&:before": { display: "none" },
mb: 4,
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
px: 2.5,
py: 1,
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 1.5,
minWidth: 0,
},
}}
>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
Saved reports
</Typography>
{reports !== null && (
<Typography variant="caption" color="text.secondary">
{reports.length} report{reports.length === 1 ? "" : "s"}
</Typography>
)}
</AccordionSummary>
<AccordionDetails sx={{ px: 2.5, pb: 2.5, pt: 0 }}>
{reports !== null && reports.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
<EmptyState
icon={<AssessmentIcon />}
title="No reports yet"
description="Generate your first snapshot above — choose granularities and grouping dimensions, then slice the cached cube by period, payee and tag."
/>
</Paper>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<ReportList
reports={reports ?? []}
loading={loading}
fields={reportFields}
selectedId={selectedId}
onView={(id) => setSelectedId(id)}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
/>
</Box>
)}
</AccordionDetails>
</Accordion>
{selectedId && (
<ReportViewer
key={selectedId}
id={selectedId}
version={viewerVersion}
fields={txnFields}
onClose={() => setSelectedId(null)}
onRegenerated={handleRegenerate}
/>
)}
</Container>
);
}

116
src/Reports/ReportList.tsx Normal file
View File

@@ -0,0 +1,116 @@
import React from "react";
import { Box, Paper, Typography, Button, IconButton, Skeleton, Tooltip } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import VisibilityIcon from "@mui/icons-material/Visibility";
import CachedIcon from "@mui/icons-material/Cached";
import { ListCellRenderer } from "../../react-openapi";
import type { ReportFieldConfigs } from "./types";
interface ReportListProps {
reports: any[];
loading: boolean;
fields: ReportFieldConfigs | null;
selectedId: string | null;
onView: (id: string) => void;
onRegenerate: (report: any) => void;
onDelete: (id: string) => void;
}
export function ReportList({ reports, loading, fields, selectedId, onView, onRegenerate, onDelete }: ReportListProps) {
if (loading && reports.length === 0) {
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="rounded" height={88} sx={{ borderRadius: 2 }} />
))}
</Box>
);
}
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{reports.map((report) => {
const q = report.query ?? {};
const range = q.start_date || q.end_date ? `range ${q.start_date || "…"} → ${q.end_date || "…"}` : null;
const dims = Array.isArray(q.group_dims) ? q.group_dims.join(", ") : "";
const granularities = Array.isArray(q.granularities) ? q.granularities.join(", ") : "";
const accounts = Array.isArray(q.accounts) ? `${q.accounts.length} account${q.accounts.length === 1 ? "" : "s"}` : "all accounts";
const amounts =
q.min_amount != null || q.max_amount != null
? `amount ${q.min_amount ?? "0"} → ${q.max_amount ?? "∞"}`
: null;
return (
<Paper
key={report.id}
variant="outlined"
sx={{
p: 2,
borderRadius: 2,
borderColor: selectedId === report.id ? "primary.main" : "divider",
transition: "border-color 160ms ease",
"&:hover": { borderColor: "primary.light" },
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 260px", minWidth: 0 }}>
<Typography variant="body1" fontWeight={600} noWrap sx={{ fontSize: "0.9375rem" }}>
{report.name || report.id}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", mt: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{granularities}
</Typography>
<Typography variant="caption" color="text.disabled">
/
</Typography>
<Typography variant="caption" color="text.secondary">
{dims}
</Typography>
{range && (
<Typography variant="caption" color="text.disabled">
{range}
</Typography>
)}
</Box>
<Box sx={{ display: "flex", gap: 1.5, mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{accounts}
</Typography>
{amounts && (
<Typography variant="caption" color="text.secondary">
{amounts}
</Typography>
)}
{fields && (
<Box sx={{ color: "text.secondary" }}>
<ListCellRenderer field={fields.generatedAt} value={report.generated_at} />
</Box>
)}
</Box>
</Box>
<Box sx={{ display: "flex", gap: 0.5, flexShrink: 0 }}>
<Button size="small" variant="contained" startIcon={<VisibilityIcon />} onClick={() => onView(report.id)}>
View
</Button>
<Button
size="small"
variant="outlined"
startIcon={<CachedIcon />}
onClick={() => onRegenerate(report)}
>
Regenerate
</Button>
<Tooltip title="Delete">
<IconButton aria-label="Delete report" onClick={() => onDelete(report.id)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Paper>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,502 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Paper, Typography, Button, IconButton, Alert, Skeleton, Chip, MenuItem, Select, FormControl, InputLabel } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import CachedIcon from "@mui/icons-material/Cached";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { FkMultiSelectField, useResource, formatCurrency } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { StatCard } from "../common/components/StatCard";
import { TransactionList } from "../common/components/TransactionList";
import type { TxnFieldConfigs } from "../common/types";
import type { ListPeriodGroup } from "../common/utils/transactions";
import { buildDimensionBars, buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types";
import type { ReportDim } from "./types";
const MAX_VISIBLE_BARS = 10;
const periodField: FieldConfig = {
name: "period",
label: "Period",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
const payeeField: FieldConfig = {
name: "payee",
label: "Payee",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
const tagField: FieldConfig = {
name: "tag",
label: "Tag",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
interface ReportViewerProps {
id: string;
version: number;
fields: TxnFieldConfigs | null;
onClose: () => void;
onRegenerated: (report: any) => void;
}
function snapshotGranularities(report: any): string[] {
const fromQuery = report?.query?.granularities;
if (Array.isArray(fromQuery) && fromQuery.length) return fromQuery;
const fromResponse = report?.granularities;
if (Array.isArray(fromResponse) && fromResponse.length) return fromResponse;
const series = report?.buckets?.[0]?.series;
return series ? Object.keys(series) : [];
}
export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) {
const { get } = useResource("reports");
const [report, setReport] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [granularity, setGranularity] = useState<string | null>(null);
const [flow, setFlow] = useState("outflows");
const [dim, setDim] = useState<ReportDim>("period");
const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const prevGranularity = useRef<string | null>(null);
const params = useMemo(() => {
const p: Record<string, any> = { flow };
if (granularity) p.granularity = [granularity];
if (selectedPeriods.length) p.period_ids = selectedPeriods;
// The active breakdown dim requests every cube value ("*") when its own
// filter is unselected, so bars show the full distribution.
if (selectedPayees.length) p.payee = selectedPayees;
else if (dim === "payee") p.payee = ["*"];
if (selectedTags.length) p.tags = selectedTags;
else if (dim === "tag") p.tags = ["*"];
return p;
}, [granularity, flow, dim, selectedPeriods, selectedPayees, selectedTags]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
const previous = prevGranularity.current;
get(id, params)
.then((res) => {
if (!mounted) return;
setReport(res);
const options = snapshotGranularities(res);
if (granularity === null && options.length) setGranularity(options[0]);
if (previous !== null && previous !== granularity) setSelectedPeriods([]);
prevGranularity.current = granularity;
})
.catch((e: any) => {
if (!mounted) return;
setError(apiErrorMessage(e));
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, [id, version, reload, params, get, granularity]);
const granularityOptions = useMemo(() => snapshotGranularities(report), [report]);
const periodOptions = useMemo(
() => (Array.isArray(report?.period_ids) ? report.period_ids.map((label: string) => ({ value: label, label })) : []),
[report],
);
const payeeOptions = useMemo(
() => (Array.isArray(report?.payees) ? report.payees.map((label: string) => ({ value: label, label })) : []),
[report],
);
const tagOptions = useMemo(
() => (Array.isArray(report?.tags) ? report.tags.map((label: string) => ({ value: label, label })) : []),
[report],
);
const filter = useMemo(
() => ({
granularity: granularity ?? granularityOptions[0] ?? "",
periods: selectedPeriods,
payees: selectedPayees,
tags: selectedTags,
}),
[granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags],
);
const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]);
const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]);
const bars = useMemo(() => buildDimensionBars(report?.buckets ?? [], filter, dim), [report, filter, dim]);
const visibleBars = useMemo(() => bars.slice(0, MAX_VISIBLE_BARS), [bars]);
const listGroups = useMemo<ListPeriodGroup[]>(
() =>
periodGroups.map((g) => ({
key: g.key,
label: g.key,
items: g.txns,
spent: g.metrics.outflows,
income: g.metrics.inflows,
currency: g.currency,
metrics: {
sum: g.metrics.sum,
count: g.metrics.count,
avg: g.metrics.avg,
min: g.metrics.min,
max: g.metrics.max,
cadence: g.metrics.cadence,
frequency: g.metrics.frequency,
},
})),
[periodGroups],
);
const barsListRef = useRef<HTMLDivElement>(null);
const [barScroll, setBarScroll] = useState({ canUp: false, canDown: false, overflow: false });
const updateBarScroll = useCallback(() => {
const el = barsListRef.current;
if (!el) return;
const overflow = el.scrollHeight > el.clientHeight + 2;
setBarScroll({
overflow,
canUp: overflow && el.scrollTop > 2,
canDown: overflow && el.scrollTop < el.scrollHeight - el.clientHeight - 2,
});
}, []);
useEffect(() => {
const el = barsListRef.current;
if (el) el.scrollTop = 0;
updateBarScroll();
}, [dim, visibleBars.length, updateBarScroll]);
const scrollBarsByPage = useCallback((dir: number) => {
const el = barsListRef.current;
if (!el) return;
el.scrollTo({ top: dir > 0 ? el.scrollHeight : 0, behavior: "smooth" });
}, []);
if (loading && !report) {
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
<Skeleton variant="text" width={220} height={28} />
<Skeleton variant="rounded" height={120} sx={{ my: 2, borderRadius: 2 }} />
<Skeleton variant="rounded" height={240} sx={{ borderRadius: 2 }} />
</Paper>
);
}
if (error) {
return (
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3 }}>
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
<Button size="small" sx={{ mt: 2 }} onClick={() => setReload((r) => r + 1)}>
Retry
</Button>
</Paper>
);
}
if (!report) return null;
const activeGranularity = granularity ?? report.granularities?.[0] ?? "";
const maxBar = bars.reduce((m, b) => Math.max(m, Math.abs(b.sum)), 0);
const dimOptions: { id: ReportDim; label: string; count: number }[] = [
{ id: "period", label: "Period", count: report.period_ids?.length ?? 0 },
{ id: "payee", label: "Payees", count: report.payees?.length ?? 0 },
{ id: "tag", label: "Tags", count: report.tags?.length ?? 0 },
];
const activeDimIndex = Math.max(
0,
dimOptions.findIndex((d) => d.id === dim),
);
const range =
report.query?.start_date || report.query?.end_date
? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}`
: null;
return (
<Paper variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
px: 2.5,
py: 2,
borderBottom: "1px solid",
borderColor: "divider",
flexWrap: "wrap",
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{report.name}
</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<Typography variant="caption" color="text.secondary">
flow {report.flow} · generated {report.generated_at ?? report.created_at}
</Typography>
{range && (
<Typography variant="caption" color="text.disabled">
{range}
</Typography>
)}
<Typography variant="caption" color="text.disabled">
{report.payees?.length ?? 0} payees · {report.tags?.length ?? 0} tags
</Typography>
</Box>
</Box>
<IconButton aria-label="Regenerate report" onClick={() => onRegenerated(report)}>
<CachedIcon />
</IconButton>
<IconButton aria-label="Close report" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", alignItems: "center", mb: 2 }}>
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{granularityOptions.map((g: string) => (
<Chip
key={g}
label={g}
clickable
size="small"
color={activeGranularity === g ? "primary" : "default"}
variant={activeGranularity === g ? "filled" : "outlined"}
onClick={() => setGranularity(g)}
/>
))}
</Box>
<FormControl size="small" sx={{ width: 140 }}>
<InputLabel id="viewer-flow-label">Flow</InputLabel>
<Select labelId="viewer-flow-label" label="Flow" value={flow} onChange={(e) => setFlow(e.target.value)}>
{FLOW_OPTIONS.map((f) => (
<MenuItem key={f} value={f}>
{f}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2.5 }}>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...periodField, readOnly: periodOptions.length === 0 }}
fkOptions={periodOptions}
value={selectedPeriods}
onChange={setSelectedPeriods}
/>
</Box>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...payeeField, readOnly: payeeOptions.length === 0 }}
fkOptions={payeeOptions}
value={selectedPayees}
onChange={setSelectedPayees}
/>
</Box>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...tagField, readOnly: tagOptions.length === 0 }}
fkOptions={tagOptions}
value={selectedTags}
onChange={setSelectedTags}
/>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: "center" }}>
{slice.txns.length} transactions · {slice.count} rows
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 2.5 }}>
<StatCard label="Outflows" value={formatCurrency(slice.outflows, slice.currency)} color="error.main" />
<StatCard label="Inflows" value={formatCurrency(slice.inflows, slice.currency)} color="success.main" />
<StatCard label="Net" value={formatCurrency(slice.inflows - slice.outflows, slice.currency)} color="info.main" />
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box>
<Box sx={{ mb: 2.5 }}>
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "stretch",
width: "fit-content",
maxWidth: "100%",
border: "1px solid",
borderColor: "divider",
borderRadius: "999px",
p: 0.5,
backgroundColor: "background.default",
}}
>
<Box
aria-hidden
sx={{
position: "absolute",
top: 4,
bottom: 4,
left: 4,
width: `calc((100% - 8px) / ${dimOptions.length})`,
borderRadius: "999px",
backgroundColor: "primary.main",
transform: `translateX(${activeDimIndex * 100}%)`,
transition: "transform 250ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
{dimOptions.map((d) => {
const active = d.id === dim;
return (
<Box
key={d.id}
component="button"
type="button"
disabled={d.count <= 1}
onClick={() => setDim(d.id)}
sx={{
position: "relative",
zIndex: 1,
flex: 1,
border: "none",
background: "transparent",
px: 3,
py: 1,
borderRadius: "999px",
cursor: d.count <= 1 ? "default" : "pointer",
typography: "body2",
fontWeight: 700,
letterSpacing: "-0.01em",
color:
d.count <= 1
? "text.disabled"
: active
? "primary.contrastText"
: "text.secondary",
whiteSpace: "nowrap",
transition: "color 160ms ease",
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" },
}}
>
{d.label}
</Box>
);
})}
</Box>
{visibleBars.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mt: 1.5 }}>
<Typography variant="body2" color="text.secondary">
No data for this slice. Try another granularity, period or payer.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, mt: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "stretch" }}>
<Box
ref={barsListRef}
onScroll={updateBarScroll}
sx={{
flex: 1,
minWidth: 0,
maxHeight: 220,
overflowY: "auto",
py: 1,
px: 2,
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
>
{visibleBars.map((b) => (
<Box key={b.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{b.key}
</Typography>
<Box
sx={{
height: 20,
borderRadius: 1,
bgcolor: flow === "outflows" ? "error.main" : "success.main",
opacity: 0.85,
minWidth: 4,
flexGrow: 0,
}}
style={{
width: `${maxBar ? Math.max((Math.abs(b.sum) / maxBar) * 100, 2) : 2}%`,
}}
/>
<Typography variant="body2" fontWeight={600}>
{formatCurrency(b.sum, slice.currency)}
</Typography>
<Typography variant="caption" color="text.disabled">
{b.count} txn{b.count === 1 ? "" : "s"}
</Typography>
</Box>
))}
</Box>
{barScroll.overflow && (
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: 0.5, pr: 1.5 }}>
<IconButton
size="small"
aria-label="Scroll bars up"
disabled={!barScroll.canUp}
onClick={() => scrollBarsByPage(-1)}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Scroll bars down"
disabled={!barScroll.canDown}
onClick={() => scrollBarsByPage(1)}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
</Paper>
)}
</Box>
{slice.txns.length === 0 ? null : fields ? (
<TransactionList fields={fields} groups={listGroups} showMetrics />
) : null}
</Box>
</Paper>
);
}

288
src/Reports/types.ts Normal file
View File

@@ -0,0 +1,288 @@
import type { FieldConfig, ResourceConfig } from "../../react-openapi";
import { parseOccurredAt } from "../common/utils/dates";
export interface PeriodMetricsVM {
outflows: number;
inflows: number;
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
firstDate: string | null;
lastDate: string | null;
cadence: number | null;
frequency: number | null;
}
export interface ReportPeriodGroup {
key: string;
metrics: PeriodMetricsVM;
txns: any[];
currency: string;
}
export interface SliceSummary {
outflows: number;
inflows: number;
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
firstDate: string | null;
lastDate: string | null;
txns: any[];
currency: string;
}
export interface SliceFilter {
granularity: string;
periods?: string[];
payees?: string[];
tags?: string[];
}
export interface ReportFieldConfigs {
name: FieldConfig;
generatedAt: FieldConfig;
}
export interface MetricLabel {
key: string;
label: string;
order: number;
}
export function apiErrorMessage(e: any): string {
if (e?.response?.data) {
const d = e.response.data;
if (Array.isArray(d)) return d.map((x: any) => x?.msg ?? String(x)).join("; ");
if (typeof d.detail === "string") return d.detail;
if (typeof d.detail?.msg === "string") return d.detail.msg;
if (typeof d === "string") return d;
}
return e?.message ?? "Request failed";
}
export const FLOW_OPTIONS = ["both", "inflows", "outflows"];
export function metricLabels(schemas: Record<string, any>): MetricLabel[] {
const props: Record<string, any> = schemas?.ReportMetrics?.properties ?? {};
return Object.entries(props)
.filter(([, p]) => p && typeof p === "object")
.map(([key, p]) => ({
key,
label: (p as any)["x-label"] ?? key,
order: (p as any)["x-order"] ?? Infinity,
}))
.sort((a, b) => a.order - b.order);
}
function dateVal(value: string): number {
try {
return parseOccurredAt(value).getTime();
} catch {
return 0;
}
}
function bucketMatches(bucket: any, filter: SliceFilter): boolean {
const gk = bucket?.group_key ?? {};
if (filter.payees?.length && !(gk.payee ?? []).some((p: string) => filter.payees?.includes(p))) return false;
if (filter.tags?.length && !(gk.tag ?? []).some((t: string) => filter.tags?.includes(t))) return false;
return true;
}
function num(v: any): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/**
* Groups cube periods by their canonical period_id, merging buckets per
* period. The server returns disjoint slices, so additive metric merge is
* safe; txn ids are deduped defensively. Metrics come verbatim from the API
* except when multiple buckets contribute to one period — then cadence and
* frequency are re-derived from the merged txn dates.
*/
export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPeriodGroup[] {
interface Acc {
metrics: PeriodMetricsVM;
txns: any[];
sources: number;
apiCadence: number | null;
apiFrequency: number | null;
}
const byPeriod = new Map<string, Acc>();
const seenTxnIds = new Set<string>();
let currency = "INR";
for (const bucket of buckets ?? []) {
if (!bucketMatches(bucket, filter)) continue;
for (const period of bucket.series?.[filter.granularity] ?? []) {
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
const m = period.metrics ?? {};
let acc = byPeriod.get(period.period_id);
if (!acc) {
acc = {
metrics: {
outflows: 0,
inflows: 0,
sum: 0,
count: 0,
avg: null,
min: null,
max: null,
firstDate: null,
lastDate: null,
cadence: null,
frequency: null,
},
txns: [],
sources: 0,
apiCadence: num(m.cadence),
apiFrequency: num(m.frequency),
};
byPeriod.set(period.period_id, acc);
}
acc.sources += 1;
const vm = acc.metrics;
vm.outflows += m.outflows ?? 0;
vm.inflows += m.inflows ?? 0;
vm.sum += m.sum ?? 0;
vm.count += m.count ?? 0;
const mn = num(m.min);
if (mn != null) vm.min = vm.min == null ? mn : Math.min(vm.min, mn);
const mx = num(m.max);
if (mx != null) vm.max = vm.max == null ? mx : Math.max(vm.max, mx);
if (m.first_date && (!vm.firstDate || dateVal(String(m.first_date)) < dateVal(vm.firstDate))) {
vm.firstDate = String(m.first_date);
}
if (m.last_date && (!vm.lastDate || dateVal(String(m.last_date)) > dateVal(vm.lastDate))) {
vm.lastDate = String(m.last_date);
}
for (const txn of period.txns ?? []) {
if (txn?.id != null) {
if (seenTxnIds.has(txn.id)) continue;
seenTxnIds.add(txn.id);
}
acc.txns.push(txn);
const c = txn?.account?.currency;
if (c) currency = c;
}
}
}
return [...byPeriod.entries()]
.map(([key, acc]) => {
const vm = acc.metrics;
vm.avg = vm.count ? Math.round((vm.sum / vm.count) * 100) / 100 : null;
if (acc.sources > 1) {
const dates = acc.txns
.map((t) => {
try {
return parseOccurredAt(t?.occurred_at).getTime();
} catch {
return NaN;
}
})
.filter((t) => !Number.isNaN(t))
.sort((a, b) => a - b);
if (dates.length >= 2) {
let gapSum = 0;
for (let i = 0; i < dates.length - 1; i += 1) gapSum += (dates[i + 1] - dates[i]) / 86400000;
const cadence = Math.round((gapSum / (dates.length - 1)) * 100) / 100;
vm.cadence = cadence > 0 ? cadence : null;
vm.frequency = cadence > 0 ? Math.round((1 / cadence) * 100) / 100 : null;
} else {
vm.cadence = null;
vm.frequency = null;
}
} else {
vm.cadence = acc.apiCadence;
vm.frequency = acc.apiFrequency;
}
return { key, metrics: vm, txns: acc.txns, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
export function sliceSummary(groups: ReportPeriodGroup[]): SliceSummary {
const outflows = groups.reduce((s, g) => s + g.metrics.outflows, 0);
const inflows = groups.reduce((s, g) => s + g.metrics.inflows, 0);
const sum = groups.reduce((s, g) => s + g.metrics.sum, 0);
const count = groups.reduce((s, g) => s + g.metrics.count, 0);
let min: number | null = null;
let max: number | null = null;
let firstDate: string | null = null;
let lastDate: string | null = null;
let currency = "INR";
const txns: any[] = [];
for (const g of groups) {
if (g.metrics.min != null) min = min == null ? g.metrics.min : Math.min(min, g.metrics.min);
if (g.metrics.max != null) max = max == null ? g.metrics.max : Math.max(max, g.metrics.max);
if (g.metrics.firstDate && (!firstDate || dateVal(g.metrics.firstDate) < dateVal(firstDate))) {
firstDate = g.metrics.firstDate;
}
if (g.metrics.lastDate && (!lastDate || dateVal(g.metrics.lastDate) > dateVal(lastDate))) {
lastDate = g.metrics.lastDate;
}
if (g.currency) currency = g.currency;
txns.push(...g.txns);
}
return { outflows, inflows, sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, currency };
}
export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null {
const reportsRes = resources.find((r) => r.name === "reports");
if (!reportsRes) return null;
const find = (name: string) => reportsRes.fields.find((f) => f.name === name);
const name = find("name");
const generatedAt = find("generated_at");
if (!name || !generatedAt) return null;
return { name, generatedAt };
}
export type ReportDim = "period" | "payee" | "tag";
export interface DimensionBar {
key: string;
sum: number;
count: number;
}
/**
* Bar-chart rows for a given dimension. "period" reuses the period groups;
* "payee"/"tag" fold each bucket's metrics into every value listed under
* group_key[dim] — safe because server buckets are disjoint slices.
*/
export function buildDimensionBars(buckets: any[], filter: SliceFilter, dim: ReportDim): DimensionBar[] {
if (dim === "period") {
return buildPeriodGroups(buckets, filter).map((g) => ({
key: g.key,
sum: g.metrics.sum,
count: g.metrics.count,
}));
}
const acc = new Map<string, DimensionBar>();
for (const bucket of buckets ?? []) {
if (!bucketMatches(bucket, filter)) continue;
const keys: string[] = bucket.group_key?.[dim] ?? [];
for (const period of bucket.series?.[filter.granularity] ?? []) {
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
const m = period.metrics ?? {};
const sum = typeof m.sum === "number" ? m.sum : 0;
const count = typeof m.count === "number" ? m.count : 0;
for (const k of keys) {
const cur = acc.get(k);
if (cur) {
cur.sum += sum;
cur.count += count;
} else {
acc.set(k, { key: k, sum, count });
}
}
}
}
return [...acc.values()].sort((a, b) => Math.abs(b.sum) - Math.abs(a.sum));
}

View File

@@ -0,0 +1,26 @@
import { Paper, Typography } from "@mui/material";
interface StatCardProps {
label: string;
value: string;
color?: string;
hint?: string;
}
export function StatCard({ label, value, color, hint }: StatCardProps) {
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3, flex: 1, minWidth: 180 }}>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ mt: 0.5, letterSpacing: "-0.02em", color }}>
{value}
</Typography>
{hint && (
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
)}
</Paper>
);
}

View File

@@ -0,0 +1,356 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Menu,
MenuItem,
ListItemText,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { formatCurrency } from "../../../react-openapi";
import type { ExpenseItem, TxnFieldConfigs } from "../types";
import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions";
import type { ListGroupMetrics, ListPeriodGroup, PeriodGranularity } from "../utils/transactions";
import { TransactionRow } from "./TransactionRow";
import { StatCard } from "./StatCard";
interface TransactionListProps {
items?: ExpenseItem[];
fields: TxnFieldConfigs;
granularity?: PeriodGranularity;
showMetrics?: boolean;
groups?: ListPeriodGroup[];
}
function cadenceRow(cadenceDays: number | null, frequency: number | null): { label: string; value: string } {
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(2));
if (cadenceDays != null && cadenceDays < 1) {
return { label: "Frequency", value: `${frequency == null ? "—" : fmt(frequency)} /day` };
}
return {
label: "Cadence",
value: cadenceDays == null ? "—" : `${fmt(cadenceDays)} days`,
};
}
function txnFallbackMetrics(items: ExpenseItem[]): ListGroupMetrics {
const t = computeTxnMetrics(items);
return {
sum: t.sum,
count: t.count,
avg: t.avg,
min: t.min,
max: t.max,
cadence: t.cadenceDays,
frequency: t.frequency,
};
}
function GroupMetrics({
items,
currency,
metrics,
}: {
items: ExpenseItem[];
currency: string;
metrics?: ListGroupMetrics;
}) {
const m = metrics ?? txnFallbackMetrics(items);
const rows: { label: string; value: string }[] = [
{ label: "Sum", value: formatCurrency(m.sum, currency) },
{ label: "Avg", value: m.avg == null ? "—" : formatCurrency(m.avg, currency) },
{ label: "Min", value: m.min == null ? "—" : formatCurrency(m.min, currency) },
{ label: "Max", value: m.max == null ? "—" : formatCurrency(m.max, currency) },
cadenceRow(m.cadence, m.frequency),
];
return (
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
{rows.map((row) => (
<StatCard key={row.label} label={row.label} value={row.value} />
))}
</Box>
);
}
export function TransactionList({
items,
fields,
granularity = "monthly",
showMetrics = false,
groups: externalGroups,
}: TransactionListProps) {
const [activeMonth, setActiveMonth] = useState<string | null>(null);
const [openMonth, setOpenMonth] = useState<string | null>(null);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
const groups = useMemo<ListPeriodGroup[]>(
() => externalGroups ?? groupByPeriod(items ?? [], granularity),
[externalGroups, items, granularity],
);
const listRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const didInitOpenMonth = useRef(false);
useEffect(() => {
if (!didInitOpenMonth.current && groups.length > 0) {
didInitOpenMonth.current = true;
setOpenMonth(groups[0].key);
}
}, [groups]);
const scrollToMonth = useCallback((key: string) => {
const anchor = listRef.current?.querySelector<HTMLElement>(
`[data-month-anchor="${key}"]`,
);
if (!anchor) return;
const offset = window.matchMedia("(min-width: 900px)").matches ? 64 : 56;
const top = anchor.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
}, []);
const handleSelectMonth = useCallback(
(key: string) => {
setMenuAnchor(null);
setOpenMonth(key);
scrollToMonth(key);
},
[scrollToMonth],
);
const handlePillKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (pillRef.current) setMenuAnchor(pillRef.current);
}
},
[],
);
const selectedMonth = activeMonth ?? openMonth ?? groups[0]?.key ?? null;
useEffect(() => {
const root = listRef.current;
if (!root || groups.length === 0) return;
let ticking = false;
const update = () => {
ticking = false;
const headers = root.querySelectorAll<HTMLElement>("[data-month-header]");
let current: string | null = null;
for (const header of headers) {
if (header.getBoundingClientRect().top <= 72) {
current = header.dataset.monthHeader ?? null;
} else {
break;
}
}
setActiveMonth(current);
};
const onScroll = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [groups]);
return (
<>
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
{groups.map((group) => (
<Box key={group.key}>
<span data-month-anchor={group.key} aria-hidden="true" style={{ display: "block", height: 0 }} />
<Accordion
disableGutters
data-month-header={group.key}
expanded={openMonth === group.key}
onChange={(_, isExpanded) => setOpenMonth(isExpanded ? group.key : null)}
TransitionProps={{ unmountOnExit: true }}
sx={{
border: "1px solid",
borderColor: openMonth === group.key ? "primary.main" : "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
backgroundColor: "background.paper",
"&:before": { display: "none" },
transition: "border-color 160ms ease, background-color 160ms ease",
"&:hover": { borderColor: "primary.light" },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
px: 2,
py: 1,
"& .MuiAccordionSummary-content": {
flexDirection: "column",
alignItems: "stretch",
gap: 0.75,
minWidth: 0,
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, minWidth: 0 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{group.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="body2" fontWeight={700} color="error.main">
{formatCurrency(group.spent, group.currency)}
</Typography>
<Typography variant="body2" color="text.disabled">
/
</Typography>
<Typography variant="body2" fontWeight={700} color="success.main">
{formatCurrency(group.income, group.currency)}
</Typography>
</Box>
{showMetrics && (
<GroupMetrics items={group.items} currency={group.currency} metrics={group.metrics} />
)}
</AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{groupByDate(group.items).map((dateGroup) => (
<Box
key={dateGroup.date}
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
backgroundColor: "background.default",
p: 1,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 1,
pb: 1,
borderBottom: "1px solid",
borderColor: "divider",
mb: 1,
}}
>
<Typography variant="subtitle2" fontWeight={600} sx={{ letterSpacing: "-0.01em" }}>
{dateGroup.label}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="caption" color="text.secondary">
{dateGroup.items.length} transaction{dateGroup.items.length === 1 ? "" : "s"}
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{dateGroup.items.map((item) => (
<TransactionRow
key={item.id}
item={item}
currency={group.currency}
fields={fields}
/>
))}
</Box>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
</Box>
))}
</Box>
<Box
sx={{
position: "fixed",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: 3,
pointerEvents: activeMonth ? "auto" : "none",
opacity: activeMonth ? 1 : 0,
transition: "opacity 160ms ease",
}}
>
<Box
ref={pillRef}
role="button"
tabIndex={0}
onClick={(e) => setMenuAnchor(e.currentTarget)}
onKeyDown={handlePillKeyDown}
aria-haspopup="menu"
aria-expanded={Boolean(menuAnchor)}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: "999px",
cursor: "pointer",
userSelect: "none",
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
backdropFilter: "blur(8px)",
border: "1px solid",
borderColor: "divider",
boxShadow: 1,
transition: "background-color 160ms ease, transform 160ms ease",
"&:hover": {
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.95),
transform: "translateY(-1px)",
},
"&:focus-visible": {
outline: "2px solid",
outlineColor: "primary.main",
},
}}
>
<Typography variant="caption" fontWeight={700} color="text.secondary">
{activeMonth ? (groups.find((g) => g.key === activeMonth)?.label ?? activeMonth) : ""}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: "text.secondary" }} />
</Box>
</Box>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={() => setMenuAnchor(null)}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
transformOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{groups.map((group) => (
<MenuItem
key={group.key}
selected={selectedMonth === group.key}
onClick={() => handleSelectMonth(group.key)}
>
<ListItemText
primary={group.label}
secondary={`${group.items.length} transactions`}
/>
</MenuItem>
))}
</Menu>
</>
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Box, Typography } from "@mui/material";
import { ListCellRenderer, CurrencyField, applyDisplayFormat } from "../../../react-openapi";
import type { ExpenseItem, TxnFieldConfigs } from "../types";
interface TransactionRowProps {
item: ExpenseItem;
currency: string;
fields: TxnFieldConfigs;
}
export const TransactionRow = React.memo(function TransactionRow({ item, currency, fields }: TransactionRowProps) {
const itemCurrency = item.account?.currency ?? currency;
return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
px: 2,
py: 1.25,
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
backgroundColor: "background.paper",
transition: "background-color 160ms ease, border-color 160ms ease",
"&:hover": {
backgroundColor: "action.hover",
borderColor: "primary.light",
},
}}
>
<Box
sx={{
width: 40,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{item.entity?.logo ? (
<ListCellRenderer field={fields.logo} value={item.entity.logo} />
) : (
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: "action.hover" }} />
)}
</Box>
<Box sx={{ flex: "0 1 auto", minWidth: 0 }}>
<Typography
variant="body1"
fontWeight={600}
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ color: "text.secondary" }}>
<ListCellRenderer field={fields.occurredAt} value={item.occurred_at} />
</Box>
{item.account?.name && (
<ListCellRenderer field={fields.account} value={item.account} displayFormat={fields.formats.account} />
)}
</Box>
</Box>
<Box sx={{ flex: 1 }} />
<CurrencyField value={item.amount} currency={itemCurrency} large />
</Box>
);
});

21
src/common/types.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { FieldConfig } from "../../react-openapi";
export interface ExpenseItem {
id: string;
entity?: { name?: string; type?: string; logo?: string } | null;
amount: number;
account?: { name?: string; number?: string; type?: string; currency?: string } | null;
tags?: { icon?: string; name?: string }[];
occurred_at?: string;
created_at?: string;
updated_at?: string;
}
export interface TxnFieldConfigs {
entity: FieldConfig;
amount: FieldConfig;
account: FieldConfig;
occurredAt: FieldConfig;
logo: FieldConfig;
formats: { entity: string; account: string };
}

71
src/common/utils/dates.ts Normal file
View File

@@ -0,0 +1,71 @@
const DDMMYYYY = /^(\d{2})-(\d{2})-(\d{4})$/;
export function parseOccurredAt(value?: string): Date {
const m = value?.match(DDMMYYYY);
if (!m) {
throw new Error(`Expense occurred_at is not DD-MM-YYYY: ${value}`);
}
const [, dd, mm, yyyy] = m;
const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
if (
Number.isNaN(d.getTime()) ||
d.getDate() !== Number(dd) ||
d.getMonth() !== Number(mm) - 1 ||
d.getFullYear() !== Number(yyyy)
) {
throw new Error(`Invalid expense occurred_at date: ${value}`);
}
return d;
}
export function isDdmmyyyy(value?: string): boolean {
if (!value) return true;
const m = value.match(DDMMYYYY);
if (!m) return false;
const [, dd, mm, yyyy] = m;
const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
return !(
Number.isNaN(d.getTime()) ||
d.getDate() !== Number(dd) ||
d.getMonth() !== Number(mm) - 1 ||
d.getFullYear() !== Number(yyyy)
);
}
export function parseDdmmyyyy(value: string): Date {
const m = value.match(DDMMYYYY);
if (!m) throw new Error(`Date is not DD-MM-YYYY: ${value}`);
const [, dd, mm, yyyy] = m;
const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
if (
Number.isNaN(d.getTime()) ||
d.getDate() !== Number(dd) ||
d.getMonth() !== Number(mm) - 1 ||
d.getFullYear() !== Number(yyyy)
) {
throw new Error(`Invalid date: ${value}`);
}
return d;
}
export function monthKey(value?: string): string {
const d = parseOccurredAt(value);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function currentMonthKey(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
export function monthLabel(key: string): string {
const [y, m] = key.split("-").map(Number);
if (!y || !m) return key;
const d = new Date(y, m - 1, 1);
return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" });
}
export function dateLabel(value: string): string {
const d = parseOccurredAt(value);
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
}

View File

@@ -0,0 +1,26 @@
import type { ResourceConfig } from "../../../react-openapi";
import type { TxnFieldConfigs } from "../types";
export function buildTxnFieldConfigs(resources: ResourceConfig[]): TxnFieldConfigs | null {
const expensesRes = resources.find((r) => r.name === "expenses");
const entitiesRes = resources.find((r) => r.name === "entities");
const accountsRes = resources.find((r) => r.name === "accounts");
const find = (res: ResourceConfig | undefined, name: string) => res?.fields.find((f) => f.name === name);
const entity = find(expensesRes, "entity");
const amount = find(expensesRes, "amount");
const account = find(expensesRes, "account");
const occurredAt = find(expensesRes, "occurred_at");
const logo = find(entitiesRes, "logo");
if (!entity || !amount || !account || !occurredAt || !logo) return null;
return {
entity,
amount,
account,
occurredAt,
logo,
formats: {
entity: entitiesRes?.displayFormat ?? "{name}",
account: accountsRes?.displayFormat ?? "{name}",
},
};
}

View File

@@ -0,0 +1,161 @@
import type { ExpenseItem } from "../types";
import { dateLabel, monthKey, monthLabel, parseOccurredAt } from "./dates";
export function isExpense(item: ExpenseItem): boolean {
return (item.amount ?? 0) < 0;
}
export interface DateGroup {
date: string;
label: string;
items: ExpenseItem[];
}
export function groupByDate(items: ExpenseItem[]): DateGroup[] {
const sorted = [...items].sort(
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
);
const map = new Map<string, ExpenseItem[]>();
for (const item of sorted) {
const key = item.occurred_at ?? "";
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return [...map.entries()].map(([date, list]) => ({
date,
label: dateLabel(date),
items: list,
}));
}
export type PeriodGranularity = "weekly" | "monthly" | "quarterly" | "yearly";
export interface PeriodGroup {
key: string;
label: string;
items: ExpenseItem[];
spent: number;
income: number;
currency: string;
}
/** Metrics verbatim from the API's ReportMetrics (camelCased). */
export interface ListGroupMetrics {
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
cadence: number | null;
frequency: number | null;
}
export interface ListPeriodGroup extends PeriodGroup {
metrics?: ListGroupMetrics;
}
function isoWeekKey(d: Date): string {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
function quarterKey(d: Date): string {
return `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`;
}
export function periodKey(value: string | undefined, granularity: PeriodGranularity): string {
const d = parseOccurredAt(value);
switch (granularity) {
case "weekly":
return isoWeekKey(d);
case "quarterly":
return quarterKey(d);
case "yearly":
return String(d.getFullYear());
default:
return monthKey(value);
}
}
export function periodLabel(key: string, granularity: PeriodGranularity): string {
switch (granularity) {
case "weekly":
case "yearly":
return key;
case "quarterly": {
const [y, q] = key.split("-Q");
return `Q${q} ${y}`;
}
default:
return monthLabel(key);
}
}
export function groupByPeriod(items: ExpenseItem[], granularity: PeriodGranularity = "monthly"): PeriodGroup[] {
const map = new Map<string, ExpenseItem[]>();
for (const item of items) {
const key = periodKey(item.occurred_at, granularity);
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return [...map.entries()]
.map(([key, list]) => {
const sorted = [...list].sort(
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
);
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0);
const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0);
return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
export interface TxnMetrics {
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
cadenceDays: number | null;
frequency: number | null;
}
/** Mirrors backend ReportMetrics.compute_metrics over a list of transactions. */
export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics {
const amounts = items.filter((it) => typeof it.amount === "number").map((it) => it.amount as number);
const empty = { sum: 0, count: 0, avg: null, min: null, max: null, cadenceDays: null, frequency: null };
if (amounts.length === 0) {
return empty;
}
const dates = items
.map((it) => it.occurred_at)
.filter((d): d is string => !!d)
.map((d) => parseOccurredAt(d).getTime())
.sort((a, b) => a - b);
const sum = amounts.reduce((s, a) => s + a, 0);
let cadenceDays: number | null = null;
if (dates.length >= 2) {
const gaps: number[] = [];
for (let i = 0; i < dates.length - 1; i += 1) {
gaps.push((dates[i + 1] - dates[i]) / 86400000);
}
cadenceDays = Math.round((gaps.reduce((s, g) => s + g, 0) / gaps.length) * 100) / 100;
}
const frequency = cadenceDays != null && cadenceDays > 0 ? Math.round((1 / cadenceDays) * 100) / 100 : null;
return {
sum,
count: amounts.length,
avg: sum / amounts.length,
min: Math.min(...amounts),
max: Math.max(...amounts),
cadenceDays,
frequency,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More