2 Commits
1.2.0 ... main

Author SHA1 Message Date
2dd7f64f2b Fetch-request source types, glass-pane create page, date formatting (#18)
# MR: Fetch-request source types, glass-pane create page, date formatting

**Branch:** `xlsx-statement-support` → `main`
**Commits:** 1af0a6b231869d (6 commits)

## Summary

Three related strands of UI work on the fetch-request flow, plus library-level improvements in `react-openapi`:

1. **Source types** — the create form now offers Pdf / Xlsx / Csv / Email sources (spec-driven); legacy generic `file` is no longer offered for new fetch requests, and a per-request `trust_fallback` (`amount` | `balance`) can be set at creation.
2. **Create page redesign** — the flat single-column form is now two equal-height frosted "glass" panes over a subtle brand-indigo gradient backdrop: *Source & Account* (account/bank above the expanding source field) and *Window & Policy* (start/end/pipeline/trusted). Recent fetch-request rows share the same glass recipe.
3. **Date formatting** — all dates/datetimes across lists, details and display-format templates render as `"24 Aug 2026"` / `"24 Aug 2026, 09:07"` instead of raw ISO strings; datetime fields can opt into editing as a plain date picker while storing midnight.

## Changes

### react-openapi (library)
- **`src/utils/datetime.ts` (new)** — zero-dep `formatDate` / `formatDateTime` (en-IN via `Intl`). Only ISO-shaped strings are rewritten; DD-MM-YYYY expense strings and non-dates pass through untouched. Exported from the package index.
- Display wiring: `ListCellRenderer` catch-all + inline-dialog cells, `InlineRefField`, and `applyDisplayFormat` template interpolation (ISO-shaped values auto-format).
- **`x-edit-as` mechanism**: new `FieldConfig.editAs`; `DateField` renders a `date-time` field with `x-edit-as: date` as a plain date input, normalizes inbound values to `YYYY-MM-DD`, and submits `T00:00:00`. Routing sites unchanged.

### App
- `fetch-requests.models.ts`: `SourceType` gains `"csv"`, new `CsvSource`, create-side union drops `FileSource`.
- `FetchRequestCreate.tsx`: two-pane grid layout (explicit field partitioning, grid stretch for equal heights), fixed backdrop layer, local `GlassPanel` helper, list rows restyled; `created_at` pinned right before row actions.
- `ReportList.tsx`: generated date right-aligned before action buttons; outer row no longer wraps.
- `ReportViewer.tsx`: header timestamp formatted via exported `formatDateTime`.

## Verification

- `tsc -b` clean at every commit.
- Formatter behavior spot-checked: `2026-08-24T09:07:25.168000` → `24 Aug 2026, 09:07`; `01-05-2026` unchanged; `Z`-suffixed UTC converts to local wall time.

## Deploy notes

- Pairs with the khata-app MR (same branch name): backend supplies the Source oneOf (pdf/xlsx/csv/email), `trust_fallback`, and `x-edit-as` annotations consumed here.
- No dependency changes; no API changes client-side beyond honoring existing spec extensions.

Reviewed-on: #18
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-08-24 20:28:18 +00:00
9377460e79 refactor-for-llama-parse (#17)
# MR: LlamaParse pipeline support, fetch request deletion, and UX improvements

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

## Summary

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

## Changes

### Features

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

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

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

### Refactoring / Cleanup

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

### Chores

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

## Files changed

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

Reviewed-on: #17
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-08-23 16:58:32 +00:00
16 changed files with 374 additions and 93 deletions

1
.gitignore vendored
View File

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

View File

@@ -17,5 +17,6 @@ export { getApi } from "./src/hooks/useApi";
export { useItemSse } from "./src/hooks/useItemSse";
export { sanitizePayload } from "./src/utils/sanitize-payload";
export type { FkResolver } from "./src/utils/sanitize-payload";
export { formatDate, formatDateTime } from "./src/utils/datetime";
export type { FilterComponentProps } from "./src/context/useResource";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents, AuthConfig } from "./src/types";

View File

@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../types";
import { applyDisplayFormat, resolveMediaUrl } from "./utils";
import { formatByFieldFormat } from "../../utils/datetime";
import { InlineRefField } from "./renderers/InlineRefField";
import { CurrencyField } from "./renderers/CurrencyField";
import { extractFields } from "../../transformers/field-config";
@@ -57,7 +58,7 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
{sf.label}
</Typography>
<Typography variant="body2">
{fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : String(fv)}
{fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : formatByFieldFormat(fv, sf.format)}
</Typography>
</Box>
</Grid>
@@ -153,5 +154,5 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
}
return <Typography variant="body2">{String(value)}</Typography>;
return <Typography variant="body2">{formatByFieldFormat(value, field.format)}</Typography>;
}

