major refactor of the dashboard and react-openapi integration #1
@@ -1,5 +1,4 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { useAuth, AuthPage } from "../react-auth";
|
import { useAuth, AuthPage } from "../react-auth";
|
||||||
import { UploadProvider } from "./providers/UploadProvider";
|
import { UploadProvider } from "./providers/UploadProvider";
|
||||||
import AdminLayout from "./components/AdminLayout";
|
import AdminLayout from "./components/AdminLayout";
|
||||||
@@ -13,13 +12,9 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useParams,
|
useParams,
|
||||||
Navigate,
|
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
import { ConfigContext } from "./providers/ConfigContext";
|
||||||
|
|
||||||
// Create a context for the app config
|
|
||||||
export const ConfigContext = React.createContext<AppConfig | null>(null);
|
|
||||||
|
|
||||||
function Dashboard({ basePath }: { basePath: string }) {
|
function Dashboard({ basePath }: { basePath: string }) {
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
@@ -127,14 +122,17 @@ interface AdminProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
|
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
|
||||||
const [config, setConfig] = React.useState<AppConfig | null>(null);
|
const existingConfig = React.useContext(ConfigContext);
|
||||||
|
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!existingConfig) {
|
||||||
getAppConfig(resourceOverrides, profileConfig).then((cfg) => {
|
getAppConfig(resourceOverrides, profileConfig).then((cfg) => {
|
||||||
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
|
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
|
||||||
setConfig(cfg);
|
setConfig(cfg);
|
||||||
});
|
});
|
||||||
}, [resourceOverrides, profileConfig]);
|
}
|
||||||
|
}, [resourceOverrides, profileConfig, existingConfig]);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
@@ -151,13 +149,21 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ConfigContext.Provider value={config}>
|
|
||||||
<UploadProvider>
|
<UploadProvider>
|
||||||
<AdminApp basePath={basePath} />
|
<AdminApp basePath={basePath} />
|
||||||
</UploadProvider>
|
</UploadProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we have an existing config, we are already inside a Provider and QueryClient
|
||||||
|
if (existingConfig) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for standalone usage
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={config}>
|
||||||
|
{content}
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useUpload } from '../providers/UploadProvider';
|
|||||||
import { useQueries } from '@tanstack/react-query';
|
import { useQueries } from '@tanstack/react-query';
|
||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
import FormField from './fields/FormField';
|
import FormField from './fields/FormField';
|
||||||
import { ConfigContext } from '../Admin';
|
import { ConfigContext } from '../providers/ConfigContext';
|
||||||
|
|
||||||
interface GenericFormProps {
|
interface GenericFormProps {
|
||||||
config: ResourceConfig;
|
config: ResourceConfig;
|
||||||
@@ -67,6 +67,7 @@ export default function GenericForm({
|
|||||||
const relationDataMap = React.useMemo(() => {
|
const relationDataMap = React.useMemo(() => {
|
||||||
const map: Record<string, any[]> = {};
|
const map: Record<string, any[]> = {};
|
||||||
allRelations.forEach((relName, index) => {
|
allRelations.forEach((relName, index) => {
|
||||||
|
// @ts-ignore
|
||||||
map[relName] = queries[index].data || [];
|
map[relName] = queries[index].data || [];
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
|
import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
|
||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
import GenericForm from './GenericForm';
|
import GenericForm from './GenericForm';
|
||||||
import { ConfigContext } from '../Admin';
|
import { ConfigContext } from '../providers/ConfigContext';
|
||||||
|
|
||||||
export default function ProfileView() {
|
export default function ProfileView() {
|
||||||
const appConfig = React.useContext(ConfigContext);
|
const appConfig = React.useContext(ConfigContext);
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { ResourceConfig } from "../types/config";
|
import { ResourceConfig } from "../types/config";
|
||||||
|
import { ConfigContext } from "../providers/ConfigContext";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
export function useResource<T = any>(config: ResourceConfig) {
|
export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { name, endpoint, primaryKey } = config;
|
|
||||||
|
// Return empty/disabled hooks if config is missing
|
||||||
|
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
|
||||||
|
|
||||||
// --- READ ALL ---
|
// --- READ ALL ---
|
||||||
const useList = (params?: any) =>
|
const useList = (params?: any) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: [name, "list", params],
|
queryKey: [name, "list", params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!endpoint) return { data: [], total: 0 };
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await api.get<T[]>(endpoint, { params });
|
const res = await api.get<T[]>(endpoint, { params });
|
||||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||||
@@ -18,7 +23,8 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
data: res.data,
|
data: res.data,
|
||||||
total: isNaN(total as any) ? undefined : total
|
total: isNaN(total as any) ? undefined : total
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
enabled: !!endpoint,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- READ ONE ---
|
// --- READ ONE ---
|
||||||
@@ -26,18 +32,19 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: [name, "detail", id],
|
queryKey: [name, "detail", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!id) return null;
|
if (!id || !endpoint) return null;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await api.get<T>(`${endpoint}/${id}`);
|
const res = await api.get<T>(`${endpoint}/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id && !!endpoint,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- CREATE ---
|
// --- CREATE ---
|
||||||
const useCreate = () =>
|
const useCreate = () =>
|
||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async (data: Partial<T>) => {
|
mutationFn: async (data: Partial<T>) => {
|
||||||
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await api.post<T>(endpoint, data);
|
const res = await api.post<T>(endpoint, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -51,6 +58,7 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
const useUpdate = () =>
|
const useUpdate = () =>
|
||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||||
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -67,6 +75,7 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
const useDelete = () =>
|
const useDelete = () =>
|
||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async (id: string) => {
|
mutationFn: async (id: string) => {
|
||||||
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
await api.delete(`${endpoint}/${id}`);
|
await api.delete(`${endpoint}/${id}`);
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
@@ -79,6 +88,7 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
const getListQueryOptions = (params?: any) => ({
|
const getListQueryOptions = (params?: any) => ({
|
||||||
queryKey: [name, "list", params],
|
queryKey: [name, "list", params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!endpoint) return { data: [], total: 0 };
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await api.get<T[]>(endpoint, { params });
|
const res = await api.get<T[]>(endpoint, { params });
|
||||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||||
@@ -87,6 +97,7 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
total: isNaN(total as any) ? undefined : total
|
total: isNaN(total as any) ? undefined : total
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
enabled: !!endpoint,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- READ ME ---
|
// --- READ ME ---
|
||||||
@@ -94,16 +105,19 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: [name, "me"],
|
queryKey: [name, "me"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
if (!endpoint) return null;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await api.get<T>(`${endpoint}/me`);
|
const res = await api.get<T>(`${endpoint}/me`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
enabled: !!endpoint,
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- UPDATE ME ---
|
// --- UPDATE ME ---
|
||||||
const useUpdateMe = () =>
|
const useUpdateMe = () =>
|
||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async (data: Partial<T>) => {
|
mutationFn: async (data: Partial<T>) => {
|
||||||
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const res = await api.put<T>(`${endpoint}/me`, data);
|
const res = await api.put<T>(`${endpoint}/me`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -125,3 +139,10 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
getListQueryOptions,
|
getListQueryOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useResourceByName<T = any>(name: string) {
|
||||||
|
const config = React.useContext(ConfigContext);
|
||||||
|
const resourceConfig = config?.resources.find((r) => r.name === name);
|
||||||
|
return useResource<T>(resourceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ export { default as Admin } from "./Admin";
|
|||||||
export { api, auth, initializeApiClients } from "./api/client";
|
export { api, auth, initializeApiClients } from "./api/client";
|
||||||
export { getAppConfig } from "./config";
|
export { getAppConfig } from "./config";
|
||||||
export type { AppConfig, ResourceConfig, ResourceField } from "./types/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";
|
||||||
|
|||||||
70
react-openapi/providers/AppProvider.tsx
Normal file
70
react-openapi/providers/AppProvider.tsx
Normal file
@@ -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<string, any>;
|
||||||
|
profileConfig?: any;
|
||||||
|
queryClient?: QueryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppProvider({
|
||||||
|
children,
|
||||||
|
resourceOverrides = {},
|
||||||
|
profileConfig = {},
|
||||||
|
queryClient = defaultQueryClient,
|
||||||
|
}: AppProviderProps) {
|
||||||
|
const [config, setConfig] = React.useState<AppConfig | null>(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 (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConfigContext.Provider value={config}>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
react-openapi/providers/ConfigContext.tsx
Normal file
12
react-openapi/providers/ConfigContext.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { AppConfig } from "../types/config";
|
||||||
|
|
||||||
|
export const ConfigContext = React.createContext<AppConfig | null>(null);
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
const context = React.useContext(ConfigContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useConfig must be used within a ConfigProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,24 +1,15 @@
|
|||||||
import { api } from "../../../react-openapi";
|
|
||||||
import { LatestItem } from "../../components/LatestItems";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
|
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
|
||||||
|
import { LatestItem } from "../../components/LatestItems";
|
||||||
import { fetchReport } from "../report/report.api";
|
|
||||||
import { mapReportToDashboard } from "../report/report.mapper";
|
|
||||||
|
|
||||||
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
|
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
|
||||||
sx: { color: "#388e3c" }
|
sx: { color: "#388e3c" }
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function fetchLatestTransactions(
|
export function mapToLatestItems(
|
||||||
|
items: any[],
|
||||||
type: "expense" | "income"
|
type: "expense" | "income"
|
||||||
): Promise<LatestItem[]> {
|
): LatestItem[] {
|
||||||
const res = await api.get("/expenses", {
|
|
||||||
params: { limit: 100, sort: "-occurred_at" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = res.data || [];
|
|
||||||
|
|
||||||
const isValid = (amt: number) =>
|
const isValid = (amt: number) =>
|
||||||
type === "expense" ? amt < 0 : amt > 0;
|
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");
|
|
||||||
@@ -1,24 +1,48 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useResourceByName } from "../../../react-openapi";
|
||||||
import {
|
import { mapToLatestItems } from "./dashboard.mapper";
|
||||||
fetchAggregatedData,
|
import { mapReportToDashboard } from "../report/report.mapper";
|
||||||
fetchLatestTransactions,
|
|
||||||
} from "./dashboard.service";
|
|
||||||
|
|
||||||
export function useDashboardData(type: "expense" | "income") {
|
export function useDashboardData(type: "expense" | "income") {
|
||||||
const aggregated = useQuery({
|
const { useList: useExpenseList } = useResourceByName("expenses");
|
||||||
queryKey: ["dashboard", type],
|
const { useList: useReportList } = useResourceByName("reports");
|
||||||
queryFn: () => fetchAggregatedData(type),
|
|
||||||
|
// Fetch latest transactions
|
||||||
|
const latestQuery = useExpenseList({
|
||||||
|
limit: 100,
|
||||||
|
sort: "-occurred_at"
|
||||||
});
|
});
|
||||||
|
|
||||||
const latest = useQuery({
|
// Fetch reports for aggregation
|
||||||
queryKey: ["latest", type],
|
const weeklyReport = useReportList({ period: "weekly", rolling: true });
|
||||||
queryFn: () => fetchLatestTransactions(type),
|
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 {
|
return {
|
||||||
data: aggregated.data,
|
data: aggregatedData,
|
||||||
latest: latest.data,
|
latest: latest,
|
||||||
isLoading: aggregated.isLoading || latest.isLoading,
|
isLoading,
|
||||||
error: aggregated.error || latest.error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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<string, number> = {};
|
|
||||||
|
|
||||||
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<AggregatedDashboardData> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useResourceByName } from "../../../react-openapi";
|
||||||
import { fetchReport } from "./report.api";
|
|
||||||
|
|
||||||
export interface ReportParams {
|
export interface ReportParams {
|
||||||
period: "weekly" | "monthly" | "yearly" | "fyly";
|
period: "weekly" | "monthly" | "yearly" | "fyly";
|
||||||
@@ -10,8 +9,6 @@ export interface ReportParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useReport(params: ReportParams) {
|
export function useReport(params: ReportParams) {
|
||||||
return useQuery({
|
const { useList } = useResourceByName("reports");
|
||||||
queryKey: ["report", params],
|
return useList(params);
|
||||||
queryFn: () => fetchReport(params),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
24
src/main.jsx
24
src/main.jsx
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Home from './Home';
|
import Home from './Home';
|
||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
import { Admin, initializeApiClients } from '../react-openapi';
|
import { Admin, AppProvider } from '../react-openapi';
|
||||||
import { configuration, profileConfiguration } from './openapi-config';
|
import { configuration, profileConfiguration } from './openapi-config';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
import process from 'process';
|
import process from 'process';
|
||||||
@@ -21,30 +21,14 @@ import Header from './Header';
|
|||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
import AppTheme from './AppTheme';
|
import AppTheme from './AppTheme';
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
window.Buffer = Buffer;
|
window.Buffer = Buffer;
|
||||||
window.process = process;
|
window.process = process;
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
|
||||||
const AUTH_BASE = import.meta.env.VITE_AUTH_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 = [
|
const routerMapping = [
|
||||||
{ path: "/", component: Home, headerTitle: "Home" },
|
{ path: "/", component: Home, headerTitle: "Home" },
|
||||||
{ path: "/home", component: Home, headerTitle: "Home" },
|
{ path: "/home", component: Home, headerTitle: "Home" },
|
||||||
@@ -53,7 +37,7 @@ const routerMapping = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<AppProvider resourceOverrides={configuration} profileConfig={profileConfiguration}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider authBaseUrl={AUTH_BASE}>
|
<AuthProvider authBaseUrl={AUTH_BASE}>
|
||||||
<AppTheme>
|
<AppTheme>
|
||||||
@@ -70,7 +54,7 @@ root.render(
|
|||||||
path={path}
|
path={path}
|
||||||
element={
|
element={
|
||||||
path.startsWith("/admin") ? (
|
path.startsWith("/admin") ? (
|
||||||
<Component basePath="/admin" resourceOverrides={configuration} profileConfig={profileConfiguration} />
|
<Component basePath="/admin" />
|
||||||
) : (
|
) : (
|
||||||
<Component />
|
<Component />
|
||||||
)
|
)
|
||||||
@@ -84,5 +68,5 @@ root.render(
|
|||||||
</AppTheme>
|
</AppTheme>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user