Merge pull request 'feat/transaction-tables' (#2) from feat/transaction-tables into main

Reviewed-on: #2
This commit is contained in:
Mitchell 2025-11-18 19:49:33 +01:00
commit 96983c6cec
35 changed files with 1980 additions and 1437 deletions

View File

@ -5,7 +5,6 @@ import { useEffect, useRef } from "react";
import { useDispatch } from "react-redux";
import { AppDispatch } from "@/app/redux/types";
import { validateAuth } from "./redux/auth/authSlice";
import { fetchMetadata } from "./redux/metadata/metadataSlice";
export function AuthBootstrap() {
const dispatch = useDispatch<AppDispatch>();
const startedRef = useRef(false);

View File

@ -71,7 +71,6 @@ export async function PUT(
// Transform the request body to match backend format
const transformedBody = transformUserUpdateData(body);
console.log("[PUT /api/v1/users/{id}] - transformed body", transformedBody);
// Get the auth token from cookies
const { cookies } = await import("next/headers");

View File

@ -1,88 +0,0 @@
import { GridColDef } from "@mui/x-data-grid";
export const AuditColumns: GridColDef[] = [
{ field: "actionType", headerName: "Action Type", width: 130 },
{
field: "timeStampOfTheAction",
headerName: "Timestamp of the action",
width: 130,
},
{ field: "adminUsername", headerName: "Admin username", width: 130 },
{ field: "adminId", headerName: "Admin ID", width: 130 },
{ field: "affectedUserId", headerName: "Affected user ID", width: 130 },
{ field: "adminIPAddress", headerName: "Admin IP address", width: 130 },
{ field: "reasonNote", headerName: "Reason/Note", width: 130 },
];
export const AuditData = [
{
id: "1",
actionType: "Create",
timeStampOfTheAction: "2023-03-01T12:00:00",
adminUsername: "admin1",
adminId: "12345",
affectedUserId: "67890",
adminIPAddress: "192.168.1.1",
reasonNote: "New user created",
},
{
id: "2",
actionType: "Update",
timeStampOfTheAction: "2023-03-02T12:00:00",
adminUsername: "admin2",
adminId: "54321",
affectedUserId: "09876",
adminIPAddress: "192.168.2.2",
reasonNote: "User details updated",
},
{
id: "3",
actionType: "Delete",
timeStampOfTheAction: "2023-03-03T12:00:00",
adminUsername: "admin3",
adminId: "98765",
affectedUserId: "45678",
adminIPAddress: "192.168.3.3",
reasonNote: "User deleted",
},
{
id: "4",
actionType: "Create",
timeStampOfTheAction: "2023-03-04T12:00:00",
adminUsername: "admin4",
adminId: "98765",
affectedUserId: "45678",
adminIPAddress: "192.168.3.3",
reasonNote: "New user created",
},
{
id: "5",
actionType: "Update",
timeStampOfTheAction: "2023-03-05T12:00:00",
adminUsername: "admin2",
adminId: "98765",
affectedUserId: "45678",
adminIPAddress: "192.168.3.3",
reasonNote: "User details updated",
},
];
export const AuditSearchLabels = [
{ label: "Action Type", field: "actionType", type: "text" },
{ label: "Date / Time", field: "dateTime", type: "date" },
{
label: "affectedUserId",
field: "Affected user ID",
type: "text",
},
{
label: "Admin ID",
field: "adminId",
type: "text",
},
{
label: "Admin username",
field: "adminUsername",
type: "text",
},
];

View File

@ -1,69 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { AuditColumns, AuditData, AuditSearchLabels } from "./mockData";
const AUDITS_BASE_URL =
process.env.AUDITS_BASE_URL ||
process.env.BE_BASE_URL ||
"http://localhost:8583";
const COOKIE_NAME = "auth_token";
const DEFAULT_LIMIT = "25";
const DEFAULT_PAGE = "1";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
try {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
const actionType = searchParams.get("actionType");
const affectedUserId = searchParams.get("affectedUserId");
const adminId = searchParams.get("adminId");
const adminUsername = searchParams.get("adminUsername");
const dateTimeStart = searchParams.get("dateTime_start");
const dateTimeEnd = searchParams.get("dateTime_end");
let filteredRows = [...AuditData];
if (actionType) {
filteredRows = filteredRows.filter(
tx => tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase()
);
}
if (affectedUserId) {
filteredRows = filteredRows.filter(
tx => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase()
);
}
if (adminId) {
filteredRows = filteredRows.filter(tx => tx.adminId === adminId);
}
if (adminUsername) {
filteredRows = filteredRows.filter(
tx => tx.adminUsername === adminUsername
);
}
if (dateTimeStart && dateTimeEnd) {
const start = new Date(dateTimeStart);
const end = new Date(dateTimeEnd);
// Validate the date range to ensure its correct
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
if (!token) {
return NextResponse.json(
{
error: "Invalid date range",
},
{ status: 400 }
{ message: "Missing Authorization header" },
{ status: 401 }
);
}
filteredRows = filteredRows.filter(tx => {
const txDate = new Date(tx.timeStampOfTheAction);
const { searchParams } = new URL(request.url);
const proxiedParams = new URLSearchParams();
// Validate if the timestamp is a valid date
if (isNaN(txDate.getTime())) {
return false; // Skip invalid dates
}
return txDate >= start && txDate <= end;
// Forward provided params
searchParams.forEach((value, key) => {
if (value == null || value === "") return;
proxiedParams.append(key, value);
});
}
return NextResponse.json({
tableRows: filteredRows,
tableColumns: AuditColumns,
tableSearchLabels: AuditSearchLabels,
});
if (!proxiedParams.has("limit")) {
proxiedParams.set("limit", DEFAULT_LIMIT);
}
if (!proxiedParams.has("page")) {
proxiedParams.set("page", DEFAULT_PAGE);
}
const backendUrl = `${AUDITS_BASE_URL}/api/v1/audit${
proxiedParams.size ? `?${proxiedParams.toString()}` : ""
}`;
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to fetch audits" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch audits",
},
{ status: response.status }
);
}
const data = await response.json();
console.log("[AUDITS] data:", data);
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.log("[AUDITS] error:", err);
console.error("Proxy GET /api/v1/audits error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
const COOKIE_NAME = "auth_token";
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = await params;
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ message: "Missing Authorization header" },
{ status: 401 }
);
}
const payload = await request.json();
const upstream = await fetch(`${BE_BASE_URL}/api/v1/transactions/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
const data = await upstream.json();
console.log("[DEBUG] [TRANSACTIONS] [PUT] Response data:", data);
return NextResponse.json(data, { status: upstream.status });
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,264 +0,0 @@
import { GridColDef } from "@mui/x-data-grid";
export const allTransactionDummyData = [
{
id: 1,
userId: 17,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 2,
userId: 17,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 3,
userId: 17,
merchandId: 100987997,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 4,
userId: 19,
merchandId: 100987997,
transactionId: 1049136973,
depositMethod: "Card",
status: "Completed",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 5,
userId: 19,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 6,
userId: 27,
merchandId: 100987997,
transactionId: 1049131973,
depositMethod: "Card",
status: "Pending",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 7,
userId: 175,
merchandId: 100987938,
transactionId: 1049136973,
depositMethod: "Card",
status: "Pending",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 8,
userId: 172,
merchandId: 100987938,
transactionId: 1049131973,
depositMethod: "Card",
status: "Pending",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-12 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 9,
userId: 174,
merchandId: 100987938,
transactionId: 1049131973,
depositMethod: "Bank Transfer",
status: "Inprogress",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 10,
userId: 7,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Bank Transfer",
status: "Inprogress",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 11,
userId: 1,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Bank Transfer",
status: "Error",
// options: [
// { value: "Pending", label: "Pending" },
// { value: "Completed", label: "Completed" },
// { value: "Inprogress", label: "Inprogress" },
// { value: "Error", label: "Error" },
// ],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
];
export const allTransactionsColumns: GridColDef[] = [
{ field: "userId", headerName: "User ID", width: 130 },
{ field: "merchandId", headerName: "Merchant ID", width: 130 },
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
{ field: "depositMethod", headerName: "Deposit Method", width: 130 },
{ field: "status", headerName: "Status", width: 130 },
// { field: "actions", headerName: "Actions", width: 150 },
{ field: "amount", headerName: "Amount", width: 130 },
{ field: "currency", headerName: "Currency", width: 130 },
{ field: "dateTime", headerName: "Date / Time", width: 130 },
{ field: "errorInfo", headerName: "Error Info", width: 130 },
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
];
export const allTransactionsExtraColumns: GridColDef[] = [
{ field: "currency", headerName: "Currency", width: 130 },
{ field: "errorInfo", headerName: "Error Info", width: 130 },
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
];
export const extraColumns = ["currency", "errorInfo", "fraudScore"];
export const allTransactionsSearchLabels = [
{ label: "User", field: "userId", type: "text" },
{ label: "Transaction ID", field: "transactionId", type: "text" },
{
label: "Transaction Reference ID",
field: "transactionReferenceId",
type: "text",
},
{
label: "Currency",
field: "currency",
type: "select",
options: ["USD", "EUR", "GBP"],
},
{
label: "Status",
field: "status",
type: "select",
options: ["Pending", "Inprogress", "Completed", "Failed"],
},
{
label: "Payment Method",
field: "depositMethod",
type: "select",
options: ["Card", "Bank Transfer"],
},
{ label: "Date / Time", field: "dateTime", type: "date" },
];

View File

@ -1,84 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
allTransactionDummyData,
allTransactionsColumns,
allTransactionsSearchLabels,
extraColumns,
} from "./mockData";
// import { formatToDateTimeString } from "@/app/utils/formatDate";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
const userId = searchParams.get("userId");
const depositMethod = searchParams.get("depositMethod");
const merchandId = searchParams.get("merchandId");
const transactionId = searchParams.get("transactionId");
// const dateTime = searchParams.get("dateTime");
const dateTimeStart = searchParams.get("dateTime_start");
const dateTimeEnd = searchParams.get("dateTime_end");
let filteredTransactions = [...allTransactionDummyData];
if (userId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.userId.toString() === userId
);
}
if (status) {
filteredTransactions = filteredTransactions.filter(
tx => tx.status.toLowerCase() === status.toLowerCase()
);
}
if (depositMethod) {
filteredTransactions = filteredTransactions.filter(
tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
);
}
if (merchandId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.merchandId.toString() === merchandId
);
}
if (transactionId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.transactionId.toString() === transactionId
);
}
if (dateTimeStart && dateTimeEnd) {
const start = new Date(dateTimeStart);
const end = new Date(dateTimeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return NextResponse.json(
{
error: "Invalid date range",
},
{ status: 400 }
);
}
filteredTransactions = filteredTransactions.filter(tx => {
const txDate = new Date(tx.dateTime);
if (isNaN(txDate.getTime())) {
return false;
}
return txDate >= start && txDate <= end;
});
}
return NextResponse.json({
tableRows: filteredTransactions,
tableSearchLabels: allTransactionsSearchLabels,
tableColumns: allTransactionsColumns,
extraColumns: extraColumns,
});
}

View File

@ -1,264 +0,0 @@
import { GridColDef } from "@mui/x-data-grid";
export const depositTransactionDummyData = [
{
id: 1,
userId: 17,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 2,
userId: 17,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 3,
userId: 17,
merchandId: 100987997,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 4,
userId: 19,
merchandId: 100987997,
transactionId: 1049136973,
depositMethod: "Card",
status: "Completed",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 5,
userId: 19,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Card",
status: "Completed",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 6,
userId: 27,
merchandId: 100987997,
transactionId: 1049131973,
depositMethod: "Card",
status: "Pending",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 7,
userId: 175,
merchandId: 100987938,
transactionId: 1049136973,
depositMethod: "Card",
status: "Pending",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 8,
userId: 172,
merchandId: 100987938,
transactionId: 1049131973,
depositMethod: "Card",
status: "Pending",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-12 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 9,
userId: 174,
merchandId: 100987938,
transactionId: 1049131973,
depositMethod: "Bank Transfer",
status: "Inprogress",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 10,
userId: 7,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Bank Transfer",
status: "Inprogress",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 11,
userId: 1,
merchandId: 100987998,
transactionId: 1049131973,
depositMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
currency: "EUR",
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
];
export const depositTransactionsColumns: GridColDef[] = [
{ field: "userId", headerName: "User ID", width: 130 },
{ field: "merchandId", headerName: "Merchant ID", width: 130 },
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
{ field: "depositMethod", headerName: "Deposit Method", width: 130 },
{ field: "status", headerName: "Status", width: 130 },
{ field: "actions", headerName: "Actions", width: 150 },
{ field: "amount", headerName: "Amount", width: 130 },
{ field: "currency", headerName: "Currency", width: 130 },
{ field: "dateTime", headerName: "Date / Time", width: 130 },
{ field: "errorInfo", headerName: "Error Info", width: 130 },
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
];
export const depositTransactionsExtraColumns: GridColDef[] = [
{ field: "currency", headerName: "Currency", width: 130 },
{ field: "errorInfo", headerName: "Error Info", width: 130 },
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
];
// export const extraColumns = ["currency", "errorInfo", "fraudScore"]
export const depositTransactionsSearchLabels = [
{ label: "User", field: "userId", type: "text" },
{ label: "Transaction ID", field: "transactionId", type: "text" },
{
label: "Transaction Reference ID",
field: "transactionReferenceId",
type: "text",
},
{
label: "Currency",
field: "currency",
type: "select",
options: ["USD", "EUR", "GBP"],
},
{
label: "Status",
field: "status",
type: "select",
options: ["Pending", "Inprogress", "Completed", "Failed"],
},
{
label: "Payment Method",
field: "depositMethod",
type: "select",
options: ["Card", "Bank Transfer"],
},
{ label: "Date / Time", field: "dateTime", type: "date" },
];

View File

@ -1,82 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import {
depositTransactionDummyData,
depositTransactionsColumns,
depositTransactionsSearchLabels,
// extraColumns
} from "./mockData";
// import { formatToDateTimeString } from "@/app/utils/formatDate";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
const COOKIE_NAME = "auth_token";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
type FilterValue =
| string
| {
operator?: string;
value: string;
};
const status = searchParams.get("status");
const userId = searchParams.get("userId");
const depositMethod = searchParams.get("depositMethod");
const merchandId = searchParams.get("merchandId");
const transactionId = searchParams.get("transactionId");
// const dateTime = searchParams.get("dateTime");
export async function POST(request: NextRequest) {
try {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
const dateTimeStart = searchParams.get("dateTime_start");
const dateTimeEnd = searchParams.get("dateTime_end");
let filteredTransactions = [...depositTransactionDummyData];
if (userId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.userId.toString() === userId
);
}
if (status) {
filteredTransactions = filteredTransactions.filter(
tx => tx.status.toLowerCase() === status.toLowerCase()
);
}
if (depositMethod) {
filteredTransactions = filteredTransactions.filter(
tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase()
);
}
if (merchandId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.merchandId.toString() === merchandId
);
}
if (transactionId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.transactionId.toString() === transactionId
);
}
if (dateTimeStart && dateTimeEnd) {
const start = new Date(dateTimeStart);
const end = new Date(dateTimeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
if (!token) {
return NextResponse.json(
{
error: "Invalid date range",
},
{ status: 400 }
{ message: "Missing Authorization header" },
{ status: 401 }
);
}
filteredTransactions = filteredTransactions.filter(tx => {
const txDate = new Date(tx.dateTime);
const body = await request.json();
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
if (isNaN(txDate.getTime())) {
return false;
// Force deposits filter while allowing other filters to stack
const mergedFilters: Record<string, FilterValue> = {
...filters,
Type: {
operator: "==",
value: "deposit",
},
};
const queryParts: string[] = [];
queryParts.push(`limit=${pagination.limit}`);
queryParts.push(`page=${pagination.page}`);
if (sort) {
queryParts.push(`sort=${sort.field}:${sort.order}`);
}
for (const [key, filterValue] of Object.entries(mergedFilters)) {
if (!filterValue) continue;
let operator: string;
let value: string;
if (typeof filterValue === "string") {
operator = "==";
value = filterValue;
} else {
operator = filterValue.operator || "==";
value = filterValue.value;
}
return txDate >= start && txDate <= end;
});
}
if (!value) continue;
return NextResponse.json({
tableRows: filteredTransactions,
tableSearchLabels: depositTransactionsSearchLabels,
tableColumns: depositTransactionsColumns,
});
const encodedValue = encodeURIComponent(value);
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
queryParts.push(`${key}${operatorSegment}/${encodedValue}`);
}
const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${
queryString ? `?${queryString}` : ""
}`;
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to fetch deposits" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch deposits",
},
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error(
"Proxy POST /api/dashboard/transactions/deposits error:",
err
);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from "next/server";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
const COOKIE_NAME = "auth_token";
export async function POST(request: NextRequest) {
try {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ message: "Missing Authorization header" },
{ status: 401 }
);
}
// Parse request body
const body = await request.json();
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
// Build query string for backend
const queryParts: string[] = [];
// Add pagination (standard key=value format)
queryParts.push(`limit=${pagination.limit}`);
queryParts.push(`page=${pagination.page}`);
// Add sorting if provided (still key=value)
if (sort) {
queryParts.push(`sort=${sort.field}:${sort.order}`);
}
// Process filters - convert FilterValue objects to operator/value format
for (const [key, filterValue] of Object.entries(filters)) {
if (!filterValue) continue;
let op: string;
let value: string;
if (typeof filterValue === "string") {
// Simple string filter - default to ==
op = "==";
value = filterValue;
} else {
// FilterValue object with operator and value
const filterVal = filterValue as { operator?: string; value: string };
op = filterVal.operator || "==";
value = filterVal.value;
}
if (!value) continue;
// Encode value to prevent breaking URL
const encodedValue = encodeURIComponent(value);
queryParts.push(`${key}=${op}/${encodedValue}`);
}
const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
console.log("[DEBUG] [TRANSACTIONS] Backend URL:", backendUrl);
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to fetch transactions" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch transactions",
},
{ status: response.status }
);
}
const data = await response.json();
console.log("[DEBUG] [TRANSACTIONS] Response data:", data);
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error("Proxy GET /api/v1/transactions error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -28,8 +28,6 @@ export async function GET() {
const data = await res.json();
console.log("metadata", data);
if (!res.ok) {
return NextResponse.json(
{

View File

@ -0,0 +1,42 @@
.audits-page {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
.page-title {
font-size: 1.5rem;
font-weight: 500;
margin: 0;
}
.error-alert {
margin-bottom: 8px;
padding: 12px 16px;
background-color: #fee;
color: #c62828;
border-radius: 4px;
border: 1px solid #ffcdd2;
}
.table-container {
width: 100%;
background-color: #fff;
border-radius: 4px;
box-shadow:
0px 2px 1px -1px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14),
0px 1px 3px 0px rgba(0, 0, 0, 0.12);
overflow: hidden;
.scroll-wrapper {
width: 100dvw;
overflow-x: auto;
overflow-y: hidden;
.table-inner {
min-width: 1200px;
}
}
}
}

View File

@ -1,22 +1,293 @@
import DataTable from "@/app/features/DataTable/DataTable";
import { getAudits } from "@/app/services/audits";
"use client";
export default async function AuditPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// Await searchParams before processing
const params = await searchParams;
// Create a safe query string by filtering only string values
const safeParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
safeParams[key] = value;
import { useEffect, useMemo, useState } from "react";
import {
DataGrid,
GridColDef,
GridPaginationModel,
GridSortModel,
} from "@mui/x-data-grid";
import { getAudits } from "@/app/services/audits";
import "./page.scss";
type AuditRow = Record<string, unknown> & { id: string | number };
interface AuditApiResponse {
total?: number;
limit?: number;
page?: number;
data?: unknown;
items?: unknown[];
audits?: unknown[];
logs?: unknown[];
results?: unknown[];
records?: unknown[];
meta?: { total?: number };
pagination?: { total?: number };
}
const DEFAULT_PAGE_SIZE = 25;
const FALLBACK_COLUMNS: GridColDef[] = [
{
field: "placeholder",
headerName: "Audit Data",
flex: 1,
sortable: false,
filterable: false,
},
];
const CANDIDATE_ARRAY_KEYS: (keyof AuditApiResponse)[] = [
"items",
"audits",
"logs",
"results",
"records",
];
const normalizeValue = (value: unknown): string | number => {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string" || typeof value === "number") {
return value;
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return JSON.stringify(value);
};
const toTitle = (field: string) =>
field
.replace(/_/g, " ")
.replace(/-/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim()
.replace(/^\w/g, char => char.toUpperCase());
const deriveColumns = (rows: AuditRow[]): GridColDef[] => {
if (!rows.length) return [];
return Object.keys(rows[0]).map(field => ({
field,
headerName: toTitle(field),
flex: field === "id" ? 0 : 1,
minWidth: field === "id" ? 140 : 200,
sortable: true,
}));
};
const extractArray = (payload: AuditApiResponse): unknown[] => {
if (Array.isArray(payload)) {
return payload;
}
for (const key of CANDIDATE_ARRAY_KEYS) {
const candidate = payload[key];
if (Array.isArray(candidate)) {
return candidate;
}
}
const query = new URLSearchParams(safeParams).toString();
const data = await getAudits({ query });
return <DataTable data={data} />;
const dataRecord =
payload.data &&
typeof payload.data === "object" &&
!Array.isArray(payload.data)
? (payload.data as Record<string, unknown>)
: null;
if (dataRecord) {
for (const key of CANDIDATE_ARRAY_KEYS) {
const candidate = dataRecord[key];
if (Array.isArray(candidate)) {
return candidate;
}
}
}
if (Array.isArray(payload.data)) {
return payload.data;
}
return [];
};
const resolveTotal = (payload: AuditApiResponse, fallback: number): number => {
const fromPayload = payload.total;
const fromMeta = payload.meta?.total;
const fromPagination = payload.pagination?.total;
const fromData =
payload.data &&
typeof payload.data === "object" &&
!Array.isArray(payload.data)
? (payload.data as { total?: number }).total
: undefined;
return (
(typeof fromPayload === "number" && fromPayload) ||
(typeof fromMeta === "number" && fromMeta) ||
(typeof fromPagination === "number" && fromPagination) ||
(typeof fromData === "number" && fromData) ||
fallback
);
};
const normalizeRows = (entries: unknown[], page: number): AuditRow[] =>
entries.map((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return {
id: `${page}-${index}`,
value: normalizeValue(entry),
};
}
const record = entry as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
Object.entries(record).forEach(([key, value]) => {
normalized[key] = normalizeValue(value);
});
const identifier =
record.id ??
record.audit_id ??
record.log_id ??
record._id ??
`${page}-${index}`;
return {
id: (identifier as string | number) ?? `${page}-${index}`,
...normalized,
};
});
export default function AuditPage() {
const [rows, setRows] = useState<AuditRow[]>([]);
const [columns, setColumns] = useState<GridColDef[]>([]);
const [rowCount, setRowCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: DEFAULT_PAGE_SIZE,
});
const [sortModel, setSortModel] = useState<GridSortModel>([]);
useEffect(() => {
const controller = new AbortController();
const fetchAudits = async () => {
setLoading(true);
setError(null);
const sortParam =
sortModel.length && sortModel[0].field && sortModel[0].sort
? `${sortModel[0].field}:${sortModel[0].sort}`
: undefined;
try {
const payload = (await getAudits({
limit: paginationModel.pageSize,
page: paginationModel.page + 1,
sort: sortParam,
signal: controller.signal,
})) as AuditApiResponse;
const auditEntries = extractArray(payload);
const normalized = normalizeRows(auditEntries, paginationModel.page);
setColumns(prev =>
normalized.length
? deriveColumns(normalized)
: prev.length
? prev
: FALLBACK_COLUMNS
);
setRows(normalized);
setRowCount(resolveTotal(payload, normalized.length));
} catch (err) {
if (controller.signal.aborted) return;
const message =
err instanceof Error ? err.message : "Failed to load audits";
setError(message);
setRows([]);
setColumns(prev => (prev.length ? prev : FALLBACK_COLUMNS));
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
};
fetchAudits();
return () => controller.abort();
}, [paginationModel, sortModel]);
const handlePaginationChange = (model: GridPaginationModel) => {
setPaginationModel(model);
};
const handleSortModelChange = (model: GridSortModel) => {
setSortModel(model);
setPaginationModel(prev => ({ ...prev, page: 0 }));
};
const pageTitle = useMemo(
() =>
sortModel.length && sortModel[0].field
? `Audit Logs · sorted by ${toTitle(sortModel[0].field)}`
: "Audit Logs",
[sortModel]
);
return (
<div className="audits-page">
<h1 className="page-title">{pageTitle}</h1>
{error && (
<div className="error-alert" role="alert">
{error}
</div>
)}
<div className="table-container">
<div className="scroll-wrapper">
<div
className="table-inner"
style={{ minWidth: `${columns.length * 200}px` }}
>
<DataGrid
rows={rows}
columns={columns.length ? columns : FALLBACK_COLUMNS}
loading={loading}
paginationMode="server"
sortingMode="server"
paginationModel={paginationModel}
onPaginationModelChange={handlePaginationChange}
rowCount={rowCount}
sortModel={sortModel}
onSortModelChange={handleSortModelChange}
pageSizeOptions={[10, 25, 50, 100]}
disableRowSelectionOnClick
sx={{
border: 0,
minHeight: 500,
"& .MuiDataGrid-cell": {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
},
}}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,23 +1,77 @@
"use client";
import DataTable from "@/app/features/DataTable/DataTable";
import { getTransactions } from "@/app/services/transactions";
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
selectFilters,
selectPagination,
selectSort,
} from "@/app/redux/advanedSearch/selectors";
import { AppDispatch } from "@/app/redux/store";
import { setError as setAdvancedSearchError } from "@/app/redux/advanedSearch/advancedSearchSlice";
import { TransactionRow, BackendTransaction } from "../interface";
export default async function AllTransactionPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// Await searchParams before processing
const params = await searchParams;
// Create a safe query string by filtering only string values
const safeParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
safeParams[key] = value;
}
}
const query = new URLSearchParams(safeParams).toString();
const transactionType = "all";
const data = await getTransactions({ transactionType, query });
export default function AllTransactionPage() {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
return <DataTable data={data} />;
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
const extraColumns: string[] = []; // static for now
// Memoize rows to avoid new reference each render
const memoizedRows = useMemo(() => tableRows, [tableRows]);
// Fetch data when filters, pagination, or sort changes
useEffect(() => {
const fetchData = async () => {
dispatch(setAdvancedSearchError(null));
try {
const response = await fetch("/api/dashboard/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filters, pagination, sort }),
});
if (!response.ok) {
dispatch(setAdvancedSearchError("Failed to fetch transactions"));
setTableRows([]);
return;
}
const backendData = await response.json();
const transactions = backendData.transactions || [];
const rows = transactions.map((tx: BackendTransaction) => ({
id: tx.id || 0,
userId: tx.customer,
transactionId: tx.external_id || tx.id,
type: tx.type,
currency: tx.currency,
amount: tx.amount,
status: tx.status,
dateTime: tx.created || tx.modified,
merchantId: tx.merchant_id,
pspId: tx.psp_id,
methodId: tx.method_id,
modified: tx.modified,
}));
setTableRows(rows);
} catch (error) {
dispatch(
setAdvancedSearchError(
error instanceof Error ? error.message : "Unknown error"
)
);
setTableRows([]);
}
};
fetchData();
}, [dispatch, filters, pagination, sort]);
return <DataTable rows={memoizedRows} extraColumns={extraColumns} />;
}

View File

@ -1,23 +1,93 @@
"use client";
import DataTable from "@/app/features/DataTable/DataTable";
import { getTransactions } from "@/app/services/transactions";
import { useDispatch, useSelector } from "react-redux";
import { AppDispatch } from "@/app/redux/store";
import {
selectFilters,
selectPagination,
selectSort,
} from "@/app/redux/advanedSearch/selectors";
import {
setStatus,
setError as setAdvancedSearchError,
} from "@/app/redux/advanedSearch/advancedSearchSlice";
import { useEffect, useMemo, useState } from "react";
import { TransactionRow, BackendTransaction } from "../interface";
export default async function DepositTransactionPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// Await searchParams before processing
const params = await searchParams;
// Create a safe query string by filtering only string values
const safeParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
safeParams[key] = value;
}
}
const query = new URLSearchParams(safeParams).toString();
const transactionType = "deposits";
const data = await getTransactions({ transactionType, query });
export default function DepositTransactionPage() {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
return <DataTable data={data} />;
const memoizedRows = useMemo(() => tableRows, [tableRows]);
const depositFilters = useMemo(() => {
return {
...filters,
Type: {
operator: "==",
value: "deposit",
},
};
}, [filters]);
useEffect(() => {
const fetchDeposits = async () => {
dispatch(setStatus("loading"));
dispatch(setAdvancedSearchError(null));
try {
const response = await fetch("/api/dashboard/transactions/deposits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters: depositFilters,
pagination,
sort,
}),
});
if (!response.ok) {
dispatch(setAdvancedSearchError("Failed to fetch deposits"));
setTableRows([]);
return;
}
const backendData = await response.json();
const transactions: BackendTransaction[] =
backendData.transactions || [];
const rows: TransactionRow[] = transactions.map(tx => ({
id: tx.id,
userId: tx.customer,
transactionId: String(tx.external_id ?? tx.id),
type: tx.type,
currency: tx.currency,
amount: tx.amount,
status: tx.status,
dateTime: tx.created || tx.modified,
merchantId: tx.merchant_id,
pspId: tx.psp_id,
methodId: tx.method_id,
modified: tx.modified,
}));
setTableRows(rows);
dispatch(setStatus("succeeded"));
} catch (error) {
dispatch(
setAdvancedSearchError(
error instanceof Error ? error.message : "Unknown error"
)
);
setTableRows([]);
}
};
fetchDeposits();
}, [dispatch, depositFilters, pagination, sort]);
return <DataTable rows={memoizedRows} enableStatusActions />;
}