View File

@@ -10,11 +10,26 @@ interface Props {
}
export function DateField({ field, value, onChange, error }: Props) {
const inputType = field.format === "date" ? "date" : "datetime-local";
// `x-edit-as: date` renders a date-time field as a plain date picker;
// the picked day is submitted as midnight so the stored type is intact.
const editAsDate = field.format === "date" || field.editAs === "date";
const normalized = field.format === "date-time" && typeof value === "string"
? value.replace(/\.\d+Z$/, "").replace(/Z$/, "")
: value;
const inputType = editAsDate ? "date" : "datetime-local";
const normalized = (() => {
if (field.format !== "date-time" || typeof value !== "string") return value;
if (editAsDate) return value.slice(0, 10);
return value.replace(/\.\d+Z$/, "").replace(/Z$/, "");
})();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
if (editAsDate && field.format === "date-time" && /^\d{4}-\d{2}-\d{2}$/.test(v)) {
onChange(`${v}T00:00:00`);
} else {
onChange(v);
}
};
return (
<TextField
@@ -22,7 +37,7 @@ export function DateField({ field, value, onChange, error }: Props) {
label={field.label}
type={inputType}
value={normalized ?? ""}
onChange={(e) => onChange(e.target.value)}
onChange={handleChange}
error={!!error}
helperText={error || field.description || undefined}
placeholder={field.description || undefined}

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { applyDisplayFormat } from "../utils";
import { formatByFieldFormat } from "../../../utils/datetime";
import { extractFields } from "../../../transformers/field-config";
import { useAppContext } from "../../../context/AppContext";
@@ -68,7 +69,7 @@ export function InlineRefField({ field, value }: Props) {
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{sf.label}
</Typography>
<Typography variant="body2">{value?.[sf.name] == null ? "—" : typeof value[sf.name] === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(value[sf.name], sf.inlineDisplayFormat) : JSON.stringify(value[sf.name])) : String(value[sf.name])}</Typography>
<Typography variant="body2">{value?.[sf.name] == null ? "—" : typeof value[sf.name] === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(value[sf.name], sf.inlineDisplayFormat) : JSON.stringify(value[sf.name])) : formatByFieldFormat(value[sf.name], sf.format)}</Typography>
</Box>
</Grid>
))}

View File

@@ -1,3 +1,5 @@
import { formatIsoLike } from "../../utils/datetime";
function getNested(obj: any, path: string): any {
return path.split(".").reduce((o, k) => o?.[k], obj);
}
@@ -6,7 +8,7 @@ export function applyDisplayFormat(item: any, format: string): string {
if (!item || typeof item !== "object") return String(item ?? "");
return format.replace(/\{([\w.]+)\}/g, (_, key) => {
const val = getNested(item, key);
return val != null ? String(val) : "";
return val != null ? formatIsoLike(val) : "";
});
}

View File

