# Summary Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. ## Changes ### Admin UI Customization * Added support for custom: * Dashboard component * Layout component * Login page component * Introduced `AdminAppProps` and extended `Admin` configuration API. * Renamed internal dashboard implementation to `DefaultDashboard`. ### Field Component Architecture * Extracted field rendering into dedicated field components: * TextField * NumberField * BooleanField * DateField * EnumField * RelationField * ObjectField * FallbackField * DateRangeField * NumberRangeField * Added `defaultFieldComponents` registry. * Refactored `FormField` to resolve components dynamically from a component map instead of hardcoded field type handling. ### Resource Customization * Added `FieldComponents` support across: * Admin * ResourceView * GenericForm * useResource * Introduced wrapped `FormField` and `GenericForm` components generated from configured field overrides. ### Table Customization * Added `EnhancedTableComponents`. * Added support for custom cell renderers per field type. * Enabled custom rendering for both desktop and mobile table layouts. ### Filter Improvements * Exported `FilterAutocomplete`. * Added support for custom date-range and number-range filter components. * Added filter component extension points. * Updated filter option label resolution to support `displayFormat`. ### Display Formatting * Replaced `displayField` usage with `displayFormat`. * Added template-based display rendering support through `resolveTemplate`. * Improved relation display configuration handling. ### TypeScript Improvements * Added TypeScript as a project dependency. * Removed multiple `@ts-ignore` usages. * Added strongly typed Axios wrapper methods with generic response support. * Improved typing across hooks and component interfaces. ### OpenAPI Configuration Validation * Added validation for enum fields without enum values. * Added validation for relation resources missing `referenceOptions.enumOption`. * Improved relation metadata propagation during schema parsing. ### Library Exports * Exported: * Field component types * Override types * EnhancedTable * GenericForm * ResourceView * Field components and defaults * Expanded public API surface for consumers extending the framework. ## Benefits * Enables complete UI customization without modifying framework internals. * Simplifies creation of custom field types and renderers. * Improves type safety and developer experience. * Provides consistent extension points for forms, tables, filters, and admin layouts. * Makes the framework more suitable for reusable library distribution. Reviewed-on: #11 Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com> Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
675 lines
26 KiB
TypeScript
675 lines
26 KiB
TypeScript
import * as React from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import {
|
|
Box,
|
|
Container,
|
|
Paper,
|
|
Typography,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Alert,
|
|
Stepper,
|
|
Step,
|
|
StepLabel,
|
|
StepIcon,
|
|
LinearProgress,
|
|
IconButton,
|
|
Snackbar,
|
|
} from "@mui/material";
|
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
|
import ReplayIcon from "@mui/icons-material/Replay";
|
|
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
|
import ErrorIcon from "@mui/icons-material/Error";
|
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
|
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
|
|
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
|
import {
|
|
useFetchRequestAmbiguities,
|
|
useResolveAmbiguity,
|
|
} from "./features/fetch-requests";
|
|
import type {
|
|
FetchRequestStatus,
|
|
SSEEvent,
|
|
ProgressMessage,
|
|
} from "./features/fetch-requests";
|
|
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
|
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
|
|
|
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
|
pending: "default",
|
|
processing: "info",
|
|
paused: "warning",
|
|
raw_expenses_done: "primary",
|
|
enriched_done: "warning",
|
|
completed: "success",
|
|
failed: "error",
|
|
};
|
|
|
|
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
|
|
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
|
|
processing: <CircularProgress size={14} />,
|
|
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
|
|
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
|
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
|
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
|
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
|
|
};
|
|
|
|
function computeProgressPercent(
|
|
status: FetchRequestStatus,
|
|
liveCount: number,
|
|
seenSteps: Set<string>,
|
|
stepStats: Record<string, number>,
|
|
txnBlockCount: number,
|
|
txnDictCount: number,
|
|
): number {
|
|
if (status === "pending") return 0;
|
|
if (status === "completed") return 100;
|
|
|
|
let pct = 0;
|
|
|
|
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
|
|
|
|
if (txnBlockCount > 0) {
|
|
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);
|
|
pct += Math.min(1, current / txnBlockCount) * 20;
|
|
}
|
|
|
|
if (txnDictCount > 0) {
|
|
pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50;
|
|
pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20;
|
|
}
|
|
|
|
return Math.round(Math.min(100, pct));
|
|
}
|
|
|
|
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
|
|
|
|
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
|
|
if (status === "completed") return stepLabels.length;
|
|
|
|
if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return stepLabels.length;
|
|
if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3;
|
|
|
|
if (seenSteps.has("enrich/completed")) return 3;
|
|
if (seenSteps.has("enrich")) return 2;
|
|
|
|
if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2;
|
|
if (seenSteps.has("txn_dicts")) return 1;
|
|
|
|
if (seenSteps.has("txn_blocks/completed")) return 1;
|
|
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0;
|
|
|
|
if (status === "processing" || status === "paused") return 0;
|
|
|
|
return -1;
|
|
}
|
|
|
|
function formatProgressMessage(msg: ProgressMessage): string {
|
|
if (msg.lines !== undefined) return `${msg.lines} lines`;
|
|
if (msg.blocks !== undefined) return `${msg.blocks} blocks`;
|
|
if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`;
|
|
if (msg.count !== undefined) return `${msg.count} items`;
|
|
if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`;
|
|
if (msg.error) return msg.error.slice(0, 80);
|
|
return "";
|
|
}
|
|
|
|
function sseIcon(status: SSEEvent["status"]) {
|
|
switch (status) {
|
|
case "started": return <CircularProgress size={14} />;
|
|
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
|
|
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
|
|
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
|
|
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
|
|
case "progress": return (
|
|
<FiberManualRecordIcon
|
|
sx={{ fontSize: 14, color: "info.main" }}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
function isMathValid(candidate: { amount: number; balance: number }, prevBalance: number) {
|
|
return (
|
|
candidate.balance === prevBalance + candidate.amount ||
|
|
candidate.balance === prevBalance - candidate.amount ||
|
|
Math.abs(candidate.balance - (prevBalance + candidate.amount)) < 0.01 ||
|
|
Math.abs(candidate.balance - (prevBalance - candidate.amount)) < 0.01
|
|
);
|
|
}
|
|
|
|
export default function FetchRequestDetail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const config = useConfig();
|
|
|
|
const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
|
|
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
|
|
const updateMutation = usePatch();
|
|
const resolveMutation = useResolveAmbiguity();
|
|
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
|
|
|
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
|
|
const [sseConnected, setSseConnected] = React.useState(false);
|
|
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
|
|
const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
|
|
const [failNotif, setFailNotif] = React.useState<string | null>(null);
|
|
const sseRef = React.useRef<EventSource | null>(null);
|
|
const feedRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
const txnBlockCount = React.useMemo(() => {
|
|
const blocks = (fetchRequest as any)?.source?.txn_blocks;
|
|
if (!blocks) return 0;
|
|
return Object.values(blocks).reduce(
|
|
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
|
|
0,
|
|
);
|
|
}, [fetchRequest]);
|
|
|
|
const stepMessages = React.useMemo(() => {
|
|
const msgs: Record<number, string> = {};
|
|
const source = (fetchRequest as any)?.source;
|
|
|
|
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
|
|
if (rawLineCount) msgs[0] = `${rawLineCount}`;
|
|
|
|
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
|
|
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
|
|
const dictCurrent = Math.max(dictLive, sourceDictCount);
|
|
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
|
|
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
|
|
|
|
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
|
|
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
|
|
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
|
|
|
|
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
|
|
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
|
|
|
|
return msgs;
|
|
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
|
|
|
|
React.useEffect(() => {
|
|
if (!id || !config?.baseUrl) return;
|
|
const url = `${config.baseUrl}/fetch-requests/${id}/events`;
|
|
const es = new EventSource(url);
|
|
sseRef.current = es;
|
|
|
|
es.onopen = () => setSseConnected(true);
|
|
es.onerror = () => setSseConnected(false);
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const parsed: SSEEvent = JSON.parse(event.data);
|
|
setSseEvents((prev) => [...prev, parsed]);
|
|
|
|
if (parsed.status === "progress" && parsed.message.count !== undefined) {
|
|
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
|
|
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
|
|
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
|
|
}
|
|
|
|
if (parsed.status === "completed" && parsed.message.count !== undefined) {
|
|
const stats: Record<string, number> = {};
|
|
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
|
|
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
|
|
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
|
|
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
|
|
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
|
|
if (Object.keys(stats).length) {
|
|
setStepStats((prev) => ({ ...prev, ...stats }));
|
|
}
|
|
}
|
|
|
|
if (parsed.status === "paused") {
|
|
refetchRequest();
|
|
refetchAmbiguities();
|
|
}
|
|
if (parsed.status === "failed") {
|
|
setFailNotif(parsed.message.error || "Fetch request failed");
|
|
refetchRequest();
|
|
}
|
|
if (parsed.status === "completed" || parsed.step === "resume_extract") {
|
|
refetchRequest();
|
|
}
|
|
} catch {
|
|
// ignore malformed events
|
|
}
|
|
};
|
|
|
|
return () => {
|
|
es.close();
|
|
sseRef.current = null;
|
|
};
|
|
}, [id, config?.baseUrl]);
|
|
|
|
React.useEffect(() => {
|
|
if (feedRef.current) {
|
|
feedRef.current.scrollTop = feedRef.current.scrollHeight;
|
|
}
|
|
}, [sseEvents]);
|
|
|
|
const displayEvents = React.useMemo(() => {
|
|
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
|
|
const lastProgressIdx: Record<string, number> = {};
|
|
for (let i = sseEvents.length - 1; i >= 0; i--) {
|
|
const e = sseEvents[i];
|
|
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
|
|
lastProgressIdx[e.step] = i;
|
|
}
|
|
}
|
|
|
|
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
|
|
return sseEvents.filter((e, i) => {
|
|
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
|
|
if (e.status === "started") {
|
|
return !sseEvents.slice(i + 1).some(
|
|
(later) => later.step === e.step && terminalStatuses.has(later.status),
|
|
);
|
|
}
|
|
return true;
|
|
});
|
|
}, [sseEvents]);
|
|
|
|
const seenSteps = React.useMemo(() => {
|
|
const steps = new Set<string>();
|
|
for (const evt of sseEvents) {
|
|
steps.add(evt.step);
|
|
if (evt.status === "completed") steps.add(`${evt.step}/completed`);
|
|
if (evt.status === "failed") steps.add(`${evt.step}/failed`);
|
|
if (evt.status === "started") steps.add(`${evt.step}/started`);
|
|
if (evt.status === "progress") steps.add(`${evt.step}/progress`);
|
|
}
|
|
return steps;
|
|
}, [sseEvents]);
|
|
|
|
const displayParsedCount = React.useMemo(() => {
|
|
if (liveParsedCount && liveParsedCount > 0) return liveParsedCount;
|
|
const source = (fetchRequest as any)?.source;
|
|
const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
|
|
if (persistedCount > 0) return persistedCount;
|
|
const dicts = source?.txn_dicts;
|
|
if (Array.isArray(dicts) && dicts.length > 0) return dicts.length;
|
|
return 0;
|
|
}, [liveParsedCount, fetchRequest]);
|
|
|
|
const txnDictCount = React.useMemo(() => {
|
|
const source = (fetchRequest as any)?.source;
|
|
if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts;
|
|
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
|
|
}, [fetchRequest, stepStats]);
|
|
|
|
const progressPercent = React.useMemo(
|
|
() => computeProgressPercent(
|
|
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
|
|
displayParsedCount,
|
|
seenSteps,
|
|
stepStats,
|
|
txnBlockCount,
|
|
txnDictCount,
|
|
),
|
|
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
|
|
);
|
|
|
|
const handleRetry = async () => {
|
|
if (!id) return;
|
|
try {
|
|
await updateMutation.mutateAsync({ id, data: { status: "pending" } });
|
|
} catch (err: any) {
|
|
setFailNotif(formatApiError(err));
|
|
}
|
|
};
|
|
|
|
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
|
|
await resolveMutation.mutateAsync({
|
|
ambiguityId: ambiguity.id,
|
|
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
|
|
});
|
|
refetchAmbiguities();
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (fetchError || !fetchRequest) {
|
|
return (
|
|
<Container sx={{ mt: 4 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
|
|
Back
|
|
</Button>
|
|
<Alert severity="error">Failed to load fetch request</Alert>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
const req = fetchRequest as any;
|
|
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
|
|
const retryCount = req.retry_count ?? 0;
|
|
const isRetryExhausted = retryCount >= RETRY_MAX;
|
|
const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? [];
|
|
const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? [];
|
|
const hasAmbiguities = ambiguities && ambiguities.length > 0;
|
|
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0;
|
|
const ambiguitiesLoading = !ambiguities;
|
|
|
|
return (
|
|
<Container sx={{ mt: 4, mb: 4 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
|
|
Back to Fetch Requests
|
|
</Button>
|
|
|
|
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
|
|
<Chip
|
|
icon={statusIcons[req.status as FetchRequestStatus] as any}
|
|
label={req.status.replace(/_/g, " ")}
|
|
color={statusColors[req.status as FetchRequestStatus]}
|
|
/>
|
|
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
|
|
<Chip
|
|
label={"path" in req.source ? "File" : "Email"}
|
|
size="small"
|
|
variant="outlined"
|
|
color={"path" in req.source ? "primary" : "secondary"}
|
|
/>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Date Range</Typography>
|
|
<Typography variant="body2">
|
|
{(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} → {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"}
|
|
</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Created</Typography>
|
|
<Typography variant="body2">{new Date(req.created_at).toLocaleString()}</Typography>
|
|
</Box>
|
|
{req.completed_at && (
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Completed</Typography>
|
|
<Typography variant="body2">{new Date(req.completed_at).toLocaleString()}</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Box sx={{ mb: 2 }}>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Overall Progress
|
|
</Typography>
|
|
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
|
|
<Typography variant="caption" fontWeight={600} color="info.main">
|
|
Validated: {displayParsedCount} transactions
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={progressPercent}
|
|
color={req.status === "failed" ? "error" : req.status === "completed" ? "success" : "primary"}
|
|
sx={{ borderRadius: 1, height: 8, transition: "width 0.3s ease" }}
|
|
/>
|
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: "block" }}>
|
|
{progressPercent}%
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
|
<Box sx={{ flex: 1, maxWidth: 300 }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Retries: {retryCount}/{RETRY_MAX}
|
|
</Typography>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={(retryCount / RETRY_MAX) * 100}
|
|
color={isRetryExhausted ? "error" : "primary"}
|
|
sx={{ mt: 0.5, borderRadius: 1, height: 6 }}
|
|
/>
|
|
</Box>
|
|
{req.status === "failed" && !isRetryExhausted && (
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
startIcon={<ReplayIcon />}
|
|
onClick={handleRetry}
|
|
disabled={updateMutation.isPending}
|
|
>
|
|
Retry
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
|
|
{req.status === "failed" && req.error_message && (
|
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
|
{req.error_message}
|
|
</Alert>
|
|
)}
|
|
|
|
{isRetryExhausted && req.status === "failed" && (
|
|
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
|
|
Max retries reached — no further retry attempts will be made.
|
|
</Alert>
|
|
)}
|
|
|
|
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
|
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
|
Pipeline Progress
|
|
</Typography>
|
|
<Stepper activeStep={activeStep} alternativeLabel>
|
|
{stepLabels.map((label, index) => {
|
|
const isCompleted = index < activeStep;
|
|
const isActive = index === activeStep;
|
|
const isPaused = req.status === "paused" && isActive;
|
|
const isFailed = req.status === "failed" && isActive;
|
|
|
|
let icon: React.ReactNode;
|
|
if (isCompleted) {
|
|
icon = <CheckCircleIcon sx={{ color: "success.main" }} />;
|
|
} else if (isFailed) {
|
|
icon = <ErrorIcon sx={{ color: "error.main" }} />;
|
|
} else if (isPaused) {
|
|
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
|
|
} else if (isActive) {
|
|
icon = <CircularProgress size={20} />;
|
|
} else {
|
|
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
|
|
}
|
|
|
|
const stepMsg = stepMessages[index];
|
|
|
|
return (
|
|
<Step key={label}>
|
|
<StepLabel
|
|
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
|
|
>
|
|
<Typography variant="body2" fontWeight={600}>{label}</Typography>
|
|
{stepMsg && (
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
|
|
{stepMsg}
|
|
</Typography>
|
|
)}
|
|
</StepLabel>
|
|
</Step>
|
|
);
|
|
})}
|
|
</Stepper>
|
|
</Paper>
|
|
|
|
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
|
|
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
|
|
Progress Events
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: "50%",
|
|
bgcolor: sseConnected ? "success.main" : "error.main",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{sseConnected ? "Connected" : "Disconnected"}
|
|
</Typography>
|
|
</Box>
|
|
<Box
|
|
ref={feedRef}
|
|
sx={{
|
|
maxHeight: 300,
|
|
overflowY: "auto",
|
|
p: 2,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{displayEvents.length === 0 ? (
|
|
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
|
|
Waiting for events...
|
|
</Typography>
|
|
) : (
|
|
displayEvents.map((evt, i) => (
|
|
<Box
|
|
key={i}
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1.5,
|
|
p: 1,
|
|
borderRadius: 2,
|
|
bgcolor: "action.hover",
|
|
}}
|
|
>
|
|
{sseIcon(evt.status)}
|
|
<Box sx={{ flex: 1 }}>
|
|
<Typography variant="body2" fontWeight={600}>
|
|
{evt.step.replace(/_/g, " ")}
|
|
</Typography>
|
|
{evt.message && formatProgressMessage(evt.message) && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{formatProgressMessage(evt.message)}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
<Typography variant="caption" color="text.disabled">
|
|
{new Date().toLocaleTimeString()}
|
|
</Typography>
|
|
</Box>
|
|
))
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
|
|
{hasAmbiguities && (
|
|
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
|
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
|
Ambiguity Resolution
|
|
</Typography>
|
|
|
|
{allResolved ? (
|
|
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
|
|
All ambiguities resolved — pipeline will resume on next poll cycle
|
|
</Alert>
|
|
) : (
|
|
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
|
|
Pipeline paused — resolve ambiguities to continue
|
|
</Alert>
|
|
)}
|
|
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
{ambiguities.map((ambiguity: any) => {
|
|
const isResolved = ambiguity.status === "resolved";
|
|
return (
|
|
<Paper
|
|
key={ambiguity.id}
|
|
sx={{
|
|
p: 2,
|
|
borderRadius: 3,
|
|
border: 1,
|
|
borderColor: isResolved ? "success.main" : "divider",
|
|
opacity: isResolved ? 0.8 : 1,
|
|
}}
|
|
variant="outlined"
|
|
>
|
|
<Box sx={{ fontFamily: "monospace", fontSize: "0.85rem", mb: 1.5, p: 1, bgcolor: "grey.900", borderRadius: 1, color: "grey.100" }}>
|
|
{ambiguity.line}
|
|
</Box>
|
|
|
|
<Box sx={{ display: "flex", gap: 3, mb: 1.5, flexWrap: "wrap" }}>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">OCR Amount</Typography>
|
|
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
|
|
₹{ambiguity.ocr_amount}
|
|
</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">OCR Balance</Typography>
|
|
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
|
|
₹{ambiguity.ocr_balance}
|
|
</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Previous Balance</Typography>
|
|
<Typography variant="body2">₹{ambiguity.prev_balance}</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
{isResolved ? (
|
|
<Alert severity="success" sx={{ py: 0.5, borderRadius: 2 }} icon={<CheckCircleIcon />}>
|
|
Resolved: ₹{ambiguity.chosen?.amount} / ₹{ambiguity.chosen?.balance}
|
|
</Alert>
|
|
) : (
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
{ambiguity.candidates.map((candidate: any, ci: number) => {
|
|
const isCredit = candidate.amount > 0;
|
|
const isDebit = candidate.amount < 0;
|
|
const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined;
|
|
return (
|
|
<Button
|
|
key={ci}
|
|
variant="outlined"
|
|
size="small"
|
|
onClick={() => handleResolve(ambiguity, candidate)}
|
|
disabled={resolveMutation.isPending}
|
|
sx={{
|
|
borderColor: cColor,
|
|
color: cColor,
|
|
"&:hover": cColor ? { borderColor: cColor } : undefined,
|
|
}}
|
|
>
|
|
₹{candidate.amount} / ₹{candidate.balance}
|
|
</Button>
|
|
);
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Paper>
|
|
)}
|
|
<Snackbar
|
|
open={!!failNotif}
|
|
autoHideDuration={6000}
|
|
onClose={() => setFailNotif(null)}
|
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
|
>
|
|
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
|
|
{failNotif}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Container>
|
|
);
|
|
}
|