5 Commits

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

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

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

## Files changed

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

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

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

## Changes

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

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

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

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

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

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

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

## Changes

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

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

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

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

Reviewed-on: #12
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-07-19 14:47:30 +00:00
23 changed files with 330 additions and 1321 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
export { AuthProvider, useAuth } from "./contexts"; export { AuthProvider, useAuth } from "./contexts";
export type { AuthServerConfig } from "./contexts";
export { createApiClient } from "./axios"; export { createApiClient } from "./axios";
export { AuthPage } from "./AuthPage"; export { AuthPage } from "./AuthPage";
export { ProfileCreate } from "./ProfileCreate"; export { ProfileCreate } from "./ProfileCreate";

View File

@@ -12,4 +12,4 @@ export { useItemSse } from "./src/hooks/useItemSse";
export { sanitizePayload } from "./src/utils/sanitize-payload"; export { sanitizePayload } from "./src/utils/sanitize-payload";
export type { FkResolver } from "./src/utils/sanitize-payload"; export type { FkResolver } from "./src/utils/sanitize-payload";
export type { FilterComponentProps } from "./src/context/useResource"; export type { FilterComponentProps } from "./src/context/useResource";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents } from "./src/types"; export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents, AuthConfig } from "./src/types";

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
import { Box, CircularProgress, Alert } from "@mui/material"; import { Box, CircularProgress, Alert } from "@mui/material";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { Layout } from "./Layout"; import { Layout } from "./Layout";
@@ -80,46 +80,49 @@ export function ProfileRoutes() {
const { profileOperations, profileComponents } = useAppContext(); const { profileOperations, profileComponents } = useAppContext();
const ops = profileOperations; const ops = profileOperations;
const getOp = ops.find((o) => o.method === "GET"); const getOp = ops.find((o) => o.method === "GET");
const location = useLocation();
const navigate = useNavigate();
return ( console.log("[ProfileRoutes] render pathname=%s ops=%o", location.pathname, ops.map(o => o.path));
<Routes>
<Route path=":username" element={<Navigate to="me" replace />} />
{ops.map((op) => {
const basePath = friendlyProfilePath(op.path);
let Component: React.ComponentType<any> | undefined;
let mode: "view" | "create" | "edit" | undefined;
if (op.method === "GET" && profileComponents.view) { // Redirect /:username to /me (only "me" and "me/edit" are valid)
Component = profileComponents.view; const match = location.pathname.match(/^\/profile\/(.+)$/);
mode = "view"; const username = match ? match[1] : "";
} else if (op.method === "POST" && profileComponents.create) { console.log("[ProfileRoutes] match=%o username=%s", match && match[1], username);
Component = profileComponents.create; if (username !== "me" && username !== "me/edit") {
mode = "create"; console.log("[ProfileRoutes] redirecting to /profile/me");
} else if ((op.method === "PUT" || op.method === "PATCH") && profileComponents.edit) { navigate("/profile/me", { replace: true });
Component = profileComponents.edit; return null;
mode = "edit";
} }
if (!Component || !mode) return null; const isEdit = username === "me/edit";
const mode = isEdit ? "edit" as const : "view" as const;
console.log("[ProfileRoutes] mode=%s", mode);
const routePath = mode === "view" ? basePath : `${basePath}/edit`; 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 ( return (
<Route
key={op.operationId}
path={routePath}
element={
<ProfileComponentWrapper <ProfileComponentWrapper
Component={Component} Component={Component}
mode={mode} mode={mode}
operation={op} operation={op}
getOperation={getOp} getOperation={getOp}
/> />
}
/>
);
})}
</Routes>
); );
} }
@@ -134,19 +137,24 @@ function ProfileComponentWrapper({
operation: { path: string; method: string }; operation: { path: string; method: string };
getOperation?: { path: string }; getOperation?: { path: string };
}) { }) {
const navigate = useNavigate();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(mode !== "create");
const [error, setError] = useState<string | null>(null); 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 fetchPath = getOperation?.path ?? operation.path;
const fetchData = async () => { const fetchData = async () => {
console.log("[ProfileComponentWrapper] fetchData START path=%s", fetchPath);
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res = await getApi().get(fetchPath); const res = await getApi().get(fetchPath);
console.log("[ProfileComponentWrapper] fetchData SUCCESS", res.data);
setData(res.data); setData(res.data);
} catch (e: any) { } catch (e: any) {
console.log("[ProfileComponentWrapper] fetchData ERROR", e.message, e.response?.status);
setError(e.response?.data?.detail ?? e.message ?? "Failed to load profile"); setError(e.response?.data?.detail ?? e.message ?? "Failed to load profile");
} finally { } finally {
setLoading(false); setLoading(false);
@@ -155,11 +163,13 @@ function ProfileComponentWrapper({
useEffect(() => { useEffect(() => {
if (mode !== "create") { if (mode !== "create") {
console.log("[ProfileComponentWrapper] useEffect calling fetchData");
fetchData(); fetchData();
} }
}, [fetchPath, mode]); }, [fetchPath, mode]);
const handleSubmit = async (formData: Record<string, any>) => { const handleSubmit = async (formData: Record<string, any>) => {
console.log("[ProfileComponentWrapper] handleSubmit START");
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
@@ -168,11 +178,11 @@ function ProfileComponentWrapper({
} else { } else {
await getApi()({ method: operation.method.toLowerCase(), url: operation.path, data: formData }); await getApi()({ method: operation.method.toLowerCase(), url: operation.path, data: formData });
} }
// Navigate back to the base profile view path console.log("[ProfileComponentWrapper] handleSubmit SUCCESS, navigating to /profile/me");
const base = friendlyProfilePath(operation.path); const base = friendlyProfilePath(operation.path);
window.history.pushState(null, "", `/${base}`); navigate(`/profile/${base}`, { replace: true });
window.location.reload();
} catch (e: any) { } catch (e: any) {
console.log("[ProfileComponentWrapper] handleSubmit ERROR", e.message, e.response?.status);
setError(e.response?.data?.detail ?? e.message ?? "Operation failed"); setError(e.response?.data?.detail ?? e.message ?? "Operation failed");
} finally { } finally {
setLoading(false); setLoading(false);
@@ -203,10 +213,7 @@ function ProfileComponentWrapper({
props.username = data.username; props.username = data.username;
props.email = data.email; props.email = data.email;
} }
props.onEdit = () => { props.onEdit = () => navigate("/profile/me/edit");
window.history.pushState(null, "", `/profile/me/edit`);
window.location.reload();
};
props.loading = loading; props.loading = loading;
} }

View File

@@ -140,7 +140,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
return ( return (
<TabPanel key={t.key} value={tabIndex} index={i + 1}> <TabPanel key={t.key} value={tabIndex} index={i + 1}>
{sub.streaming ? ( {sub.streaming ? (
<SseStreamView resource={sub} pathParams={{ [pathParam]: Number(id!) }} /> <SseStreamView resource={sub} pathParams={{ [pathParam]: id! }} />
) : null} ) : null}
</TabPanel> </TabPanel>
); );

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from "react"; import React, { useEffect, useState, useMemo } from "react";
import type { ProfileOperation, SpecConfiguration, ResourceConfig, ValidationMessage } from "../types"; import type { AuthConfig, ProfileOperation, SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
import { AppContext } from "./AppContext"; import { AppContext } from "./AppContext";
import { loadSpec } from "../spec-loader"; import { loadSpec } from "../spec-loader";
import { validateSpec } from "../spec-validator"; import { validateSpec } from "../spec-validator";
@@ -11,6 +11,19 @@ interface AppProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
function extractAuthConfig(spec: any): AuthConfig {
const bearer: Record<string, any> =
spec?.components?.securitySchemes?.bearerAuth ?? {};
return {
serverUrl: bearer["x-server-url"] ?? "",
loginPath: bearer["x-login-path"] ?? "/login",
registerPath: bearer["x-register-path"] ?? "/register",
logoutPath: bearer["x-logout-path"] ?? "/logout",
mePath: bearer["x-me-path"] ?? "/me",
introspectPath: bearer["x-introspect-path"] ?? "/introspect",
};
}
function extractProfileOperations(spec: any): ProfileOperation[] { function extractProfileOperations(spec: any): ProfileOperation[] {
const ops: ProfileOperation[] = []; const ops: ProfileOperation[] = [];
const paths = spec.paths ?? {}; const paths = spec.paths ?? {};
@@ -30,10 +43,20 @@ function extractProfileOperations(spec: any): ProfileOperation[] {
return ops; return ops;
} }
const DEFAULT_AUTH_CONFIG: AuthConfig = {
serverUrl: "",
loginPath: "/login",
registerPath: "/register",
logoutPath: "/logout",
mePath: "/me",
introspectPath: "/introspect",
};
export function AppProvider({ specConfiguration, children }: AppProviderProps) { export function AppProvider({ specConfiguration, children }: AppProviderProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resources, setResources] = useState<ResourceConfig[]>([]); const [resources, setResources] = useState<ResourceConfig[]>([]);
const [profileOperations, setProfileOperations] = useState<ProfileOperation[]>([]); const [profileOperations, setProfileOperations] = useState<ProfileOperation[]>([]);
const [authConfig, setAuthConfig] = useState<AuthConfig>(DEFAULT_AUTH_CONFIG);
const [schemas, setSchemas] = useState<Record<string, any>>({}); const [schemas, setSchemas] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<ValidationMessage[]>([]); const [errors, setErrors] = useState<ValidationMessage[]>([]);
const [warnings, setWarnings] = useState<ValidationMessage[]>([]); const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
@@ -59,6 +82,7 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
setWarnings(warns); setWarnings(warns);
setSchemas(spec.components?.schemas ?? {}); setSchemas(spec.components?.schemas ?? {});
setProfileOperations(extractProfileOperations(spec)); setProfileOperations(extractProfileOperations(spec));
setAuthConfig(extractAuthConfig(spec));
} }
if (errs.length === 0) { if (errs.length === 0) {
@@ -97,12 +121,13 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
resources, resources,
profileOperations, profileOperations,
profileComponents, profileComponents,
authConfig,
schemas, schemas,
loading, loading,
errors, errors,
warnings, warnings,
}), }),
[specConfiguration, resources, profileOperations, profileComponents, schemas, loading, errors, warnings] [specConfiguration, resources, profileOperations, profileComponents, authConfig, schemas, loading, errors, warnings]
); );
return React.createElement(AppContext.Provider, { value }, children); return React.createElement(AppContext.Provider, { value }, children);

View File

@@ -56,6 +56,7 @@ interface UseResourceReturn {
get: (id: string | number, params?: Record<string, any>) => Promise<any>; get: (id: string | number, params?: Record<string, any>) => Promise<any>;
create: (data: any) => Promise<any>; create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>; update: (id: string | number, data: any) => Promise<any>;
patch?: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>; remove: (id: string | number) => Promise<void>;
stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription; stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
loading: boolean; loading: boolean;
@@ -596,7 +597,7 @@ export function useResource(resourceName: string): UseResourceReturn {
const rPath = resource?.path; const rPath = resource?.path;
const rPagination = resource?.pagination; const rPagination = resource?.pagination;
const rUpdateMethod = resource?.updateMethod; const rPatch = resource ? (resource.operations.patch || undefined) : false;
const rStreaming = resource?.streaming; const rStreaming = resource?.streaming;
const rFields = resource?.fields; const rFields = resource?.fields;
@@ -706,8 +707,7 @@ export function useResource(resourceName: string): UseResourceReturn {
const sanitized = rFields && schemas const sanitized = rFields && schemas
? await sanitizePayload(data, rFields, schemas, resolveFk) ? await sanitizePayload(data, rFields, schemas, resolveFk)
: data; : data;
const method = rUpdateMethod ?? "put"; const res = await api.put(`${rPath}/${id}`, sanitized);
const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, sanitized);
return res.data; return res.data;
} catch (e: any) { } catch (e: any) {
setError(parseError(e)); setError(parseError(e));
@@ -716,7 +716,29 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false); setLoading(false);
} }
}, },
[rPath, rFields, schemas, resolveFk, rUpdateMethod, setLoading, setError] [rPath, rFields, schemas, resolveFk, setLoading, setError]
);
const _patch = useCallback(
async (id: string | number, data: any): Promise<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
setLoading(true);
setError(null);
try {
const api = getApi();
const sanitized = rFields && schemas
? await sanitizePayload(data, rFields, schemas, resolveFk)
: data;
const res = await api.patch(`${rPath}/${id}`, sanitized);
return res.data;
} catch (e: any) {
setError(parseError(e));
throw e;
} finally {
setLoading(false);
}
},
[rPath, rFields, schemas, resolveFk, setLoading, setError]
); );
const remove = useCallback( const remove = useCallback(
@@ -791,6 +813,7 @@ export function useResource(resourceName: string): UseResourceReturn {
get, get,
create, create,
update, update,
patch: undefined,
remove, remove,
stream: undefined, stream: undefined,
loading: false, loading: false,
@@ -798,5 +821,5 @@ export function useResource(resourceName: string): UseResourceReturn {
}; };
} }
return { resource, components, list, get, create, update, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error }; return { resource, components, list, get, create, update, patch: rPatch ? _patch : undefined, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
} }