@@ -64,6 +64,7 @@ function extractOneOfOptions(schema: any, schemas: Record<string, any>, discrimi
description: prop["x-description"] ?? "",
type: prop.type ?? "string",
format: prop.format,
editAs: prop["x-edit-as"],
order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false,
@@ -130,6 +131,7 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
description: prop["x-description"] ?? "",
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
format: prop.format,
editAs: prop["x-edit-as"],
order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false,

View File

@@ -101,6 +101,8 @@ export interface FieldConfig {
description: string;
type: string;
format?: string;
/** `x-edit-as: date` — edit a `date-time` field with a plain date input; submits midnight. */
editAs?: string;
order: number;
hidden: { form?: boolean; list?: boolean; detail?: boolean };
filterable: boolean;

View File

@@ -0,0 +1,63 @@
/**
* Zero-dependency date/time display helpers.
*
* Only ISO-shaped strings ("2026-08-24", "2026-08-24T09:07:25[.f][Z]") are
* reformatted; anything else (DD-MM-YYYY expense strings, free text) passes
* through untouched. Naive strings without a zone are parsed as clock time
* so the displayed wall time matches what the backend stored.
*/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
function parseIso(value: any): Date | null {
if (typeof value !== "string") return null;
if (!ISO_DATE.test(value) && !ISO_DATETIME.test(value)) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
const DATE_FMT = new Intl.DateTimeFormat("en-IN", { day: "numeric", month: "short", year: "numeric" });
const DATETIME_FMT = new Intl.DateTimeFormat("en-IN", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
function fallback(value: any): string {
return typeof value === "string" ? value : value != null ? String(value) : "";
}
/** "2026-08-24" -> "24 Aug 2026"; non-ISO values pass through. */
export function formatDate(value: any): string {
const d = parseIso(value);
return d ? DATE_FMT.format(d) : fallback(value);
}
/** "2026-08-24T09:07:25" -> "24 Aug 2026, 09:07"; non-ISO values pass through. */
export function formatDateTime(value: any): string {
const d = parseIso(value);
return d ? DATETIME_FMT.format(d) : fallback(value);
}
/** Route by OpenAPI field format ("date" | "date-time"); other formats render plainly. */
export function formatByFieldFormat(value: any, format?: string): string {
if (format === "date") return formatDate(value);
if (format === "date-time") return formatDateTime(value);
return fallback(value);
}
/**
* Metadata-free variant for display-format templates ({key} interpolation):
* reformats a scalar only when it happens to be ISO-shaped.
*/
export function formatIsoLike(value: any): string {
if (typeof value === "string") {
if (ISO_DATETIME.test(value)) return formatDateTime(value);
if (ISO_DATE.test(value)) return formatDate(value);
}
return fallback(value);
}

View File

@@ -1,22 +1,60 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box, Container, Typography, Paper, Button, Alert,
CircularProgress,
CircularProgress, IconButton, Tooltip,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { alpha } from "@mui/material/styles";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { formatApiError } from "../features/fetch-requests";
import { useToast } from "../ui/Toast";
const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
const CREATE_FIELDS = ["account", "bank", "pipeline", "trust_fallback", "start_date", "end_date", "source"];
// Two-pane form partition — explicit names, spec order is unreliable.
// Grid stretch keeps both panes equal-height regardless of which source
// variant is active (email adds From/Subject under the type select).
const SOURCE_PANE_FIELDS = ["account", "bank"];
const POLICY_PANE_FIELDS = ["start_date", "end_date", "pipeline", "trust_fallback"];
const glassSx = (theme: any) => ({
backgroundColor: alpha(theme.palette.background.default, 0.72),
backdropFilter: "blur(8px)",
borderColor: "divider",
boxShadow: 1,
});
function GlassPanel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Paper variant="outlined" sx={(theme) => ({ p: 3, borderRadius: 3, ...glassSx(theme) })}>
<Typography
variant="overline"
sx={{ display: "block", mb: 2, color: "text.secondary", letterSpacing: 1.2, lineHeight: 1.4 }}
>
{title}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>{children}</Box>
</Paper>
);
}
function FetchRequestList() {
const navigate = useNavigate();
const { list, resource } = useResource("fetch-requests");
const { list, remove, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [rows, setRows] = useState<any[] | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { showToast } = useToast();
const reload = () => {
if (!resource) return;
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
};
const columns = useMemo(() => {
if (!resource) return [];
@@ -30,6 +68,26 @@ function FetchRequestList() {
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
}, [resource?.name]);
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeletingId(id);
try {
await remove(id);
showToast("Fetch request deleted");
reload();
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeletingId(null);
}
};
if (!rows) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
@@ -58,30 +116,63 @@ function FetchRequestList() {
<Paper
key={row.id ?? i}
variant="outlined"
sx={{
sx={(theme) => ({
p: 2,
cursor: "pointer",
borderRadius: 2,
transition: "border-color 160ms ease, box-shadow 160ms ease",
"&:hover": { borderColor: "primary.main", boxShadow: 1 },
}}
borderRadius: 3,
transition: "border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease",
"&:hover": {
borderColor: "primary.main",
boxShadow: 1,
backgroundColor: alpha(theme.palette.background.default, 0.9),
},
...glassSx(theme),
})}
onClick={() => navigate(`/fetch-requests/${row.id}`)}
>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{columns.map((col) => (
<Box key={col.name} sx={{ minWidth: 120 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{col.label}
</Typography>
<ListCellRenderer
field={col}
value={row[col.name]}
displayFormat={col.fk
? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? displayFormat)
: displayFormat}
/>
</Box>
))}
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center", flex: 1, minWidth: 0 }}>
{columns
.filter((col) => col.name !== "created_at")
.map((col) => (
<Box key={col.name} sx={{ minWidth: 120 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{col.label}
</Typography>
<ListCellRenderer
field={col}
value={row[col.name]}
displayFormat={col.fk
? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? displayFormat)
: displayFormat}
/>
</Box>
))}
</Box>
{(() => {
const dateCol = columns.find((col) => col.name === "created_at");
if (!dateCol) return null;
return (
<Box sx={{ flexShrink: 0, textAlign: "right" }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{dateCol.label}
</Typography>
<ListCellRenderer field={dateCol} value={row.created_at} displayFormat={displayFormat} />
</Box>
);
})()}
<Box sx={{ flexShrink: 0 }}>
<Tooltip title="Delete fetch request">
<IconButton
size="small"
color="error"
disabled={deletingId === row.id || row.status === "processing"}
onClick={(e) => handleDelete(e, row.id)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Paper>
);
@@ -94,6 +185,7 @@ export default function FetchRequestCreate() {
const { resources: allResources } = useAppContext();
const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]);
const { create } = useResource("fetch-requests");
const navigate = useNavigate();
const [formData, setFormData] = useState<Record<string, any>>({});
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
const [loading, setLoading] = useState(false);
@@ -143,6 +235,10 @@ export default function FetchRequestCreate() {
const display = applyDisplayFormat(created, resource!.displayFormat);
setResult({ severity: "success", message: `Created: ${display}` });
setFormData({});
const newId = (created as any)?.id;
if (newId) {
navigate(`/fetch-requests/${newId}`);
}
} catch (e: any) {
const detail = e?.response?.data?.detail;
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error");
@@ -160,59 +256,90 @@ export default function FetchRequestCreate() {
);
}
const renderPaneFields = (names: string[]) =>
names.map((name) => {
const field = formFields.find((f) => f.name === name);
if (!field) return null;
return (
<FormFieldRenderer
key={field.name}
field={field}
value={formData[field.name] ?? ""}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
/>
);
});
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests" }]}
title="Fetch Requests"
subtitle="Import transactions from bank statements or email and track pipeline progress."
<Box sx={{ position: "relative" }}>
{/* Decorative gradient backdrop — gives the frosted panes something to blur. */}
<Box
aria-hidden
sx={{
position: "fixed",
inset: 0,
zIndex: 0,
pointerEvents: "none",
background: (theme) =>
[
`radial-gradient(640px circle at 12% 8%, ${alpha(theme.palette.primary.main, 0.14)}, transparent 70%)`,
`radial-gradient(900px circle at 88% 92%, ${alpha(theme.palette.primary.main, 0.09)}, transparent 70%)`,
`radial-gradient(520px circle at 78% 22%, ${alpha(theme.palette.primary.light, 0.07)}, transparent 70%)`,
].join(", "),
}}
/>
<Container maxWidth="lg" sx={{ py: 4, position: "relative", zIndex: 1 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests" }]}
title="Fetch Requests"
subtitle="Import transactions from bank statements or email and track pipeline progress."
/>
<Paper
id="new-fetch-request"
variant="outlined"
sx={{ p: 3, mb: 4, borderRadius: 3 }}
>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
New Fetch Request
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, maxWidth: 520, lineHeight: 1.6 }}>
Choose an account, pipeline, and a file or email source to kick off an import.
</Typography>
<Box id="new-fetch-request" sx={{ mb: 4 }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom>
New Fetch Request
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5, maxWidth: 560, lineHeight: 1.6 }}>
Choose an account, pipeline, and a file or email source to kick off an import.
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, maxWidth: 520 }}>
{formFields.map((field) => (
<FormFieldRenderer
key={field.name}
field={field}
value={formData[field.name] ?? ""}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
/>
))}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 2,
}}
>
<GlassPanel title="Source & Account">
{renderPaneFields(SOURCE_PANE_FIELDS)}
{renderPaneFields(["source"])}
</GlassPanel>
<GlassPanel title="Window & Policy">{renderPaneFields(POLICY_PANE_FIELDS)}</GlassPanel>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3, gap: 1.5 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading} sx={{ minWidth: 180 }}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request
</Button>
</Box>
{result && (
<Alert severity={result.severity} sx={{ mt: 2 }} onClose={() => setResult(null)}>
{result.message}
</Alert>
)}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3, gap: 1.5 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading} sx={{ minWidth: 180 }}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request
</Button>
</Box>
{result && (
<Alert severity={result.severity} sx={{ mt: 2 }} onClose={() => setResult(null)}>
{result.message}
</Alert>
)}
</Paper>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Recent Fetch Requests
</Typography>
<FetchRequestList />
</Container>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Recent Fetch Requests
</Typography>
<FetchRequestList />
</Container>
</Box>
);
}

