Added more to dashboard

This commit is contained in:
Mitchell Magro 2025-12-27 10:57:56 +01:00
parent 7636e35eda
commit 6d6bfd9089
17 changed files with 746 additions and 284 deletions

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; 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"; import { transformHealthDataToStats } from "@/app/features/GeneralHealthCard/utils";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@ -8,22 +8,24 @@ export async function GET(request: NextRequest) {
const dateStart = searchParams.get("dateStart") ?? undefined; const dateStart = searchParams.get("dateStart") ?? undefined;
const dateEnd = searchParams.get("dateEnd") ?? 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 // Transform health data to stats format using shared util
const stats = transformHealthDataToStats(data); const stats = transformHealthDataToStats(dashboardData.healthData);
console.log("[stats]", stats); const response = {
...dashboardData.healthData,
stats,
overviewData: dashboardData.overviewData,
reviewTransactions: dashboardData.reviewTransactions,
};
return NextResponse.json( return NextResponse.json(response, { status: 200 });
{
...data,
stats,
},
{ status: 200 }
);
} catch (err: unknown) { } catch (err: unknown) {
console.error("Proxy GET /api/v1/transactions/health error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error"; const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json( return NextResponse.json(
{ success: false, message: errorMessage }, { success: false, message: errorMessage },

View File

@ -1,31 +1,33 @@
import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage"; import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage";
import { transformHealthDataToStats } from "../features/GeneralHealthCard/utils"; import { transformHealthDataToStats } from "../features/GeneralHealthCard/utils";
import { fetchHealthDataService } from "../services/health"; import { fetchDashboardDataService } from "../services/health";
import { getDefaultDateRange } from "../utils/formatDate"; import { getDefaultDateRange } from "../utils/formatDate";
export default async function DashboardPage() { 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(); const defaultDates = getDefaultDateRange();
let initialHealthData = null; let initialHealthData = null;
let initialStats = null; let initialStats = null;
let initialOverviewData = null;
let initialReviewTransactions = null;
try { try {
const healthData = await fetchHealthDataService({ const dashboardData = await fetchDashboardDataService({
dateStart: defaultDates.dateStart, dateStart: defaultDates.dateStart,
dateEnd: defaultDates.dateEnd, dateEnd: defaultDates.dateEnd,
}); });
const { healthData, overviewData, reviewTransactions } = dashboardData;
initialHealthData = healthData; initialHealthData = healthData;
// console.log("[healthData]", healthData);
initialStats = healthData.stats ?? transformHealthDataToStats(healthData); initialStats = healthData.stats ?? transformHealthDataToStats(healthData);
initialOverviewData = overviewData;
initialReviewTransactions = reviewTransactions;
} catch (_error: unknown) { } catch (_error: unknown) {
// If fetch fails, component will handle it client-side // If fetch fails, component will handle it client-side
// console.error("Failed to fetch initial health data:", error);
const error = _error as 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 ( return (
<DashboardHomePage <DashboardHomePage
initialHealthData={initialHealthData} initialHealthData={initialHealthData}
@ -37,6 +39,8 @@ export default async function DashboardPage() {
key: "selection", key: "selection",
}, },
]} ]}
initialOverviewData={initialOverviewData}
initialReviewTransactions={initialReviewTransactions}
/> />
); );
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { import {
Box, Box,
Card, Card,
@ -8,6 +8,7 @@ import {
Typography, Typography,
IconButton, IconButton,
Alert, Alert,
CircularProgress,
} from "@mui/material"; } from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
@ -17,7 +18,9 @@ import { DateRangePicker } from "../DateRangePicker/DateRangePicker";
import { StatItem } from "./components/StatItem"; import { StatItem } from "./components/StatItem";
import { DEFAULT_DATE_RANGE } from "./constants"; import { DEFAULT_DATE_RANGE } from "./constants";
import { IHealthData } from "@/app/services/health"; 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"; import "./GeneralHealthCard.scss";
@ -38,71 +41,56 @@ export const GeneralHealthCard = ({
initialStats = null, initialStats = null,
initialDateRange, initialDateRange,
}: IGeneralHealthCardProps) => { }: IGeneralHealthCardProps) => {
const [dateRange, setDateRange] = useState<Range[]>(
initialDateRange ?? DEFAULT_DATE_RANGE
);
const [healthData, setHealthData] = useState<IHealthData | null>( const [healthData, setHealthData] = useState<IHealthData | null>(
initialHealthData initialHealthData
); );
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
/** /**
* Fetch health data for a given range * Fetch health data for a given range
*/ */
const fetchHealthData = async (range: Range[]) => { const fetchHealthData = async (range: Range[]) => {
if (!range || !range[0]) return;
const startDate = range[0]?.startDate; const startDate = range[0]?.startDate;
const endDate = range[0]?.endDate; const endDate = range[0]?.endDate;
if (!startDate || !endDate) return; if (!startDate || !endDate) return;
setError(null); setError(null);
setIsLoading(true);
try { try {
const data = await getHealthData({ // Normalize dates to ensure full day coverage
dateStart: startDate.toISOString(), const { dateStart, dateEnd } = normalizeDateRangeForAPI(
dateEnd: endDate.toISOString(), startDate,
endDate
);
// This will update the service and notify all subscribers
const { healthData } = await dashboardService.fetchDashboardData({
dateStart,
dateEnd,
}); });
setHealthData(data); setHealthData(healthData);
} catch (err) { } catch (err) {
const message = const message =
err instanceof Error ? err.message : "Failed to fetch health data"; err instanceof Error ? err.message : "Failed to fetch health data";
setError(message); setError(message);
setHealthData(null); setHealthData(null);
} finally {
setIsLoading(false);
} }
}; };
/** const { dateRange, handleDateRangeChange } = useDebouncedDateRange({
* Date picker change handler initialDateRange: initialDateRange ?? DEFAULT_DATE_RANGE,
* (state only side effects are debounced below) debounceMs: DEBOUNCE_MS,
*/ onDateRangeChange: fetchHealthData,
const handleDateRangeChange = (newRange: Range[]) => { skipInitialFetch: !!initialHealthData,
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 * Resolve stats source
@ -144,12 +132,25 @@ export const GeneralHealthCard = ({
</Alert> </Alert>
)} )}
{!error && ( {isLoading ? (
<Box className="general-health-card__stat-items"> <Box
{stats.map((item, i) => ( sx={{
<StatItem key={`${item.label}-${i}`} {...item} /> display: "flex",
))} justifyContent: "center",
alignItems: "center",
minHeight: 100,
}}
>
<CircularProgress />
</Box> </Box>
) : (
!error && (
<Box className="general-health-card__stat-items">
{stats.map((item, i) => (
<StatItem key={`${item.label}-${i}`} {...item} />
))}
</Box>
)
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,13 +1,15 @@
"use client"; "use client";
import { useEffect } from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { GeneralHealthCard } from "../../GeneralHealthCard/GeneralHealthCard"; import { GeneralHealthCard } from "../../GeneralHealthCard/GeneralHealthCard";
import { TransactionsWaitingApproval } from "../../TransactionsWaitingApproval/TransactionsWaitingApproval"; 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 { TransactionsOverView } from "../../TransactionsOverView/TransactionsOverview";
import { Range } from "react-date-range"; import { Range } from "react-date-range";
import {
ITransactionsOverviewData,
IReviewTransactionsData,
} from "@/app/services/health";
import { dashboardService } from "@/app/services/dashboardService";
interface IDashboardHomePageProps { interface IDashboardHomePageProps {
initialHealthData?: { initialHealthData?: {
@ -25,13 +27,28 @@ interface IDashboardHomePageProps {
change: string; change: string;
}> | null; }> | null;
initialDateRange?: Range[]; initialDateRange?: Range[];
initialOverviewData?: ITransactionsOverviewData | null;
initialReviewTransactions?: IReviewTransactionsData | null;
} }
export const DashboardHomePage = ({ export const DashboardHomePage = ({
initialHealthData, initialHealthData,
initialStats, initialStats,
initialDateRange, initialDateRange,
initialOverviewData,
initialReviewTransactions,
}: IDashboardHomePageProps) => { }: 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 ( return (
<> <>
{/* Conditional rendering of the Generic Modal, passing LoginModal as children */} {/* Conditional rendering of the Generic Modal, passing LoginModal as children */}
@ -42,12 +59,14 @@ export const DashboardHomePage = ({
initialDateRange={initialDateRange} initialDateRange={initialDateRange}
/> />
</Box> </Box>
{/* <TransactionsOverView /> */} <TransactionsOverView initialOverviewData={initialOverviewData} />
<FetchReport /> {/* <FetchReport /> */}
<TransactionsWaitingApproval /> <TransactionsWaitingApproval
<Documentation /> initialReviewTransactions={initialReviewTransactions}
/>
{/* <Documentation />
<AccountIQ /> <AccountIQ />
<WhatsNew /> <WhatsNew /> */}
</> </>
); );
}; };

View File

@ -2,15 +2,25 @@
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import "./PieCharts.scss"; 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 A", value: 100 },
{ name: "Group B", value: 200 }, { name: "Group B", value: 200 },
{ name: "Group C", value: 400 }, { name: "Group C", value: 400 },
{ name: "Group D", value: 300 }, { name: "Group D", value: 300 },
]; ];
const COLORS = ["#4caf50", "#ff9800", "#f44336", "#9e9e9e"];
const RADIAN = Math.PI / 180; const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({ const renderCustomizedLabel = ({
cx, cx,
@ -37,7 +47,7 @@ const renderCustomizedLabel = ({
</text> </text>
); );
}; };
export const PieCharts = () => { export const PieCharts = ({ data = defaultData }: IPieChartsProps) => {
return ( return (
<Box className="pie-charts"> <Box className="pie-charts">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">

View File

@ -1,26 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Box, Button, IconButton, Paper, Typography } from "@mui/material"; import { Box, Button, IconButton, Paper, Typography } from "@mui/material";
import { PieCharts } from "../PieCharts/PieCharts"; import { PieCharts } from "../PieCharts/PieCharts";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable"; 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"; import "./TransactionsOverView.scss";
export const TransactionsOverView = () => { interface ITransactionsOverviewProps {
initialOverviewData?: ITransactionsOverviewData | null;
}
export const TransactionsOverView = ({
initialOverviewData = null,
}: ITransactionsOverviewProps) => {
const router = useRouter(); const router = useRouter();
const [overviewData, setOverviewData] =
useState<ITransactionsOverviewData | null>(
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 ( return (
<Paper className="transaction-overview" elevation={3}> <Paper className="transaction-overview" elevation={3}>
{/* Title and All Transactions Button */}
<Box className="transaction-overview__header"> <Box className="transaction-overview__header">
<Typography variant="h5" fontWeight="bold"> <Typography variant="h5" fontWeight="bold">
Transactions Overview (Last 24h) Transactions Overview
</Typography> </Typography>
<Box> <Box>
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
onClick={() => router.push("dashboard/transactions")} onClick={() => router.push("dashboard/transactions/all")}
> >
All Transactions All Transactions
</Button> </Button>
@ -30,11 +101,12 @@ export const TransactionsOverView = () => {
</Box> </Box>
</Box> </Box>
{/* Chart and Table */} {overviewData && (
<Box className="transaction-overview__chart-table"> <Box className="transaction-overview__chart-table">
<PieCharts /> <PieCharts data={pieChartData} />
<TransactionsOverViewTable /> <TransactionsOverViewTable data={tableData} />
</Box> </Box>
)}
</Paper> </Paper>
); );
}; };

View File

@ -12,14 +12,27 @@ import {
import "./TransactionsOverViewTable.scss"; 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: "Success", count: 120, percentage: "60%", color: "green" },
{ state: "Pending", count: 50, percentage: "25%", color: "orange" }, { state: "Pending", count: 50, percentage: "25%", color: "orange" },
{ state: "Failed", count: 20, percentage: "10%", color: "red" }, { state: "Failed", count: 20, percentage: "10%", color: "red" },
{ state: "Other", count: 10, percentage: "5%", color: "gray" }, { state: "Other", count: 10, percentage: "5%", color: "gray" },
]; ];
export const TransactionsOverViewTable = () => { export const TransactionsOverViewTable = ({
data = defaultData,
}: ITransactionsOverViewTableProps) => {
return ( return (
<TableContainer className="transactions-overview-table" component={Paper}> <TableContainer className="transactions-overview-table" component={Paper}>
<Table> <Table>
@ -32,8 +45,8 @@ export const TransactionsOverViewTable = () => {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{data1.map((row, i) => ( {data.map((row, i) => (
<TableRow key={row.state + i}> <TableRow key={`${row.state}-${i}`}>
<TableCell align="center"> <TableCell align="center">
<Box className="transactions-overview-table__state-wrapper"> <Box className="transactions-overview-table__state-wrapper">
<Box <Box

View File

@ -0,0 +1,98 @@
/**
* Map transaction state to color
*/
export const getStateColor = (state: string): string => {
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),
}));
};

View File

@ -1,3 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { import {
Box, Box,
Button, Button,
@ -13,110 +16,71 @@ import {
} from "@mui/material"; } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CancelIcon from "@mui/icons-material/Cancel"; import CancelIcon from "@mui/icons-material/Cancel";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import "./TransactionsWaitingApproval.scss"; import { dashboardService } from "@/app/services/dashboardService";
const transactions = [ import { formatToDateTimeString } from "@/app/utils/formatDate";
{
id: "1049078821", import "./TransactionsWaitingApproval.scss";
user: "17", import {
created: "2025-06-17 16:45", type IReviewTransactionsData,
type: "BestPayWithdrawal", type IReviewTransaction,
amount: "-787.49 TRY", } from "@/app/services/types";
psp: "BestPay",
}, interface ITransactionsWaitingApprovalProps {
{ initialReviewTransactions?: IReviewTransactionsData | null;
id: "1049078822", }
user: "17",
created: "2025-06-17 16:45", export const TransactionsWaitingApproval = ({
type: "BestPayWithdrawal", initialReviewTransactions = null,
amount: "-787.49 TRY", }: ITransactionsWaitingApprovalProps) => {
psp: "BestPay", const [reviewTransactions, setReviewTransactions] =
}, useState<IReviewTransactionsData | null>(
{ initialReviewTransactions ||
id: "1049078823", dashboardService.getCurrentDashboardData()?.reviewTransactions ||
user: "17", null
created: "2025-06-17 16:45", );
type: "BestPayWithdrawal",
amount: "-787.49 TRY", /**
psp: "BestPay", * Subscribe to dashboard data changes
}, */
{ useEffect(() => {
id: "1049078824", const subscription = dashboardService
user: "17", .getDashboardData$()
created: "2025-06-17 16:45", .subscribe(data => {
type: "BestPayWithdrawal", if (data?.reviewTransactions) {
amount: "-787.49 TRY", setReviewTransactions(data.reviewTransactions);
psp: "BestPay", }
}, });
{
id: "1049078821", // Cleanup subscription on unmount
user: "17", return () => subscription.unsubscribe();
created: "2025-06-17 16:45", }, []);
type: "BestPayWithdrawal",
amount: "-787.49 TRY", /**
psp: "BestPay", * Format transaction for display
}, */
{ const formatTransaction = (tx: IReviewTransaction) => {
id: "1049078822", const createdDate = tx.created || tx.modified || "";
user: "17", const formattedDate = createdDate
created: "2025-06-17 16:45", ? formatToDateTimeString(createdDate)
type: "BestPayWithdrawal", : "";
amount: "-787.49 TRY",
psp: "BestPay", return {
}, id: tx.id?.toString() || tx.external_id || "",
{ user: tx.customer || "",
id: "1049078823", created: formattedDate,
user: "17", type: tx.type || "",
created: "2025-06-17 16:45", amount: tx.amount
type: "BestPayWithdrawal", ? `${tx.amount < 0 ? "-" : ""}${Math.abs(tx.amount).toFixed(2)} ${tx.currency || ""}`
amount: "-787.49 TRY", : "",
psp: "BestPay", psp: tx.psp_id || "",
}, };
{ };
id: "1049078824",
user: "17", const transactions = reviewTransactions?.transactions || [];
created: "2025-06-17 16:45", const displayTransactions = transactions.map(formatTransaction);
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",
},
];
export const TransactionsWaitingApproval = () => {
return ( return (
<Paper elevation={3} className="transactions-waiting-approval"> <Paper elevation={3} className="transactions-waiting-approval">
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
@ -128,65 +92,77 @@ export const TransactionsWaitingApproval = () => {
<Button variant="outlined">All Pending Withdrawals</Button> <Button variant="outlined">All Pending Withdrawals</Button>
<IconButton size="small"> <IconButton size="small">
<MoreVertIcon fontSize="small" /> <MoreVertIcon fontSize="small" />
</IconButton>{" "} </IconButton>
</Box> </Box>
</Box> </Box>
<TableContainer {reviewTransactions && (
component={Paper} <TableContainer
sx={{ component={Paper}
maxHeight: 400, // Set desired height sx={{
overflow: "auto", maxHeight: 400,
}} overflow: "auto",
> }}
<Table> >
<TableHead> <Table>
<TableRow> <TableHead>
<TableCell> <TableRow>
<strong>ID</strong>
</TableCell>
<TableCell>
<strong>User</strong>
</TableCell>
<TableCell>
<strong>Created</strong>
</TableCell>
<TableCell>
<strong>Type</strong>
</TableCell>
<TableCell>
<strong>Amount</strong>
</TableCell>
<TableCell>
<strong>PSP</strong>
</TableCell>
<TableCell>
<strong>Action</strong>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{transactions.map((tx, i) => (
<TableRow key={tx.id + i}>
<TableCell>{tx.id}</TableCell>
<TableCell>{tx.user}</TableCell>
<TableCell>{tx.created}</TableCell>
<TableCell>{tx.type}</TableCell>
<TableCell>{tx.amount}</TableCell>
<TableCell>{tx.psp}</TableCell>
<TableCell> <TableCell>
<IconButton color="success"> <strong>ID</strong>
<CheckCircleIcon /> </TableCell>
</IconButton> <TableCell>
<IconButton color="error"> <strong>User</strong>
<CancelIcon /> </TableCell>
</IconButton> <TableCell>
<strong>Created</strong>
</TableCell>
<TableCell>
<strong>Type</strong>
</TableCell>
<TableCell>
<strong>Amount</strong>
</TableCell>
<TableCell>
<strong>PSP</strong>
</TableCell>
<TableCell>
<strong>Action</strong>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {displayTransactions.length > 0 ? (
</TableContainer> displayTransactions.map((tx, i) => (
<TableRow key={`${tx.id}-${i}`}>
<TableCell>{tx.id}</TableCell>
<TableCell>{tx.user}</TableCell>
<TableCell>{tx.created}</TableCell>
<TableCell>{tx.type}</TableCell>
<TableCell>{tx.amount}</TableCell>
<TableCell>{tx.psp}</TableCell>
<TableCell>
<IconButton color="success">
<CheckCircleIcon />
</IconButton>
<IconButton color="error">
<CancelIcon />
</IconButton>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
No transactions waiting for approval
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box> </Box>
</Paper> </Paper>
); );

View File

@ -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<void>;
skipInitialFetch?: boolean;
}
export const useDebouncedDateRange = ({
initialDateRange,
debounceMs = 1000,
onDateRangeChange,
skipInitialFetch = false,
}: IUseDebouncedDateRangeOptions = {}) => {
const [dateRange, setDateRange] = useState<Range[]>(initialDateRange ?? []);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(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,
};
};

View File

@ -0,0 +1,42 @@
import { BehaviorSubject, Observable } from "rxjs";
import { IDashboardData } from "./types";
import { getDashboardData } from "./transactions";
class DashboardService {
private dashboardData$ = new BehaviorSubject<IDashboardData | null>(null);
/**
* Get observable for dashboard data
*/
getDashboardData$(): Observable<IDashboardData | null> {
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<IDashboardData> {
const data = await getDashboardData(params);
this.updateDashboardData(data);
return data;
}
}
export const dashboardService = new DashboardService();

View File

@ -7,31 +7,23 @@ import {
REVALIDATE_SECONDS, REVALIDATE_SECONDS,
HEALTH_CACHE_TAG, HEALTH_CACHE_TAG,
} from "./constants"; } from "./constants";
import {
type IDashboardData,
type IFetchHealthDataParams,
type IHealthData,
type IReviewTransactionsData,
type ITransactionsOverviewData,
} from "./types";
export interface IHealthData { /**
success?: boolean; * Fetch both health and overview data concurrently
message?: string; * This is optimized for initial page load
total?: number; * Always includes overview data with the provided date range
successful?: number; */
acceptance_rate?: number; export async function fetchDashboardDataService({
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, dateStart,
dateEnd, dateEnd,
}: IFetchHealthDataParams = {}): Promise<IHealthData> { }: IFetchHealthDataParams): Promise<IDashboardData> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
@ -53,11 +45,8 @@ export async function fetchHealthDataService({
} }
const queryString = queryParts.join("&"); const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions/health${
queryString ? `?${queryString}` : ""
}`;
const response = await fetch(backendUrl, { const fetchConfig = {
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -67,17 +56,78 @@ export async function fetchHealthDataService({
revalidate: REVALIDATE_SECONDS, revalidate: REVALIDATE_SECONDS,
tags: [HEALTH_CACHE_TAG], tags: [HEALTH_CACHE_TAG],
}, },
}); };
if (!response.ok) { // Fetch all three endpoints concurrently
const errorData = await response 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() .json()
.catch(() => ({ message: "Failed to fetch health data" })); .catch(() => ({ message: "Failed to fetch health data" }));
throw new Error(errorData?.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); // Handle overview data response
return data; 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,
};
} }

View File

@ -1,5 +1,5 @@
import { getBaseUrl } from "./constants"; import { getBaseUrl } from "./constants";
import { IHealthData, IFetchHealthDataParams } from "./health"; import { IFetchHealthDataParams, IHealthData, IDashboardData } from "./types";
export async function getTransactions({ export async function getTransactions({
transactionType, 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, dateStart,
dateEnd, dateEnd,
}: IFetchHealthDataParams = {}): Promise<IHealthData> { }: IFetchHealthDataParams = {}): Promise<IDashboardData> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (dateStart) params.set("dateStart", dateStart); if (dateStart) params.set("dateStart", dateStart);
if (dateEnd) params.set("dateEnd", dateEnd); if (dateEnd) params.set("dateEnd", dateEnd);
@ -52,5 +53,24 @@ export async function getHealthData({
throw new Error(errorData.message || `HTTP error! status: ${res.status}`); 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,
},
};
} }

63
app/services/types.ts Normal file
View File

@ -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;
}

View File

@ -21,3 +21,29 @@ export const getDefaultDateRange = () => {
dateEnd: endDate.toISOString(), 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(),
};
};

View File

@ -8,7 +8,6 @@ export async function editUser(id: string, data: IEditUserForm) {
}); });
if (!res.ok) { if (!res.ok) {
console.log("[editUser] - FAILING", id, data);
throw new Error("Failed to update user"); throw new Error("Failed to update user");
} }