diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx index 738495d..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,17 +12,17 @@ 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); const navigate = useNavigate(); + + const resources = config?.resources || []; + const visibleResources = resources.filter((res) => !res.hidden); + return ( @@ -41,7 +40,7 @@ function Dashboard({ basePath }: { basePath: string }) { mt: 4, }} > - {config?.resources.map((res) => ( + {visibleResources.map((res) => ( !res.hidden); + if (!currentUser) { return ( navigate(`/admin/${name}`)} - resources={config?.resources || []} + resources={visibleResources} > } /> @@ -120,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 ( @@ -144,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/api/client.ts b/react-openapi/api/client.ts index a7f8aef..97abc9e 100644 --- a/react-openapi/api/client.ts +++ b/react-openapi/api/client.ts @@ -7,6 +7,28 @@ import { createApiClient } from "../../react-auth"; let _api: AxiosInstance | null = null; let _auth: AxiosInstance | null = null; +function withParamsSerializer(instance: AxiosInstance): AxiosInstance { + instance.defaults.paramsSerializer = { + serialize: (params) => { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((v) => { + searchParams.append(key, String(v)); // NO [] + }); + } else if (value !== undefined && value !== null) { + searchParams.append(key, String(value)); + } + }); + + return searchParams.toString(); + }, + }; + + return instance; +} + export const api = { get: (...args: Parameters) => { if (!_api) throw new Error("API client not initialized"); @@ -38,6 +60,6 @@ export const auth = { }; export function initializeApiClients(baseUrl: string, authBaseUrl: string) { - _api = createApiClient(baseUrl); - _auth = createApiClient(authBaseUrl); + _api = withParamsSerializer(createApiClient(baseUrl)); + _auth = withParamsSerializer(createApiClient(authBaseUrl)); } 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..8934129 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -1,16 +1,22 @@ 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 }; + console.log('params:', params); // @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 +24,8 @@ export function useResource(config: ResourceConfig) { data: res.data, total: isNaN(total as any) ? undefined : total }; - } + }, + enabled: !!endpoint, }); // --- READ ONE --- @@ -26,18 +33,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 +59,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 +76,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 +89,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 +98,7 @@ export function useResource(config: ResourceConfig) { total: isNaN(total as any) ? undefined : total }; }, + enabled: !!endpoint, }); // --- READ ME --- @@ -94,16 +106,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 +140,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/react-openapi/types/config.ts b/react-openapi/types/config.ts index b8b434c..24ee18e 100644 --- a/react-openapi/types/config.ts +++ b/react-openapi/types/config.ts @@ -30,6 +30,7 @@ export interface ResourceConfig { primaryKey: string; fields: Record; pagination?: boolean; + hidden?: boolean; } export interface AppConfig { diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index 797604f..6a37d10 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -12,4 +12,5 @@ export interface FieldOverride { export interface ResourceOverride { fields?: Record; pagination?: boolean; + hidden?: boolean; } diff --git a/react-openapi/utils/openapi_loader.ts b/react-openapi/utils/openapi_loader.ts index c99ca42..112f74c 100644 --- a/react-openapi/utils/openapi_loader.ts +++ b/react-openapi/utils/openapi_loader.ts @@ -162,6 +162,7 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco primaryKey: "id", // Strict default, no heuristics fields, pagination: resourceOverride.pagination, + hidden: resourceOverride.hidden, }); } diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index 740b691..2fb1971 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -2,100 +2,30 @@ import * as React from "react"; import { Box, Container, - Grid, CircularProgress, - Alert, - ToggleButton, - ToggleButtonGroup + Alert } from "@mui/material"; -import LatestItemsList, { LatestItem } from "./components/LatestItemsList"; -import HistoryChart from "./components/HistoryChart"; +import ConfigurableDashboard from "./components/Dashboard"; +import { configuration } from "./dashboard-config"; import { - AggregatedDashboardData -} from "./types/historyChart"; - -import { - fetchLatestTransactions, - fetchAggregatedExpenses, - fetchAggregatedIncome, -} from "./utils/dashboardLoader"; + useReport, + prepareReport, +} from "./features/report"; export default function Dashboard() { - const [latest, setLatest] = React.useState<{ - expense: LatestItem[]; - income: LatestItem[]; - }>({ - expense: [], - income: [] - }); + const report = useReport({ + periods: ["weekly", "monthly", "full"], + rolling: true, + include_transactions: true, + group_by: ["tags"], + }) - const [aggregated, setAggregated] = React.useState<{ - expense: AggregatedDashboardData | null; - income: AggregatedDashboardData | null; - }>({ - expense: null, - income: null - }); + const isLoading = report.isLoading; + const error = report.error; - const [mode, setMode] = React.useState<"expense" | "income">("expense"); - const [period, setPeriod] = React.useState<"rolling" | "calendar">("rolling"); - const [comparison, setComparison] = React.useState(false); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - // -------- LOAD ONCE -------- - React.useEffect(() => { - async function loadData() { - try { - setLoading(true); - - const [ - latestExpense, - latestIncome, - expenseData, - incomeData - ] = await Promise.all([ - fetchLatestTransactions("expense"), - fetchLatestTransactions("income"), - fetchAggregatedExpenses(), - fetchAggregatedIncome() - ]); - - setLatest({ - expense: latestExpense, - income: latestIncome - }); - - setAggregated({ - expense: expenseData, - income: incomeData - }); - - } catch (err: any) { - console.error(err); - setError(err.message || "Failed to load dashboard data"); - } finally { - setLoading(false); - } - } - - loadData(); - }, []); - - const currentData = aggregated[mode]; - if (!currentData) { - return ( - - - - ); - } - const currentLatest = latest[mode]; - - // -------- UI STATES -------- - if (loading) { + if (isLoading) { return ( @@ -106,49 +36,20 @@ export default function Dashboard() { if (error) { return ( - {error} + {String(error)} ); } + if (!report) { + return null; + } + + const data = prepareReport(report.data?.data); return ( - - {/* -------- TOGGLE -------- */} - - val && setMode(val)} - > - Expenses - Income - - - - - - - - - - - {}} - /> - - - - + ); -} \ No newline at end of file +} diff --git a/src/Header.tsx b/src/Header.tsx index cd9cd0a..58ad72b 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -83,12 +83,14 @@ export default function Header({ navigate("/")} > {headerTitle} + + {/* AUTH SECTION */} {isAuthenticated ? ( <> diff --git a/src/components/Dashboard/Dashboard.models.ts b/src/components/Dashboard/Dashboard.models.ts new file mode 100644 index 0000000..e801720 --- /dev/null +++ b/src/components/Dashboard/Dashboard.models.ts @@ -0,0 +1,51 @@ +import * as React from "react"; +import { + ReportData +} from "../../features/report"; + +export type DashboardMode = "expense" | "income"; +export type DashboardPeriodType = "rolling" | "calendar"; +export type DashboardSelectedPeriodId = string | null; + +export interface DashboardState { + mode: DashboardMode; + periodType: DashboardPeriodType; + selectedPeriodId: DashboardSelectedPeriodId; + comparison: boolean; +} + +export interface DashboardSection { + id: string; + title?: string; + summary?: string; + component: React.ComponentType; + settings?: Record; + isList?: boolean; + style?: { + size?: number; + [key: string]: any; + }; +} + +export interface ColorDefinition { + primary: string; + background?: string; + text?: string; +} + +export interface ThemeAwarePalette { + light: ColorDefinition; + dark: ColorDefinition; +} + +export interface DashboardConfig { + sections: DashboardSection[]; + style?: { + palette?: Record; + }; +} + +export interface DashboardProps { + config: DashboardConfig; + data: ReportData; +} diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx new file mode 100644 index 0000000..4dc0580 --- /dev/null +++ b/src/components/Dashboard/Dashboard.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import DashboardView from "./Dashboard.view"; +import { DashboardProps, DashboardState } from "./Dashboard.models"; + +export default function Dashboard(props: DashboardProps) { + const [state, setState] = React.useState({ + mode: "expense", + periodType: "rolling", + selectedPeriodId: null, + comparison: false, + }); + + const toggleMode = () => { + setState(prev => ({ + ...prev, + mode: prev.mode === "expense" ? "income" : "expense", + })); + }; + + const togglePeriodType = () => { + setState(prev => ({ + ...prev, + periodType: prev.periodType === "rolling" ? "calendar" : "rolling", + })); + }; + + const toggleComparison = () => { + setState(prev => ({ + ...prev, + comparison: !prev.comparison, + })); + }; + + const setSelectedPeriodId = (selectedPeriodId: typeof state.selectedPeriodId) => { + setState(prev => ({ ...prev, selectedPeriodId })); + }; + + return ( + + ); +} diff --git a/src/components/Dashboard/Dashboard.view.tsx b/src/components/Dashboard/Dashboard.view.tsx new file mode 100644 index 0000000..e4aeae9 --- /dev/null +++ b/src/components/Dashboard/Dashboard.view.tsx @@ -0,0 +1,134 @@ +import * as React from "react"; +import { + Box, + Container, + Grid, + Typography, + ToggleButton, + ToggleButtonGroup +} from "@mui/material"; +import { useTheme, alpha } from "@mui/material/styles"; +import { DashboardProps, DashboardState } from "./Dashboard.models"; + +interface ViewProps extends DashboardProps { + state: DashboardState; + setState: React.Dispatch>; + toggleMode: () => void; + togglePeriodType: () => void; + setSelectedPeriodId: (id: string | null) => void; + toggleComparison: () => void; +} + +export default function DashboardView({ + config, + data, + state, + setState, + toggleMode, + togglePeriodType, + toggleComparison, + setSelectedPeriodId, +}: ViewProps) { + const theme = useTheme(); + const themeMode = theme.palette.mode; + const { mode, periodType, comparison, selectedPeriodId } = state; + + // Resolve colors with fallbacks + const colors = React.useMemo(() => { + const palette = config.style?.palette?.[mode]; + const modeColors = palette ? palette[themeMode] : null; + + if (modeColors) { + return { + primary: modeColors.primary, + light: modeColors.background || alpha(modeColors.primary, 0.1), + text: modeColors.text || (themeMode === 'light' ? theme.palette.text.primary : '#fff') + }; + } + + // Fallback to standard theme colors + const themeColor = mode === 'expense' ? theme.palette.error : theme.palette.success; + return { + primary: themeColor.main, + light: alpha(themeColor.main, themeMode === 'light' ? 0.08 : 0.15), + text: themeColor.main + }; + }, [config.style?.palette, mode, themeMode, theme.palette]); + + return ( + + + + Expenses + Income + + + + + {config.sections.map((section) => { + const Component = section.component; + + return ( + + {section.title && !section.isList && ( + + + {section.title} + + + )} + + + + ); + })} + + + ); +} diff --git a/src/components/Dashboard/index.ts b/src/components/Dashboard/index.ts new file mode 100644 index 0000000..2f01984 --- /dev/null +++ b/src/components/Dashboard/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Dashboard"; +export * from "./Dashboard.models"; diff --git a/src/components/HistoryChart.tsx b/src/components/HistoryChart.tsx deleted file mode 100644 index 5540d9e..0000000 --- a/src/components/HistoryChart.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import * as React from "react"; -import { - Box, - Typography, - ToggleButtonGroup, - ToggleButton, - Paper -} from "@mui/material"; -import { - ChartDataPoint, - HistoryChartProps, - ChartData, -} from "../types/historyChart"; -import IconButton from "@mui/material/IconButton"; -import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; - -const formatDisplay = ( - point: ChartDataPoint, - tab: string, - comparison: boolean -) => { - const base = point.amount; - const cmp = point.compareAmount ?? 0; - - const formatShort = (val: number) => { - if (tab === "monthly") { - if (val >= 100000) return `${(val / 100000).toFixed(2)}L`; - } - if (tab === "weekly") { - if (val >= 1000) return `${(val / 1000).toFixed(1)}K`; - } - return val.toLocaleString("en-IN"); - }; - - // Only hide diff when comparison OFF or compare is undefined - if (!comparison) { - return `₹ ${formatShort(base)}`; - } - - const diff = base - cmp; - const sign = diff >= 0 ? "+" : "-"; - const absDiff = Math.abs(diff); - - return `₹ ${formatShort(base)} (${sign}${formatShort(absDiff)})`; -}; - -const formatLabel = (label: string, type: string) => { - if (type === "monthly") return label; - - if (type === "weekly") { - const parts = label.split(" - "); - if (parts.length === 2) { - const [start, end] = parts; - const startDay = start.split(" ")[0]; - const endParts = end.split(" "); - const endDay = endParts[0]; - const month = endParts[1]; - return `${startDay}–${endDay} ${month}`; - } - } - - return label; -}; - -export default function HistoryChart({ - header, - summary, - tabs, - data, - period, - onPeriodChange, - comparison, - setComparison, -}: HistoryChartProps) { - const [activeTab, setActiveTab] = React.useState(tabs[0] || ""); - - const handleTabChange = (_: React.MouseEvent, newTab: string | null) => { - if (newTab !== null) setActiveTab(newTab); - }; - - const activeDataKey = activeTab.toLowerCase() as keyof ChartData; - - let rawData: ChartDataPoint[] = []; - - if (activeDataKey === "daily") { - rawData = data.daily || []; - } else { - const section = data[activeDataKey]; - rawData = section?.[period] || []; - } - - const currentData = rawData; - - const maxAmount = - currentData.length > 0 - ? Math.max( - ...currentData.flatMap((d) => - comparison ? [d.amount, d.compareAmount || 0] : [d.amount] - ), - 1 - ) - : 1; - - const [startIndex, setStartIndex] = React.useState(0); - const visibleCountDataTabMapping = { - daily: 7, - weekly: 6, - monthly: 4, - } - const visibleCount = visibleCountDataTabMapping[activeDataKey]; - const total = currentData.length; - - // clamp startIndex so we always show full 5 (when possible) - const clampedStartIndex = Math.min( - startIndex, - Math.max(total - visibleCount, 0) - ); - - const visibleData = currentData.slice( - clampedStartIndex, - clampedStartIndex + visibleCount - ); - - const canGoLeft = startIndex > 0; - const canGoRight = startIndex + visibleCount < currentData.length; - - const handlePrev = () => { - if (canGoLeft) setStartIndex((prev) => prev - visibleCount); - }; - - const handleNext = () => { - if (canGoRight) setStartIndex((prev) => prev + visibleCount); - }; - - return ( - - - {header} - - - {summary && ( - - {summary} - - )} - - - {tabs.map((tab) => ( - - {tab} - - ))} - - - - {/* Rolling / Calendar */} - v && onPeriodChange(v)} - size="small" - > - Rolling - - Calendar - - - - {/* Compare toggle */} - setComparison(!comparison)} - size="small" - sx={{ - textTransform: "none", - borderRadius: 2, - px: 2, - - // OFF - color: "text.secondary", - border: "1px solid", - borderColor: "divider", - - // ON - "&.Mui-selected": { - color: "white", - bgcolor: "success.main", - borderColor: "success.main" - }, - "&.Mui-selected:hover": { - bgcolor: "success.dark" - } - }} - > - Compare - - - - {currentData.length > 0 ? ( - - - {/* LEFT ARROW */} - {canGoLeft && ( - - - - )} - - {/* CHART */} - - {visibleData.map((point) => { - const currentHeight = (point.amount / maxAmount) * 100; - const compareHeight = comparison - ? ((point.compareAmount || 0) / maxAmount) * 100 - : 0; - const labelHeight = Math.max(currentHeight, compareHeight); - - return ( - - - - {formatDisplay(point, activeTab.toLowerCase(), comparison)} - - - {/* Compare */} - {comparison && ( - - )} - - {/* Spacer */} - - - {/* Current */} - - - - - - {formatLabel(point.id, activeDataKey)} - - - - {point.compareLabel - ? formatLabel(point.compareLabel, activeDataKey) - : "placeholder"} - - - - ); - })} - - - {/* RIGHT ARROW */} - {canGoRight && ( - - - - )} - - ) : ( - - No Data Available - - )} - - ); -} diff --git a/src/components/HistoryChart/HistoryChart.models.ts b/src/components/HistoryChart/HistoryChart.models.ts new file mode 100644 index 0000000..cf6e2fc --- /dev/null +++ b/src/components/HistoryChart/HistoryChart.models.ts @@ -0,0 +1,40 @@ +import { + DashboardMode, + DashboardPeriodType, + DashboardSelectedPeriodId +} from "../Dashboard"; +import { ReportData } from "../../features/report"; + +export interface _ChartDataPoint { + id: string; + label: string; + amount: number; + highlighted?: boolean; +} + +export interface ChartDataPoint extends _ChartDataPoint { + compare?: _ChartDataPoint; +} + +export interface HistoryChartProps { + header: string; + summary?: string; + tabs: string[]; + + reportData: ReportData; + + colorScheme: { + primary: string; + light: string; + text: string; + }; + + mode: DashboardMode; + periodType: DashboardPeriodType; + selectedPeriodId: DashboardSelectedPeriodId; + comparison: boolean; + + togglePeriodType: () => void; + setSelectedPeriodId: (id: string | null) => void; + toggleComparison: () => void; +} diff --git a/src/components/HistoryChart/HistoryChart.tsx b/src/components/HistoryChart/HistoryChart.tsx new file mode 100644 index 0000000..4488676 --- /dev/null +++ b/src/components/HistoryChart/HistoryChart.tsx @@ -0,0 +1,212 @@ +import * as React from "react"; +import { HistoryChartProps, ChartDataPoint } from "./HistoryChart.models"; +import HistoryChartView from "./HistoryChart.view"; +import { ReportPeriod } from "../../features/report"; + +type DecoratedPeriod = ReportPeriod & { + id: string; + label: string; +}; + +const TAB_TO_KEY: Record = { + Weekly: "weekly", + Monthly: "monthly", + Yearly: "yearly", + 'Financial Year': "fyly", + 'All Time': "full" +}; + +function getAmount(p: ReportPeriod, mode: "expense" | "income") { + return mode === "expense" ? p.expenses.sum : p.incomes.sum; +} + +function mergeMetric(a: any, b: any) { + const sum = a.sum + b.sum; + const count = a.count + b.count; + + return { + ...a, + sum, + count, + average: count > 0 ? sum / count : 0, + transactions: a.transactions || b.transactions + ? [ + ...(a.transactions || []), + ...(b.transactions || []) + ] + : undefined + }; +} + +function mergeBuckets( + buckets: any[], + key: "weekly" | "monthly" | "yearly" | "fyly" | "full" +): DecoratedPeriod[] { + const map = new Map(); + + for (const bucket of buckets) { + const periods = (bucket.periods[key] || []) as DecoratedPeriod[]; + + for (const p of periods) { + const existing = map.get(p.id); + + if (!existing) { + map.set(p.id, { + ...p, + expenses: { ...p.expenses }, + incomes: { ...p.incomes } + }); + } else { + map.set(p.id, { + ...existing, + expenses: mergeMetric(existing.expenses, p.expenses), + incomes: mergeMetric(existing.incomes, p.incomes) + }); + } + } + } + + return Array.from(map.values()).sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime() + ); +} + +function attachComparison( + points: ChartDataPoint[], + key: "weekly" | "monthly" | "yearly" | "fyly" | "full" +): ChartDataPoint[] { + const getCompareIndex = (i: number) => { + if (key === "weekly") return i - 4; + if (key === "monthly") return i - 12; + if (key === "yearly") return i - 1; + if (key === "fyly") return i - 1; + return -1; + }; + + return points.map((p, i) => { + const ci = getCompareIndex(i); + + return { + ...p, + compare: + ci >= 0 && points[ci] + ? { + id: points[ci].id, + label: points[ci].label, + amount: points[ci].amount + } + : undefined + }; + }); +} + +function buildChartData( + reportData: HistoryChartProps["reportData"], + key: "weekly" | "monthly" | "yearly" | "fyly" | "full", + mode: "expense" | "income", + comparison: boolean +): ChartDataPoint[] { + const merged = mergeBuckets(reportData.buckets, key); + console.log("Merged periods:", merged); + + let points: ChartDataPoint[] = merged.map((p) => ({ + id: p.id, + label: p.label, + amount: getAmount(p, mode) + })); + + if (comparison) { + points = attachComparison(points, key); + } + + return points; +} + +export default function HistoryChart(props: HistoryChartProps) { + const { + tabs, + reportData, + mode, + periodType, + comparison, + selectedPeriodId, + setSelectedPeriodId + } = props; + + const [activeTab, setActiveTab] = React.useState(tabs[0] || ""); + const [startIndex, setStartIndex] = React.useState(0); + + const activeDataKey = TAB_TO_KEY[activeTab]; + + const currentData = React.useMemo(() => { + return buildChartData(reportData, activeDataKey, mode, comparison); + }, [reportData, activeDataKey, mode, comparison]); + + const maxAmount = + currentData.length > 0 + ? Math.max( + ...currentData.flatMap((d) => + comparison + ? [d.amount, ...(d.compare ? [d.compare.amount] : [])] + : [d.amount] + ), + 1 + ) + : 1; + + const visibleCountMap = { + weekly: 6, + monthly: 4, + yearly: 4, + fyly: 4, + full: 4, + }; + + const visibleCount = visibleCountMap[activeDataKey] ?? 4; + + const total = currentData.length; + + const clampedStartIndex = Math.min( + startIndex, + Math.max(total - visibleCount, 0) + ); + + React.useEffect(() => { + if (startIndex !== clampedStartIndex) { + setStartIndex(clampedStartIndex); + } + }, [startIndex, clampedStartIndex]); + + const visibleData = currentData.slice( + clampedStartIndex, + clampedStartIndex + visibleCount + ); + + React.useEffect(() => { + setSelectedPeriodId(null); + }, [activeTab, periodType]); + + React.useEffect(() => { + if ( + selectedPeriodId && + !visibleData.some((p) => p.id === selectedPeriodId) + ) { + setSelectedPeriodId(null); + } + }, [visibleData, selectedPeriodId]); + + return ( + + ); +} diff --git a/src/components/HistoryChart/HistoryChart.utils.ts b/src/components/HistoryChart/HistoryChart.utils.ts new file mode 100644 index 0000000..a7bed97 --- /dev/null +++ b/src/components/HistoryChart/HistoryChart.utils.ts @@ -0,0 +1,27 @@ +import { ChartDataPoint } from "./HistoryChart.models"; + +export const formatDisplay = ( + point: ChartDataPoint, + tab: string, + comparison: boolean +) => { + const base = point.amount; + const cmp = point.compare?.amount ?? 0; + + const formatShort = (val: number) => { + if (tab === "monthly" && val >= 100000) { + return `${(val / 100000).toFixed(2)}L`; + } + if (tab === "weekly" && val >= 1000) { + return `${(val / 1000).toFixed(1)}K`; + } + return val.toLocaleString("en-IN"); + }; + + if (!comparison) return `₹ ${formatShort(base)}`; + + const diff = base - cmp; + const sign = diff >= 0 ? "+" : "-"; + + return `₹ ${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`; +}; diff --git a/src/components/HistoryChart/HistoryChart.view.tsx b/src/components/HistoryChart/HistoryChart.view.tsx new file mode 100644 index 0000000..11c7e13 --- /dev/null +++ b/src/components/HistoryChart/HistoryChart.view.tsx @@ -0,0 +1,208 @@ +import * as React from "react"; +import { + Box, + Typography, + ToggleButtonGroup, + ToggleButton, + Paper +} from "@mui/material"; +import { useTheme, alpha } from "@mui/material/styles"; +import IconButton from "@mui/material/IconButton"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { + ChartDataPoint, + HistoryChartProps, +} from "./HistoryChart.models"; +import { formatDisplay } from "./HistoryChart.utils"; + +interface ViewProps extends HistoryChartProps { + activeTab: string; + setActiveTab: (v: string) => void; + currentData: ChartDataPoint[]; + visibleData: ChartDataPoint[]; + maxAmount: number; + visibleCount: number; + startIndex: number; + setStartIndex: React.Dispatch>; + activeDataKey: string; +} + +export default function HistoryChartView(props: ViewProps) { + const { + header, + summary, + tabs, + colorScheme, + + mode, + periodType, + selectedPeriodId, + comparison, + + togglePeriodType, + setSelectedPeriodId, + toggleComparison, + + activeTab, + setActiveTab, + currentData, + visibleData, + maxAmount, + visibleCount, + startIndex, + setStartIndex, + activeDataKey, + } = props; + + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + + const handleTabChange = (_: React.MouseEvent, newTab: string | null) => { + if (newTab !== null) setActiveTab(newTab); + }; + + const canGoLeft = startIndex > 0; + const canGoRight = startIndex + visibleCount < currentData.length; + + const handlePrev = () => { + if (canGoLeft) setStartIndex((prev) => prev - visibleCount); + }; + + const handleNext = () => { + if (canGoRight) setStartIndex((prev) => prev + visibleCount); + }; + + return ( + + + {header} + + + {summary && ( + + {summary} + + )} + + + {tabs.map((tab) => ( + + {tab} + + ))} + + + + + Rolling + Calendar + + + + Compare + + + + {currentData.length > 0 ? ( + + {canGoLeft && ( + + + + )} + + + {visibleData.map((point) => { + const currentHeight = (point.amount / maxAmount) * 100; + const compareHeight = comparison + ? ((point.compare?.amount ?? 0) / maxAmount) * 100 + : 0; + + const isSelected = selectedPeriodId === point.id; + const display = formatDisplay(point, activeDataKey, comparison); + + return ( + + setSelectedPeriodId(isSelected ? null : point.id) + } + sx={{ + flex: 1, + display: "flex", + flexDirection: "column", + alignItems: "center", + cursor: "pointer", + height: "100%" + }} + > + + {comparison && ( + + )} + + + + + + {point.label} + + + {comparison && point.compare && ( + + {point.compare.label} + + )} + + + {display} + + + ); + })} + + + {canGoRight && ( + + + + )} + + ) : ( + + No Data Available + + )} + + ); +} diff --git a/src/components/HistoryChart/index.ts b/src/components/HistoryChart/index.ts new file mode 100644 index 0000000..28b6303 --- /dev/null +++ b/src/components/HistoryChart/index.ts @@ -0,0 +1,2 @@ +export { default } from "./HistoryChart"; +export * from "./HistoryChart.models"; diff --git a/src/components/LatestItems/LatestItems.models.ts b/src/components/LatestItems/LatestItems.models.ts new file mode 100644 index 0000000..bd15502 --- /dev/null +++ b/src/components/LatestItems/LatestItems.models.ts @@ -0,0 +1,18 @@ +import * as React from "react"; + +export interface LatestItem { + id: string | number; + icon: React.ReactNode; + iconBgColor?: string; + title: string; + subtitle: string; + amount: string; + timeAgo: string; +} + +export interface LatestItemsListProps { + title?: string; + items: LatestItem[]; + onViewAll?: () => void; + accentColor: string; +} diff --git a/src/components/LatestItemsList.tsx b/src/components/LatestItems/LatestItems.tsx similarity index 95% rename from src/components/LatestItemsList.tsx rename to src/components/LatestItems/LatestItems.tsx index b3f55e3..9c9f247 100644 --- a/src/components/LatestItemsList.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -24,12 +24,14 @@ export interface LatestItemsListProps { title?: string; items: LatestItem[]; onViewAll?: () => void; + accentColor: any; } -export default function LatestItemsList({ +export default function LatestItems({ title = "Recent Transactions", items, onViewAll, + accentColor, }: LatestItemsListProps) { return ( @@ -69,7 +71,7 @@ export default function LatestItemsList({ ; +} diff --git a/src/components/LatestItems/index.ts b/src/components/LatestItems/index.ts new file mode 100644 index 0000000..2847eeb --- /dev/null +++ b/src/components/LatestItems/index.ts @@ -0,0 +1,2 @@ +export { default } from "./LatestItems"; +export * from "./LatestItems.models"; diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx deleted file mode 100644 index 74c66d3..0000000 --- a/src/components/ProgressCard.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import * as React from "react"; -import { Box, Typography, Paper, LinearProgress, linearProgressClasses } from "@mui/material"; - -export interface ProgressCardProps { - header: string; - summary?: string; - progressAmount: number; - totalAmount: number; - colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning"; -} - -export default function ProgressCard({ - header, - summary, - progressAmount, - totalAmount, - colorTheme = "info", -}: ProgressCardProps) { - const percentage = Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)) || 0; - - const displaySummary = summary ?? `Rs ${progressAmount} / Rs ${totalAmount}`; - - const parts = displaySummary.split('/'); - const prefixAmount = parts[0]?.trim() || ''; - const suffixString = parts.length > 1 ? `/ ${parts.slice(1).join('/').trim()}` : ''; - - return ( - - colorTheme === "info" - ? "linear-gradient(135deg, #0284c7 0%, #06b6d4 100%)" - : `linear-gradient(135deg, ${theme.palette[colorTheme].main} 0%, ${theme.palette[colorTheme].light} 100%)`, - color: "#fff", - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - position: 'relative', - overflow: 'hidden', - boxShadow: (theme) => `0 12px 24px -10px ${theme.palette.mode === 'dark' ? '#000' : theme.palette[colorTheme].main}`, - }} - > - - {header} - - - - {prefixAmount}{" "} - {suffixString && ( - - {suffixString} - - )} - - - - - - - ); -} diff --git a/src/components/ProgressCard/ProgressCard.models.ts b/src/components/ProgressCard/ProgressCard.models.ts new file mode 100644 index 0000000..2815bf4 --- /dev/null +++ b/src/components/ProgressCard/ProgressCard.models.ts @@ -0,0 +1,8 @@ +export interface ProgressCardProps { + header: string; + summary?: string; + progressAmount: number; + totalAmount: number; + colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning"; + compact?: boolean; +} diff --git a/src/components/ProgressCard/ProgressCard.tsx b/src/components/ProgressCard/ProgressCard.tsx new file mode 100644 index 0000000..3441a2f --- /dev/null +++ b/src/components/ProgressCard/ProgressCard.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import ProgressCardView from "./ProgressCard.view"; +import { ProgressCardProps } from "./ProgressCard.models"; +import { getPercentage, formatCurrency } from "./ProgressCard.utils"; + +export default function ProgressCard(props: ProgressCardProps) { + const { progressAmount, totalAmount, compact = false } = props; + + const percentage = getPercentage(progressAmount, totalAmount); + + const formattedProgress = formatCurrency(progressAmount); + const formattedTotal = formatCurrency(totalAmount); + + return ( + + ); +} diff --git a/src/components/ProgressCard/ProgressCard.utils.ts b/src/components/ProgressCard/ProgressCard.utils.ts new file mode 100644 index 0000000..de50ef5 --- /dev/null +++ b/src/components/ProgressCard/ProgressCard.utils.ts @@ -0,0 +1,15 @@ +export const getPercentage = (progressAmount: number, totalAmount: number) => { + if (!totalAmount) return 0; + return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)); +}; + +export const formatCurrency = (val: number) => { + const absVal = Math.abs(val); + if (absVal >= 100000) { + return `₹ ${(val / 100000).toFixed(2)}L`; + } + if (absVal >= 1000) { + return `₹ ${(val / 1000).toFixed(2)}k`; + } + return `₹ ${val.toFixed(2)}`; +}; diff --git a/src/components/ProgressCard/ProgressCard.view.tsx b/src/components/ProgressCard/ProgressCard.view.tsx new file mode 100644 index 0000000..7f784fe --- /dev/null +++ b/src/components/ProgressCard/ProgressCard.view.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import { + Box, + Typography, + Paper, + LinearProgress, + Divider, + linearProgressClasses +} from "@mui/material"; +import { useTheme, alpha } from "@mui/material/styles"; +import { ProgressCardProps } from "./ProgressCard.models"; + +interface ViewProps extends ProgressCardProps { + percentage: number; + formattedProgress: string; + formattedTotal: string; +} + +export default function ProgressCardView({ + header, + colorTheme = "info", + percentage, + formattedProgress, + formattedTotal, + compact = false, +}: ViewProps) { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + + return ( + { + const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main; + const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light; + return isDark + ? `linear-gradient(135deg, ${alpha(baseColor, 0.9)} 0%, ${alpha(baseColor, 0.3)} 100%)` + : `linear-gradient(135deg, ${baseColor} 0%, ${lightColor} 100%)`; + }, + color: "#fff", + display: "flex", + flexDirection: "column", + alignItems: compact ? "flex-start" : "center", + justifyContent: "center", + position: "relative", + overflow: "hidden", + border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none", + boxShadow: (theme) => + `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${ + isDark + ? "rgba(0,0,0,0.5)" + : theme.palette[colorTheme]?.main || theme.palette.primary.main + }`, + }} + > + + {header} + + + + + {formattedProgress} + + + + + + of {formattedTotal} + + + + + + + + ); +} diff --git a/src/components/ProgressCard/TopTags.tsx b/src/components/ProgressCard/TopTags.tsx new file mode 100644 index 0000000..c8aea82 --- /dev/null +++ b/src/components/ProgressCard/TopTags.tsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import { Box } from "@mui/material"; +import { ReportData, ReportPeriod } from "../../features/report"; +import ProgressCard from "./ProgressCard"; + +type Props = { + reportData: ReportData; + mode: "expense" | "income"; + selectedPeriodId?: string | null; + compact?: boolean; +}; + +type DecoratedPeriod = ReportPeriod & { + id: string; + label: string; +}; + +function getAmount(p: ReportPeriod, mode: "expense" | "income") { + return mode === "expense" ? p.expenses.sum : p.incomes.sum; +} + +function findPeriod( + periods: DecoratedPeriod[], + selectedPeriodId?: string | null +) { + if (!periods.length) return null; + + if (selectedPeriodId) { + const match = periods.find((p) => p.id === selectedPeriodId); + if (match) return match; + } + + // fallback → latest + return periods.reduce((latest, p) => + new Date(p.start).getTime() > new Date(latest.start).getTime() + ? p + : latest + ); +} + +export default function TopTags({ + reportData, + mode, + selectedPeriodId, + compact = true +}: Props) { + const { items, total } = React.useMemo(() => { + const tagMap = new Map(); + + for (const bucket of reportData.buckets) { + const tags = bucket.group_key.tags; + if (!tags || tags.length === 0) continue; + + // Prefer FULL if available + const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[]; + + const periodsToUse = + selectedPeriodId + ? Object.values(bucket.periods).flat() as DecoratedPeriod[] + : fullPeriods; + + const period = findPeriod(periodsToUse, selectedPeriodId); + if (!period) continue; + + const amount = getAmount(period, mode); + + for (const tag of tags) { + tagMap.set(tag, (tagMap.get(tag) || 0) + amount); + } + } + + const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({ + tag, + amount + })); + + arr.sort((a, b) => b.amount - a.amount); + + const top = arr.slice(0, 4); + const total = top.reduce((sum, t) => sum + t.amount, 0); + + return { items: top, total }; + }, [reportData, mode, selectedPeriodId]); + + return ( + + {items.map((item) => ( + + ))} + + ); +} diff --git a/src/components/ProgressCard/index.ts b/src/components/ProgressCard/index.ts new file mode 100644 index 0000000..f242754 --- /dev/null +++ b/src/components/ProgressCard/index.ts @@ -0,0 +1,2 @@ +export { default } from "./ProgressCard"; +export * from "./ProgressCard.models"; diff --git a/src/dashboard-config.ts b/src/dashboard-config.ts new file mode 100644 index 0000000..5d7bab9 --- /dev/null +++ b/src/dashboard-config.ts @@ -0,0 +1,70 @@ +import HistoryChart from "./components/HistoryChart"; +import LatestItems from "./components/LatestItems"; +import { DashboardConfig } from "./components/Dashboard"; +import TopTags from "./components/ProgressCard/TopTags"; + +export const configuration: DashboardConfig = { + sections: [ + { + id: "breakdown", + title: "Breakdown", + summary: "Interactive chronological tracking", + component: HistoryChart, + settings: { + tabs: ["Weekly", "Monthly"], + // tabs: ["Weekly", "Monthly", "Yearly", "Financial Year", "All Time"], + }, + style: { + size: 12, + }, + }, + { + id: "top-payees", + title: 'Top Payees', + component: TopTags, + settings: { + compact: true, + }, + style: { + size: 12, + }, + }, + // { + // id: "latest", + // title: 'Recent Transactions', + // component: LatestItems, + // dataKey: "latest", + // style: { + // size: 12, + // }, + // }, + ], + style: { + palette: { + expense: { + light: { + primary: "#d32f2f", + background: "#fdecea", + text: "#b71c1c" + }, + dark: { + primary: "#f44336", + background: "rgba(244, 67, 54, 0.15)", + text: "#ffcdd2" + } + }, + income: { + light: { + primary: "#2e7d32", + background: "#e8f5e9", + text: "#1b5e20" + }, + dark: { + primary: "#4caf50", + background: "rgba(76, 175, 80, 0.15)", + text: "#c8e6c9" + } + } + } + } +}; diff --git a/src/features/report/index.ts b/src/features/report/index.ts new file mode 100644 index 0000000..9092544 --- /dev/null +++ b/src/features/report/index.ts @@ -0,0 +1,11 @@ +export { + useReport +} from './useReport' +export type { + Transaction, + ReportData, + ReportPeriod, +} from './report.models' +export { + prepareReport +} from './report.utils' diff --git a/src/features/report/report.models.ts b/src/features/report/report.models.ts new file mode 100644 index 0000000..d905aa5 --- /dev/null +++ b/src/features/report/report.models.ts @@ -0,0 +1,90 @@ +export interface Payor { + name: string; +} + +export interface Payee { + name: string; +} + +export interface Account { + name: string; + number: string; +} + +export interface Tag { + name: string; + icon: string; + description: string; +} + +export interface Transaction { + payor: Payor; + payee: Payee; + amount: number; + account: Account; + tags: Tag[]; + occurred_at: Date; +} + +// ----------------------------- +// Metrics +// ----------------------------- + +export interface ReportMetric { + sum: number; + count: number; + average: number; + transactions?: Transaction[]; +} + +// ----------------------------- +// Period +// ----------------------------- + +export interface ReportPeriod { + start: Date; + end: Date; + + expenses: ReportMetric; + incomes: ReportMetric; +} + +// ----------------------------- +// Group (bucket) +// ----------------------------- + +export type GroupKey = { + payee?: string[]; + tags?: string[]; + flow?: string[]; +}; + +export interface ReportBucket { + group_key: GroupKey; + + periods: { + weekly?: ReportPeriod[]; + monthly?: ReportPeriod[]; + yearly?: ReportPeriod[]; + fyly?: ReportPeriod[]; + full?: ReportPeriod[]; + }; +} + +// ----------------------------- +// Final Report +// ----------------------------- + +export interface ReportData { + periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; + + rolling: boolean; + report_date?: string; + + group_by: ("payee" | "tags")[]; + + ignore_self: boolean; + include_transactions: boolean; + + buckets: ReportBucket[]; +} diff --git a/src/features/report/report.utils.ts b/src/features/report/report.utils.ts new file mode 100644 index 0000000..b478dcf --- /dev/null +++ b/src/features/report/report.utils.ts @@ -0,0 +1,134 @@ +import { + ReportData, + ReportPeriod +} from "./report.models"; + +/* ---------- ID BUILDING ---------- */ + +function formatDate(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function buildPeriodId( + type: "weekly" | "monthly" | "yearly" | "fyly" | "full", + start: Date, + end: Date +): string { + const s = formatDate(start); + const e = formatDate(end); + + switch (type) { + case "weekly": + return `W:${s}_${e}`; + case "monthly": + return `M:${s}_${e}`; + case "yearly": + return `Y:${s}_${e}`; + case "fyly": + return `FY:${s}_${e}`; + case "full": + return `FULL:${s}_${e}`; + default: + return `${s}_${e}`; + } +} + +/* ---------- LABEL BUILDING ---------- */ + +const dayFmt = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + timeZone: "UTC", +}); + +const monthDayFmt = new Intl.DateTimeFormat("en-GB", { + month: "short", + day: "numeric", + timeZone: "UTC", +}); + +const monthFmt = new Intl.DateTimeFormat("en-GB", { + month: "short", + timeZone: "UTC", +}); + +const yearFmt = new Intl.DateTimeFormat("en-GB", { + year: "numeric", + timeZone: "UTC", +}); + +function sameMonth(a: Date, b: Date) { + return ( + a.getUTCFullYear() === b.getUTCFullYear() && + a.getUTCMonth() === b.getUTCMonth() + ); +} + +function buildLabel( + type: "weekly" | "monthly" | "yearly" | "fyly" | "full", + start: Date, + end: Date +): string { + switch (type) { + case "weekly": + if (sameMonth(start, end)) { + const sDay = start.getUTCDate(); + const eDay = end.getUTCDate(); + const m = monthFmt.format(start); + return `${sDay} ${m} - ${eDay} ${m}`; + } + return `${dayFmt.format(start)} - ${dayFmt.format(end)}`; + + case "monthly": + return `${monthFmt.format(start)} ${yearFmt.format(start)}`; + + case "yearly": + return yearFmt.format(start); + + case "fyly": { + const startY = start.getUTCFullYear(); + const endY = end.getUTCFullYear(); + return `FY ${startY}–${String(endY).slice(-2)}`; + } + + default: + return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`; + } +} + +/* ---------- MAIN ---------- */ + +function decoratePeriods( + type: "weekly" | "monthly" | "yearly" | "fyly" | "full", + periods: ReportPeriod[] +): (ReportPeriod & { id: string; label: string })[] { + return periods.map((p) => ({ + ...p, + id: buildPeriodId(type, new Date(p.start + "Z"), new Date(p.end + "Z")), + label: buildLabel(type, new Date(p.start + "Z"), new Date(p.end + "Z")), + })); +} + +export function prepareReport(reportData: ReportData): ReportData { + return { + ...reportData, + buckets: reportData.buckets.map((bucket) => { + const newPeriods: typeof bucket.periods = {}; + + for (const type of reportData.periods) { + const arr = bucket.periods[type]; + if (arr) { + newPeriods[type] = decoratePeriods(type, arr); + } + } + + return { + ...bucket, + periods: newPeriods, + }; + }), + }; +} \ No newline at end of file diff --git a/src/features/report/useReport.ts b/src/features/report/useReport.ts new file mode 100644 index 0000000..d479d33 --- /dev/null +++ b/src/features/report/useReport.ts @@ -0,0 +1,20 @@ +import { useResourceByName } from "../../../react-openapi"; + +export interface ReportParams { + periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; + rolling?: boolean; + report_date?: string; + group_by?: ("payee" | "tags")[]; + ignore_self?: boolean; + include_transactions?: boolean; +} + +export function useReport(params: ReportParams) { + const { useList } = useResourceByName("reports"); + + return useList({ + ...params, + periods: params.periods, + group_by: params.group_by, + }); +} diff --git a/src/main.jsx b/src/main.jsx index c1c76f8..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,17 +21,13 @@ import Header from './Header'; import Footer from './Footer'; import AppTheme from './AppTheme'; -// Polyfill Node.js globals for browser environment (needed by SwaggerParser) 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 AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; const routerMapping = [ { path: "/", component: Home, headerTitle: "Home" }, @@ -41,35 +37,36 @@ const routerMapping = [ ]; root.render( - - - - -
+ + + + + +
- - + + - - {routerMapping.map(({ path, component: Component }) => ( - - ) : ( - - ) - } - /> - ))} - + + {routerMapping.map(({ path, component: Component }) => ( + + ) : ( + + ) + } + /> + ))} + + - - -