View File

@@ -10,6 +10,7 @@ import {
CircularProgress,
Alert,
Divider,
Tooltip,
} from "@mui/material";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
@@ -18,6 +19,7 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
@@ -85,11 +87,12 @@ function Section({ title, children, action }: { title: string; children: React.R
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { get, patch, resource } = useResource("fetch-requests");
const { get, patch, remove, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false);
const [deleting, setDeleting] = useState(false);
const { showToast } = useToast();
const feedRef = useRef<HTMLDivElement>(null);
@@ -174,6 +177,26 @@ export default function FetchRequestDetail() {
}
};
const handleDelete = async () => {
if (!id || !remove || deleting) return;
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeleting(true);
try {
await remove(id);
showToast("Fetch request deleted");
navigate("/fetch-requests");
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeleting(false);
}
};
const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
@@ -286,6 +309,20 @@ export default function FetchRequestDetail() {
Retry
</Button>
)}
<Tooltip title={status === "processing" ? "Cannot delete while processing" : "Delete fetch request"}>
<span>
<Button
variant="outlined"
size="small"
color="error"
startIcon={<DeleteOutlineIcon />}
onClick={handleDelete}
disabled={deleting || status === "processing"}
>
Delete
</Button>
</span>
</Tooltip>
</Box>
</Section>