View File

@ -0,0 +1,29 @@
export interface TransactionRow {
id: number;
userId?: string;
transactionId: string;
type?: string;
currency?: string;
amount?: number;
status?: string;
dateTime?: string;
merchantId?: string;
pspId?: string;
methodId?: string;
modified?: string;
}
export interface BackendTransaction {
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;
}

View File

@ -1,4 +1,5 @@
"use client";
import {
Box,
TextField,
@ -16,57 +17,158 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import SearchIcon from "@mui/icons-material/Search";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useSearchParams, useRouter } from "next/navigation";
import { useDispatch, useSelector } from "react-redux";
import { useState, useEffect, useMemo } from "react";
import { ISearchLabel } from "../DataTable/types";
import { AppDispatch } from "@/app/redux/store";
import {
updateFilter,
clearFilters,
FilterValue,
} from "@/app/redux/advanedSearch/advancedSearchSlice";
import { selectFilters } from "@/app/redux/advanedSearch/selectors";
import { normalizeValue, defaultOperatorForField } from "./utils/utils";
// -----------------------------------------------------
// COMPONENT
// -----------------------------------------------------
export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
const searchParams = useSearchParams();
const router = useRouter();
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const [open, setOpen] = useState(false);
// Local form state for UI (synced with Redux)
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [operators, setOperators] = useState<Record<string, string>>({});
// -----------------------------------------------------
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
// -----------------------------------------------------
useEffect(() => {
const initialParams = Object.fromEntries(searchParams.entries());
setFormValues(initialParams);
}, [searchParams]);
const values: Record<string, string> = {};
const ops: Record<string, string> = {};
const updateURL = useMemo(
labels.forEach(({ field, type }) => {
const filter = filters[field];
if (filter) {
if (typeof filter === "string") {
// Simple string filter
values[field] = filter;
ops[field] = defaultOperatorForField(field, type);
} else {
// FilterValue object with operator and value
values[field] = filter.value;
ops[field] = filter.operator;
}
}
// Handle date ranges
const startKey = `${field}_start`;
const endKey = `${field}_end`;
const startFilter = filters[startKey];
const endFilter = filters[endKey];
if (startFilter && typeof startFilter === "string") {
values[startKey] = startFilter;
}
if (endFilter && typeof endFilter === "string") {
values[endKey] = endFilter;
}
});
setFormValues(values);
setOperators(ops);
}, [filters, labels]);
// -----------------------------------------------------
// DEBOUNCED FILTER UPDATE
// -----------------------------------------------------
const debouncedUpdateFilter = useMemo(
() =>
debounce((newValues: Record<string, string>) => {
const updatedParams = new URLSearchParams();
Object.entries(newValues).forEach(([key, value]) => {
if (value) updatedParams.set(key, value);
});
router.push(`?${updatedParams.toString()}`);
}, 500),
[router]
debounce(
(field: string, value: string | undefined, operator?: string) => {
if (!value || value === "") {
dispatch(updateFilter({ field, value: undefined }));
return;
}
const safeValue = normalizeValue(value);
if (!safeValue) {
dispatch(updateFilter({ field, value: undefined }));
return;
}
// For text/select fields, use FilterValue with operator
const filterValue: FilterValue = {
operator: operator ?? defaultOperatorForField(field, "text"),
value: safeValue,
};
dispatch(updateFilter({ field, value: filterValue }));
},
300
),
[dispatch]
);
const handleFieldChange = (field: string, value: string) => {
const updatedValues = { ...formValues, [field]: value };
console.log(updatedValues);
setFormValues(updatedValues);
updateURL(updatedValues);
// -----------------------------------------------------
// handlers
// -----------------------------------------------------
const updateField = (field: string, value: string) => {
setFormValues(prev => ({ ...prev, [field]: value }));
const operator = operators[field] ?? defaultOperatorForField(field, "text");
debouncedUpdateFilter(field, value, operator);
};
const updateOperator = (field: string, op: string) => {
setOperators(prev => ({ ...prev, [field]: op }));
// If value exists, update filter immediately with new operator
const currentValue = formValues[field];
if (currentValue) {
const safeValue = normalizeValue(currentValue);
if (safeValue) {
dispatch(
updateFilter({
field,
value: { operator: op, value: safeValue },
})
);
}
}
};
const updateDateRange = (
field: string,
start: string | undefined,
end: string | undefined
) => {
if (start) {
dispatch(updateFilter({ field: `${field}_start`, value: start }));
} else {
dispatch(updateFilter({ field: `${field}_start`, value: undefined }));
}
if (end) {
dispatch(updateFilter({ field: `${field}_end`, value: end }));
} else {
dispatch(updateFilter({ field: `${field}_end`, value: undefined }));
}
};
const resetForm = () => {
setFormValues({});
router.push("?");
setOperators({});
dispatch(clearFilters());
};
const toggleDrawer =
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (
event.type === "keydown" &&
((event as React.KeyboardEvent).key === "Tab" ||
(event as React.KeyboardEvent).key === "Shift")
) {
return;
}
setOpen(open);
};
// -----------------------------------------------------
// render
// -----------------------------------------------------
return (
<Box sx={{ width: "185px" }}>
<Button
@ -79,134 +181,211 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
boxShadow: "inset 0 0 0 1px #ddd",
fontWeight: 400,
fontSize: "16px",
justifyContent: "flex-start",
"& .MuiButton-startIcon": {
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
"&:hover": {
backgroundColor: "#e0e0e0",
},
"&:hover": { backgroundColor: "#e0e0e0" },
}}
startIcon={<SearchIcon />}
onClick={toggleDrawer(true)}
onClick={() => setOpen(true)}
>
Advanced Search
</Button>
<Drawer anchor="right" open={open} onClose={toggleDrawer(false)}>
<Box sx={{ width: 400 }} role="presentation">
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
<Box sx={{ width: 400 }} p={2}>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Box p={2}>
<Box sx={{ display: "flex", gap: "60px" }}>
<Typography variant="h6" gutterBottom>
Search
</Typography>
<Box display="flex" justifyContent="flex-end" gap={2}>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={() => console.log("Params:", formValues)}
>
Apply Filter
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={resetForm}
/>
</Box>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}
>
<Typography variant="h6">Search</Typography>
<Box display="flex" gap={2}>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={() => {
// Apply all current form values to Redux
labels.forEach(({ field, type }) => {
const val = formValues[field];
if (!val) return;
if (type === "select" || type === "text") {
const operator =
operators[field] ??
defaultOperatorForField(field, type);
const safeValue = normalizeValue(val);
if (safeValue) {
dispatch(
updateFilter({
field,
value: { operator, value: safeValue },
})
);
}
}
});
}}
>
Apply
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={resetForm}
/>
</Box>
<Stack spacing={2}>
{labels?.map(({ label, field, type, options }) => (
<Box key={field}>
<Typography variant="body2" fontWeight={600} mb={0.5}>
{label}
</Typography>
{type === "text" && (
<TextField
fullWidth
size="small"
value={formValues[field] || ""}
onChange={e => handleFieldChange(field, e.target.value)}
/>
)}
{type === "select" && (
<FormControl fullWidth size="small">
<Select
value={formValues[field] || ""}
onChange={e =>
handleFieldChange(field, e.target.value)
}
displayEmpty
>
<MenuItem value="">
<em>{label}</em>
</MenuItem>
{options?.map(option => (
<MenuItem value={option} key={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
)}
{type === "date" && (
<Stack spacing={2}>
<DatePicker
label="Start Date"
value={
formValues[`${field}_start`]
? new Date(formValues[`${field}_start`])
: null
}
onChange={newValue => {
if (!newValue)
return handleFieldChange(`${field}_start`, "");
const start = new Date(newValue);
start.setHours(0, 0, 0, 0); // force start of day
handleFieldChange(
`${field}_start`,
start.toISOString()
);
}}
slotProps={{
textField: { fullWidth: true, size: "small" },
}}
/>
<DatePicker
label="End Date"
value={
formValues[`${field}_end`]
? new Date(formValues[`${field}_end`])
: null
}
onChange={newValue => {
if (!newValue)
return handleFieldChange(`${field}_end`, "");
const end = new Date(newValue);
end.setHours(23, 59, 59, 999); // force end of day
handleFieldChange(
`${field}_end`,
end.toISOString()
);
}}
slotProps={{
textField: { fullWidth: true, size: "small" },
}}
/>
</Stack>
)}
</Box>
))}
</Stack>
</Box>
<Stack spacing={2}>
{labels.map(({ label, field, type, options }) => (
<Box key={field}>
<Typography variant="body2" fontWeight={600} mb={0.5}>
{label}
</Typography>
{/* TEXT FIELDS */}
{type === "text" && (
<>
{/* AMOUNT WITH OPERATOR */}
{field === "Amount" ? (
<Box sx={{ display: "flex", gap: 1 }}>
<FormControl size="small" sx={{ minWidth: 130 }}>
<Select
value={operators[field] ?? ">="}
onChange={e =>
updateOperator(field, e.target.value)
}
>
<MenuItem value=">=">Greater or equal</MenuItem>
<MenuItem value="<=">Less or equal</MenuItem>
<MenuItem value="=">Equal</MenuItem>
<MenuItem value="!=">Not equal</MenuItem>
<MenuItem value=">">Greater</MenuItem>
<MenuItem value="<">Less</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
size="small"
value={formValues[field] ?? ""}
onChange={e => updateField(field, e.target.value)}
placeholder="Enter amount"
/>
</Box>
) : (
<TextField
fullWidth
size="small"
value={formValues[field] ?? ""}
onChange={e => updateField(field, e.target.value)}
/>
)}
</>
)}
{/* SELECTS */}
{type === "select" && (
<FormControl fullWidth size="small">
<Select
value={formValues[field] ?? ""}
onChange={e => updateField(field, e.target.value)}
displayEmpty
>
<MenuItem value="">
<em>{label}</em>
</MenuItem>
{options?.map(opt => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
)}
{/* DATE RANGE */}
{type === "date" && (
<Stack spacing={2}>
<DatePicker
label="Start Date"
value={
formValues[`${field}_start`]
? new Date(formValues[`${field}_start`])
: null
}
onChange={value => {
if (!value) {
updateDateRange(
field,
undefined,
formValues[`${field}_end`]
);
setFormValues(prev => ({
...prev,
[`${field}_start`]: "",
}));
return;
}
const v = new Date(value);
v.setHours(0, 0, 0, 0);
const isoString = v.toISOString();
setFormValues(prev => ({
...prev,
[`${field}_start`]: isoString,
}));
updateDateRange(
field,
isoString,
formValues[`${field}_end`]
);
}}
slotProps={{
textField: { fullWidth: true, size: "small" },
}}
/>
<DatePicker
label="End Date"
value={
formValues[`${field}_end`]
? new Date(formValues[`${field}_end`])
: null
}
onChange={value => {
if (!value) {
updateDateRange(
field,
formValues[`${field}_start`],
undefined
);
setFormValues(prev => ({
...prev,
[`${field}_end`]: "",
}));
return;
}
const v = new Date(value);
v.setHours(23, 59, 59, 999);
const isoString = v.toISOString();
setFormValues(prev => ({
...prev,
[`${field}_end`]: isoString,
}));
updateDateRange(
field,
formValues[`${field}_start`],
isoString
);
}}
slotProps={{
textField: { fullWidth: true, size: "small" },
}}
/>
</Stack>
)}
</Box>
))}
</Stack>
</LocalizationProvider>
</Box>
</Drawer>

View File

@ -0,0 +1,54 @@
// -----------------------------------------------------
// UTILITIES
// -----------------------------------------------------
export const extractOperator = (val?: string | null): string | null => {
if (!val) return null;
const match = val.match(/^(==|!=|>=|<=|LIKE|>|<)/);
return match ? match[0] : null;
};
export const formatWithOperator = (operator: string, value: string) =>
`${operator}/${value}`;
export const normalizeValue = (input: any): string => {
if (input == null) return "";
if (typeof input === "string" || typeof input === "number")
return String(input);
if (input?.value) return String(input.value);
if (input?.id) return String(input.id);
if (Array.isArray(input)) {
return input.map(normalizeValue).join(",");
}
return "";
};
export const encodeFilter = (fullValue: string): string => {
// Split ONLY on the first slash
const index = fullValue.indexOf("/");
if (index === -1) return fullValue;
const operator = fullValue.slice(0, index);
const rawValue = fullValue.slice(index + 1);
return `${operator}/${encodeURIComponent(rawValue)}`;
};
export const decodeFilter = (encoded: string): string => {
const [operator, encodedValue] = encoded.split("/");
return `${operator}/${decodeURIComponent(encodedValue)}`;
};
// Default operator based on field and type
export const defaultOperatorForField = (
field: string,
type: string
): string => {
if (field === "Amount") return ">="; // numeric field
if (type === "text") return "LIKE"; // string/text search
return "=="; // everything else (select, etc.)
};

View File

@ -1,349 +1,175 @@
"use client";
import { useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Stack,
Paper,
TextField,
Box,
IconButton,
} from "@mui/material";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import AdvancedSearch from "../AdvancedSearch/AdvancedSearch";
import SearchFilters from "@/app/components/searchFilter/SearchFilters";
import { exportData } from "@/app/utils/exportData";
import StatusChangeDialog from "./StatusChangeDialog";
import { IDataTable } from "./types";
interface IDataTableProps<TRow, TColumn> {
data: IDataTable<TRow, TColumn>;
import React, { useState, useEffect, useCallback } from "react";
import { DataGrid } from "@mui/x-data-grid";
import { Box, Paper, Alert } from "@mui/material";
import DataTableHeader from "./DataTableHeader";
import StatusChangeDialog from "./StatusChangeDialog";
import Spinner from "@/app/components/Spinner/Spinner";
import { selectStatus, selectError } from "@/app/redux/advanedSearch/selectors";
import { selectEnhancedColumns } from "./re-selectors";
import { useSelector } from "react-redux";
import { DataRowBase } from "./types";
interface DataTableProps<TRow extends DataRowBase> {
rows: TRow[];
extraColumns?: string[];
enableStatusActions?: boolean;
}
export type TWithId = { id: number };
const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
data,
}: IDataTableProps<TRow, TColumn>) => {
const { tableRows, tableColumns, tableSearchLabels, extraColumns } = data;
const router = useRouter();
const searchParams = useSearchParams();
const [rows, setRows] = useState<TRow[]>(tableRows);
const [open, setOpen] = useState(false);
const [fileType, setFileType] = useState<"csv" | "xls" | "xlsx">("csv");
const [onlyCurrentTable, setOnlyCurrentTable] = useState(false);
const DataTable = <TRow extends DataRowBase>({
rows,
extraColumns,
enableStatusActions = false,
}: DataTableProps<TRow>) => {
const [showExtraColumns, setShowExtraColumns] = useState(false);
const [localRows, setLocalRows] = useState(rows);
const [modalOpen, setModalOpen] = useState(false);
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const [newStatus, setNewStatus] = useState<string>("");
const [pendingStatus, setPendingStatus] = useState<string>("");
const [reason, setReason] = useState<string>("");
const [showExtraColumns, setShowExtraColumns] = useState(false);
const [statusUpdateError, setStatusUpdateError] = useState<string | null>(
null
);
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const filters = Object.fromEntries(searchParams.entries());
const status = useSelector(selectStatus);
const errorMessage = useSelector(selectError);
useEffect(() => {
setLocalRows(rows);
}, [rows]);
const handleClickField = (field: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(field, value);
router.push(`?${params.toString()}`);
router.refresh();
};
const handleStatusChange = (id: number, newStatus: string) => {
setSelectedRowId(id);
setNewStatus(newStatus);
const handleStatusChange = useCallback((rowId: number, newStatus: string) => {
setSelectedRowId(rowId);
setPendingStatus(newStatus);
setModalOpen(true);
};
}, []);
const handleStatusSave = () => {
console.log(
`Status changed for row with ID ${selectedRowId}. New status: ${newStatus}. Reason: ${reason}`
);
const handleStatusSave = async () => {
if (!selectedRowId || !pendingStatus) return;
setStatusUpdateError(null);
setIsUpdatingStatus(true);
setRows(
rows.map(row =>
row.id === selectedRowId ? { ...row, status: newStatus } : row
)
);
setModalOpen(false);
setReason("");
};
try {
const payload = {
data: {
status: pendingStatus,
notes: reason.trim(),
},
fields: ["Status", "Notes"],
};
const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => {
return columns.map(col => {
if (col.field === "status") {
return {
...col,
renderCell: (params: GridRenderCellParams) => {
const value = params.value?.toLowerCase();
let bgColor = "#e0e0e0";
let textColor = "#000";
switch (value) {
case "completed":
bgColor = "#d0f0c0";
textColor = "#1b5e20";
break;
case "pending":
bgColor = "#fff4cc";
textColor = "#9e7700";
break;
case "inprogress":
bgColor = "#cce5ff";
textColor = "#004085";
break;
case "error":
bgColor = "#ffcdd2";
textColor = "#c62828";
break;
}
return (
<Box
sx={{
backgroundColor: bgColor,
color: textColor,
px: 1.5,
py: 0.5,
borderRadius: 1,
fontWeight: 500,
textTransform: "capitalize",
display: "inline-block",
width: "100%",
textAlign: "center",
}}
>
{params.value}
</Box>
);
const response = await fetch(
`/api/dashboard/transactions/${selectedRowId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
};
body: JSON.stringify(payload),
}
);
const result = await response.json();
if (!response.ok) {
throw new Error(
result?.message || result?.error || "Failed to update transaction"
);
}
if (col.field === "userId") {
return {
...col,
headerAlign: "center",
align: "center",
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
width: "100%",
px: 1,
}}
onClick={e => e.stopPropagation()} // keep row click from firing when clicking inside
>
<Box
sx={{
fontWeight: 500,
fontSize: "0.875rem",
color: "text.primary",
}}
>
{params.value}
</Box>
<IconButton
href={`/users/${params.value}`}
target="_blank"
rel="noopener noreferrer"
size="small"
sx={{ p: 0.5, ml: 1 }}
onClick={e => e.stopPropagation()}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Box>
),
};
}
if (col.field === "actions") {
return {
...col,
renderCell: (params: GridRenderCellParams) => {
const row = tableRows.find(r => r.id === params.id) as {
id: number;
status?: string;
options?: { value: string; label: string }[];
};
const options = row?.options;
if (!options) return params.value;
return (
<Select
value={params.value ?? row.status}
onChange={e =>
handleStatusChange(params.id as number, e.target.value)
}
size="small"
sx={{
width: "100%",
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
"& .MuiSelect-select": { py: 0.5 },
}}
onClick={e => e.stopPropagation()}
>
{options.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
);
},
};
}
return col;
});
setLocalRows(prev =>
prev.map(row =>
row.id === selectedRowId ? { ...row, status: pendingStatus } : row
)
);
setModalOpen(false);
setReason("");
setPendingStatus("");
setStatusUpdateError(null);
setSelectedRowId(null);
} catch (err) {
setStatusUpdateError(
err instanceof Error ? err.message : "Failed to update transaction"
);
} finally {
setIsUpdatingStatus(false);
}
};
let filteredColumns = tableColumns;
if (extraColumns && extraColumns.length > 0) {
filteredColumns = showExtraColumns
? tableColumns
: tableColumns.filter(col => !extraColumns.includes(col.field));
}
// Columns with custom renderers
const enhancedColumns = useSelector(state =>
selectEnhancedColumns(
state,
enableStatusActions,
extraColumns,
showExtraColumns,
localRows,
handleStatusChange
)
);
return (
<Paper sx={{ width: "calc(100vw - 300px)", overflowX: "hidden" }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
p={2}
flexWrap="wrap"
gap={2}
>
<TextField
label="Search"
variant="outlined"
size="small"
onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
sx={{ width: 300 }}
<>
{status === "loading" && <Spinner size="small" color="#fff" />}
{status === "failed" && (
<Alert severity="error">
{errorMessage || "Failed to load transactions."}
</Alert>
)}
<Paper sx={{ width: "100%", overflowX: "hidden" }}>
<DataTableHeader
extraColumns={extraColumns}
showExtraColumns={showExtraColumns}
onToggleExtraColumns={() => setShowExtraColumns(prev => !prev)}
onOpenExport={() => {}}
/>
<AdvancedSearch labels={tableSearchLabels} />
<SearchFilters filters={filters} />
<Button
variant="outlined"
startIcon={<FileUploadIcon />}
onClick={() => setOpen(true)}
>
Export
</Button>
{extraColumns && extraColumns.length > 0 && (
<Button
variant="outlined"
onClick={() => setShowExtraColumns(prev => !prev)}
>
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
</Button>
)}
</Stack>
<Box sx={{ width: "calc(100vw - 300px)", overflowX: "auto" }}>
<Box sx={{ minWidth: 1200 }}>
<DataGrid
rows={tableRows}
columns={getColumnsWithDropdown(filteredColumns)}
initialState={{
pagination: { paginationModel: { pageSize: 50 } },
}}
pageSizeOptions={[50, 100]}
sx={{
border: 0,
cursor: "pointer",
"& .MuiDataGrid-cell": {
py: 1,
textAlign: "center",
justifyContent: "center",
display: "flex",
alignItems: "center",
},
"& .MuiDataGrid-columnHeader": {
textAlign: "center",
justifyContent: "center",
},
}}
onCellClick={params => {
if (params.field !== "actions") {
handleClickField(params.field, params.value as string);
}
}}
/>
<Box sx={{ width: "100%", overflowX: "auto" }}>
<Box sx={{ minWidth: 1200 }}>
<DataGrid
rows={localRows}
columns={enhancedColumns}
pageSizeOptions={[10, 25, 50, 100]}
sx={{
border: 0,
cursor: "pointer",
"& .MuiDataGrid-cell": {
py: 1,
textAlign: "center",
justifyContent: "center",
display: "flex",
alignItems: "center",
},
"& .MuiDataGrid-columnHeader": {
textAlign: "center",
justifyContent: "center",
},
}}
/>
</Box>
</Box>
</Box>
<StatusChangeDialog
open={modalOpen}
newStatus={newStatus}
reason={reason}
setReason={setReason}
handleClose={() => setModalOpen(false)}
handleSave={handleStatusSave}
/>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Export Transactions</DialogTitle>
<DialogContent>
<FormControl fullWidth sx={{ mt: 2 }}>
<Select
value={fileType}
onChange={e =>
setFileType(e.target.value as "csv" | "xls" | "xlsx")
}
>
<MenuItem value="csv">CSV</MenuItem>
<MenuItem value="xls">XLS</MenuItem>
<MenuItem value="xlsx">XLSX</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
checked={onlyCurrentTable}
onChange={e => setOnlyCurrentTable(e.target.checked)}
/>
}
label="Only export current table"
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={() =>
exportData(
tableRows,
tableColumns,
fileType,
onlyCurrentTable,
setOpen
)
}
>
Export
</Button>
</DialogActions>
</Dialog>
</Paper>
<StatusChangeDialog
open={modalOpen}
newStatus={pendingStatus}
reason={reason}
setReason={setReason}
handleClose={() => {
setModalOpen(false);
setReason("");
setPendingStatus("");
setStatusUpdateError(null);
setSelectedRowId(null);
}}
handleSave={handleStatusSave}
isSubmitting={isUpdatingStatus}
errorMessage={statusUpdateError}
/>
</Paper>
</>
);
};
export default DataTable;
// Memoize to avoid unnecessary re-renders
export default React.memo(DataTable);

View File

@ -0,0 +1,51 @@
import { Button, TextField, Stack } from "@mui/material";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import AdvancedSearch from "../AdvancedSearch/AdvancedSearch";
import { TABLE_SEARCH_LABELS } from "./constants";
type DataTableHeaderProps = {
extraColumns?: string[];
showExtraColumns: boolean;
onToggleExtraColumns: () => void;
onOpenExport: () => void;
};
export default function DataTableHeader({
extraColumns,
showExtraColumns,
onToggleExtraColumns,
onOpenExport,
}: DataTableHeaderProps) {
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
p={2}
flexWrap="wrap"
gap={2}
>
<TextField
label="Search = To Be Implemented"
variant="outlined"
size="small"
disabled
onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
sx={{ width: 300, backgroundColor: "#f0f0f0" }}
/>
<AdvancedSearch labels={TABLE_SEARCH_LABELS} />
<Button
variant="outlined"
startIcon={<FileUploadIcon />}
onClick={onOpenExport}
>
Export
</Button>
{extraColumns && extraColumns.length > 0 && (
<Button variant="outlined" onClick={onToggleExtraColumns}>
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
</Button>
)}
</Stack>
);
}

View File

@ -5,6 +5,8 @@ import {
DialogActions,
Button,
TextField,
Alert,
CircularProgress,
} from "@mui/material";
import { useState, useEffect } from "react";
@ -15,6 +17,8 @@ interface StatusChangeDialogProps {
setReason: React.Dispatch<React.SetStateAction<string>>;
handleClose: () => void;
handleSave: () => void;
isSubmitting?: boolean;
errorMessage?: string | null;
}
const StatusChangeDialog = ({
@ -24,17 +28,19 @@ const StatusChangeDialog = ({
setReason,
handleClose,
handleSave,
isSubmitting = false,
errorMessage,
}: StatusChangeDialogProps) => {
const [isValid, setIsValid] = useState(false);
useEffect(() => {
const noSpaces = reason.replace(/\s/g, ""); // remove all spaces
const noSpaces = reason.replace(/\s/g, "");
const length = noSpaces.length;
setIsValid(length >= 12 && length <= 400);
}, [reason]);
return (
<Dialog open={open} onClose={handleClose}>
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>Change Status</DialogTitle>
<DialogContent>
You want to change the status to <b>{newStatus}</b>. Please provide a
@ -50,10 +56,22 @@ const StatusChangeDialog = ({
helperText="Reason must be between 12 and 400 characters"
sx={{ mt: 2 }}
/>
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{errorMessage}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button variant="contained" onClick={handleSave} disabled={!isValid}>
<Button onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!isValid || isSubmitting}
startIcon={isSubmitting ? <CircularProgress size={18} /> : undefined}
>
Save
</Button>
</DialogActions>

View File

@ -0,0 +1,37 @@
import { GridColDef } from "@mui/x-data-grid";
import { ISearchLabel } from "./types";
export const TABLE_COLUMNS: GridColDef[] = [
{ field: "userId", headerName: "User ID", width: 130 },
{ field: "transactionId", headerName: "Transaction ID", width: 180 },
{ field: "type", headerName: "Type", width: 120 },
{ field: "currency", headerName: "Currency", width: 100 },
{ field: "amount", headerName: "Amount", width: 120 },
{ field: "status", headerName: "Status", width: 120 },
{ field: "dateTime", headerName: "Date / Time", width: 180 },
];
export const TABLE_SEARCH_LABELS: ISearchLabel[] = [
{ label: "User", field: "Customer", type: "text" },
{ label: "Transaction ID", field: "ExternalID", type: "text" },
{
label: "Type",
field: "Type",
type: "select",
options: ["deposit", "withdrawal"],
},
{
label: "Currency",
field: "Currency",
type: "select",
options: ["USD", "EUR", "GBP", "TRY"],
},
{
label: "Status",
field: "Status",
type: "select",
options: ["pending", "completed", "failed"],
},
{ label: "Amount", field: "Amount", type: "text" },
{ label: "Date / Time", field: "created", type: "date" },
];

View File

@ -0,0 +1,238 @@
import { createSelector } from "@reduxjs/toolkit";
import { Box, IconButton, MenuItem, Select } from "@mui/material";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { RootState } from "@/app/redux/store";
import { TABLE_COLUMNS } from "../constants";
import { selectTransactionStatuses } from "@/app/redux/metadata/selectors";
import { DataRowBase } from "../types";
const TRANSACTION_STATUS_FALLBACK: string[] = [
"pending",
"completed",
"failed",
"inprogress",
"error",
];
type StatusChangeHandler = (rowId: number, newStatus: string) => void;
const selectEnableStatusActions = (
_state: RootState,
enableStatusActions: boolean
) => enableStatusActions;
const selectExtraColumns = (
_state: RootState,
_enableStatusActions: boolean,
extraColumns?: string[] | null
) => extraColumns ?? null;
const selectShowExtraColumns = (
_state: RootState,
_enableStatusActions: boolean,
_extraColumns?: string[] | null,
showExtraColumns = false
) => showExtraColumns;
const selectLocalRows = (
_state: RootState,
_enableStatusActions: boolean,
_extraColumns?: string[] | null,
_showExtraColumns?: boolean,
localRows?: DataRowBase[]
) => localRows ?? [];
const noopStatusChangeHandler: StatusChangeHandler = () => {};
const selectStatusChangeHandler = (
_state: RootState,
_enableStatusActions: boolean,
_extraColumns?: string[] | null,
_showExtraColumns?: boolean,
_localRows?: DataRowBase[],
handleStatusChange?: StatusChangeHandler
) => handleStatusChange ?? noopStatusChangeHandler;
export const selectBaseColumns = createSelector(
[selectEnableStatusActions],
enableStatusActions => {
if (!enableStatusActions) {
return TABLE_COLUMNS;
}
return [
...TABLE_COLUMNS,
{
field: "actions",
headerName: "Actions",
width: 160,
sortable: false,
filterable: false,
} as GridColDef,
];
}
);
export const selectVisibleColumns = createSelector(
[selectBaseColumns, selectExtraColumns, selectShowExtraColumns],
(baseColumns, extraColumns, showExtraColumns) => {
if (!extraColumns || extraColumns.length === 0) {
return baseColumns;
}
return showExtraColumns
? baseColumns
: baseColumns.filter(col => !extraColumns.includes(col.field));
}
);
export const selectResolvedTransactionStatuses = createSelector(
[selectTransactionStatuses],
statuses => (statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK)
);
export const selectEnhancedColumns = createSelector(
[
selectVisibleColumns,
selectLocalRows,
selectStatusChangeHandler,
selectResolvedTransactionStatuses,
],
(
visibleColumns,
localRows,
handleStatusChange,
resolvedStatusOptions
): GridColDef[] => {
return visibleColumns.map(col => {
if (col.field === "status") {
return {
...col,
renderCell: (params: GridRenderCellParams) => {
const value = params.value?.toLowerCase();
let bgColor = "#e0e0e0";
let textColor = "#000";
switch (value) {
case "completed":
bgColor = "#d0f0c0";
textColor = "#1b5e20";
break;
case "pending":
bgColor = "#fff4cc";
textColor = "#9e7700";
break;
case "inprogress":
bgColor = "#cce5ff";
textColor = "#004085";
break;
case "error":
bgColor = "#ffcdd2";
textColor = "#c62828";
break;
}
return (
<Box
sx={{
backgroundColor: bgColor,
color: textColor,
px: 1.5,
py: 0.5,
borderRadius: 1,
fontWeight: 500,
textTransform: "capitalize",
display: "inline-block",
width: "100%",
textAlign: "center",
}}
>
{params.value}
</Box>
);
},
};
}
if (col.field === "userId") {
return {
...col,
headerAlign: "center",
align: "center",
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{
display: "grid",
gridTemplateColumns: "1fr auto",
alignItems: "center",
width: "100%",
px: 1,
}}
onClick={e => e.stopPropagation()}
>
<Box
sx={{
fontWeight: 500,
fontSize: "0.875rem",
color: "text.primary",
}}
>
{params.value}
</Box>
<IconButton
href={`/users/${params.value}`}
target="_blank"
rel="noopener noreferrer"
size="small"
sx={{ p: 0.5, ml: 1 }}
onClick={e => e.stopPropagation()}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Box>
),
};
}
if (col.field === "actions") {
return {
...col,
renderCell: (params: GridRenderCellParams) => {
const currentRow = localRows.find(row => row.id === params.id);
const options =
currentRow?.options?.map(option => option.value) ??
resolvedStatusOptions;
const uniqueOptions: string[] = Array.from(new Set(options));
return (
<Select<string>
value={currentRow?.status ?? ""}
onChange={e =>
handleStatusChange(
params.id as number,
e.target.value as string
)
}
size="small"
fullWidth
displayEmpty
sx={{
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
"& .MuiSelect-select": { py: 0.5 },
}}
onClick={e => e.stopPropagation()}
>
{uniqueOptions.map(option => (
<MenuItem key={option} value={option}>
{option}
</MenuItem>
))}
</Select>
);
},
};
}
return col;
}) as GridColDef[];
}
);

View File

@ -11,3 +11,9 @@ export interface IDataTable<TRow, TColumn> {
tableSearchLabels: ISearchLabel[];
extraColumns: string[];
}
export interface DataRowBase {
id: number;
status?: string;
options?: { value: string; label: string }[];
}

View File

@ -161,7 +161,7 @@ export default function UserRoleCard({ user }: Props) {
<Modal
open={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
title="Reset Password"
title={`Reset Password - ${user.first_name}`}
>
{newPassword && (
<div className="reset-password__content">

View File

@ -7,7 +7,6 @@ import {
SelectChangeEvent,
} from "@mui/material";
import PageLinks from "../../../../components/PageLinks/PageLinks";
import { SidebarItem } from "@/app/redux/metadata/metadataSlice";
import { useSelector } from "react-redux";
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
import "./DropDown.scss";
@ -24,8 +23,6 @@ export default function SidebarDropdown({ onChange }: Props) {
onChange?.(event);
};
console.log("sidebar", sidebar);
return (
<FormControl fullWidth variant="outlined" sx={{ minWidth: 200 }}>
<InputLabel id="sidebar-dropdown-label">Navigate To</InputLabel>

View File

@ -9,7 +9,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
import { SidebarItem } from "@/app/redux/metadata/metadataSlice";
import { SidebarLink } from "@/app/redux/metadata/metadataSlice";
import { resolveIcon } from "@/app/utils/iconMap";
import "./sideBar.scss";
@ -20,7 +20,7 @@ interface SidebarProps {
const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
const sidebar = useSelector(selectNavigationSidebar)?.links;
const sidebar = useSelector(selectNavigationSidebar);
const toggleMenu = (title: string) => {
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
};
@ -32,12 +32,12 @@ const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
</button>
<div className="sidebar__header">
<span>
Betrise cashier
Cashier
<DashboardIcon fontSize="small" className="sidebar__icon-spacing" />
</span>
</div>
{sidebar?.map((link: SidebarItem) => {
{sidebar?.map((link: SidebarLink) => {
if (link.children) {
const Icon = resolveIcon(link.icon as string);
return (

View File

@ -10,13 +10,22 @@
flex-direction: column;
padding: 16px;
z-index: 1100;
border-right: 1px solid #333;
border-right: 1px solid #835454;
transition: transform 0.3s ease-in-out;
overflow: hidden;
.sidebar__submenu {
overflow: hidden;
max-height: 200px;
transition: max-height 0.3s ease-in-out;
overflow-y: auto;
&:hover {
max-height: 300px;
}
}
&--collapsed {
transform: translateX(-210px); // Hide 90% (210px out of 240px)
.sidebar__header,
.sidebar__dropdown-button,
.sidebar__submenu,
@ -153,3 +162,9 @@
display: none;
}
}
/* Track (background behind the thumb) */
.scrollable-div::-webkit-scrollbar-track {
background: #f0f0f0;
border-radius: 5px;
}

View File

@ -1,53 +1,106 @@
import { createSlice } from "@reduxjs/toolkit";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface AdvancedSearchState {
keyword: string;
transactionID: string;
transactionReferenceId: string;
user: string;
currency: string;
state: string;
statusDescription: string;
transactionType: string;
paymentMethod: string;
psps: string;
initialPsps: string;
merchants: string;
startDate: null | string;
endDate: null | string;
lastUpdatedFrom: null | string;
lastUpdatedTo: null | string;
minAmount: string;
maxAmount: string;
channel: string;
// Filter value can be a simple string or an object with operator and value
export interface FilterValue {
operator: string;
value: string;
}
export type FilterField = string | FilterValue;
export interface AdvancedSearchFilters {
[field: string]: FilterField | undefined;
}
export type FetchStatus = "idle" | "loading" | "succeeded" | "failed";
export interface AdvancedSearchState {
filters: AdvancedSearchFilters;
pagination: {
page: number;
limit: number;
};
sort?: {
field: string;
order: "asc" | "desc";
};
status: FetchStatus;
error: string | null;
}
const initialState: AdvancedSearchState = {
keyword: "",
transactionID: "",
transactionReferenceId: "",
user: "",
currency: "",
state: "",
statusDescription: "",
transactionType: "",
paymentMethod: "",
psps: "",
initialPsps: "",
merchants: "",
startDate: null,
endDate: null,
lastUpdatedFrom: null,
lastUpdatedTo: null,
minAmount: "",
maxAmount: "",
channel: "",
filters: {},
pagination: {
page: 1,
limit: 10,
},
status: "idle",
error: null,
};
const advancedSearchSlice = createSlice({
name: "advancedSearch",
initialState,
reducers: {},
reducers: {
setFilters: (state, action: PayloadAction<AdvancedSearchFilters>) => {
state.filters = action.payload;
// Reset to page 1 when filters change
state.pagination.page = 1;
},
updateFilter: (
state,
action: PayloadAction<{ field: string; value: FilterField | undefined }>
) => {
const { field, value } = action.payload;
if (value === undefined || value === "") {
delete state.filters[field];
} else {
state.filters[field] = value;
}
// Reset to page 1 when a filter changes
state.pagination.page = 1;
},
clearFilters: state => {
state.filters = {};
state.pagination.page = 1;
},
setPagination: (
state,
action: PayloadAction<{ page: number; limit: number }>
) => {
state.pagination = action.payload;
},
setSort: (
state,
action: PayloadAction<
{ field: string; order: "asc" | "desc" } | undefined
>
) => {
state.sort = action.payload;
},
setStatus: (state, action: PayloadAction<FetchStatus>) => {
state.status = action.payload;
if (action.payload === "loading") {
state.error = null;
}
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
if (action.payload) {
state.status = "failed";
}
},
},
});
export const {
setFilters,
updateFilter,
clearFilters,
setPagination,
setSort,
setStatus,
setError,
} = advancedSearchSlice.actions;
export default advancedSearchSlice.reducer;

View File

@ -0,0 +1,24 @@
import { RootState } from "../store";
import {
AdvancedSearchFilters,
FilterField,
FetchStatus,
} from "./advancedSearchSlice";
export const selectFilters = (state: RootState): AdvancedSearchFilters =>
state.advancedSearch.filters;
export const selectPagination = (state: RootState) =>
state.advancedSearch.pagination;
export const selectSort = (state: RootState) => state.advancedSearch.sort;
export const selectFilterValue = (
state: RootState,
field: string
): FilterField | undefined => state.advancedSearch.filters[field];
export const selectStatus = (state: RootState): FetchStatus =>
state.advancedSearch.status;
export const selectError = (state: RootState) => state.advancedSearch.error;

View File

@ -1,21 +1,30 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface SidebarItem {
export interface SidebarLink {
id?: string;
title: string;
path: string;
icon?: string; // icon name from backend; map client-side if needed
permissions?: string[]; // required permissions for visibility
children?: SidebarItem[];
icon?: string;
groups?: string[];
children?: SidebarLink[];
}
export interface SidebarPayload {
links: SidebarLink[];
}
export type FieldGroupMap = Record<string, Record<string, string>>;
export interface AppMetadata {
groups?: string[];
job_titles?: string[];
merchants?: string[];
message?: string;
sidebar?: SidebarItem[];
success: boolean;
message?: string;
field_names?: FieldGroupMap;
job_titles?: string[];
groups?: string[];
merchants?: string[];
countries?: string[];
sidebar?: SidebarPayload;
transaction_status?: string[];
}
interface MetadataState {

View File

@ -1,7 +1,35 @@
import { RootState } from "../store";
import { FieldGroupMap, SidebarLink } from "./metadataSlice";
// Selectors
export const selectMetadataState = (state: RootState) => state.metadata;
export const selectAppMetadata = (state: RootState) => state.metadata.data;
export const selectNavigationSidebar = (state: RootState) =>
state.metadata.data?.sidebar || [];
export const selectMetadataStatus = (state: RootState) =>
state.metadata?.status;
export const selectMetadataError = (state: RootState) => state.metadata?.error;
export const selectAppMetadata = (state: RootState) => state.metadata?.data;
export const selectFieldNames = (state: RootState): FieldGroupMap | undefined =>
state.metadata.data?.field_names;
export const selectSidebarLinks = (state: RootState): SidebarLink[] =>
state.metadata.data?.sidebar?.links ?? [];
export const selectJobTitles = (state: RootState): string[] =>
state.metadata.data?.job_titles ?? [];
export const selectGroups = (state: RootState): string[] =>
state.metadata.data?.groups ?? [];
export const selectMerchants = (state: RootState): string[] =>
state.metadata.data?.merchants ?? [];
export const selectCountries = (state: RootState): string[] =>
state.metadata.data?.countries ?? [];
export const selectTransactionStatuses = (state: RootState): string[] =>
state.metadata.data?.transaction_status ?? [];
export const selectNavigationSidebar = (state: RootState): SidebarLink[] =>
state.metadata.data?.sidebar?.links ?? [];

View File

@ -7,6 +7,25 @@ export type AppDispatch = typeof store.dispatch;
export type ThunkSuccess<T extends object = object> = { message: string } & T;
export type ThunkError = string;
//User related types
export type TGroupName = "Super Admin" | "Admin" | "Reader";
export type TJobTitle =
| "C-Level"
| "Admin"
| "Operations"
| "Support"
| "KYC"
| "Payments"
| "Risk"
| "Finance"
| "Trading"
| "Compliance"
| "DevOps"
| "Software Engineer";
export type TMerchantName = "Data Spin" | "Win Bot";
export interface IUserResponse {
success: boolean;
token: string;
@ -17,6 +36,8 @@ export interface IUserResponse {
lastName: string | null;
merchantId?: number | null;
username?: string | null;
groups?: TGroupName[];
merchants?: TMerchantName[];
phone?: string | null;
jobTitle?: string | null;
enabled?: boolean;

View File

@ -1,18 +1,41 @@
export async function getAudits({ query }: { query: string }) {
const res = await fetch(
`http://localhost:4000/api/dashboard/audits?${query}`,
interface GetAuditsParams {
limit?: number;
page?: number;
sort?: string;
filter?: string;
signal?: AbortSignal;
}
export async function getAudits({
limit,
page,
sort,
filter,
signal,
}: GetAuditsParams = {}) {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (page) params.set("page", String(page));
if (sort) params.set("sort", sort);
if (filter) params.set("filter", filter);
const queryString = params.toString();
const response = await fetch(
`/api/dashboard/audits${queryString ? `?${queryString}` : ""}`,
{
method: "GET",
cache: "no-store",
signal,
}
);
if (!res.ok) {
// Handle error from the API
const errorData = await res
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Unknown error" }));
throw new Error(errorData.message || `HTTP error! status: ${res.status}`);
throw new Error(errorData.message || "Failed to fetch audits");
}
return res.json();
return response.json();
}

View File

@ -18,9 +18,16 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import HistoryIcon from "@mui/icons-material/History";
import FactCheckIcon from "@mui/icons-material/FactCheck";
import WidgetsIcon from "@mui/icons-material/Widgets"; // fallback
import GroupIcon from "@mui/icons-material/Group";
import SecurityIcon from "@mui/icons-material/Security";
import TimerIcon from "@mui/icons-material/Timer";
import StoreIcon from "@mui/icons-material/Store";
import AccountBalanceIcon from "@mui/icons-material/AccountBalance";
import PaymentIcon from "@mui/icons-material/Payment";
import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange";
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
// Map string keys from backend to actual Icon components
const iconRegistry: Record<string, ElementType> = {
const IconMap = {
HomeIcon,
AccountBalanceWalletIcon,
CheckCircleIcon,
@ -37,6 +44,21 @@ const iconRegistry: Record<string, ElementType> = {
ArrowUpwardIcon,
HistoryIcon,
FactCheckIcon,
WidgetsIcon,
GroupIcon,
SecurityIcon,
TimerIcon,
StoreIcon,
AccountBalanceIcon,
PaymentIcon,
CurrencyExchangeIcon,
ViewSidebarIcon,
};
export default IconMap;
// Map string keys from backend to actual Icon components
const iconRegistry: Record<string, ElementType> = {
...IconMap,
};
export function resolveIcon(