From 71afc157ff396de20280547eb3e72a0818af7181 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 25 Apr 2026 12:13:28 +0530 Subject: [PATCH] proper use of react-openapi for resource api calls --- react-openapi/Admin.tsx | 44 ++++--- react-openapi/components/GenericForm.tsx | 5 +- react-openapi/components/ProfileView.tsx | 2 +- react-openapi/hooks/useResource.ts | 31 ++++- react-openapi/index.ts | 3 + react-openapi/providers/AppProvider.tsx | 70 +++++++++++ react-openapi/providers/ConfigContext.tsx | 12 ++ ...shboard.service.ts => dashboard.mapper.ts} | 34 +----- src/features/dashboard/useDashboardData.ts | 56 ++++++--- src/features/report/report.api.ts | 9 -- src/features/report/report.service.ts | 114 ------------------ src/features/report/useReport.ts | 9 +- src/main.jsx | 24 +--- 13 files changed, 191 insertions(+), 222 deletions(-) create mode 100644 react-openapi/providers/AppProvider.tsx create mode 100644 react-openapi/providers/ConfigContext.tsx rename src/features/dashboard/{dashboard.service.ts => dashboard.mapper.ts} (57%) delete mode 100644 src/features/report/report.api.ts delete mode 100644 src/features/report/report.service.ts diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx index b35790e..52fabc8 100644 --- a/react-openapi/Admin.tsx +++ b/react-openapi/Admin.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useAuth, AuthPage } from "../react-auth"; import { UploadProvider } from "./providers/UploadProvider"; import AdminLayout from "./components/AdminLayout"; @@ -13,13 +12,9 @@ import { Route, useNavigate, useParams, - Navigate, } from "react-router-dom"; -const queryClient = new QueryClient(); - -// Create a context for the app config -export const ConfigContext = React.createContext(null); +import { ConfigContext } from "./providers/ConfigContext"; function Dashboard({ basePath }: { basePath: string }) { const config = React.useContext(ConfigContext); @@ -127,14 +122,17 @@ interface AdminProps { } export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) { - const [config, setConfig] = React.useState(null); + const existingConfig = React.useContext(ConfigContext); + const [config, setConfig] = React.useState(existingConfig); React.useEffect(() => { - getAppConfig(resourceOverrides, profileConfig).then((cfg) => { - initializeApiClients(cfg.baseUrl, cfg.authBaseUrl); - setConfig(cfg); - }); - }, [resourceOverrides, profileConfig]); + if (!existingConfig) { + getAppConfig(resourceOverrides, profileConfig).then((cfg) => { + initializeApiClients(cfg.baseUrl, cfg.authBaseUrl); + setConfig(cfg); + }); + } + }, [resourceOverrides, profileConfig, existingConfig]); if (!config) { return ( @@ -151,13 +149,21 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro ); } + const content = ( + + + + ); + + // If we have an existing config, we are already inside a Provider and QueryClient + if (existingConfig) { + return content; + } + + // Fallback for standalone usage return ( - - - - - - - + + {content} + ); } diff --git a/react-openapi/components/GenericForm.tsx b/react-openapi/components/GenericForm.tsx index f808569..8931f07 100644 --- a/react-openapi/components/GenericForm.tsx +++ b/react-openapi/components/GenericForm.tsx @@ -11,7 +11,7 @@ import { useUpload } from '../providers/UploadProvider'; import { useQueries } from '@tanstack/react-query'; import { useResource } from '../hooks/useResource'; import FormField from './fields/FormField'; -import { ConfigContext } from '../Admin'; +import { ConfigContext } from '../providers/ConfigContext'; interface GenericFormProps { config: ResourceConfig; @@ -67,7 +67,8 @@ export default function GenericForm({ const relationDataMap = React.useMemo(() => { const map: Record = {}; allRelations.forEach((relName, index) => { - map[relName] = queries[index].data || []; + // @ts-ignore + map[relName] = queries[index].data || []; }); return map; }, [allRelations, queries]); diff --git a/react-openapi/components/ProfileView.tsx b/react-openapi/components/ProfileView.tsx index 0d45bf5..ac76e61 100644 --- a/react-openapi/components/ProfileView.tsx +++ b/react-openapi/components/ProfileView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material'; import { useResource } from '../hooks/useResource'; import GenericForm from './GenericForm'; -import { ConfigContext } from '../Admin'; +import { ConfigContext } from '../providers/ConfigContext'; export default function ProfileView() { const appConfig = React.useContext(ConfigContext); diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts index a7057ad..a3db964 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -1,16 +1,21 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { api } from "../api/client"; import { ResourceConfig } from "../types/config"; +import { ConfigContext } from "../providers/ConfigContext"; +import * as React from "react"; -export function useResource(config: ResourceConfig) { +export function useResource(config: ResourceConfig | undefined) { const queryClient = useQueryClient(); - const { name, endpoint, primaryKey } = config; + + // Return empty/disabled hooks if config is missing + const { name = '', endpoint = '', primaryKey = 'id' } = config || {}; // --- READ ALL --- const useList = (params?: any) => useQuery({ queryKey: [name, "list", params], queryFn: async () => { + if (!endpoint) return { data: [], total: 0 }; // @ts-ignore const res = await api.get(endpoint, { params }); const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; @@ -18,7 +23,8 @@ export function useResource(config: ResourceConfig) { data: res.data, total: isNaN(total as any) ? undefined : total }; - } + }, + enabled: !!endpoint, }); // --- READ ONE --- @@ -26,18 +32,19 @@ export function useResource(config: ResourceConfig) { useQuery({ queryKey: [name, "detail", id], queryFn: async () => { - if (!id) return null; + if (!id || !endpoint) return null; // @ts-ignore const res = await api.get(`${endpoint}/${id}`); return res.data; }, - enabled: !!id, + enabled: !!id && !!endpoint, }); // --- CREATE --- const useCreate = () => useMutation({ mutationFn: async (data: Partial) => { + if (!endpoint) throw new Error("Endpoint not defined"); // @ts-ignore const res = await api.post(endpoint, data); return res.data; @@ -51,6 +58,7 @@ export function useResource(config: ResourceConfig) { const useUpdate = () => useMutation({ mutationFn: async ({ id, data }: { id: string; data: Partial }) => { + if (!endpoint) throw new Error("Endpoint not defined"); // @ts-ignore const res = await api.put(`${endpoint}/${id}`, data); return res.data; @@ -67,6 +75,7 @@ export function useResource(config: ResourceConfig) { const useDelete = () => useMutation({ mutationFn: async (id: string) => { + if (!endpoint) throw new Error("Endpoint not defined"); await api.delete(`${endpoint}/${id}`); return id; }, @@ -79,6 +88,7 @@ export function useResource(config: ResourceConfig) { const getListQueryOptions = (params?: any) => ({ queryKey: [name, "list", params], queryFn: async () => { + if (!endpoint) return { data: [], total: 0 }; // @ts-ignore const res = await api.get(endpoint, { params }); const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; @@ -87,6 +97,7 @@ export function useResource(config: ResourceConfig) { total: isNaN(total as any) ? undefined : total }; }, + enabled: !!endpoint, }); // --- READ ME --- @@ -94,16 +105,19 @@ export function useResource(config: ResourceConfig) { useQuery({ queryKey: [name, "me"], queryFn: async () => { + if (!endpoint) return null; // @ts-ignore const res = await api.get(`${endpoint}/me`); return res.data; }, + enabled: !!endpoint, }); // --- UPDATE ME --- const useUpdateMe = () => useMutation({ mutationFn: async (data: Partial) => { + if (!endpoint) throw new Error("Endpoint not defined"); // @ts-ignore const res = await api.put(`${endpoint}/me`, data); return res.data; @@ -125,3 +139,10 @@ export function useResource(config: ResourceConfig) { getListQueryOptions, }; } + +export function useResourceByName(name: string) { + const config = React.useContext(ConfigContext); + const resourceConfig = config?.resources.find((r) => r.name === name); + return useResource(resourceConfig); +} + diff --git a/react-openapi/index.ts b/react-openapi/index.ts index c9dd6a5..cacffb9 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -2,3 +2,6 @@ export { default as Admin } from "./Admin"; export { api, auth, initializeApiClients } from "./api/client"; export { getAppConfig } from "./config"; export type { AppConfig, ResourceConfig, ResourceField } from "./types/config"; +export { AppProvider } from "./providers/AppProvider"; +export { ConfigContext, useConfig } from "./providers/ConfigContext"; +export { useResource, useResourceByName } from "./hooks/useResource"; diff --git a/react-openapi/providers/AppProvider.tsx b/react-openapi/providers/AppProvider.tsx new file mode 100644 index 0000000..c8c9646 --- /dev/null +++ b/react-openapi/providers/AppProvider.tsx @@ -0,0 +1,70 @@ +import * as React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ConfigContext } from "./ConfigContext"; +import { getAppConfig } from "../config"; +import { initializeApiClients } from "../api/client"; +import { AppConfig } from "../types/config"; +import { Box, CircularProgress } from "@mui/material"; + +const defaultQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + +interface AppProviderProps { + children: React.ReactNode; + resourceOverrides?: Record; + profileConfig?: any; + queryClient?: QueryClient; +} + +export function AppProvider({ + children, + resourceOverrides = {}, + profileConfig = {}, + queryClient = defaultQueryClient, +}: AppProviderProps) { + const [config, setConfig] = React.useState(null); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + getAppConfig(resourceOverrides, profileConfig) + .then((cfg) => { + initializeApiClients(cfg.baseUrl, cfg.authBaseUrl); + setConfig(cfg); + setLoading(false); + }) + .catch((err) => { + console.error("Failed to load OpenAPI configuration:", err); + setLoading(false); + }); + }, [resourceOverrides, profileConfig]); + + if (loading) { + return ( + + + + ); + } + + return ( + + + {children} + + + ); +} diff --git a/react-openapi/providers/ConfigContext.tsx b/react-openapi/providers/ConfigContext.tsx new file mode 100644 index 0000000..5e96db0 --- /dev/null +++ b/react-openapi/providers/ConfigContext.tsx @@ -0,0 +1,12 @@ +import * as React from "react"; +import { AppConfig } from "../types/config"; + +export const ConfigContext = React.createContext(null); + +export function useConfig() { + const context = React.useContext(ConfigContext); + if (context === undefined) { + throw new Error("useConfig must be used within a ConfigProvider"); + } + return context; +} diff --git a/src/features/dashboard/dashboard.service.ts b/src/features/dashboard/dashboard.mapper.ts similarity index 57% rename from src/features/dashboard/dashboard.service.ts rename to src/features/dashboard/dashboard.mapper.ts index f466cf6..4a2b3ca 100644 --- a/src/features/dashboard/dashboard.service.ts +++ b/src/features/dashboard/dashboard.mapper.ts @@ -1,24 +1,15 @@ -import { api } from "../../../react-openapi"; -import { LatestItem } from "../../components/LatestItems"; import * as React from "react"; import MonetizationOnIcon from "@mui/icons-material/MonetizationOn"; - -import { fetchReport } from "../report/report.api"; -import { mapReportToDashboard } from "../report/report.mapper"; +import { LatestItem } from "../../components/LatestItems"; const DEFAULT_ICON = React.createElement(MonetizationOnIcon, { sx: { color: "#388e3c" } }); -export async function fetchLatestTransactions( +export function mapToLatestItems( + items: any[], type: "expense" | "income" -): Promise { - const res = await api.get("/expenses", { - params: { limit: 100, sort: "-occurred_at" } - }); - - const items = res.data || []; - +): LatestItem[] { const isValid = (amt: number) => type === "expense" ? amt < 0 : amt > 0; @@ -47,20 +38,3 @@ export async function fetchLatestTransactions( }; }); } - -export async function fetchAggregatedData( - type: "expense" | "income" -) { - const [weekly, monthly] = await Promise.all([ - fetchReport({ period: "weekly", rolling: true }), - fetchReport({ period: "monthly", rolling: true }), - ]); - - return mapReportToDashboard(weekly.buckets, monthly.buckets, type); -} - -export const fetchAggregatedExpenses = () => - fetchAggregatedData("expense"); - -export const fetchAggregatedIncome = () => - fetchAggregatedData("income"); diff --git a/src/features/dashboard/useDashboardData.ts b/src/features/dashboard/useDashboardData.ts index 028be86..018f97e 100644 --- a/src/features/dashboard/useDashboardData.ts +++ b/src/features/dashboard/useDashboardData.ts @@ -1,24 +1,48 @@ -import { useQuery } from "@tanstack/react-query"; -import { - fetchAggregatedData, - fetchLatestTransactions, -} from "./dashboard.service"; +import { useResourceByName } from "../../../react-openapi"; +import { mapToLatestItems } from "./dashboard.mapper"; +import { mapReportToDashboard } from "../report/report.mapper"; export function useDashboardData(type: "expense" | "income") { - const aggregated = useQuery({ - queryKey: ["dashboard", type], - queryFn: () => fetchAggregatedData(type), + const { useList: useExpenseList } = useResourceByName("expenses"); + const { useList: useReportList } = useResourceByName("reports"); + + // Fetch latest transactions + const latestQuery = useExpenseList({ + limit: 100, + sort: "-occurred_at" }); - const latest = useQuery({ - queryKey: ["latest", type], - queryFn: () => fetchLatestTransactions(type), - }); + // Fetch reports for aggregation + const weeklyReport = useReportList({ period: "weekly", rolling: true }); + const monthlyReport = useReportList({ period: "monthly", rolling: true }); + + const isLoading = + latestQuery.isLoading || + weeklyReport.isLoading || + monthlyReport.isLoading; + + const error = + latestQuery.error || + weeklyReport.error || + monthlyReport.error; + + const latest = latestQuery.data?.data + ? mapToLatestItems(latestQuery.data.data, type) + : []; + + const aggregatedData = + weeklyReport.data?.data && monthlyReport.data?.data + ? mapReportToDashboard( + (weeklyReport.data.data as any).buckets, + (monthlyReport.data.data as any).buckets, + type + ) + : null; return { - data: aggregated.data, - latest: latest.data, - isLoading: aggregated.isLoading || latest.isLoading, - error: aggregated.error || latest.error, + data: aggregatedData, + latest: latest, + isLoading, + error, }; } diff --git a/src/features/report/report.api.ts b/src/features/report/report.api.ts deleted file mode 100644 index 528b844..0000000 --- a/src/features/report/report.api.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { api } from "../../../react-openapi"; - -export async function fetchReport(params: { - period: "weekly" | "monthly" | "yearly" | "fyly"; - rolling?: boolean; -}) { - const res = await api.get("/reports", { params }); - return res.data; -} \ No newline at end of file diff --git a/src/features/report/report.service.ts b/src/features/report/report.service.ts deleted file mode 100644 index b5726ad..0000000 --- a/src/features/report/report.service.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { fetchReport } from "./report.api"; -import { - AggregatedDashboardData, - ChartData, - ChartDataPoint, -} from "../components/HistoryChart"; - -type ReportBucket = any; // replace with generated type if available - -function sumBucket(bucket: ReportBucket, flow: "expenses" | "incomes") { - return bucket.groups.reduce( - (acc: number, g: any) => acc + (g?.[flow]?.sum || 0), - 0 - ); -} - -function toLabel(start: string, end: string, type: "weekly" | "monthly") { - const s = new Date(start); - const e = new Date(end); - - if (type === "monthly") { - return s.toLocaleString("default", { month: "short" }); - } - - const sd = s.getDate(); - const ed = e.getDate(); - const m = e.toLocaleString("default", { month: "short" }); - return `${sd}–${ed} ${m}`; -} - -function toChartPoints( - buckets: ReportBucket[], - type: "weekly" | "monthly", - flow: "expenses" | "incomes" -): ChartDataPoint[] { - return buckets.map((b, i) => { - const amount = sumBucket(b, flow); - - const prev = buckets[i - 1]; - const compareAmount = prev ? sumBucket(prev, flow) : 0; - - return { - id: toLabel(b.start, b.end, type), - amount, - compare: prev - ? { - id: toLabel(prev.start, prev.end, type), - amount: compareAmount, - } - : undefined, - }; - }); -} - -function buildChartData( - weekly: ReportBucket[], - monthly: ReportBucket[], - flow: "expenses" | "incomes" -): ChartData { - return { - daily: [], // not supported by /reports → keep empty or drop - weekly: { - rolling: toChartPoints(weekly, "weekly", flow), - calendar: toChartPoints(weekly, "weekly", flow), // same unless backend differentiates - }, - monthly: { - rolling: toChartPoints(monthly, "monthly", flow), - calendar: toChartPoints(monthly, "monthly", flow), - }, - }; -} - -function getTopPayees(buckets: ReportBucket[], flow: "expenses" | "incomes") { - const map: Record = {}; - - for (const b of buckets) { - for (const g of b.groups) { - const key = g.group_key || "Unknown"; - const amt = g?.[flow]?.sum || 0; - map[key] = (map[key] || 0) + amt; - } - } - - return Object.entries(map) - .map(([payeeName, amount]) => ({ payeeName, amount })) - .sort((a, b) => b.amount - a.amount) - .slice(0, 5); -} - -export async function getDashboardData( - type: "expense" | "income" -): Promise { - const flow = type === "expense" ? "expenses" : "incomes"; - - const [weeklyBuckets, monthlyBuckets] = await Promise.all([ - fetchReport({ period: "weekly", rolling: true }), - fetchReport({ period: "monthly", rolling: true }), - ]); - - const chartData = buildChartData(weeklyBuckets, monthlyBuckets, flow); - - const totalAmount = weeklyBuckets.reduce( - (acc: number, b: any) => acc + sumBucket(b, flow), - 0 - ); - - const topPayees = getTopPayees(weeklyBuckets, flow); - - return { - chartData, - totalAmount, - topPayees, - }; -} \ No newline at end of file diff --git a/src/features/report/useReport.ts b/src/features/report/useReport.ts index bd2e039..ad7a1e2 100644 --- a/src/features/report/useReport.ts +++ b/src/features/report/useReport.ts @@ -1,5 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; -import { fetchReport } from "./report.api"; +import { useResourceByName } from "../../../react-openapi"; export interface ReportParams { period: "weekly" | "monthly" | "yearly" | "fyly"; @@ -10,8 +9,6 @@ export interface ReportParams { } export function useReport(params: ReportParams) { - return useQuery({ - queryKey: ["report", params], - queryFn: () => fetchReport(params), - }); + const { useList } = useResourceByName("reports"); + return useList(params); } \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index 139efe0..cf64145 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -12,7 +12,7 @@ import { } from "@mui/material"; import Home from './Home'; import Dashboard from './Dashboard'; -import { Admin, initializeApiClients } from '../react-openapi'; +import { Admin, AppProvider } from '../react-openapi'; import { configuration, profileConfiguration } from './openapi-config'; import { Buffer } from 'buffer'; import process from 'process'; @@ -21,30 +21,14 @@ import Header from './Header'; import Footer from './Footer'; import AppTheme from './AppTheme'; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - window.Buffer = Buffer; window.process = process; const rootElement = document.getElementById('root'); const root = createRoot(rootElement); -const API_BASE = import.meta.env.VITE_API_BASE_URL; const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; -// Initialize global API clients so all components across khata-ui have generic API access -initializeApiClients(API_BASE, AUTH_BASE); - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - const routerMapping = [ { path: "/", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" }, @@ -53,7 +37,7 @@ const routerMapping = [ ]; root.render( - + @@ -70,7 +54,7 @@ root.render( path={path} element={ path.startsWith("/admin") ? ( - + ) : ( ) @@ -84,5 +68,5 @@ root.render( - + );