Added more to dashboard
This commit is contained in:
parent
7636e35eda
commit
6d6bfd9089
2
.vscode/settings.json
vendored
Normal file
2
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@ -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);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
...data,
|
||||
const response = {
|
||||
...dashboardData.healthData,
|
||||
stats,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
overviewData: dashboardData.overviewData,
|
||||
reviewTransactions: dashboardData.reviewTransactions,
|
||||
};
|
||||
|
||||
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 },
|
||||
|
||||
@ -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 (
|
||||
<DashboardHomePage
|
||||
initialHealthData={initialHealthData}
|
||||
@ -37,6 +39,8 @@ export default async function DashboardPage() {
|
||||
key: "selection",
|
||||
},
|
||||
]}
|
||||
initialOverviewData={initialOverviewData}
|
||||
initialReviewTransactions={initialReviewTransactions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<Range[]>(
|
||||
initialDateRange ?? DEFAULT_DATE_RANGE
|
||||
);
|
||||
const [healthData, setHealthData] = useState<IHealthData | null>(
|
||||
initialHealthData
|
||||
);
|
||||
const [error, setError] = useState<string | null>(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 = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
{isLoading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: 100,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
!error && (
|
||||
<Box className="general-health-card__stat-items">
|
||||
{stats.map((item, i) => (
|
||||
<StatItem key={`${item.label}-${i}`} {...item} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</Box>
|
||||
{/* <TransactionsOverView /> */}
|
||||
<FetchReport />
|
||||
<TransactionsWaitingApproval />
|
||||
<Documentation />
|
||||
<TransactionsOverView initialOverviewData={initialOverviewData} />
|
||||
{/* <FetchReport /> */}
|
||||
<TransactionsWaitingApproval
|
||||
initialReviewTransactions={initialReviewTransactions}
|
||||
/>
|
||||
{/* <Documentation />
|
||||
<AccountIQ />
|
||||
<WhatsNew />
|
||||
<WhatsNew /> */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 = ({
|
||||
</text>
|
||||
);
|
||||
};
|
||||
export const PieCharts = () => {
|
||||
export const PieCharts = ({ data = defaultData }: IPieChartsProps) => {
|
||||
return (
|
||||
<Box className="pie-charts">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@ -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<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 (
|
||||
<Paper className="transaction-overview" elevation={3}>
|
||||
{/* Title and All Transactions Button */}
|
||||
<Box className="transaction-overview__header">
|
||||
<Typography variant="h5" fontWeight="bold">
|
||||
Transactions Overview (Last 24h)
|
||||
Transactions Overview
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => router.push("dashboard/transactions")}
|
||||
onClick={() => router.push("dashboard/transactions/all")}
|
||||
>
|
||||
All Transactions
|
||||
</Button>
|
||||
@ -30,11 +101,12 @@ export const TransactionsOverView = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Chart and Table */}
|
||||
{overviewData && (
|
||||
<Box className="transaction-overview__chart-table">
|
||||
<PieCharts />
|
||||
<TransactionsOverViewTable />
|
||||
<PieCharts data={pieChartData} />
|
||||
<TransactionsOverViewTable data={tableData} />
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
<TableContainer className="transactions-overview-table" component={Paper}>
|
||||
<Table>
|
||||
@ -32,8 +45,8 @@ export const TransactionsOverViewTable = () => {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data1.map((row, i) => (
|
||||
<TableRow key={row.state + i}>
|
||||
{data.map((row, i) => (
|
||||
<TableRow key={`${row.state}-${i}`}>
|
||||
<TableCell align="center">
|
||||
<Box className="transactions-overview-table__state-wrapper">
|
||||
<Box
|
||||
|
||||
98
app/features/TransactionsOverview/utils.ts
Normal file
98
app/features/TransactionsOverview/utils.ts
Normal 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),
|
||||
}));
|
||||
};
|
||||
@ -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<IReviewTransactionsData | null>(
|
||||
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 (
|
||||
<Paper elevation={3} className="transactions-waiting-approval">
|
||||
<Box sx={{ p: 3 }}>
|
||||
@ -128,14 +92,15 @@ export const TransactionsWaitingApproval = () => {
|
||||
<Button variant="outlined">All Pending Withdrawals</Button>
|
||||
<IconButton size="small">
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>{" "}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{reviewTransactions && (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{
|
||||
maxHeight: 400, // Set desired height
|
||||
maxHeight: 400,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
@ -166,8 +131,9 @@ export const TransactionsWaitingApproval = () => {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{transactions.map((tx, i) => (
|
||||
<TableRow key={tx.id + i}>
|
||||
{displayTransactions.length > 0 ? (
|
||||
displayTransactions.map((tx, i) => (
|
||||
<TableRow key={`${tx.id}-${i}`}>
|
||||
<TableCell>{tx.id}</TableCell>
|
||||
<TableCell>{tx.user}</TableCell>
|
||||
<TableCell>{tx.created}</TableCell>
|
||||
@ -183,10 +149,20 @@ export const TransactionsWaitingApproval = () => {
|
||||
</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>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
65
app/hooks/useDebouncedDateRange.ts
Normal file
65
app/hooks/useDebouncedDateRange.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
42
app/services/dashboardService.ts
Normal file
42
app/services/dashboardService.ts
Normal 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();
|
||||
@ -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<IHealthData> {
|
||||
}: IFetchHealthDataParams): Promise<IDashboardData> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<IHealthData> {
|
||||
}: IFetchHealthDataParams = {}): Promise<IDashboardData> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
63
app/services/types.ts
Normal file
63
app/services/types.ts
Normal 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;
|
||||
}
|
||||
@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user