1 Commits

Author SHA1 Message Date
6d38b793f4 Add Fetch Request pipeline UI with real-time SSEs (#8)
## Summary

Add Fetch Request pipeline UI with real-time SSE progress tracking, ambiguity resolution, list page with filtering/retry, and detail page with stepper/event feed.

## Changes

### New files

- **`src/FetchRequestDetail.tsx`** (+675 lines) — Full detail page for a single fetch request with:
  - SSE connection for real-time pipeline progress (`/fetch-requests/:id/events`)
  - 4-step stepper (Extract → Raw Expense → Enrich → Save) with active/completed/failed/paused states
  - Granular progress bar (10% Extract, 20% Raw Expense, 50% Enrich, 20% Save)
  - Progress event feed with auto-scroll and deduplication (only latest `progress` per step, hides `started` when terminal event follows)
  - Ambiguity resolution cards with candidate selection buttons
  - Retry support with retry-count progress bar
  - Failure/success snackbar notifications
  - Error display for failed fetch requests

### Modified files

- **`react-openapi/api/client.ts`** — added `api.patch()` method for PATCH requests
- **`react-openapi/hooks/useResource.ts`** — added `usePatch()` mutation hook for partial updates with cache invalidation
- **`src/FetchRequests.tsx`** (+347/−73 lines) — Major list page rewrite:
  - Row-level actions: retry (failed <3 retries), navigate to detail (paused), delete with confirmation dialog
  - Filter bar: status multi-select, account text filter, file/email source toggle
  - Account name autocomplete from API
  - Format dropdown driven by `resourceOverrides` config
  - Date pickers (changed from `datetime-local` to `date`)
  - Copy fingerprint button, retry count display, date range column
  - Row click navigates to detail, sorted by `created_at` desc
  - `pause` status support in `statusColors`
  - 409 conflict handling on duplicate fingerprint
  - `formatApiError()` for 422 validation error display
- **`src/features/fetch-requests/fetch-requests.models.ts`** — Added types:
  - `paused` to `FetchRequestStatus`
  - `FetchRequestUpdate` interface
  - `retry_count` to `FetchRequest` interface
  - `raw_lines`, `txn_blocks`, `txn_dicts`, `txn_dict_count`/`txn_dicts_count` to source types
  - `PendingAmbiguity`, `AmbiguityCandidate`, `ResolveAmbiguityPayload`
  - `SSEEvent`, `SSEEventStep`, `SSEEventStatus`, `ProgressMessage`
  - `FetchRequestFilters`
  - `formatApiError()` helper for FastAPI 422 error parsing
  - `RETRY_MAX = 3` constant
- **`src/features/fetch-requests/index.ts`** — Barrel exports for all new types, hooks, and helpers
- **`src/features/fetch-requests/useFetchRequests.ts`** — Added hooks:
  - `useUpdateFetchRequest()` — PATCH via `usePatch`
  - `useFetchRequestAmbiguities(id)` — queries `/fetch-requests/:id/ambiguities`
  - `useResolveAmbiguity()` — posts to `/ambiguities/:id/resolve` with cache invalidation
- **`src/main.jsx`** — Added route `/fetch-requests/:id` → `FetchRequestDetail`

## Key decisions

- SSE event stream is the single source of truth for progress; REST is only fallback for page load
- Stepper shows granular ratio-based progress counts (e.g. `150/246` for enrich)
- `pipeline/failed` SSE event triggers refetch + error snackbar
- `load_content` events excluded from event feed entirely
- Enrich/Save progress counts come only from SSE (no REST fallback since those phases don't pause)

Reviewed-on: #8
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-05-30 15:58:48 +00:00
9 changed files with 30 additions and 110 deletions

View File

@@ -31,7 +31,6 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import { getFieldOptions, toGridValueOptions } from '../utils/options';
interface EnhancedTableProps { interface EnhancedTableProps {
config: ResourceConfig; config: ResourceConfig;
@@ -97,7 +96,8 @@ export default function EnhancedTable({
} }
if (muiType === 'singleSelect' && field.options) { if (muiType === 'singleSelect' && field.options) {
col.valueOptions = toGridValueOptions(getFieldOptions(field)); // @ts-ignore
col.valueOptions = field.options;
} }
return col; return col;
@@ -379,11 +379,6 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : ''; if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
if (field.type === 'enum' && field.options) {
const opt = getFieldOptions(field).find(o => o.key === value);
return opt?.value ?? value;
}
if (isPk && !isMobile) { if (isPk && !isMobile) {
return ( return (
<Chip <Chip

View File

@@ -11,7 +11,6 @@ import {
import DoneIcon from "@mui/icons-material/Done"; import DoneIcon from "@mui/icons-material/Done";
import FilterListIcon from "@mui/icons-material/FilterList"; import FilterListIcon from "@mui/icons-material/FilterList";
import { ResourceField, ResourceMode } from "../types/config"; import { ResourceField, ResourceMode } from "../types/config";
import { getFieldOptions } from "../utils/options";
function FilterAutocomplete({ function FilterAutocomplete({
options, options,
@@ -111,9 +110,7 @@ function extractOptions(
): string[] { ): string[] {
const values = new Set<string>(); const values = new Set<string>();
if (field.type === 'enum' && field.options) { if (field.options) return field.options;
return getFieldOptions(field).map(o => o.key);
}
if (!data) return []; if (!data) return [];
const pull = (item: any): string | null => { const pull = (item: any): string | null => {

View File

@@ -12,7 +12,6 @@ import {
Divider, Divider,
} from '@mui/material'; } from '@mui/material';
import { ResourceField } from '../../types/config'; import { ResourceField } from '../../types/config';
import { getFieldOptions } from '../../utils/options';
import ImageUploadField from './ImageUploadField'; import ImageUploadField from './ImageUploadField';
interface FormFieldProps { interface FormFieldProps {
@@ -74,40 +73,40 @@ export default function FormField({
if (field.relation && relationDataMap[field.relation]) { if (field.relation && relationDataMap[field.relation]) {
const relationData = relationDataMap[field.relation].data; const relationData = relationDataMap[field.relation].data;
const isArrayRelation = field.type === 'array'; const isArrayRelation = field.type === 'array';
const options = getFieldOptions(field, relationData);
const keyField = field.enumOption?.key ?? 'id'; // Determine how to display the related item
const getOptionLabel = (option: any) => {
if (!option) return "";
if (field.displayField && option[field.displayField]) return option[field.displayField];
// Standard naming fields
return option.name || option.title || option.label || option.id || JSON.stringify(option);
};
// Normalize value: API returns whole objects on GET, but form uses key strings const getOptionValue = (option: any) => {
const normalizedValue = (() => { // Return the whole object to maintain identity
if (isArrayRelation && Array.isArray(value)) { return option;
return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v))); };
}
if (value != null && typeof value === 'object') {
return String(value[keyField] ?? '');
}
return value ?? (isArrayRelation ? [] : "");
})();
return ( return (
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel shrink>{label}</InputLabel> <InputLabel shrink>{label}</InputLabel>
<Select <Select
multiple={isArrayRelation} multiple={isArrayRelation}
value={normalizedValue} value={value || (isArrayRelation ? [] : "")}
label={label} label={label}
displayEmpty displayEmpty
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
disabled={disabled} disabled={disabled}
renderValue={(selected: any) => { renderValue={(selected: any) => {
if (isArrayRelation) { if (isArrayRelation) {
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', '); return (selected as any[]).map(getOptionLabel).join(', ');
} }
return options.find(o => o.key === selected)?.value ?? selected; return getOptionLabel(selected);
}} }}
> >
{options.map((opt) => ( {relationData.map((option) => (
<MenuItem key={opt.key} value={opt.key}> <MenuItem key={option.id || JSON.stringify(option)} value={getOptionValue(option)}>
{opt.value} {getOptionLabel(option)}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
@@ -150,7 +149,6 @@ export default function FormField({
// 5. Enum Handling // 5. Enum Handling
if (field.type === 'enum' && field.options) { if (field.type === 'enum' && field.options) {
const options = getFieldOptions(field);
return ( return (
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{label}</InputLabel> <InputLabel>{label}</InputLabel>
@@ -160,9 +158,9 @@ export default function FormField({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
disabled={disabled} disabled={disabled}
> >
{options.map((opt) => ( {field.options.map((opt: string) => (
<MenuItem key={opt.key} value={opt.key}> <MenuItem key={opt} value={opt}>
{opt.value} {opt}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>

View File

@@ -10,16 +10,6 @@ export type FieldType =
| 'object' | 'object'
| 'array'; | 'array';
export interface SelectOption {
key: string;
value: string;
}
export interface EnumOption {
key: string;
value: string | string[];
}
export interface ResourceField { export interface ResourceField {
type: FieldType; type: FieldType;
label: string; label: string;
@@ -29,10 +19,8 @@ export interface ResourceField {
schema?: Record<string, ResourceField>; schema?: Record<string, ResourceField>;
displayField?: string | string[]; displayField?: string | string[];
formatter?: (value: any) => string; formatter?: (value: any) => string;
relation?: string; relation?: string; // Name of the target resource
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
} }
export type ResourceMode = "server" | "client"; export type ResourceMode = "server" | "client";
@@ -50,7 +38,6 @@ export interface ResourceConfig {
mode?: ResourceMode; mode?: ResourceMode;
fields?: string[]; fields?: string[];
}; };
enumOption?: EnumOption;
} }
export interface AppConfig { export interface AppConfig {

View File

@@ -1,14 +1,13 @@
export interface EnumOption { /**
key: string; * This file contains application-specific overrides and configuration
value: string | string[]; * for the generic Admin Panel.
} */
export interface FieldOverride { export interface FieldOverride {
displayField?: string | string[]; displayField?: string | string[];
display?: boolean; display?: boolean;
formatter?: (value: any) => string; formatter?: (value: any) => string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumLabels?: Record<string, string>;
} }
export interface ResourceOverride { export interface ResourceOverride {
@@ -19,5 +18,4 @@ export interface ResourceOverride {
mode?: "server" | "client"; mode?: "server" | "client";
fields?: string[]; fields?: string[];
}; };
enumOption?: EnumOption;
} }

View File

@@ -80,21 +80,6 @@ function parseSchemaFields(
const relation = schemaToResourceMap.get(targetSchema); const relation = schemaToResourceMap.get(targetSchema);
if (relation) { if (relation) {
fields[key].relation = relation; fields[key].relation = relation;
// Propagate enumOption from target resource config, or derive from target schema
const explicitEnumOption = configuration[relation]?.enumOption;
if (explicitEnumOption) {
fields[key].enumOption = explicitEnumOption;
} else {
const targetProps = targetSchema.properties || {};
const valueField = Object.entries(targetProps).find(
([name, p]: [string, any]) => name !== 'id' && p.type === 'string'
)?.[0];
fields[key].enumOption = {
key: 'id',
value: valueField ?? 'id',
};
}
} }
// Recursively parse nested objects (only if not a relation) // Recursively parse nested objects (only if not a relation)

View File

@@ -1,28 +0,0 @@
import { ResourceField, SelectOption } from "../types/config";
export function getFieldOptions(field: ResourceField, relationData?: any[]): SelectOption[] {
if (field.type === 'enum' && field.options) {
return field.options.map(opt => ({
key: opt,
value: field.enumLabels?.[opt] ?? opt,
}));
}
if (field.relation) {
const data = relationData ?? [];
const enumOption = field.enumOption ?? { key: 'id', value: 'name' };
return data.map(item => ({
key: String(item[enumOption.key] ?? ''),
value: Array.isArray(enumOption.value)
? enumOption.value.map(k => item[k]).filter(v => v != null).join(' ')
: String(item[enumOption.value] ?? ''),
}));
}
return [];
}
export function toGridValueOptions(options: SelectOption[]): { value: string; label: string }[] {
return options.map(opt => ({ value: opt.key, label: opt.value }));
}

View File

@@ -129,7 +129,7 @@ export default function FetchRequests() {
const config = useConfig(); const config = useConfig();
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests"); const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
const formatOptions: string[] = (fetchRes?.fields?.source?.schema?.format?.options as string[]) ?? ["axis", "icici"]; const formatOptions: string[] = (fetchRes?.fields?.source?.schema?.format?.options as string[]) ?? ["axis", "icici_ocr"];
const createMutation = useCreateFetchRequest(); const createMutation = useCreateFetchRequest();
const updateMutation = useUpdateFetchRequest(); const updateMutation = useUpdateFetchRequest();

View File

@@ -50,18 +50,6 @@ export const configuration: Record<string, ResourceOverride> = {
} }
}, },
}, },
accounts: {
enumOption: {
key: 'id',
value: ['name', 'number']
}
},
tags: {
enumOption: {
key: 'id',
value: ['icon', 'name']
}
},
}; };
export const profileConfiguration = { export const profileConfiguration = {