From 6d6bfd90895ec17351b190d48ae8d8a318010418 Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Sat, 27 Dec 2025 10:57:56 +0100 Subject: [PATCH] Added more to dashboard --- .vscode/settings.json | 2 + app/api/dashboard/route.ts | 28 +- app/dashboard/page.tsx | 20 +- .../GeneralHealthCard/GeneralHealthCard.tsx | 91 +++--- .../DashboardHomePage/DashboardHomePage.tsx | 37 ++- app/features/PieCharts/PieCharts.tsx | 18 +- .../TransactionsOverview.tsx | 90 +++++- .../components/TransactionsOverViewTable.tsx | 21 +- app/features/TransactionsOverview/utils.ts | 98 ++++++ .../TransactionsWaitingApproval.tsx | 284 ++++++++---------- app/hooks/useDebouncedDateRange.ts | 65 ++++ app/services/dashboardService.ts | 42 +++ app/services/health.ts | 114 +++++-- app/services/transactions.ts | 30 +- app/services/types.ts | 63 ++++ app/utils/formatDate.ts | 26 ++ services/roles.services.ts | 1 - 17 files changed, 746 insertions(+), 284 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/features/TransactionsOverview/utils.ts create mode 100644 app/hooks/useDebouncedDateRange.ts create mode 100644 app/services/dashboardService.ts create mode 100644 app/services/types.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts index fb64c48..116d251 100644 --- a/app/api/dashboard/route.ts +++ b/app/api/dashboard/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { fetchHealthDataService } from "@/app/services/health"; +import { fetchDashboardDataService } from "@/app/services/health"; import { transformHealthDataToStats } from "@/app/features/GeneralHealthCard/utils"; export async function GET(request: NextRequest) { @@ -8,22 +8,24 @@ export async function GET(request: NextRequest) { const dateStart = searchParams.get("dateStart") ?? undefined; const dateEnd = searchParams.get("dateEnd") ?? undefined; - const data = await fetchHealthDataService({ dateStart, dateEnd }); + // Fetch all dashboard data (health, overview, and review transactions) concurrently + const dashboardData = await fetchDashboardDataService({ + dateStart, + dateEnd, + }); - // Transform data to stats format using shared util - const stats = transformHealthDataToStats(data); + // Transform health data to stats format using shared util + const stats = transformHealthDataToStats(dashboardData.healthData); - console.log("[stats]", stats); + const response = { + ...dashboardData.healthData, + stats, + overviewData: dashboardData.overviewData, + reviewTransactions: dashboardData.reviewTransactions, + }; - return NextResponse.json( - { - ...data, - stats, - }, - { status: 200 } - ); + return NextResponse.json(response, { 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 }, diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index f614c64..f3ec067 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,31 +1,33 @@ import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage"; import { transformHealthDataToStats } from "../features/GeneralHealthCard/utils"; -import { fetchHealthDataService } from "../services/health"; +import { fetchDashboardDataService } from "../services/health"; import { getDefaultDateRange } from "../utils/formatDate"; export default async function DashboardPage() { - // Fetch initial health data server-side with default 24h range + // Fetch all dashboard data (health, overview, and review transactions) concurrently with default 24h range const defaultDates = getDefaultDateRange(); let initialHealthData = null; let initialStats = null; + let initialOverviewData = null; + let initialReviewTransactions = null; try { - const healthData = await fetchHealthDataService({ + const dashboardData = await fetchDashboardDataService({ dateStart: defaultDates.dateStart, dateEnd: defaultDates.dateEnd, }); + + const { healthData, overviewData, reviewTransactions } = dashboardData; initialHealthData = healthData; - // console.log("[healthData]", healthData); initialStats = healthData.stats ?? transformHealthDataToStats(healthData); + initialOverviewData = overviewData; + initialReviewTransactions = reviewTransactions; } 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.error("Failed to fetch dashboard data:", error.cause); } - console.log("[initialStats]", initialStats); - return ( ); } diff --git a/app/features/GeneralHealthCard/GeneralHealthCard.tsx b/app/features/GeneralHealthCard/GeneralHealthCard.tsx index 9f70368..a317e23 100644 --- a/app/features/GeneralHealthCard/GeneralHealthCard.tsx +++ b/app/features/GeneralHealthCard/GeneralHealthCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Box, Card, @@ -8,6 +8,7 @@ import { Typography, IconButton, Alert, + CircularProgress, } from "@mui/material"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; @@ -17,7 +18,9 @@ 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 { useDebouncedDateRange } from "@/app/hooks/useDebouncedDateRange"; +import { dashboardService } from "@/app/services/dashboardService"; +import { normalizeDateRangeForAPI } from "@/app/utils/formatDate"; import "./GeneralHealthCard.scss"; @@ -38,71 +41,56 @@ export const GeneralHealthCard = ({ initialStats = null, initialDateRange, }: IGeneralHealthCardProps) => { - const [dateRange, setDateRange] = useState( - initialDateRange ?? DEFAULT_DATE_RANGE - ); const [healthData, setHealthData] = useState( initialHealthData ); const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); /** * Fetch health data for a given range */ const fetchHealthData = async (range: Range[]) => { + if (!range || !range[0]) return; + const startDate = range[0]?.startDate; const endDate = range[0]?.endDate; if (!startDate || !endDate) return; setError(null); + setIsLoading(true); try { - const data = await getHealthData({ - dateStart: startDate.toISOString(), - dateEnd: endDate.toISOString(), + // Normalize dates to ensure full day coverage + const { dateStart, dateEnd } = normalizeDateRangeForAPI( + startDate, + endDate + ); + + // This will update the service and notify all subscribers + const { healthData } = await dashboardService.fetchDashboardData({ + dateStart, + dateEnd, }); - setHealthData(data); + setHealthData(healthData); } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch health data"; setError(message); setHealthData(null); + } finally { + setIsLoading(false); } }; - /** - * 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]); + const { dateRange, handleDateRangeChange } = useDebouncedDateRange({ + initialDateRange: initialDateRange ?? DEFAULT_DATE_RANGE, + debounceMs: DEBOUNCE_MS, + onDateRangeChange: fetchHealthData, + skipInitialFetch: !!initialHealthData, + }); /** * Resolve stats source @@ -144,12 +132,25 @@ export const GeneralHealthCard = ({ )} - {!error && ( - - {stats.map((item, i) => ( - - ))} + {isLoading ? ( + + + ) : ( + !error && ( + + {stats.map((item, i) => ( + + ))} + + ) )} diff --git a/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx b/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx index 4798b15..cc6c52c 100644 --- a/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx +++ b/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx @@ -1,13 +1,15 @@ "use client"; +import { useEffect } from "react"; import { Box } from "@mui/material"; import { GeneralHealthCard } from "../../GeneralHealthCard/GeneralHealthCard"; import { TransactionsWaitingApproval } from "../../TransactionsWaitingApproval/TransactionsWaitingApproval"; -import { FetchReport } from "../../FetchReports/FetchReports"; -import { Documentation } from "../../Documentation/Documentation"; -import { AccountIQ } from "../../AccountIQ/AccountIQ"; -import { WhatsNew } from "../../WhatsNew/WhatsNew"; import { TransactionsOverView } from "../../TransactionsOverView/TransactionsOverview"; import { Range } from "react-date-range"; +import { + ITransactionsOverviewData, + IReviewTransactionsData, +} from "@/app/services/health"; +import { dashboardService } from "@/app/services/dashboardService"; interface IDashboardHomePageProps { initialHealthData?: { @@ -25,13 +27,28 @@ interface IDashboardHomePageProps { change: string; }> | null; initialDateRange?: Range[]; + initialOverviewData?: ITransactionsOverviewData | null; + initialReviewTransactions?: IReviewTransactionsData | null; } export const DashboardHomePage = ({ initialHealthData, initialStats, initialDateRange, + initialOverviewData, + initialReviewTransactions, }: IDashboardHomePageProps) => { + // Initialize service with server data if available + useEffect(() => { + if (initialHealthData && initialOverviewData && initialReviewTransactions) { + dashboardService.updateDashboardData({ + healthData: initialHealthData, + overviewData: initialOverviewData, + reviewTransactions: initialReviewTransactions, + }); + } + }, [initialHealthData, initialOverviewData, initialReviewTransactions]); + return ( <> {/* Conditional rendering of the Generic Modal, passing LoginModal as children */} @@ -42,12 +59,14 @@ export const DashboardHomePage = ({ initialDateRange={initialDateRange} /> - {/* */} - - - + + {/* */} + + {/* - + */} ); }; diff --git a/app/features/PieCharts/PieCharts.tsx b/app/features/PieCharts/PieCharts.tsx index 77a3ac8..9a2d766 100644 --- a/app/features/PieCharts/PieCharts.tsx +++ b/app/features/PieCharts/PieCharts.tsx @@ -2,15 +2,25 @@ import { Box } from "@mui/material"; import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; import "./PieCharts.scss"; -const data = [ + +interface IPieChartData { + name: string; + value: number; +} + +interface IPieChartsProps { + data?: IPieChartData[]; +} + +const COLORS = ["#4caf50", "#ff9800", "#f44336", "#9e9e9e"]; + +const defaultData: IPieChartData[] = [ { name: "Group A", value: 100 }, { name: "Group B", value: 200 }, { name: "Group C", value: 400 }, { name: "Group D", value: 300 }, ]; -const COLORS = ["#4caf50", "#ff9800", "#f44336", "#9e9e9e"]; - const RADIAN = Math.PI / 180; const renderCustomizedLabel = ({ cx, @@ -37,7 +47,7 @@ const renderCustomizedLabel = ({ ); }; -export const PieCharts = () => { +export const PieCharts = ({ data = defaultData }: IPieChartsProps) => { return ( diff --git a/app/features/TransactionsOverview/TransactionsOverview.tsx b/app/features/TransactionsOverview/TransactionsOverview.tsx index e5a8a21..67e0d61 100644 --- a/app/features/TransactionsOverview/TransactionsOverview.tsx +++ b/app/features/TransactionsOverview/TransactionsOverview.tsx @@ -1,26 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Box, Button, IconButton, Paper, Typography } from "@mui/material"; import { PieCharts } from "../PieCharts/PieCharts"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable"; +import { type ITransactionsOverviewData } from "@/app/services/types"; +import { dashboardService } from "@/app/services/dashboardService"; +import { + transformOverviewResponse, + calculatePercentages, + enrichOverviewData, +} from "./utils"; import "./TransactionsOverView.scss"; -export const TransactionsOverView = () => { +interface ITransactionsOverviewProps { + initialOverviewData?: ITransactionsOverviewData | null; +} + +export const TransactionsOverView = ({ + initialOverviewData = null, +}: ITransactionsOverviewProps) => { const router = useRouter(); + const [overviewData, setOverviewData] = + useState( + initialOverviewData || + dashboardService.getCurrentDashboardData()?.overviewData || + null + ); + + /** + * Subscribe to dashboard data changes + */ + useEffect(() => { + const subscription = dashboardService + .getDashboardData$() + .subscribe(data => { + if (data?.overviewData) { + setOverviewData(data.overviewData); + } + }); + + // Cleanup subscription on unmount + return () => subscription.unsubscribe(); + }, []); + + /** + * Transform overview data from flat object to array format + */ + const transformedData = overviewData + ? transformOverviewResponse({ + cancelled: overviewData.cancelled, + failed: overviewData.failed, + successful: overviewData.successful, + waiting: overviewData.waiting, + }) + : []; + + /** + * Calculate percentages and enrich with colors + */ + const enrichedData = + transformedData.length > 0 + ? enrichOverviewData(calculatePercentages(transformedData)) + : []; + + /** + * Transform overview data for PieCharts (expects { name, value }[]) + */ + const pieChartData = enrichedData.map(item => ({ + name: item.state, + value: item.count, + })); + + /** + * Transform overview data for table (expects { state, count, percentage, color }[]) + */ + const tableData = enrichedData; + return ( - {/* Title and All Transactions Button */} - Transactions Overview (Last 24h) + Transactions Overview @@ -30,11 +101,12 @@ export const TransactionsOverView = () => { - {/* Chart and Table */} - - - - + {overviewData && ( + + + + + )} ); }; diff --git a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx index dd16547..d66cf8c 100644 --- a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx +++ b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx @@ -12,14 +12,27 @@ import { import "./TransactionsOverViewTable.scss"; -const data1 = [ +interface ITableData { + state: string; + count: number; + percentage: string; + color?: string; +} + +interface ITransactionsOverViewTableProps { + data?: ITableData[]; +} + +const defaultData: ITableData[] = [ { state: "Success", count: 120, percentage: "60%", color: "green" }, { state: "Pending", count: 50, percentage: "25%", color: "orange" }, { state: "Failed", count: 20, percentage: "10%", color: "red" }, { state: "Other", count: 10, percentage: "5%", color: "gray" }, ]; -export const TransactionsOverViewTable = () => { +export const TransactionsOverViewTable = ({ + data = defaultData, +}: ITransactionsOverViewTableProps) => { return ( @@ -32,8 +45,8 @@ export const TransactionsOverViewTable = () => { - {data1.map((row, i) => ( - + {data.map((row, i) => ( + { + const normalizedState = state.toLowerCase(); + + switch (normalizedState) { + case "success": + case "completed": + case "successful": + return "#4caf50"; // green + case "pending": + case "waiting": + return "#ff9800"; // orange + case "failed": + case "error": + return "#f44336"; // red + case "cancelled": + case "canceled": + return "#9e9e9e"; // gray + default: + return "#9e9e9e"; // gray + } +}; + +/** + * Transform flat API overview response to array format + */ +export const transformOverviewResponse = (data: { + cancelled?: number; + failed?: number; + successful?: number; + waiting?: number; +}): Array<{ + state: string; + count: number; +}> => { + const states = [ + { key: "successful", label: "Successful" }, + { key: "waiting", label: "Waiting" }, + { key: "failed", label: "Failed" }, + { key: "cancelled", label: "Cancelled" }, + ]; + + return states + .map(({ key, label }) => ({ + state: label, + count: data[key as keyof typeof data] || 0, + })) + .filter(item => item.count > 0); // Only include states with counts > 0 +}; + +/** + * Calculate percentage for each state + */ +export const calculatePercentages = ( + items: Array<{ state: string; count: number }> +): Array<{ + state: string; + count: number; + percentage: string; +}> => { + const total = items.reduce((sum, item) => sum + item.count, 0); + + if (total === 0) { + return items.map(item => ({ + ...item, + percentage: "0%", + })); + } + + return items.map(item => ({ + ...item, + percentage: `${Math.round((item.count / total) * 100)}%`, + })); +}; + +/** + * Transform API overview data to include colors if missing + */ +export const enrichOverviewData = ( + data: Array<{ + state: string; + count: number; + percentage: string; + color?: string; + }> +): Array<{ + state: string; + count: number; + percentage: string; + color: string; +}> => { + return data.map(item => ({ + ...item, + color: item.color || getStateColor(item.state), + })); +}; diff --git a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx index 34ddc01..1f1d092 100644 --- a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx +++ b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useEffect, useState } from "react"; import { Box, Button, @@ -13,110 +16,71 @@ import { } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CancelIcon from "@mui/icons-material/Cancel"; - import MoreVertIcon from "@mui/icons-material/MoreVert"; -import "./TransactionsWaitingApproval.scss"; -const transactions = [ - { - id: "1049078821", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078822", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078823", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078824", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078821", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078822", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078823", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078824", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078821", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078822", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078823", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, - { - id: "1049078824", - user: "17", - created: "2025-06-17 16:45", - type: "BestPayWithdrawal", - amount: "-787.49 TRY", - psp: "BestPay", - }, -]; +import { dashboardService } from "@/app/services/dashboardService"; +import { formatToDateTimeString } from "@/app/utils/formatDate"; + +import "./TransactionsWaitingApproval.scss"; +import { + type IReviewTransactionsData, + type IReviewTransaction, +} from "@/app/services/types"; + +interface ITransactionsWaitingApprovalProps { + initialReviewTransactions?: IReviewTransactionsData | null; +} + +export const TransactionsWaitingApproval = ({ + initialReviewTransactions = null, +}: ITransactionsWaitingApprovalProps) => { + const [reviewTransactions, setReviewTransactions] = + useState( + initialReviewTransactions || + dashboardService.getCurrentDashboardData()?.reviewTransactions || + null + ); + + /** + * Subscribe to dashboard data changes + */ + useEffect(() => { + const subscription = dashboardService + .getDashboardData$() + .subscribe(data => { + if (data?.reviewTransactions) { + setReviewTransactions(data.reviewTransactions); + } + }); + + // Cleanup subscription on unmount + return () => subscription.unsubscribe(); + }, []); + + /** + * Format transaction for display + */ + const formatTransaction = (tx: IReviewTransaction) => { + const createdDate = tx.created || tx.modified || ""; + const formattedDate = createdDate + ? formatToDateTimeString(createdDate) + : ""; + + return { + id: tx.id?.toString() || tx.external_id || "", + user: tx.customer || "", + created: formattedDate, + type: tx.type || "", + amount: tx.amount + ? `${tx.amount < 0 ? "-" : ""}${Math.abs(tx.amount).toFixed(2)} ${tx.currency || ""}` + : "", + psp: tx.psp_id || "", + }; + }; + + const transactions = reviewTransactions?.transactions || []; + const displayTransactions = transactions.map(formatTransaction); -export const TransactionsWaitingApproval = () => { return ( @@ -128,65 +92,77 @@ export const TransactionsWaitingApproval = () => { - {" "} + - -
- - - - ID - - - User - - - Created - - - Type - - - Amount - - - PSP - - - Action - - - - - {transactions.map((tx, i) => ( - - {tx.id} - {tx.user} - {tx.created} - {tx.type} - {tx.amount} - {tx.psp} + {reviewTransactions && ( + +
+ + - - - - - - + ID + + + User + + + Created + + + Type + + + Amount + + + PSP + + + Action - ))} - -
-
+ + + {displayTransactions.length > 0 ? ( + displayTransactions.map((tx, i) => ( + + {tx.id} + {tx.user} + {tx.created} + {tx.type} + {tx.amount} + {tx.psp} + + + + + + + + + + )) + ) : ( + + + + No transactions waiting for approval + + + + )} + + + + )}
); diff --git a/app/hooks/useDebouncedDateRange.ts b/app/hooks/useDebouncedDateRange.ts new file mode 100644 index 0000000..53ecf4a --- /dev/null +++ b/app/hooks/useDebouncedDateRange.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { Range } from "react-date-range"; + +interface IUseDebouncedDateRangeOptions { + initialDateRange?: Range[]; + debounceMs?: number; + onDateRangeChange?: (range: Range[]) => void | Promise; + skipInitialFetch?: boolean; +} + +export const useDebouncedDateRange = ({ + initialDateRange, + debounceMs = 1000, + onDateRangeChange, + skipInitialFetch = false, +}: IUseDebouncedDateRangeOptions = {}) => { + const [dateRange, setDateRange] = useState(initialDateRange ?? []); + const debounceTimeoutRef = useRef(null); + const isFirstMount = useRef(true); + + const handleDateRangeChange = useCallback( + (newRange: Range[]) => { + // Update state immediately for UI responsiveness + setDateRange(newRange); + + // Clear any existing debounce timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + // Skip fetch on first mount if requested + if (isFirstMount.current) { + isFirstMount.current = false; + if (skipInitialFetch) { + return; + } + } + + const currentRange = newRange[0]; + if (!currentRange?.startDate || !currentRange?.endDate) return; + + // Debounce the callback + debounceTimeoutRef.current = setTimeout(() => { + onDateRangeChange?.(newRange); + }, debounceMs); + }, + [onDateRangeChange, debounceMs, skipInitialFetch] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, []); + + return { + dateRange, + handleDateRangeChange, + }; +}; diff --git a/app/services/dashboardService.ts b/app/services/dashboardService.ts new file mode 100644 index 0000000..84906c2 --- /dev/null +++ b/app/services/dashboardService.ts @@ -0,0 +1,42 @@ +import { BehaviorSubject, Observable } from "rxjs"; +import { IDashboardData } from "./types"; +import { getDashboardData } from "./transactions"; + +class DashboardService { + private dashboardData$ = new BehaviorSubject(null); + + /** + * Get observable for dashboard data + */ + getDashboardData$(): Observable { + return this.dashboardData$.asObservable(); + } + + /** + * Get current dashboard data + */ + getCurrentDashboardData(): IDashboardData | null { + return this.dashboardData$.getValue(); + } + + /** + * Update dashboard data (called when fetching new data) + */ + updateDashboardData(data: IDashboardData): void { + this.dashboardData$.next(data); + } + + /** + * Fetch and update dashboard data + */ + async fetchDashboardData(params: { + dateStart?: string; + dateEnd?: string; + }): Promise { + const data = await getDashboardData(params); + this.updateDashboardData(data); + return data; + } +} + +export const dashboardService = new DashboardService(); diff --git a/app/services/health.ts b/app/services/health.ts index 45f8f02..31ece41 100644 --- a/app/services/health.ts +++ b/app/services/health.ts @@ -7,31 +7,23 @@ import { REVALIDATE_SECONDS, HEALTH_CACHE_TAG, } from "./constants"; +import { + type IDashboardData, + type IFetchHealthDataParams, + type IHealthData, + type IReviewTransactionsData, + type ITransactionsOverviewData, +} from "./types"; -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({ +/** + * Fetch both health and overview data concurrently + * This is optimized for initial page load + * Always includes overview data with the provided date range + */ +export async function fetchDashboardDataService({ dateStart, dateEnd, -}: IFetchHealthDataParams = {}): Promise { +}: IFetchHealthDataParams): Promise { const cookieStore = await cookies(); const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; @@ -53,11 +45,8 @@ export async function fetchHealthDataService({ } const queryString = queryParts.join("&"); - const backendUrl = `${BE_BASE_URL}/api/v1/transactions/health${ - queryString ? `?${queryString}` : "" - }`; - const response = await fetch(backendUrl, { + const fetchConfig = { method: "GET", headers: { "Content-Type": "application/json", @@ -67,17 +56,78 @@ export async function fetchHealthDataService({ revalidate: REVALIDATE_SECONDS, tags: [HEALTH_CACHE_TAG], }, - }); + }; - if (!response.ok) { - const errorData = await response + // Fetch all three endpoints concurrently + const [healthResponse, overviewResponse, reviewResponse] = await Promise.all([ + fetch( + `${BE_BASE_URL}/api/v1/transactions/health${ + queryString ? `?${queryString}` : "" + }`, + fetchConfig + ), + fetch( + `${BE_BASE_URL}/api/v1/transactions/overview${ + queryString ? `?${queryString}` : "" + }`, + fetchConfig + ), + fetch( + `${BE_BASE_URL}/api/v1/transactions?limit=1000${ + queryString ? `&${queryString}` : "" + }&Status==/review`, + fetchConfig + ), + ]); + + // Handle health data response + if (!healthResponse.ok) { + const errorData = await healthResponse .json() .catch(() => ({ message: "Failed to fetch health data" })); throw new Error(errorData?.message || "Failed to fetch health data"); } - const data = (await response.json()) as IHealthData; + const healthData = (await healthResponse.json()) as IHealthData; - console.log("[data]", data.stats); - return data; + // Handle overview data response + let overviewData: ITransactionsOverviewData = { + success: false, + cancelled: 0, + failed: 0, + successful: 0, + waiting: 0, + }; + + if (!overviewResponse.ok) { + // Don't fail the whole request if overview fails, just log it + console.error("Failed to fetch transactions overview"); + } else { + overviewData = (await overviewResponse.json()) as ITransactionsOverviewData; + } + + // Handle review transactions response + let reviewTransactions: IReviewTransactionsData = { + success: false, + transactions: [], + total: 0, + }; + + if (!reviewResponse.ok) { + // Don't fail the whole request if review transactions fail, just log it + console.error("Failed to fetch review transactions"); + } else { + const reviewData = (await reviewResponse.json()) as IReviewTransactionsData; + reviewTransactions = { + success: reviewData.success ?? true, + transactions: reviewData.transactions || [], + total: reviewData.total || 0, + }; + } + + return { + healthData, + overviewData, + reviewTransactions, + }; } diff --git a/app/services/transactions.ts b/app/services/transactions.ts index 3747652..f3d5d67 100644 --- a/app/services/transactions.ts +++ b/app/services/transactions.ts @@ -1,5 +1,5 @@ import { getBaseUrl } from "./constants"; -import { IHealthData, IFetchHealthDataParams } from "./health"; +import { IFetchHealthDataParams, IHealthData, IDashboardData } from "./types"; export async function getTransactions({ transactionType, @@ -27,12 +27,13 @@ export async function getTransactions({ } /** - * Client-side function to fetch health data via the /api/dashboard proxy + * Client-side function to fetch dashboard data (health + overview) via the /api/dashboard proxy + * This function calls a single endpoint that returns both health and overview data */ -export async function getHealthData({ +export async function getDashboardData({ dateStart, dateEnd, -}: IFetchHealthDataParams = {}): Promise { +}: IFetchHealthDataParams = {}): Promise { const params = new URLSearchParams(); if (dateStart) params.set("dateStart", dateStart); if (dateEnd) params.set("dateEnd", dateEnd); @@ -52,5 +53,24 @@ export async function getHealthData({ throw new Error(errorData.message || `HTTP error! status: ${res.status}`); } - return res.json(); + const data = await res.json(); + + // Extract overviewData and reviewTransactions from the response + const { overviewData, reviewTransactions, ...healthDataWithStats } = data; + + return { + healthData: healthDataWithStats as IHealthData, + overviewData: overviewData || { + success: false, + cancelled: 0, + failed: 0, + successful: 0, + waiting: 0, + }, + reviewTransactions: reviewTransactions || { + success: false, + transactions: [], + total: 0, + }, + }; } diff --git a/app/services/types.ts b/app/services/types.ts new file mode 100644 index 0000000..82c6c11 --- /dev/null +++ b/app/services/types.ts @@ -0,0 +1,63 @@ +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 interface ITransactionsOverviewData { + success?: boolean; + message?: string; + cancelled?: number; + failed?: number; + successful?: number; + waiting?: number; + data?: Array<{ + state: string; + count: number; + percentage: string; + color?: string; + }>; + total?: number; +} + +export interface IDashboardData { + healthData: IHealthData; + overviewData: ITransactionsOverviewData; + reviewTransactions: IReviewTransactionsData; +} + +export interface IReviewTransaction { + id: number; + customer?: string; + external_id?: string; + type?: string; + currency?: string; + amount?: number; + status?: string; + created?: string; + modified?: string; + merchant_id?: string; + psp_id?: string; + method_id?: string; +} + +export interface IReviewTransactionsData { + success?: boolean; + message?: string; + transactions?: IReviewTransaction[]; + total?: number; +} diff --git a/app/utils/formatDate.ts b/app/utils/formatDate.ts index bd1a494..a399f4d 100644 --- a/app/utils/formatDate.ts +++ b/app/utils/formatDate.ts @@ -21,3 +21,29 @@ export const getDefaultDateRange = () => { dateEnd: endDate.toISOString(), }; }; + +/** + * Normalize date range for API calls + * - Start date is set to beginning of day (00:00:00.000) + * - End date is set to end of day (23:59:59.999) + * This ensures same-day selections include the entire day + */ +export const normalizeDateRangeForAPI = ( + startDate: Date, + endDate: Date +): { dateStart: string; dateEnd: string } => { + // Clone dates to avoid mutating originals + const normalizedStart = new Date(startDate); + const normalizedEnd = new Date(endDate); + + // Set start date to beginning of day + normalizedStart.setHours(0, 0, 0, 0); + + // Set end date to end of day + normalizedEnd.setHours(23, 59, 59, 999); + + return { + dateStart: normalizedStart.toISOString(), + dateEnd: normalizedEnd.toISOString(), + }; +}; diff --git a/services/roles.services.ts b/services/roles.services.ts index 718180a..20b8d3d 100644 --- a/services/roles.services.ts +++ b/services/roles.services.ts @@ -8,7 +8,6 @@ export async function editUser(id: string, data: IEditUserForm) { }); if (!res.ok) { - console.log("[editUser] - FAILING", id, data); throw new Error("Failed to update user"); }