View File

@@ -1,12 +1,16 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
let apiClient: AxiosInstance | null = null; let apiClient: AxiosInstance | null = null;
let _onUnauthorized: (() => void) | undefined;
export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance { export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance {
if (apiClient && apiClient.defaults.baseURL === baseUrl) { if (apiClient && apiClient.defaults.baseURL === baseUrl) {
_onUnauthorized = onUnauthorized;
return apiClient; return apiClient;
} }
_onUnauthorized = onUnauthorized;
apiClient = axios.create({ apiClient = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -20,6 +24,20 @@ export function initApi(baseUrl: string, getToken?: () => string | null): AxiosI
return config; return config;
}); });
apiClient.interceptors.response.use(
(res) => res,
(error) => {
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);
}
);
return apiClient; return apiClient;
} }

View File

@@ -98,9 +98,9 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
const parent = nameMap.get(parentName); const parent = nameMap.get(parentName);
if (!parent) continue; if (!parent) continue;
if (hasOperation(pathObj, "get")) parent.operations.get = true; if (hasOperation(pathObj, "get")) parent.operations.get = true;
if (hasOperation(pathObj, "put") || hasOperation(pathObj, "patch")) parent.operations.update = 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; if (hasOperation(pathObj, "delete")) parent.operations.delete = true;
if (hasOperation(pathObj, "patch") && !hasOperation(pathObj, "put")) parent.updateMethod = "patch";
continue; continue;
} }
@@ -127,8 +127,8 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields, fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [], orderedFields: [],
operations: hasSSE operations: hasSSE
? { list: true, get: false, create: false, update: false, delete: false } ? { list: true, get: false, create: false, update: false, patch: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, delete: false }, : { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, patch: false, delete: false },
updateMethod: "put", updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj), pagination: hasSSE ? null : detectPagination(pathObj),
relationships: [], relationships: [],
@@ -173,8 +173,8 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields, fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [], orderedFields: [],
operations: hasSSE operations: hasSSE
? { list: true, get: false, create: false, update: false, delete: false } ? { list: true, get: false, create: false, update: false, patch: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, delete: false }, : { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, patch: false, delete: false },
updateMethod: "put", updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj), pagination: hasSSE ? null : detectPagination(pathObj),
relationships, relationships,