View File

@@ -29,7 +29,12 @@ function computeProgressPercent(
let pct = 0;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
if (
seenSteps.has("raw_lines") ||
seenSteps.has("txn_blocks")
) {
pct += 10;
}
if (txnBlockCount > 0) {
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);

View File

@@ -51,7 +51,7 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg
"&:hover": { borderColor: "primary.light" },
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: "1 1 260px", minWidth: 0 }}>
<Typography variant="body1" fontWeight={600} noWrap sx={{ fontSize: "0.9375rem" }}>
{report.name || report.id}
@@ -81,14 +81,15 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg
{amounts}
</Typography>
)}
{fields && (
<Box sx={{ color: "text.secondary" }}>
<ListCellRenderer field={fields.generatedAt} value={report.generated_at} />
</Box>
)}
</Box>
</Box>
{fields && (
<Box sx={{ ml: "auto", flexShrink: 0, color: "text.secondary" }}>
<ListCellRenderer field={fields.generatedAt} value={report.generated_at} />
</Box>
)}
<Box sx={{ display: "flex", gap: 0.5, flexShrink: 0 }}>
<Button size="small" variant="contained" startIcon={<VisibilityIcon />} onClick={() => onView(report.id)}>
View

View File

@@ -4,7 +4,7 @@ import CloseIcon from "@mui/icons-material/Close";
import CachedIcon from "@mui/icons-material/Cached";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { FkMultiSelectField, useResource, formatCurrency } from "../../react-openapi";
import { FkMultiSelectField, useResource, formatCurrency, formatDateTime } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { StatCard } from "../common/components/StatCard";
import { TransactionList } from "../common/components/TransactionList";
@@ -266,7 +266,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<Typography variant="caption" color="text.secondary">
flow {report.flow} · generated {report.generated_at ?? report.created_at}
flow {report.flow} · generated {formatDateTime(report.generated_at ?? report.created_at)}
</Typography>
{range && (
<Typography variant="caption" color="text.disabled">

View File

@@ -7,7 +7,10 @@ export type FetchRequestStatus =
| "completed"
| "failed";
export type SourceType = "file" | "pdf" | "xlsx" | "csv" | "email";
export interface FileSource {
type?: SourceType;
path: string;
bank: string;
raw_lines?: string[];
@@ -17,7 +20,20 @@ export interface FileSource {
txn_dicts_count?: number;
}
export interface PdfSource extends FileSource {
type: "pdf";
}
export interface XlsxSource extends FileSource {
type: "xlsx";
}
export interface CsvSource extends FileSource {
type: "csv";
}
export interface EmailSource {
type: "email";
bank: string;
from_email?: string;
subject?: string;
@@ -26,12 +42,15 @@ export interface EmailSource {
txn_dicts_count?: number;
}
export type PipelineType = "heuristic" | "llm" | "llama_parser";
export type PipelineType = "heuristic" | "llm" | "llama_parse";
export type TrustFallback = "amount" | "balance";
export interface FetchRequestCreate {
source: FileSource | EmailSource;
source: PdfSource | XlsxSource | CsvSource | EmailSource;
account_name: string;
pipeline?: PipelineType;
trust_fallback?: TrustFallback | null;
payor_username?: string;
start_date?: string;
end_date?: string;
@@ -113,7 +132,7 @@ export interface SSEEvent {
export interface FetchRequestFilters {
status?: FetchRequestStatus[];
account_name?: string;
source_type?: "file" | "email";
source_type?: SourceType;
}
export function formatApiError(err: any): string {

View File

@@ -5,6 +5,9 @@ export type {
FetchRequestStatus,
FetchRequestFilters,
FileSource,
PdfSource,
XlsxSource,
SourceType,
EmailSource,
UploadResult,
PendingAmbiguity,
@@ -15,6 +18,7 @@ export type {
SSEEventStatus,
ProgressMessage,
PipelineType,
TrustFallback,
} from "./fetch-requests.models";
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
export {