From 7636e35eda4844b4af21716678eb152178bb0eaa Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Fri, 26 Dec 2025 13:10:38 +0100 Subject: [PATCH] Added General Health --- app/api/dashboard/admin/users/route.ts | 2 - app/api/dashboard/route.ts | 33 ++++ app/api/dashboard/transactions/route.ts | 1 + app/dashboard/page.tsx | 45 +++++- .../DateRangePicker/DateRangePicker.tsx | 18 ++- .../GeneralHealthCard/GeneralHealthCard.tsx | 144 ++++++++++++++++-- app/features/GeneralHealthCard/constants.ts | 9 ++ app/features/GeneralHealthCard/interfaces.ts | 0 app/features/GeneralHealthCard/utils.ts | 71 +++++++++ app/features/Pages/Admin/Users/users.tsx | 2 + .../DashboardHomePage/DashboardHomePage.tsx | 33 +++- app/features/UserRoles/userRoleCard.tsx | 4 +- app/services/constants.ts | 1 + app/services/health.ts | 83 ++++++++++ app/services/transactions.ts | 34 ++++- app/utils/formatDate.ts | 10 ++ 16 files changed, 459 insertions(+), 31 deletions(-) create mode 100644 app/api/dashboard/route.ts create mode 100644 app/features/GeneralHealthCard/constants.ts create mode 100644 app/features/GeneralHealthCard/interfaces.ts create mode 100644 app/features/GeneralHealthCard/utils.ts create mode 100644 app/services/health.ts diff --git a/app/api/dashboard/admin/users/route.ts b/app/api/dashboard/admin/users/route.ts index ad02f85..ee5764a 100644 --- a/app/api/dashboard/admin/users/route.ts +++ b/app/api/dashboard/admin/users/route.ts @@ -28,8 +28,6 @@ export async function GET(request: Request) { const data = await response.json(); - console.log("[Users] - data FROM ROUTE", data); - return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { console.error("Proxy GET /api/v1/users/list error:", err); diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts new file mode 100644 index 0000000..fb64c48 --- /dev/null +++ b/app/api/dashboard/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchHealthDataService } from "@/app/services/health"; +import { transformHealthDataToStats } from "@/app/features/GeneralHealthCard/utils"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const dateStart = searchParams.get("dateStart") ?? undefined; + const dateEnd = searchParams.get("dateEnd") ?? undefined; + + const data = await fetchHealthDataService({ dateStart, dateEnd }); + + // Transform data to stats format using shared util + const stats = transformHealthDataToStats(data); + + console.log("[stats]", stats); + + return NextResponse.json( + { + ...data, + stats, + }, + { status: 200 } + ); + } catch (err: unknown) { + console.error("Proxy GET /api/v1/transactions/health error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { success: false, message: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/transactions/route.ts b/app/api/dashboard/transactions/route.ts index 19e55a1..5a74ffb 100644 --- a/app/api/dashboard/transactions/route.ts +++ b/app/api/dashboard/transactions/route.ts @@ -98,6 +98,7 @@ export async function POST(request: NextRequest) { const queryString = queryParts.join("&"); const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`; + console.log("[Transactions] - backendUrl", backendUrl); const response = await fetch(backendUrl, { method: "GET", headers: { diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 707967c..f614c64 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,9 +1,42 @@ -"use client"; - import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage"; +import { transformHealthDataToStats } from "../features/GeneralHealthCard/utils"; +import { fetchHealthDataService } from "../services/health"; +import { getDefaultDateRange } from "../utils/formatDate"; -const DashboardPage = () => { - return ; -}; +export default async function DashboardPage() { + // Fetch initial health data server-side with default 24h range + const defaultDates = getDefaultDateRange(); + let initialHealthData = null; + let initialStats = null; -export default DashboardPage; + try { + const healthData = await fetchHealthDataService({ + dateStart: defaultDates.dateStart, + dateEnd: defaultDates.dateEnd, + }); + initialHealthData = healthData; + // console.log("[healthData]", healthData); + initialStats = healthData.stats ?? transformHealthDataToStats(healthData); + } catch (_error: unknown) { + // If fetch fails, component will handle it client-side + // console.error("Failed to fetch initial health data:", error); + const error = _error as Error; + console.error("Failed to fetch initial health data:", error.cause); + } + + console.log("[initialStats]", initialStats); + + return ( + + ); +} diff --git a/app/features/DateRangePicker/DateRangePicker.tsx b/app/features/DateRangePicker/DateRangePicker.tsx index 0dad3f4..5ef52d0 100644 --- a/app/features/DateRangePicker/DateRangePicker.tsx +++ b/app/features/DateRangePicker/DateRangePicker.tsx @@ -8,8 +8,13 @@ import "react-date-range/dist/theme/default.css"; import "./DateRangePicker.scss"; -export const DateRangePicker = () => { - const [range, setRange] = useState([ +interface IDateRangePickerProps { + value?: Range[]; + onChange?: (ranges: Range[]) => void; +} + +export const DateRangePicker = ({ value, onChange }: IDateRangePickerProps) => { + const [internalRange, setInternalRange] = useState([ { startDate: new Date(), endDate: new Date(), @@ -19,9 +24,16 @@ export const DateRangePicker = () => { const [anchorEl, setAnchorEl] = useState(null); + const range = value ?? internalRange; + const handleSelect: DateRangeProps["onChange"] = ranges => { if (ranges.selection) { - setRange([ranges.selection]); + const newRange = [ranges.selection]; + if (onChange) { + onChange(newRange); + } else { + setInternalRange(newRange); + } if (ranges.selection.endDate !== ranges.selection.startDate) { setAnchorEl(null); } diff --git a/app/features/GeneralHealthCard/GeneralHealthCard.tsx b/app/features/GeneralHealthCard/GeneralHealthCard.tsx index 2a6a0f8..9f70368 100644 --- a/app/features/GeneralHealthCard/GeneralHealthCard.tsx +++ b/app/features/GeneralHealthCard/GeneralHealthCard.tsx @@ -1,20 +1,121 @@ -import { Box, Card, CardContent, Typography, IconButton } from "@mui/material"; +"use client"; + +import { useEffect, useState } from "react"; +import { + Box, + Card, + CardContent, + Typography, + IconButton, + Alert, +} from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import { Range } from "react-date-range"; import { DateRangePicker } from "../DateRangePicker/DateRangePicker"; import { StatItem } from "./components/StatItem"; +import { DEFAULT_DATE_RANGE } from "./constants"; +import { IHealthData } from "@/app/services/health"; +import { getHealthData } from "@/app/services/transactions"; + import "./GeneralHealthCard.scss"; -const stats = [ - { label: "TOTAL", value: 5, change: "-84.85%" }, - { label: "SUCCESSFUL", value: 10, change: "100%" }, - { label: "ACCEPTANCE RATE", value: "0%", change: "-100%" }, - { label: "AMOUNT", value: "€0.00", change: "-100%" }, - { label: "ATV", value: "€0.00", change: "-100%" }, -]; +interface IGeneralHealthCardProps { + initialHealthData?: IHealthData | null; + initialStats?: Array<{ + label: string; + value: string | number; + change: string; + }> | null; + initialDateRange?: Range[]; +} + +const DEBOUNCE_MS = 1000; + +export const GeneralHealthCard = ({ + initialHealthData = null, + initialStats = null, + initialDateRange, +}: IGeneralHealthCardProps) => { + const [dateRange, setDateRange] = useState( + initialDateRange ?? DEFAULT_DATE_RANGE + ); + const [healthData, setHealthData] = useState( + initialHealthData + ); + const [error, setError] = useState(null); + + /** + * Fetch health data for a given range + */ + const fetchHealthData = async (range: Range[]) => { + const startDate = range[0]?.startDate; + const endDate = range[0]?.endDate; + + if (!startDate || !endDate) return; + + setError(null); + + try { + const data = await getHealthData({ + dateStart: startDate.toISOString(), + dateEnd: endDate.toISOString(), + }); + + setHealthData(data); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to fetch health data"; + setError(message); + setHealthData(null); + } + }; + + /** + * Date picker change handler + * (state only — side effects are debounced below) + */ + const handleDateRangeChange = (newRange: Range[]) => { + setDateRange(newRange); + }; + + /** + * Debounced fetch when date range changes + */ + useEffect(() => { + const currentRange = dateRange[0]; + if (!currentRange?.startDate || !currentRange?.endDate) return; + + const isInitialDateRange = + initialDateRange && + currentRange.startDate.getTime() === + initialDateRange[0]?.startDate?.getTime() && + currentRange.endDate.getTime() === + initialDateRange[0]?.endDate?.getTime(); + + // Skip fetch if we're still on SSR-provided data + if (initialHealthData && isInitialDateRange) return; + + const timeout = setTimeout(() => { + fetchHealthData(dateRange); + }, DEBOUNCE_MS); + + return () => clearTimeout(timeout); + }, [dateRange]); + + /** + * Resolve stats source + */ + const stats = healthData?.stats ?? + initialStats ?? [ + { label: "TOTAL", value: 0, change: "0%" }, + { label: "SUCCESSFUL", value: 0, change: "0%" }, + { label: "ACCEPTANCE RATE", value: "0%", change: "0%" }, + { label: "AMOUNT", value: "€0.00", change: "0%" }, + { label: "ATV", value: "€0.00", change: "0%" }, + ]; -export const GeneralHealthCard = () => { return ( @@ -22,21 +123,34 @@ export const GeneralHealthCard = () => { General Health + - + - - {stats.map((item, i) => ( - - ))} - + + {error && ( + + {error} + + )} + + {!error && ( + + {stats.map((item, i) => ( + + ))} + + )} ); diff --git a/app/features/GeneralHealthCard/constants.ts b/app/features/GeneralHealthCard/constants.ts new file mode 100644 index 0000000..a9e70e3 --- /dev/null +++ b/app/features/GeneralHealthCard/constants.ts @@ -0,0 +1,9 @@ +import { Range } from "react-date-range"; + +export const DEFAULT_DATE_RANGE = [ + { + startDate: new Date(), + endDate: new Date(), + key: "selection", + }, +] as Range[]; diff --git a/app/features/GeneralHealthCard/interfaces.ts b/app/features/GeneralHealthCard/interfaces.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/features/GeneralHealthCard/utils.ts b/app/features/GeneralHealthCard/utils.ts new file mode 100644 index 0000000..bac015e --- /dev/null +++ b/app/features/GeneralHealthCard/utils.ts @@ -0,0 +1,71 @@ +export const formatCurrency = (value: number | string | undefined): string => { + if (value === undefined || value === null) return "€0.00"; + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (isNaN(numValue)) return "€0.00"; + return `€${numValue.toFixed(2)}`; +}; + +export const formatPercentage = ( + value: number | string | undefined +): string => { + if (value === undefined || value === null) return "0%"; + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (isNaN(numValue)) return "0%"; + return `${numValue.toFixed(2)}%`; +}; + +interface IHealthData { + total?: number; + successful?: number; + acceptance_rate?: number; + amount?: number; + atv?: number; +} + +interface IStatItem { + label: string; + value: string | number; + change: string; +} + +export const transformHealthDataToStats = ( + healthData: IHealthData | null +): IStatItem[] => { + if (!healthData) { + return [ + { label: "TOTAL", value: 0, change: "0%" }, + { label: "SUCCESSFUL", value: 0, change: "0%" }, + { label: "ACCEPTANCE RATE", value: "0%", change: "0%" }, + { label: "AMOUNT", value: "€0.00", change: "0%" }, + { label: "ATV", value: "€0.00", change: "0%" }, + ]; + } + + return [ + { + label: "TOTAL", + value: healthData.total ?? 0, + change: "0%", + }, + { + label: "SUCCESSFUL", + value: healthData.successful ?? 0, + change: "0%", + }, + { + label: "ACCEPTANCE RATE", + value: formatPercentage(healthData.acceptance_rate), + change: "0%", + }, + { + label: "AMOUNT", + value: formatCurrency(healthData.amount), + change: "0%", + }, + { + label: "ATV", + value: formatCurrency(healthData.atv), + change: "0%", + }, + ]; +}; diff --git a/app/features/Pages/Admin/Users/users.tsx b/app/features/Pages/Admin/Users/users.tsx index 396977f..0c6f3e2 100644 --- a/app/features/Pages/Admin/Users/users.tsx +++ b/app/features/Pages/Admin/Users/users.tsx @@ -17,6 +17,8 @@ const Users: React.FC = ({ users }) => { const [showAddUser, setShowAddUser] = useState(false); const dispatch = useDispatch(); + // console.log("[Users] - users", users); + return (
{ +interface IDashboardHomePageProps { + initialHealthData?: { + success?: boolean; + message?: string; + total?: number; + successful?: number; + acceptance_rate?: number; + amount?: number; + atv?: number; + } | null; + initialStats?: Array<{ + label: string; + value: string | number; + change: string; + }> | null; + initialDateRange?: Range[]; +} + +export const DashboardHomePage = ({ + initialHealthData, + initialStats, + initialDateRange, +}: IDashboardHomePageProps) => { return ( <> {/* Conditional rendering of the Generic Modal, passing LoginModal as children */} - + - + {/* */} diff --git a/app/features/UserRoles/userRoleCard.tsx b/app/features/UserRoles/userRoleCard.tsx index b564ae0..1d6715d 100644 --- a/app/features/UserRoles/userRoleCard.tsx +++ b/app/features/UserRoles/userRoleCard.tsx @@ -41,6 +41,8 @@ export default function UserRoleCard({ user }: Props) { const dispatch = useDispatch(); const { username, first_name, last_name, email, groups } = user; const [newPassword, setNewPassword] = useState(null); + + console.log("[UserRoleCard] - user", user); const handleEditClick = () => { setIsEditing(!isEditing); }; @@ -137,7 +139,7 @@ export default function UserRoleCard({ user }: Props) { - {groups.map(role => ( + {groups?.map(role => ( ))} diff --git a/app/services/constants.ts b/app/services/constants.ts index e6c03bb..90042c4 100644 --- a/app/services/constants.ts +++ b/app/services/constants.ts @@ -1,5 +1,6 @@ export const AUDIT_CACHE_TAG = "audits"; export const USERS_CACHE_TAG = "users"; +export const HEALTH_CACHE_TAG = "health"; export const REVALIDATE_SECONDS = 100; export const BE_BASE_URL = process.env.BE_BASE_URL || ""; diff --git a/app/services/health.ts b/app/services/health.ts new file mode 100644 index 0000000..45f8f02 --- /dev/null +++ b/app/services/health.ts @@ -0,0 +1,83 @@ +"use server"; +import { cookies } from "next/headers"; + +import { + AUTH_COOKIE_NAME, + BE_BASE_URL, + REVALIDATE_SECONDS, + HEALTH_CACHE_TAG, +} from "./constants"; + +export interface IHealthData { + success?: boolean; + message?: string; + total?: number; + successful?: number; + acceptance_rate?: number; + amount?: number; + atv?: number; + stats?: Array<{ + label: string; + value: string | number; + change: string; + }>; +} + +export interface IFetchHealthDataParams { + dateStart?: string; + dateEnd?: string; +} + +export async function fetchHealthDataService({ + dateStart, + dateEnd, +}: IFetchHealthDataParams = {}): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + + if (!token) { + throw new Error("Missing auth token"); + } + + const queryParts: string[] = []; + + // Add date filter if provided + if (dateStart && dateEnd) { + queryParts.push( + `Modified=BETWEEN/${encodeURIComponent(dateStart)}/${encodeURIComponent(dateEnd)}` + ); + } else if (dateStart) { + queryParts.push(`Modified=>/${encodeURIComponent(dateStart)}`); + } else if (dateEnd) { + queryParts.push(`Modified= ({ message: "Failed to fetch health data" })); + throw new Error(errorData?.message || "Failed to fetch health data"); + } + + const data = (await response.json()) as IHealthData; + + console.log("[data]", data.stats); + return data; +} diff --git a/app/services/transactions.ts b/app/services/transactions.ts index 9f91d1b..3747652 100644 --- a/app/services/transactions.ts +++ b/app/services/transactions.ts @@ -1,3 +1,6 @@ +import { getBaseUrl } from "./constants"; +import { IHealthData, IFetchHealthDataParams } from "./health"; + export async function getTransactions({ transactionType, query, @@ -6,7 +9,7 @@ export async function getTransactions({ query: string; }) { const res = await fetch( - `http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`, + `${getBaseUrl()}/api/dashboard/transactions/${transactionType}?${query}`, { cache: "no-store", } @@ -22,3 +25,32 @@ export async function getTransactions({ return res.json(); } + +/** + * Client-side function to fetch health data via the /api/dashboard proxy + */ +export async function getHealthData({ + dateStart, + dateEnd, +}: IFetchHealthDataParams = {}): Promise { + const params = new URLSearchParams(); + if (dateStart) params.set("dateStart", dateStart); + if (dateEnd) params.set("dateEnd", dateEnd); + + const queryString = params.toString(); + const res = await fetch( + `${getBaseUrl()}/api/dashboard${queryString ? `?${queryString}` : ""}`, + { + cache: "no-store", + } + ); + + if (!res.ok) { + const errorData = await res + .json() + .catch(() => ({ message: "Unknown error" })); + throw new Error(errorData.message || `HTTP error! status: ${res.status}`); + } + + return res.json(); +} diff --git a/app/utils/formatDate.ts b/app/utils/formatDate.ts index 6fb9ff9..bd1a494 100644 --- a/app/utils/formatDate.ts +++ b/app/utils/formatDate.ts @@ -11,3 +11,13 @@ export const formatToDateTimeString = (dateString: string): string => { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; }; + +export const getDefaultDateRange = () => { + const endDate = new Date(); + const startDate = new Date(); + startDate.setHours(startDate.getHours() - 24); + return { + dateStart: startDate.toISOString(), + dateEnd: endDate.toISOString(), + }; +};