View File

@@ -21,6 +21,16 @@ export interface SpecConfiguration {
profileComponents?: ProfileComponents; 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. */ /** Represents a single operation marked with `x-profile: true` in the spec. */
export interface ProfileOperation { export interface ProfileOperation {
path: string; path: string;
@@ -55,6 +65,7 @@ export interface ResourceConfig {
get: boolean; get: boolean;
create: boolean; create: boolean;
update: boolean; update: boolean;
patch: boolean;
delete: boolean; delete: boolean;
}; };
updateMethod: "put" | "patch"; updateMethod: "put" | "patch";

View File

@@ -7,7 +7,7 @@ import { useNavigate } from "react-router-dom";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi"; import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi"; import type { FieldConfig } from "../../react-openapi";
const CREATE_FIELDS = ["account", "format", "start_date", "end_date", "source"]; const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
function FetchRequestList() { function FetchRequestList() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -9,7 +9,6 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Alert, Alert,
IconButton,
Snackbar, Snackbar,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -20,8 +19,8 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import { useResource, useItemSse, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi"; import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests"; import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests"; import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests";
import { PipelineStepper } from "./components/PipelineStepper"; import { PipelineStepper } from "./components/PipelineStepper";
@@ -71,9 +70,11 @@ function sseIcon(status: SSEEvent["status"]) {
export default function FetchRequestDetail() { export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { get, update, resource } = useResource("fetch-requests"); const { get, patch, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [stepStats, setStepStats] = useState<Record<string, number>>({}); const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined); const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false);
const [failNotif, setFailNotif] = useState<string | null>(null); const [failNotif, setFailNotif] = useState<string | null>(null);
const feedRef = useRef<HTMLDivElement>(null); const feedRef = useRef<HTMLDivElement>(null);
@@ -83,10 +84,6 @@ export default function FetchRequestDetail() {
enabled: !!id, enabled: !!id,
}); });
const updateMutation = useMutation({
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
});
const sseUrl = id ? `/fetch-requests/${id}/events` : null; const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, { const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
onEvent: (parsed: SSEEvent) => { onEvent: (parsed: SSEEvent) => {
@@ -150,15 +147,41 @@ export default function FetchRequestDetail() {
}, [sseEvents]); }, [sseEvents]);
const handleRetry = async () => { const handleRetry = async () => {
if (!id) return; if (!id || !patch || retrying) return;
setRetrying(true);
try { try {
await updateMutation.mutateAsync({ id, data: { status: "pending" } }); await patch(id, { status: "pending" });
refetchRequest(); refetchRequest();
} catch (err: any) { } catch (err: any) {
setFailNotif(formatApiError(err)); setFailNotif(formatApiError(err));
} finally {
setRetrying(false);
} }
}; };
const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = req?.status as FetchRequestStatus | undefined;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
const displayTitle = useMemo(() => {
if (!resource || !req) return "";
const resolved = Object.fromEntries(
resource.orderedFields.map((field) => {
const value = req[field.name];
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) return [field.name, applyDisplayFormat(value, target.displayFormat)];
}
return [field.name, value];
}),
);
return applyDisplayFormat(resolved, resource.displayFormat);
}, [resource, req, allResources]);
if (isLoading) { if (isLoading) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}> <Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
@@ -178,15 +201,6 @@ export default function FetchRequestDetail() {
); );
} }
const req = fetchRequest as any;
const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = req.status as FetchRequestStatus;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
@@ -200,7 +214,7 @@ export default function FetchRequestDetail() {
label={status.replace(/_/g, " ")} label={status.replace(/_/g, " ")}
color={statusColors[status]} color={statusColors[status]}
/> />
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography> <Typography variant="h6" fontWeight={600}>{displayTitle}</Typography>
<Chip <Chip
label={"path" in (req.source ?? {}) ? "File" : "Email"} label={"path" in (req.source ?? {}) ? "File" : "Email"}
size="small" size="small"
@@ -210,14 +224,22 @@ export default function FetchRequestDetail() {
</Box> </Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
{detailFields.map((field) => ( {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 <DetailFieldRenderer
key={field.name} key={field.name}
field={field} field={field}
value={req[field.name]} value={value}
displayFormat={resource?.displayFormat} displayFormat={fmt}
/> />
))} );
})}
</Box> </Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
@@ -232,7 +254,7 @@ export default function FetchRequestDetail() {
size="small" size="small"
startIcon={<ReplayIcon />} startIcon={<ReplayIcon />}
onClick={handleRetry} onClick={handleRetry}
disabled={updateMutation.isPending} disabled={retrying}
> >
Retry Retry
</Button> </Button>

View File

@@ -1,642 +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,
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, useItemSse } 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 }} />,
};
const stepLabels = ["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 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" }} />;
}
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
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 [stepStats, setStepStats] = React.useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [failNotif, setFailNotif] = React.useState<string | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null);
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();
refetchAmbiguities();
}
if (parsed.status === "failed") {
setFailNotif(parsed.message.error || "Fetch request failed");
refetchRequest();
}
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
},
});
React.useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
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 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 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]);
const progressPercent = React.useMemo(
() => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
displayParsedCount,
seenSteps,
stepStats,
txnBlockCount,
txnDictCount,
),
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
);
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 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 hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && ambiguities.every((a: any) => a.status === "resolved");
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
{/* Header Card */}
<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.start_date ? new Date(req.start_date).toLocaleDateString() : "?"} {req.end_date ? new Date(req.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>
{/* Progress Bar */}
<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>
{/* Retry Counter */}
<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>
{/* Error Alerts */}
{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>
)}
{/* Pipeline Stepper */}
<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>;
}
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>
</Paper>
{/* SSE Event Feed */}
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
Progress Events
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
<Box
ref={feedRef}
sx={{
maxHeight: 300,
overflowY: "auto",
p: 2,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{displayEvents.length === 0 ? (
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
Waiting for events...
</Typography>
) : (
displayEvents.map((evt, i) => (
<Box
key={i}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
p: 1,
borderRadius: 2,
bgcolor: "action.hover",
}}
>
{sseIcon(evt.status)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{evt.step.replace(/_/g, " ")}
</Typography>
{evt.message && formatProgressMessage(evt.message) && (
<Typography variant="caption" color="text.secondary">
{formatProgressMessage(evt.message)}
</Typography>
)}
</Box>
</Box>
))
)}
</Box>
</Paper>
{/* Ambiguity Resolution */}
{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,509 +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,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} 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, applyDisplayFormat } 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 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 { list, create, update, remove, resource: fetchRes } = useResource("fetch-requests");
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 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 — 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);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
{/* Create Form */}
<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} />
)}
</>
) : (
<>
{formatField && (
<FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
)}
<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 />
)}
/>
{payorUsernameField && (
<FormFieldRenderer field={payorUsernameField} value={payorUsername} onChange={setPayorUsername} />
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer field={startDateField} value={startDate} onChange={setStartDate} />
</Box>
)}
{endDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer field={endDateField} value={endDate} onChange={setEndDate} />
</Box>
)}
</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>
{/* Filters */}
<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) => (
<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 }}
/>
<ToggleButtonGroup
value={sourceFilter}
exclusive
onChange={(_, val) => val && setSourceFilter(val)}
size="small"
>
<ToggleButton value="all">All</ToggleButton>
<ToggleButton value="file">File</ToggleButton>
<ToggleButton value="email">Email</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* List Table */}
{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>
) : (
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Account</TableCell>
<TableCell>Source</TableCell>
<TableCell>Date Range</TableCell>
<TableCell>Status</TableCell>
<TableCell>Retries</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<TableRow
key={req.id}
hover
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/fetch-requests/${req.id}`)}
>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{shortId(req.fingerprint)}
</Typography>
<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>
</TableCell>
<TableCell>{req.account_name}</TableCell>
<TableCell>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{(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>
</TableCell>
<TableCell>
<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>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{new Date(req.created_at).toLocaleString()}
</Typography>
</TableCell>
<TableCell align="right">
<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>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<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,26 +0,0 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { useAuth, AuthPage } from "../react-auth";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { currentUser, loading, error, login, register } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = React.useState<"login" | "register">("login");
if (currentUser) {
return <>{children}</>;
}
return (
<AuthPage
mode={mode}
onBack={() => navigate("/")}
onSwitchMode={() => setMode(mode === "login" ? "register" : "login")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}

View File

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

View File

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

View File

@@ -10,12 +10,13 @@ import {
import { import {
Box, Box,
CssBaseline, CssBaseline,
CircularProgress,
Toolbar Toolbar
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequests from './FetchRequest/FetchRequestCreate';
import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail';
import { AppProvider, Admin, ProfileRoutes } from '../react-openapi'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi';
import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth"; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth";
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
@@ -27,8 +28,6 @@ const queryClient = new QueryClient();
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
// Wire profile components from react-auth into the spec-driven admin // Wire profile components from react-auth into the spec-driven admin
specConfiguration.profileComponents = { specConfiguration.profileComponents = {
create: ProfileCreate, create: ProfileCreate,
@@ -81,11 +80,21 @@ const routerMapping = [
{ path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" }, { path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" },
]; ];
root.render( /** Reads authConfig from AppProvider context and passes it to AuthProvider. */
<QueryClientProvider client={queryClient}> function AppContent() {
<AppProvider specConfiguration={specConfiguration}> const { authConfig, loading } = useAppContext();
<BrowserRouter> const navigate = useNavigate();
<AuthProvider authBaseUrl={AUTH_BASE}>
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" }}>
<CircularProgress />
</Box>
);
}
return (
<AuthProvider authConfig={authConfig} onUnauthorized={() => navigate("/login")}>
<AppTheme> <AppTheme>
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<Header routerMapping={routerMapping} /> <Header routerMapping={routerMapping} />
@@ -107,7 +116,22 @@ root.render(
<Footer /> <Footer />
</AppTheme> </AppTheme>
</AuthProvider> </AuthProvider>
</BrowserRouter> );
}
function AppWithAuth() {
const navigate = useNavigate();
return (
<AppProvider specConfiguration={specConfiguration} onUnauthorized={() => navigate("/login")}>
<AppContent />
</AppProvider> </AppProvider>
);
}
root.render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppWithAuth />
</BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
); );