updated sse supporting react-openapi

This commit is contained in:
2026-06-18 20:32:34 +05:30
parent 0a668cf98d
commit 154b15fe51
28 changed files with 2132 additions and 440 deletions

View File

@@ -23,7 +23,7 @@ import {
useReport,
prepareReport,
} from "./features/report";
import { useResourceByName } from "../react-openapi";
import { useReportSnapshotsList } from "./features/report-snapshots";
function formatSnapshotDate(iso: string) {
const d = new Date(iso);
@@ -56,13 +56,13 @@ export default function Dashboard() {
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
const { data: snapshotsData } = useResourceByName("reports").useList();
const { data: snapshotsData } = useReportSnapshotsList();
const snapshotOptions = React.useMemo(() => {
const options: { label: string; value: string | null }[] = [
{ label: "Latest (auto)", value: null },
];
if (snapshotsData?.data) {
for (const snap of snapshotsData.data) {
if (snapshotsData?.items) {
for (const snap of snapshotsData.items) {
options.push({
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
value: snap.snapshot_id,

View File

@@ -35,7 +35,8 @@ import type {
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import { useAppContext, useResource } from "../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
@@ -144,11 +145,18 @@ function isMathValid(candidate: { amount: number; balance: number }, prevBalance
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const config = useConfig();
const { resources, config } = useAppContext();
const fetchRequestResource = resources.find(r => r.name === "fetch-requests")!;
const { get, update } = useResource(fetchRequestResource);
const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
const updateMutation = usePatch();
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!);
@@ -193,8 +201,8 @@ export default function FetchRequestDetail() {
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
React.useEffect(() => {
if (!id || !config?.baseUrl) return;
const url = `${config.baseUrl}/fetch-requests/${id}/events`;
if (!id || !config?.baseApiUrl) return;
const url = `${config.baseApiUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url);
sseRef.current = es;
@@ -243,7 +251,7 @@ export default function FetchRequestDetail() {
es.close();
sseRef.current = null;
};
}, [id, config?.baseUrl]);
}, [id, config?.baseApiUrl]);
React.useEffect(() => {
if (feedRef.current) {

View File

@@ -47,8 +47,9 @@ import type {
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } from "../react-openapi";
import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi";
import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
@@ -70,6 +71,16 @@ const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
};
const STATUS_OPTIONS: FetchRequestStatus[] = [
"pending",
"processing",
"paused",
"raw_expenses_done",
"enriched_done",
"completed",
"failed",
];
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
@@ -107,33 +118,48 @@ export default function FetchRequests() {
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
const { data: listData, isLoading, isFetching, refetch } = useList({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
const { resources } = useAppContext();
const fetchRequestsRes = resources.find(r => r.name === "fetch-requests")!;
const { list, create, update, remove } = useResource(fetchRequestsRes);
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 { useList: useAccountsList } = useResourceByName("accounts");
const { data: accountsData } = useAccountsList();
const accountsResource = resources.find(r => r.name === "accounts");
const { list: listAccounts } = accountsResource ? useResource(accountsResource) : { list: async () => ({ items: [] }) };
const { data: accountsData } = useQuery({
queryKey: ["accounts", "list"],
queryFn: () => listAccounts(),
enabled: !!accountsResource,
});
const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean);
return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]);
const config = useConfig();
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
const formatOptions: string[] = formatField?.options ?? [];
const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date;
const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
const formatField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "format");
const formatOptions: string[] = formatField?.enumValues ?? [];
const startDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "end_date");
const payorUsernameField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "payor_username");
const createMutation = useCreate();
const updateMutation = usePatch();
const deleteMutation = useDelete();
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?.data ?? [];
const requests = listData?.items ?? [];
const handleUpload = async () => {
if (!file) return;
@@ -262,9 +288,8 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath}
</Alert>
)}
{formatField && components?.FormField ? (
<components.FormField
name="format"
{formatField ? (
<FormFieldRenderer
field={formatField}
value={format}
onChange={setFormat}
@@ -282,9 +307,8 @@ export default function FetchRequests() {
</>
) : (
<>
{formatField && components?.FormField ? (
<components.FormField
name="format"
{formatField ? (
<FormFieldRenderer
field={formatField}
value={format}
onChange={setFormat}
@@ -314,9 +338,8 @@ export default function FetchRequests() {
)}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
{payorUsernameField && components?.FormField ? (
<components.FormField
name="payor_username"
{payorUsernameField ? (
<FormFieldRenderer
field={payorUsernameField}
value={payorUsername}
onChange={setPayorUsername}
@@ -326,10 +349,9 @@ export default function FetchRequests() {
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.date ? (
{startDateField ? (
<Box sx={{ flex: 1 }}>
<components.date
name="start_date"
<FormFieldRenderer
field={startDateField}
value={startDate}
onChange={setStartDate}
@@ -347,10 +369,9 @@ export default function FetchRequests() {
sx={{ flex: 1 }}
/>
)}
{endDateField && components?.date ? (
{endDateField ? (
<Box sx={{ flex: 1 }}>
<components.date
name="end_date"
<FormFieldRenderer
field={endDateField}
value={endDate}
onChange={setEndDate}
@@ -391,7 +412,7 @@ export default function FetchRequests() {
input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")}
>
{(config?.enums?.FetchRequestStatus ?? []).map((s: string) => (
{STATUS_OPTIONS.map((s: string) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))}
</Select>

View File

@@ -21,8 +21,9 @@ import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } from "../react-openapi";
import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi";
import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
interface ReportSnapshotQuery {
accounts?: string[];
@@ -53,21 +54,32 @@ export default function ReportSnapshots() {
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
const { resources } = useAppContext();
const reportsResource = resources.find(r => r.name === "reports")!;
const { list, create, remove } = useResource(reportsResource);
const { data: listData, isLoading, isFetching, refetch } = useList();
const createMutation = useCreate();
const deleteMutation = useDelete();
const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["reports", "list"],
queryFn: () => list(),
});
const createMutation = useMutation({
mutationFn: (data: any) => create(data),
});
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
},
});
const config = useConfig();
const reportsRes = config?.resources.find((r: any) => r.name === "reports");
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self;
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date;
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date;
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
const ignoreSelfField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "ignore_self");
const startDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "end_date");
const minAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "min_amount");
const maxAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "max_amount");
const snapshots: ReportSnapshot[] = listData?.data ?? [];
const snapshots: ReportSnapshot[] = listData?.items ?? [];
const handleCreate = async () => {
try {
@@ -123,14 +135,13 @@ export default function ReportSnapshots() {
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ignoreSelfField && components?.FormField && (
<components.FormField
name="ignore_self"
{ignoreSelfField ? (
<FormFieldRenderer
field={ignoreSelfField}
value={ignoreSelf}
onChange={(val: boolean) => setIgnoreSelf(val)}
/>
)}
) : null}
<Box sx={{ display: "flex", gap: 2 }}>
<Box sx={{ flex: 1 }}>
@@ -158,26 +169,24 @@ export default function ReportSnapshots() {
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
{minAmountField && components?.FormField && (
{minAmountField ? (
<Box sx={{ flex: 1 }}>
<components.FormField
name="min_amount"
<FormFieldRenderer
field={minAmountField}
value={minAmount}
onChange={(val: string) => setMinAmount(val)}
/>
</Box>
)}
{maxAmountField && components?.FormField && (
) : null}
{maxAmountField ? (
<Box sx={{ flex: 1 }}>
<components.FormField
name="max_amount"
<FormFieldRenderer
field={maxAmountField}
value={maxAmount}
onChange={(val: string) => setMaxAmount(val)}
/>
</Box>
)}
) : null}
</Box>
<Button

View File

@@ -1,5 +1,4 @@
import { useResourceByName } from "../../../react-openapi";
import { api } from "../../../react-openapi/api/client";
import { useAppContext, useResource, getApi } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
@@ -8,28 +7,51 @@ export function useFetchRequestsList(params?: {
account_name?: string;
source_type?: string;
}) {
const { useList } = useResourceByName("fetch-requests");
return useList(params);
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "fetch-requests")!;
const { list } = useResource(resource);
return useQuery({
queryKey: ["fetch-requests", "list", params],
queryFn: () => list(params),
});
}
export function useFetchRequest(id: string) {
const { useRead } = useResourceByName("fetch-requests");
return useRead(id);
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "fetch-requests")!;
const { get } = useResource(resource);
return useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id),
enabled: !!id,
});
}
export function useCreateFetchRequest() {
const { useCreate } = useResourceByName("fetch-requests");
return useCreate();
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "fetch-requests")!;
const { create } = useResource(resource);
return useMutation({
mutationFn: (data: any) => create(data),
});
}
export function useUpdateFetchRequest() {
const { usePatch } = useResourceByName("fetch-requests");
return usePatch();
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "fetch-requests")!;
const { update } = useResource(resource);
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
}
export function useDeleteFetchRequest() {
const { useDelete } = useResourceByName("fetch-requests");
return useDelete();
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "fetch-requests")!;
const { remove } = useResource(resource);
return useMutation({
mutationFn: (id: string) => remove(id),
});
}
export function useUploadFile() {
@@ -37,6 +59,7 @@ export function useUploadFile() {
mutationFn: async (file: File) => {
const arrayBuffer = await file.arrayBuffer();
const binary = new Uint8Array(arrayBuffer);
const api = getApi();
const res = await api.post("/uploads", binary, {
headers: {
"Content-Type": file.type,
@@ -52,6 +75,7 @@ export function useFetchRequestAmbiguities(fetchRequestId: string) {
return useQuery({
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => {
const api = getApi();
const res = await api.get(
`/fetch-requests/${fetchRequestId}/ambiguities`
);
@@ -72,6 +96,7 @@ export function useResolveAmbiguity() {
ambiguityId: string;
payload: ResolveAmbiguityPayload;
}) => {
const api = getApi();
const res = await api.post(
`/ambiguities/${ambiguityId}/resolve`,
payload

View File

@@ -2,3 +2,8 @@ export type {
ReportSnapshot,
ReportQuery,
} from "./report-snapshots.models";
export {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./useReportSnapshots";

View File

@@ -1,16 +1,34 @@
import { useResourceByName } from "../../../react-openapi";
import { useAppContext, useResource } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export function useReportSnapshotsList() {
const { useList } = useResourceByName("reports");
return useList();
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "reports")!;
const { list } = useResource(resource);
return useQuery({
queryKey: ["reports", "list"],
queryFn: () => list(),
});
}
export function useCreateSnapshot() {
const { useCreate } = useResourceByName("reports");
return useCreate();
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "reports")!;
const { create } = useResource(resource);
return useMutation({
mutationFn: (data: any) => create(data),
});
}
export function useDeleteSnapshot() {
const { useDelete } = useResourceByName("reports");
return useDelete();
const queryClient = useQueryClient();
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "reports")!;
const { remove } = useResource(resource);
return useMutation({
mutationFn: (id: string) => remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
},
});
}

View File

@@ -1,4 +1,5 @@
import { useResourceByName } from "../../../react-openapi";
import { useAppContext, useResource } from "../../../react-openapi";
import { useQuery } from "@tanstack/react-query";
export interface ReportParams {
snapshot_id?: string;
@@ -9,13 +10,13 @@ export interface ReportParams {
}
export function useReport(params: ReportParams) {
const { useRead } = useResourceByName("reports");
const { resources } = useAppContext();
const resource = resources.find(r => r.name === "reports")!;
const { get } = useResource(resource);
return useRead(
params.snapshot_id ? params.snapshot_id : "latest",
{
...params,
periods: params.periods,
}
);
return useQuery({
queryKey: ["reports", "read", params],
queryFn: () =>
get(params.snapshot_id ? params.snapshot_id : "latest"),
});
}

View File

@@ -15,8 +15,7 @@ import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests';
import FetchRequestDetail from './FetchRequestDetail';
import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config';
import { AppProvider, Admin } from '../react-openapi';
import { Buffer } from 'buffer';
import process from 'process';
import { AuthProvider } from "../react-auth";
@@ -31,6 +30,14 @@ const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
const API_BASE = import.meta.env.VITE_API_BASE_URL;
const specConfig = {
specUrl: `${API_BASE}/openapi.yaml`,
baseApiUrl: API_BASE,
title: 'Khata Admin',
getToken: () => localStorage.getItem('token'),
};
const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" },
@@ -43,7 +50,7 @@ const routerMapping = [
];
root.render(
<AppProvider resourceOverrides={configuration} profileConfig={profileConfiguration}>
<AppProvider specConfiguration={specConfig}>
<BrowserRouter>
<AuthProvider authBaseUrl={AUTH_BASE}>
<AppTheme>
@@ -60,7 +67,7 @@ root.render(
path={path}
element={
path.startsWith("/admin") ? (
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} />
<Component basePath="/admin" />
) : (
<Component />
)

View File

@@ -1,103 +0,0 @@
import { ResourceOverride } from "../react-openapi";
export const configuration: Record<string, ResourceOverride> = {
expenses: {
filterOptions: {
mode: "client",
fields: ["account", "payee", "tags", "occurred_at", "amount"],
},
fields: {
payee: {
displayFormat: "{name}",
filterType: "autocomplete",
},
payor: {
display: false,
displayFormat: "{username}",
},
account: {
displayFormat: "{name}",
filterType: "multiselect",
refers: "accounts"
},
tags: {
displayFormat: "{icon} {name}",
filterType: "autocomplete",
refers: "tags"
},
occurred_at: {
filterType: "date-range",
formatter: (val: string) => {
const date = new Date(val);
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
const year = date.getFullYear();
const suffix = (day: number) => {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
};
return `${day}${suffix(day)} ${month} ${year}`;
}
},
amount: {
filterType: "number-range",
},
created_at: {
display: false
}
},
},
'fetch-requests': {
fields: {
format: {
path: 'source.format',
},
start_date: {
type: 'date',
label: 'Start Date',
},
end_date: {
type: 'date',
label: 'End Date',
},
// account: {
// refers: 'accounts',
// },
// tags: {
// refers: 'tags',
// },
},
},
accounts: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{name} - XX{number}',
},
autoComplete: true,
prefetch: true,
}
},
tags: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{icon} {name}',
},
autoComplete: true,
prefetch: true,
}
},
};
export const profileConfiguration = {
"extraFields": ['name'],
"resource": "payors",
// not in use
"hidden": true,
};