From cebde4c18bbecc03e502ec969da9973ca358ccdd Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 31 Jul 2026 00:14:59 +0530 Subject: [PATCH] feat(react-openapi): add patch operation support - Add patch to ResourceConfig.operations type - Separate PUT and PATCH detection in resource-config transformer - update() now always calls PUT; new patch() method available conditionally when spec defines PATCH --- react-openapi/src/context/useResource.tsx | 33 +- .../src/transformers/resource-config.ts | 14 +- react-openapi/src/types.ts | 1 + src/FetchRequestDetail.tsx | 642 ------------------ src/FetchRequests.tsx | 509 -------------- src/RequireAuth.tsx | 26 - 6 files changed, 36 insertions(+), 1189 deletions(-) delete mode 100644 src/FetchRequestDetail.tsx delete mode 100644 src/FetchRequests.tsx delete mode 100644 src/RequireAuth.tsx diff --git a/react-openapi/src/context/useResource.tsx b/react-openapi/src/context/useResource.tsx index 4e9d7de..fb2ac04 100644 --- a/react-openapi/src/context/useResource.tsx +++ b/react-openapi/src/context/useResource.tsx @@ -56,6 +56,7 @@ interface UseResourceReturn { get: (id: string | number, params?: Record) => Promise; create: (data: any) => Promise; update: (id: string | number, data: any) => Promise; + patch?: (id: string | number, data: any) => Promise; remove: (id: string | number) => Promise; stream?: (handlers: StreamHandlers, pathParams?: Record) => StreamSubscription; loading: boolean; @@ -596,7 +597,7 @@ export function useResource(resourceName: string): UseResourceReturn { const rPath = resource?.path; const rPagination = resource?.pagination; - const rUpdateMethod = resource?.updateMethod; + const rPatch = resource ? (resource.operations.patch || undefined) : false; const rStreaming = resource?.streaming; const rFields = resource?.fields; @@ -706,8 +707,7 @@ export function useResource(resourceName: string): UseResourceReturn { const sanitized = rFields && schemas ? await sanitizePayload(data, rFields, schemas, resolveFk) : data; - const method = rUpdateMethod ?? "put"; - const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, sanitized); + const res = await api.put(`${rPath}/${id}`, sanitized); return res.data; } catch (e: any) { setError(parseError(e)); @@ -716,7 +716,29 @@ export function useResource(resourceName: string): UseResourceReturn { 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 => { + 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( @@ -791,6 +813,7 @@ export function useResource(resourceName: string): UseResourceReturn { get, create, update, + patch: undefined, remove, stream: undefined, 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 }; } \ No newline at end of file diff --git a/react-openapi/src/transformers/resource-config.ts b/react-openapi/src/transformers/resource-config.ts index 8751192..1b2af49 100644 --- a/react-openapi/src/transformers/resource-config.ts +++ b/react-openapi/src/transformers/resource-config.ts @@ -98,9 +98,9 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { const parent = nameMap.get(parentName); if (!parent) continue; 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, "patch") && !hasOperation(pathObj, "put")) parent.updateMethod = "patch"; continue; } @@ -127,8 +127,8 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields, orderedFields: [], operations: hasSSE - ? { list: true, get: false, create: false, update: false, delete: false } - : { list: hasOperation(pathObj, "get"), 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, patch: false, delete: false }, updateMethod: "put", pagination: hasSSE ? null : detectPagination(pathObj), relationships: [], @@ -172,9 +172,9 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { listColumns: schema?.["x-list-columns"] ?? [], fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields, orderedFields: [], - operations: hasSSE - ? { list: true, get: false, create: false, update: false, delete: false } - : { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, delete: false }, + operations: hasSSE + ? { list: true, get: false, create: false, update: false, patch: false, delete: false } + : { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, patch: false, delete: false }, updateMethod: "put", pagination: hasSSE ? null : detectPagination(pathObj), relationships, diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts index ec4e1e5..0964d1a 100644 --- a/react-openapi/src/types.ts +++ b/react-openapi/src/types.ts @@ -65,6 +65,7 @@ export interface ResourceConfig { get: boolean; create: boolean; update: boolean; + patch: boolean; delete: boolean; }; updateMethod: "put" | "patch"; diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx deleted file mode 100644 index 2e0c42a..0000000 --- a/src/FetchRequestDetail.tsx +++ /dev/null @@ -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 = { - pending: "default", - processing: "info", - paused: "warning", - raw_expenses_done: "primary", - enriched_done: "warning", - completed: "success", - failed: "error", -}; - -const statusIcons: Record = { - pending: , - processing: , - paused: , - raw_expenses_done: , - enriched_done: , - completed: , - failed: , -}; - -const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"]; - -function computeProgressPercent( - status: FetchRequestStatus, - liveCount: number, - seenSteps: Set, - stepStats: Record, - 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): 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 ; - case "completed": return ; - case "failed": return ; - case "skipped": return ; - case "paused": return ; - case "progress": return ; - } -} - -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>({}); - const [liveParsedCount, setLiveParsedCount] = React.useState(undefined); - const [failNotif, setFailNotif] = React.useState(null); - const feedRef = React.useRef(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 = {}; - 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(); - 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 = {}; - 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 = {}; - 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 ( - - - - ); - } - - if (fetchError || !fetchRequest) { - return ( - - - Failed to load fetch request - - ); - } - - 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 ( - - - - {/* Header Card */} - - - - {req.account_name} - - - - - - Date Range - - {req.start_date ? new Date(req.start_date).toLocaleDateString() : "?"} → {req.end_date ? new Date(req.end_date).toLocaleDateString() : "?"} - - - - Created - {new Date(req.created_at).toLocaleString()} - - {req.completed_at && ( - - Completed - {new Date(req.completed_at).toLocaleString()} - - )} - - - {/* Progress Bar */} - - - Overall Progress - {["processing", "paused"].includes(req.status) && displayParsedCount > 0 && ( - - Validated: {displayParsedCount} transactions - - )} - - - - {progressPercent}% - - - - {/* Retry Counter */} - - - - Retries: {retryCount}/{RETRY_MAX} - - - - {req.status === "failed" && !isRetryExhausted && ( - - )} - - - - {/* Error Alerts */} - {req.status === "failed" && req.error_message && ( - - {req.error_message} - - )} - {isRetryExhausted && req.status === "failed" && ( - - Max retries reached — no further retry attempts will be made. - - )} - - {/* Pipeline Stepper */} - - - Pipeline Progress - - - {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 = ; - } else if (isFailed) { - icon = ; - } else if (isPaused) { - icon = ; - } else if (isActive) { - icon = ; - } else { - icon = {index + 1}; - } - - return ( - - {icon}} - > - {label} - {stepMessages[index] && ( - - {stepMessages[index]} - - )} - - - ); - })} - - - - {/* SSE Event Feed */} - - - - Progress Events - - - - {sseConnected ? "Connected" : "Disconnected"} - - - - {displayEvents.length === 0 ? ( - - Waiting for events... - - ) : ( - displayEvents.map((evt, i) => ( - - {sseIcon(evt.status)} - - - {evt.step.replace(/_/g, " ")} - - {evt.message && formatProgressMessage(evt.message) && ( - - {formatProgressMessage(evt.message)} - - )} - - - )) - )} - - - - {/* Ambiguity Resolution */} - {hasAmbiguities && ( - - - Ambiguity Resolution - - - {allResolved ? ( - - All ambiguities resolved — pipeline will resume on next poll cycle - - ) : ( - - Pipeline paused — resolve ambiguities to continue - - )} - - - {ambiguities.map((ambiguity: any) => { - const isResolved = ambiguity.status === "resolved"; - return ( - - - {ambiguity.line} - - - - - OCR Amount - - ₹{ambiguity.ocr_amount} - - - - OCR Balance - - ₹{ambiguity.ocr_balance} - - - - Previous Balance - ₹{ambiguity.prev_balance} - - - - {isResolved ? ( - }> - Resolved: ₹{ambiguity.chosen?.amount} / ₹{ambiguity.chosen?.balance} - - ) : ( - - {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 ( - - ); - })} - - )} - - ); - })} - - - )} - - setFailNotif(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - > - setFailNotif(null)} sx={{ borderRadius: 2 }}> - {failNotif} - - - - ); -} diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx deleted file mode 100644 index 1937192..0000000 --- a/src/FetchRequests.tsx +++ /dev/null @@ -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 = { - pending: "default", - processing: "info", - paused: "warning", - raw_expenses_done: "primary", - enriched_done: "warning", - completed: "success", - failed: "error", -}; - -const statusIcons: Record = { - pending: , - processing: , - paused: , - raw_expenses_done: , - enriched_done: , - completed: , - failed: , -}; - -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(null); - const [uploadedPath, setUploadedPath] = React.useState(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(null); - - const [statusFilter, setStatusFilter] = React.useState([]); - 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 ( - - - Fetch Request Pipeline - - - {/* Create Form */} - - - New Fetch Request - - - val && setSourceType(val)} - sx={{ mb: 3 }} - size="small" - > - File Upload - Email Fetch - - - - {sourceType === "file" ? ( - <> - - - - {file ? file.name : "No file selected"} - - - - {uploadedPath && ( - - Uploaded as: {uploadedPath} - - )} - {formatField && ( - - )} - - ) : ( - <> - {formatField && ( - - )} - setFromEmail(e.target.value)} size="small" /> - setSubject(e.target.value)} size="small" /> - setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" /> - - )} - - setAccountName(val ?? "")} - renderInput={(params) => ( - - )} - /> - - {payorUsernameField && ( - - )} - - - {startDateField && ( - - - - )} - {endDateField && ( - - - - )} - - - - - - - {/* Filters */} - - - - Status - - - setAccountFilter(val ?? "")} - renderInput={(params) => ( - - )} - sx={{ minWidth: 160 }} - /> - val && setSourceFilter(val)} - size="small" - > - All - File - Email - - - refetch()} disabled={isFetching}> - - - - - - {/* List Table */} - {isLoading ? ( - - - - ) : requests.length === 0 ? ( - - No fetch requests yet - - ) : ( - - - - - ID - Account - Source - Date Range - Status - Retries - Created - Actions - - - - {[...requests] - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) - .map((req: FetchRequest) => ( - navigate(`/fetch-requests/${req.id}`)} - > - - - - {shortId(req.fingerprint)} - - { - e.stopPropagation(); - navigator.clipboard.writeText(req.fingerprint); - setSnackbar({ message: "Copied!", severity: "success" }); - }} - sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} - > - - - - - {req.account_name} - - - - - - {(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() : "?"} - - - - - - - - - - {(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"} - - - - - {new Date(req.created_at).toLocaleString()} - - - - - {req.status === "paused" && ( - - { e.stopPropagation(); navigate(`/fetch-requests/${req.id}`); }}> - - - - )} - {req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && ( - - { e.stopPropagation(); handleRetry(req); }}> - - - - )} - - { e.stopPropagation(); setDeleteTarget(req); }}> - - - - - - - ))} - -
-
- )} - - setSnackbar(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - > - {snackbar ? setSnackbar(null)}>{snackbar.message} : undefined} - - - setDeleteTarget(null)}> - Delete Fetch Request? - - - This will permanently delete the fetch request and all associated data. - - - - - - - -
- ); -} diff --git a/src/RequireAuth.tsx b/src/RequireAuth.tsx deleted file mode 100644 index a0ff87a..0000000 --- a/src/RequireAuth.tsx +++ /dev/null @@ -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 ( - navigate("/")} - onSwitchMode={() => setMode(mode === "login" ? "register" : "login")} - login={login} - register={register} - loading={loading} - error={error} - currentUser={currentUser} - /> - ); -}