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(); const data = await response.json();
console.log("[Users] - data FROM ROUTE", data);
return NextResponse.json(data, { status: response.status }); return NextResponse.json(data, { status: response.status });
} catch (err: unknown) { } catch (err: unknown) {
console.error("Proxy GET /api/v1/users/list error:", err); 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 queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`; const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
console.log("[Transactions] - backendUrl", backendUrl);
const response = await fetch(backendUrl, { const response = await fetch(backendUrl, {
method: "GET", method: "GET",
headers: { headers: {

View File

@ -1,9 +1,42 @@
"use client";
import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage"; 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 = () => { export default async function DashboardPage() {
return <DashboardHomePage />; // 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"; import "./DateRangePicker.scss";
export const DateRangePicker = () => { interface IDateRangePickerProps {
const [range, setRange] = useState<Range[]>([ value?: Range[];
onChange?: (ranges: Range[]) => void;
}
export const DateRangePicker = ({ value, onChange }: IDateRangePickerProps) => {
const [internalRange, setInternalRange] = useState<Range[]>([
{ {
startDate: new Date(), startDate: new Date(),
endDate: new Date(), endDate: new Date(),
@ -19,9 +24,16 @@ export const DateRangePicker = () => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const range = value ?? internalRange;
const handleSelect: DateRangeProps["onChange"] = ranges => { const handleSelect: DateRangeProps["onChange"] = ranges => {
if (ranges.selection) { if (ranges.selection) {
setRange([ranges.selection]); const newRange = [ranges.selection];
if (onChange) {
onChange(newRange);
} else {
setInternalRange(newRange);
}
if (ranges.selection.endDate !== ranges.selection.startDate) { if (ranges.selection.endDate !== ranges.selection.startDate) {
setAnchorEl(null); 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 MoreVertIcon from "@mui/icons-material/MoreVert";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import { Range } from "react-date-range";
import { DateRangePicker } from "../DateRangePicker/DateRangePicker"; import { DateRangePicker } from "../DateRangePicker/DateRangePicker";
import { StatItem } from "./components/StatItem"; 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"; import "./GeneralHealthCard.scss";
const stats = [ interface IGeneralHealthCardProps {
{ label: "TOTAL", value: 5, change: "-84.85%" }, initialHealthData?: IHealthData | null;
{ label: "SUCCESSFUL", value: 10, change: "100%" }, initialStats?: Array<{
{ label: "ACCEPTANCE RATE", value: "0%", change: "-100%" }, label: string;
{ label: "AMOUNT", value: "€0.00", change: "-100%" }, value: string | number;
{ label: "ATV", value: "€0.00", change: "-100%" }, 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 ( return (
<Card className="general-health-card"> <Card className="general-health-card">
<CardContent> <CardContent>
@ -22,21 +123,34 @@ export const GeneralHealthCard = () => {
<Typography variant="h5" fontWeight="bold"> <Typography variant="h5" fontWeight="bold">
General Health General Health
</Typography> </Typography>
<Box className="general-health-card__right-side"> <Box className="general-health-card__right-side">
<CalendarTodayIcon fontSize="small" /> <CalendarTodayIcon fontSize="small" />
<Typography variant="body2"> <Typography variant="body2">
<DateRangePicker /> <DateRangePicker
value={dateRange}
onChange={handleDateRangeChange}
/>
</Typography> </Typography>
<IconButton size="small"> <IconButton size="small">
<MoreVertIcon fontSize="small" /> <MoreVertIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
</Box> </Box>
<Box className="general-health-card__stat-items">
{stats.map((item, i) => ( {error && (
<StatItem key={item.label + i} {...item} /> <Alert severity="error" sx={{ mb: 2 }}>
))} {error}
</Box> </Alert>
)}
{!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

@ -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 [showAddUser, setShowAddUser] = useState(false);
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
// console.log("[Users] - users", users);
return ( return (
<div> <div>
<UserTopBar <UserTopBar

View File

@ -7,15 +7,42 @@ import { Documentation } from "../../Documentation/Documentation";
import { AccountIQ } from "../../AccountIQ/AccountIQ"; import { AccountIQ } from "../../AccountIQ/AccountIQ";
import { WhatsNew } from "../../WhatsNew/WhatsNew"; import { WhatsNew } from "../../WhatsNew/WhatsNew";
import { TransactionsOverView } from "../../TransactionsOverView/TransactionsOverview"; 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 ( return (
<> <>
{/* Conditional rendering of the Generic Modal, passing LoginModal as children */} {/* Conditional rendering of the Generic Modal, passing LoginModal as children */}
<Box sx={{ p: 2 }}> <Box sx={{ p: 2 }}>
<GeneralHealthCard /> <GeneralHealthCard
initialHealthData={initialHealthData}
initialStats={initialStats}
initialDateRange={initialDateRange}
/>
</Box> </Box>
<TransactionsOverView /> {/* <TransactionsOverView /> */}
<FetchReport /> <FetchReport />
<TransactionsWaitingApproval /> <TransactionsWaitingApproval />
<Documentation /> <Documentation />

View File

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

View File

@ -1,5 +1,6 @@
export const AUDIT_CACHE_TAG = "audits"; export const AUDIT_CACHE_TAG = "audits";
export const USERS_CACHE_TAG = "users"; export const USERS_CACHE_TAG = "users";
export const HEALTH_CACHE_TAG = "health";
export const REVALIDATE_SECONDS = 100; export const REVALIDATE_SECONDS = 100;
export const BE_BASE_URL = process.env.BE_BASE_URL || ""; 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({ export async function getTransactions({
transactionType, transactionType,
query, query,
@ -6,7 +9,7 @@ export async function getTransactions({
query: string; query: string;
}) { }) {
const res = await fetch( const res = await fetch(
`http://localhost:4000/api/dashboard/transactions/${transactionType}?${query}`, `${getBaseUrl()}/api/dashboard/transactions/${transactionType}?${query}`,
{ {
cache: "no-store", cache: "no-store",
} }
@ -22,3 +25,32 @@ export async function getTransactions({
return res.json(); 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}`; 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(),
};
};