feat/build-branch #4

Open
Mitchell wants to merge 12 commits from feat/build-branch into main
16 changed files with 459 additions and 31 deletions
Showing only changes of commit 7636e35eda - Show all commits

View File

@ -28,8 +28,6 @@ export async function GET(request: Request) {
const data = await response.json();
console.log("[Users] - data FROM ROUTE", data);
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error("Proxy GET /api/v1/users/list error:", err);

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { fetchHealthDataService } from "@/app/services/health";
import { transformHealthDataToStats } from "@/app/features/GeneralHealthCard/utils";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const dateStart = searchParams.get("dateStart") ?? undefined;
const dateEnd = searchParams.get("dateEnd") ?? undefined;
const data = await fetchHealthDataService({ dateStart, dateEnd });
// Transform data to stats format using shared util
const stats = transformHealthDataToStats(data);
console.log("[stats]", stats);
return NextResponse.json(
{
...data,
stats,
},
{ status: 200 }
);
} catch (err: unknown) {
console.error("Proxy GET /api/v1/transactions/health error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ success: false, message: errorMessage },
{ status: 500 }
);
}
}

View File

@ -98,6 +98,7 @@ export async function POST(request: NextRequest) {
const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
console.log("[Transactions] - backendUrl", backendUrl);
const response = await fetch(backendUrl, {
method: "GET",
headers: {

View File

@ -1,9 +1,42 @@
"use client";
import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage";
import { transformHealthDataToStats } from "../features/GeneralHealthCard/utils";
import { fetchHealthDataService } from "../services/health";
import { getDefaultDateRange } from "../utils/formatDate";
const DashboardPage = () => {
return <DashboardHomePage />;
};
export default async function DashboardPage() {
// Fetch initial health data server-side with default 24h range
const defaultDates = getDefaultDateRange();
let initialHealthData = null;
let initialStats = null;
export default DashboardPage;
try {
const healthData = await fetchHealthDataService({
dateStart: defaultDates.dateStart,
dateEnd: defaultDates.dateEnd,
});
initialHealthData = healthData;
// console.log("[healthData]", healthData);
initialStats = healthData.stats ?? transformHealthDataToStats(healthData);
} catch (_error: unknown) {
// If fetch fails, component will handle it client-side
// console.error("Failed to fetch initial health data:", error);
const error = _error as Error;
console.error("Failed to fetch initial health data:", error.cause);
}
console.log("[initialStats]", initialStats);
return (
<DashboardHomePage
initialHealthData={initialHealthData}
initialStats={initialStats}
initialDateRange={[
{
startDate: new Date(defaultDates.dateStart),
endDate: new Date(defaultDates.dateEnd),
key: "selection",
},
]}
/>
);
}

View File

@ -8,8 +8,13 @@ import "react-date-range/dist/theme/default.css";
import "./DateRangePicker.scss";
export const DateRangePicker = () => {
const [range, setRange] = useState<Range[]>([
interface IDateRangePickerProps {
value?: Range[];
onChange?: (ranges: Range[]) => void;
}
export const DateRangePicker = ({ value, onChange }: IDateRangePickerProps) => {
const [internalRange, setInternalRange] = useState<Range[]>([
{
startDate: new Date(),
endDate: new Date(),
@ -19,9 +24,16 @@ export const DateRangePicker = () => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const range = value ?? internalRange;
const handleSelect: DateRangeProps["onChange"] = ranges => {
if (ranges.selection) {
setRange([ranges.selection]);
const newRange = [ranges.selection];
if (onChange) {
onChange(newRange);
} else {
setInternalRange(newRange);
}
if (ranges.selection.endDate !== ranges.selection.startDate) {
setAnchorEl(null);
}

View File

@ -1,20 +1,121 @@
import { Box, Card, CardContent, Typography, IconButton } from "@mui/material";
"use client";
import { useEffect, useState } from "react";
import {
Box,
Card,
CardContent,
Typography,
IconButton,
Alert,
} from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import { Range } from "react-date-range";
import { DateRangePicker } from "../DateRangePicker/DateRangePicker";
import { StatItem } from "./components/StatItem";
import { DEFAULT_DATE_RANGE } from "./constants";
import { IHealthData } from "@/app/services/health";
import { getHealthData } from "@/app/services/transactions";
import "./GeneralHealthCard.scss";
const stats = [
{ label: "TOTAL", value: 5, change: "-84.85%" },
{ label: "SUCCESSFUL", value: 10, change: "100%" },
{ label: "ACCEPTANCE RATE", value: "0%", change: "-100%" },
{ label: "AMOUNT", value: "€0.00", change: "-100%" },
{ label: "ATV", value: "€0.00", change: "-100%" },
];
interface IGeneralHealthCardProps {
initialHealthData?: IHealthData | null;
initialStats?: Array<{
label: string;
value: string | number;
change: string;
}> | null;
initialDateRange?: Range[];
}
const DEBOUNCE_MS = 1000;
export const GeneralHealthCard = ({
initialHealthData = null,
initialStats = null,
initialDateRange,
}: IGeneralHealthCardProps) => {
const [dateRange, setDateRange] = useState<Range[]>(
initialDateRange ?? DEFAULT_DATE_RANGE
);
const [healthData, setHealthData] = useState<IHealthData | null>(
initialHealthData
);
const [error, setError] = useState<string | null>(null);
/**
* Fetch health data for a given range
*/
const fetchHealthData = async (range: Range[]) => {
const startDate = range[0]?.startDate;
const endDate = range[0]?.endDate;
if (!startDate || !endDate) return;
setError(null);
try {
const data = await getHealthData({
dateStart: startDate.toISOString(),
dateEnd: endDate.toISOString(),
});
setHealthData(data);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch health data";
setError(message);
setHealthData(null);
}
};
/**
* Date picker change handler
* (state only side effects are debounced below)
*/
const handleDateRangeChange = (newRange: Range[]) => {
setDateRange(newRange);
};
/**
* Debounced fetch when date range changes
*/
useEffect(() => {
const currentRange = dateRange[0];
if (!currentRange?.startDate || !currentRange?.endDate) return;
const isInitialDateRange =
initialDateRange &&
currentRange.startDate.getTime() ===
initialDateRange[0]?.startDate?.getTime() &&
currentRange.endDate.getTime() ===
initialDateRange[0]?.endDate?.getTime();
// Skip fetch if we're still on SSR-provided data
if (initialHealthData && isInitialDateRange) return;
const timeout = setTimeout(() => {
fetchHealthData(dateRange);
}, DEBOUNCE_MS);
return () => clearTimeout(timeout);
}, [dateRange]);
/**
* Resolve stats source
*/
const stats = healthData?.stats ??
initialStats ?? [
{ label: "TOTAL", value: 0, change: "0%" },
{ label: "SUCCESSFUL", value: 0, change: "0%" },
{ label: "ACCEPTANCE RATE", value: "0%", change: "0%" },
{ label: "AMOUNT", value: "€0.00", change: "0%" },
{ label: "ATV", value: "€0.00", change: "0%" },
];
export const GeneralHealthCard = () => {
return (
<Card className="general-health-card">
<CardContent>
@ -22,21 +123,34 @@ export const GeneralHealthCard = () => {
<Typography variant="h5" fontWeight="bold">
General Health
</Typography>
<Box className="general-health-card__right-side">
<CalendarTodayIcon fontSize="small" />
<Typography variant="body2">
<DateRangePicker />
<DateRangePicker
value={dateRange}
onChange={handleDateRangeChange}
/>
</Typography>
<IconButton size="small">
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{!error && (
<Box className="general-health-card__stat-items">
{stats.map((item, i) => (
<StatItem key={item.label + i} {...item} />
<StatItem key={`${item.label}-${i}`} {...item} />
))}
</Box>
)}
</CardContent>
</Card>
);

View File

@ -0,0 +1,9 @@
import { Range } from "react-date-range";
export const DEFAULT_DATE_RANGE = [
{
startDate: new Date(),
endDate: new Date(),
key: "selection",
},
] as Range[];

View File

@ -0,0 +1,71 @@
export const formatCurrency = (value: number | string | undefined): string => {
if (value === undefined || value === null) return "€0.00";
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return "€0.00";
return `${numValue.toFixed(2)}`;
};
export const formatPercentage = (
value: number | string | undefined
): string => {
if (value === undefined || value === null) return "0%";
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return "0%";
return `${numValue.toFixed(2)}%`;
};
interface IHealthData {
total?: number;
successful?: number;
acceptance_rate?: number;
amount?: number;
atv?: number;
}
interface IStatItem {
label: string;
value: string | number;
change: string;
}
export const transformHealthDataToStats = (
healthData: IHealthData | null
): IStatItem[] => {
if (!healthData) {
return [
{ label: "TOTAL", value: 0, change: "0%" },
{ label: "SUCCESSFUL", value: 0, change: "0%" },
{ label: "ACCEPTANCE RATE", value: "0%", change: "0%" },
{ label: "AMOUNT", value: "€0.00", change: "0%" },
{ label: "ATV", value: "€0.00", change: "0%" },
];
}
return [
{
label: "TOTAL",
value: healthData.total ?? 0,
change: "0%",
},
{
label: "SUCCESSFUL",
value: healthData.successful ?? 0,
change: "0%",
},
{
label: "ACCEPTANCE RATE",
value: formatPercentage(healthData.acceptance_rate),
change: "0%",
},
{
label: "AMOUNT",
value: formatCurrency(healthData.amount),
change: "0%",
},
{
label: "ATV",
value: formatCurrency(healthData.atv),
change: "0%",
},
];
};

View File

@ -17,6 +17,8 @@ const Users: React.FC<UsersProps> = ({ users }) => {
const [showAddUser, setShowAddUser] = useState(false);
const dispatch = useDispatch<AppDispatch>();
// console.log("[Users] - users", users);
return (
<div>
<UserTopBar

View File

@ -7,15 +7,42 @@ 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";
export const DashboardHomePage = () => {
interface IDashboardHomePageProps {
initialHealthData?: {
success?: boolean;
message?: string;
total?: number;
successful?: number;
acceptance_rate?: number;
amount?: number;
atv?: number;
} | null;
initialStats?: Array<{
label: string;
value: string | number;
change: string;
}> | null;
initialDateRange?: Range[];
}
export const DashboardHomePage = ({
initialHealthData,
initialStats,
initialDateRange,
}: IDashboardHomePageProps) => {
return (
<>
{/* Conditional rendering of the Generic Modal, passing LoginModal as children */}
<Box sx={{ p: 2 }}>
<GeneralHealthCard />
<GeneralHealthCard
initialHealthData={initialHealthData}
initialStats={initialStats}
initialDateRange={initialDateRange}
/>
</Box>
<TransactionsOverView />
{/* <TransactionsOverView /> */}
<FetchReport />
<TransactionsWaitingApproval />
<Documentation />

View File

@ -41,6 +41,8 @@ export default function UserRoleCard({ user }: Props) {
const dispatch = useDispatch<AppDispatch>();
const { username, first_name, last_name, email, groups } = user;
const [newPassword, setNewPassword] = useState<string | null>(null);
console.log("[UserRoleCard] - user", user);
const handleEditClick = () => {
setIsEditing(!isEditing);
};
@ -137,7 +139,7 @@ export default function UserRoleCard({ user }: Props) {
</Typography>
<Stack direction="row" spacing={1} mt={1} flexWrap="wrap">
<Stack direction="row" spacing={1}>
{groups.map(role => (
{groups?.map(role => (
<Chip key={role} label={role} size="small" />
))}
</Stack>

View File

@ -1,5 +1,6 @@
export const AUDIT_CACHE_TAG = "audits";
export const USERS_CACHE_TAG = "users";
export const HEALTH_CACHE_TAG = "health";
export const REVALIDATE_SECONDS = 100;
export const BE_BASE_URL = process.env.BE_BASE_URL || "";

83
app/services/health.ts Normal file
View File

@ -0,0 +1,83 @@
"use server";
import { cookies } from "next/headers";
import {
AUTH_COOKIE_NAME,
BE_BASE_URL,
REVALIDATE_SECONDS,
HEALTH_CACHE_TAG,
} from "./constants";
export interface IHealthData {
success?: boolean;
message?: string;
total?: number;
successful?: number;
acceptance_rate?: number;
amount?: number;
atv?: number;
stats?: Array<{
label: string;
value: string | number;
change: string;
}>;
}
export interface IFetchHealthDataParams {
dateStart?: string;
dateEnd?: string;
}
export async function fetchHealthDataService({
dateStart,
dateEnd,
}: IFetchHealthDataParams = {}): Promise<IHealthData> {
const cookieStore = await cookies();
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
if (!token) {
throw new Error("Missing auth token");
}
const queryParts: string[] = [];
// Add date filter if provided
if (dateStart && dateEnd) {
queryParts.push(
`Modified=BETWEEN/${encodeURIComponent(dateStart)}/${encodeURIComponent(dateEnd)}`
);
} else if (dateStart) {
queryParts.push(`Modified=>/${encodeURIComponent(dateStart)}`);
} else if (dateEnd) {
queryParts.push(`Modified=</${encodeURIComponent(dateEnd)}`);
}
const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions/health${
queryString ? `?${queryString}` : ""
}`;
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
next: {
revalidate: REVALIDATE_SECONDS,
tags: [HEALTH_CACHE_TAG],
},
});
if (!response.ok) {
const errorData = await response
.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;
console.log("[data]", data.stats);
return data;
}

View File

@ -1,3 +1,6 @@
import { getBaseUrl } from "./constants";
import { IHealthData, IFetchHealthDataParams } from "./health";
export async function getTransactions({
transactionType,
query,
@ -6,7 +9,7 @@ export async function getTransactions({
query: string;
}) {
const res = await fetch(
`http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`,
`${getBaseUrl()}/api/dashboard/transactions/${transactionType}?${query}`,
{
cache: "no-store",
}
@ -22,3 +25,32 @@ export async function getTransactions({
return res.json();
}
/**
* Client-side function to fetch health data via the /api/dashboard proxy
*/
export async function getHealthData({
dateStart,
dateEnd,
}: IFetchHealthDataParams = {}): Promise<IHealthData> {
const params = new URLSearchParams();
if (dateStart) params.set("dateStart", dateStart);
if (dateEnd) params.set("dateEnd", dateEnd);
const queryString = params.toString();
const res = await fetch(
`${getBaseUrl()}/api/dashboard${queryString ? `?${queryString}` : ""}`,
{
cache: "no-store",
}
);
if (!res.ok) {
const errorData = await res
.json()
.catch(() => ({ message: "Unknown error" }));
throw new Error(errorData.message || `HTTP error! status: ${res.status}`);
}
return res.json();
}

View File

@ -11,3 +11,13 @@ export const formatToDateTimeString = (dateString: string): string => {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
export const getDefaultDateRange = () => {
const endDate = new Date();
const startDate = new Date();
startDate.setHours(startDate.getHours() - 24);
return {
dateStart: startDate.toISOString(),
dateEnd: endDate.toISOString(),
};
};