Merge pull request 'feat/transaction-tables' (#2) from feat/transaction-tables into main
Reviewed-on: #2
This commit is contained in:
commit
96983c6cec
@ -5,7 +5,6 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { AppDispatch } from "@/app/redux/types";
|
import { AppDispatch } from "@/app/redux/types";
|
||||||
import { validateAuth } from "./redux/auth/authSlice";
|
import { validateAuth } from "./redux/auth/authSlice";
|
||||||
import { fetchMetadata } from "./redux/metadata/metadataSlice";
|
|
||||||
export function AuthBootstrap() {
|
export function AuthBootstrap() {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
|
|||||||
@ -71,7 +71,6 @@ export async function PUT(
|
|||||||
|
|
||||||
// Transform the request body to match backend format
|
// Transform the request body to match backend format
|
||||||
const transformedBody = transformUserUpdateData(body);
|
const transformedBody = transformUserUpdateData(body);
|
||||||
console.log("[PUT /api/v1/users/{id}] - transformed body", transformedBody);
|
|
||||||
|
|
||||||
// Get the auth token from cookies
|
// Get the auth token from cookies
|
||||||
const { cookies } = await import("next/headers");
|
const { cookies } = await import("next/headers");
|
||||||
|
|||||||
@ -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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,69 +1,80 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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) {
|
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");
|
if (!token) {
|
||||||
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 it’s correct
|
|
||||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ message: "Missing Authorization header" },
|
||||||
error: "Invalid date range",
|
{ status: 401 }
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredRows = filteredRows.filter(tx => {
|
const { searchParams } = new URL(request.url);
|
||||||
const txDate = new Date(tx.timeStampOfTheAction);
|
const proxiedParams = new URLSearchParams();
|
||||||
|
|
||||||
// Validate if the timestamp is a valid date
|
// Forward provided params
|
||||||
if (isNaN(txDate.getTime())) {
|
searchParams.forEach((value, key) => {
|
||||||
return false; // Skip invalid dates
|
if (value == null || value === "") return;
|
||||||
}
|
proxiedParams.append(key, value);
|
||||||
|
|
||||||
return txDate >= start && txDate <= end;
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
if (!proxiedParams.has("limit")) {
|
||||||
tableRows: filteredRows,
|
proxiedParams.set("limit", DEFAULT_LIMIT);
|
||||||
tableColumns: AuditColumns,
|
}
|
||||||
tableSearchLabels: AuditSearchLabels,
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/api/dashboard/transactions/[id]/route.ts
Normal file
44
app/api/dashboard/transactions/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" },
|
|
||||||
];
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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" },
|
|
||||||
];
|
|
||||||
@ -1,82 +1,108 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
||||||
depositTransactionDummyData,
|
const COOKIE_NAME = "auth_token";
|
||||||
depositTransactionsColumns,
|
|
||||||
depositTransactionsSearchLabels,
|
|
||||||
// extraColumns
|
|
||||||
} from "./mockData";
|
|
||||||
// import { formatToDateTimeString } from "@/app/utils/formatDate";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
type FilterValue =
|
||||||
const { searchParams } = new URL(request.url);
|
| string
|
||||||
|
| {
|
||||||
|
operator?: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
const status = searchParams.get("status");
|
export async function POST(request: NextRequest) {
|
||||||
const userId = searchParams.get("userId");
|
try {
|
||||||
const depositMethod = searchParams.get("depositMethod");
|
const { cookies } = await import("next/headers");
|
||||||
const merchandId = searchParams.get("merchandId");
|
const cookieStore = await cookies();
|
||||||
const transactionId = searchParams.get("transactionId");
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||||
// const dateTime = searchParams.get("dateTime");
|
|
||||||
|
|
||||||
const dateTimeStart = searchParams.get("dateTime_start");
|
if (!token) {
|
||||||
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())) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ message: "Missing Authorization header" },
|
||||||
error: "Invalid date range",
|
{ status: 401 }
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTransactions = filteredTransactions.filter(tx => {
|
const body = await request.json();
|
||||||
const txDate = new Date(tx.dateTime);
|
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
||||||
|
|
||||||
if (isNaN(txDate.getTime())) {
|
// Force deposits filter while allowing other filters to stack
|
||||||
return false;
|
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({
|
const encodedValue = encodeURIComponent(value);
|
||||||
tableRows: filteredTransactions,
|
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
|
||||||
tableSearchLabels: depositTransactionsSearchLabels,
|
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
|
||||||
tableColumns: depositTransactionsColumns,
|
|
||||||
});
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
99
app/api/dashboard/transactions/route.ts
Normal file
99
app/api/dashboard/transactions/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,8 +28,6 @@ export async function GET() {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
console.log("metadata", data);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
|||||||
42
app/dashboard/audits/page.scss
Normal file
42
app/dashboard/audits/page.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,22 +1,293 @@
|
|||||||
import DataTable from "@/app/features/DataTable/DataTable";
|
"use client";
|
||||||
import { getAudits } from "@/app/services/audits";
|
|
||||||
|
|
||||||
export default async function AuditPage({
|
import { useEffect, useMemo, useState } from "react";
|
||||||
searchParams,
|
import {
|
||||||
}: {
|
DataGrid,
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
GridColDef,
|
||||||
}) {
|
GridPaginationModel,
|
||||||
// Await searchParams before processing
|
GridSortModel,
|
||||||
const params = await searchParams;
|
} from "@mui/x-data-grid";
|
||||||
// Create a safe query string by filtering only string values
|
import { getAudits } from "@/app/services/audits";
|
||||||
const safeParams: Record<string, string> = {};
|
import "./page.scss";
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (typeof value === "string") {
|
type AuditRow = Record<string, unknown> & { id: string | number };
|
||||||
safeParams[key] = value;
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import DataTable from "@/app/features/DataTable/DataTable";
|
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({
|
export default function AllTransactionPage() {
|
||||||
searchParams,
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
}: {
|
const filters = useSelector(selectFilters);
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
const pagination = useSelector(selectPagination);
|
||||||
}) {
|
const sort = useSelector(selectSort);
|
||||||
// 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 });
|
|
||||||
|
|
||||||
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import DataTable from "@/app/features/DataTable/DataTable";
|
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({
|
export default function DepositTransactionPage() {
|
||||||
searchParams,
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
}: {
|
const filters = useSelector(selectFilters);
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
const pagination = useSelector(selectPagination);
|
||||||
}) {
|
const sort = useSelector(selectSort);
|
||||||
// Await searchParams before processing
|
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
|
||||||
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 });
|
|
||||||
|
|
||||||
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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/dashboard/transactions/interface.ts
Normal file
29
app/dashboard/transactions/interface.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
TextField,
|
TextField,
|
||||||
@ -16,57 +17,158 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
|||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
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 { useState, useEffect, useMemo } from "react";
|
||||||
import { ISearchLabel } from "../DataTable/types";
|
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[] }) {
|
export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||||
const searchParams = useSearchParams();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const router = useRouter();
|
const filters = useSelector(selectFilters);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Local form state for UI (synced with Redux)
|
||||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
|
const [operators, setOperators] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// -----------------------------------------------------
|
||||||
|
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
|
||||||
|
// -----------------------------------------------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initialParams = Object.fromEntries(searchParams.entries());
|
const values: Record<string, string> = {};
|
||||||
setFormValues(initialParams);
|
const ops: Record<string, string> = {};
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
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>) => {
|
debounce(
|
||||||
const updatedParams = new URLSearchParams();
|
(field: string, value: string | undefined, operator?: string) => {
|
||||||
Object.entries(newValues).forEach(([key, value]) => {
|
if (!value || value === "") {
|
||||||
if (value) updatedParams.set(key, value);
|
dispatch(updateFilter({ field, value: undefined }));
|
||||||
});
|
return;
|
||||||
router.push(`?${updatedParams.toString()}`);
|
}
|
||||||
}, 500),
|
|
||||||
[router]
|
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 };
|
// handlers
|
||||||
console.log(updatedValues);
|
// -----------------------------------------------------
|
||||||
setFormValues(updatedValues);
|
|
||||||
updateURL(updatedValues);
|
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 = () => {
|
const resetForm = () => {
|
||||||
setFormValues({});
|
setFormValues({});
|
||||||
router.push("?");
|
setOperators({});
|
||||||
|
dispatch(clearFilters());
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDrawer =
|
// -----------------------------------------------------
|
||||||
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
// render
|
||||||
if (
|
// -----------------------------------------------------
|
||||||
event.type === "keydown" &&
|
|
||||||
((event as React.KeyboardEvent).key === "Tab" ||
|
|
||||||
(event as React.KeyboardEvent).key === "Shift")
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ width: "185px" }}>
|
<Box sx={{ width: "185px" }}>
|
||||||
<Button
|
<Button
|
||||||
@ -79,134 +181,211 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
|||||||
boxShadow: "inset 0 0 0 1px #ddd",
|
boxShadow: "inset 0 0 0 1px #ddd",
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
fontSize: "16px",
|
fontSize: "16px",
|
||||||
justifyContent: "flex-start",
|
"&:hover": { backgroundColor: "#e0e0e0" },
|
||||||
"& .MuiButton-startIcon": {
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
"&:hover": {
|
|
||||||
backgroundColor: "#e0e0e0",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
startIcon={<SearchIcon />}
|
startIcon={<SearchIcon />}
|
||||||
onClick={toggleDrawer(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
Advanced Search
|
Advanced Search
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Drawer anchor="right" open={open} onClose={toggleDrawer(false)}>
|
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
|
||||||
<Box sx={{ width: 400 }} role="presentation">
|
<Box sx={{ width: 400 }} p={2}>
|
||||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
<Box p={2}>
|
<Box
|
||||||
<Box sx={{ display: "flex", gap: "60px" }}>
|
sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}
|
||||||
<Typography variant="h6" gutterBottom>
|
>
|
||||||
Search
|
<Typography variant="h6">Search</Typography>
|
||||||
</Typography>
|
|
||||||
<Box display="flex" justifyContent="flex-end" gap={2}>
|
<Box display="flex" gap={2}>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<SearchIcon />}
|
startIcon={<SearchIcon />}
|
||||||
onClick={() => console.log("Params:", formValues)}
|
onClick={() => {
|
||||||
>
|
// Apply all current form values to Redux
|
||||||
Apply Filter
|
labels.forEach(({ field, type }) => {
|
||||||
</Button>
|
const val = formValues[field];
|
||||||
<Button
|
if (!val) return;
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
if (type === "select" || type === "text") {
|
||||||
onClick={resetForm}
|
const operator =
|
||||||
/>
|
operators[field] ??
|
||||||
</Box>
|
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>
|
</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>
|
</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>
|
</LocalizationProvider>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
54
app/features/AdvancedSearch/utils/utils.ts
Normal file
54
app/features/AdvancedSearch/utils/utils.ts
Normal 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.)
|
||||||
|
};
|
||||||
@ -1,349 +1,175 @@
|
|||||||
"use client";
|
"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> {
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
data: IDataTable<TRow, TColumn>;
|
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 DataRowBase>({
|
||||||
|
rows,
|
||||||
const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
extraColumns,
|
||||||
data,
|
enableStatusActions = false,
|
||||||
}: IDataTableProps<TRow, TColumn>) => {
|
}: DataTableProps<TRow>) => {
|
||||||
const { tableRows, tableColumns, tableSearchLabels, extraColumns } = data;
|
const [showExtraColumns, setShowExtraColumns] = useState(false);
|
||||||
const router = useRouter();
|
const [localRows, setLocalRows] = useState(rows);
|
||||||
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 [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||||
const [newStatus, setNewStatus] = useState<string>("");
|
const [pendingStatus, setPendingStatus] = useState<string>("");
|
||||||
const [reason, setReason] = 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 handleStatusChange = useCallback((rowId: number, newStatus: string) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
setSelectedRowId(rowId);
|
||||||
params.set(field, value);
|
setPendingStatus(newStatus);
|
||||||
router.push(`?${params.toString()}`);
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusChange = (id: number, newStatus: string) => {
|
|
||||||
setSelectedRowId(id);
|
|
||||||
setNewStatus(newStatus);
|
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleStatusSave = () => {
|
const handleStatusSave = async () => {
|
||||||
console.log(
|
if (!selectedRowId || !pendingStatus) return;
|
||||||
`Status changed for row with ID ${selectedRowId}. New status: ${newStatus}. Reason: ${reason}`
|
setStatusUpdateError(null);
|
||||||
);
|
setIsUpdatingStatus(true);
|
||||||
|
|
||||||
setRows(
|
try {
|
||||||
rows.map(row =>
|
const payload = {
|
||||||
row.id === selectedRowId ? { ...row, status: newStatus } : row
|
data: {
|
||||||
)
|
status: pendingStatus,
|
||||||
);
|
notes: reason.trim(),
|
||||||
setModalOpen(false);
|
},
|
||||||
setReason("");
|
fields: ["Status", "Notes"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => {
|
const response = await fetch(
|
||||||
return columns.map(col => {
|
`/api/dashboard/transactions/${selectedRowId}`,
|
||||||
if (col.field === "status") {
|
{
|
||||||
return {
|
method: "PUT",
|
||||||
...col,
|
headers: {
|
||||||
renderCell: (params: GridRenderCellParams) => {
|
"Content-Type": "application/json",
|
||||||
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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
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") {
|
setLocalRows(prev =>
|
||||||
return {
|
prev.map(row =>
|
||||||
...col,
|
row.id === selectedRowId ? { ...row, status: pendingStatus } : row
|
||||||
headerAlign: "center",
|
)
|
||||||
align: "center",
|
);
|
||||||
renderCell: (params: GridRenderCellParams) => (
|
setModalOpen(false);
|
||||||
<Box
|
setReason("");
|
||||||
sx={{
|
setPendingStatus("");
|
||||||
display: "grid",
|
setStatusUpdateError(null);
|
||||||
gridTemplateColumns: "1fr auto",
|
setSelectedRowId(null);
|
||||||
alignItems: "center",
|
} catch (err) {
|
||||||
width: "100%",
|
setStatusUpdateError(
|
||||||
px: 1,
|
err instanceof Error ? err.message : "Failed to update transaction"
|
||||||
}}
|
);
|
||||||
onClick={e => e.stopPropagation()} // keep row click from firing when clicking inside
|
} finally {
|
||||||
>
|
setIsUpdatingStatus(false);
|
||||||
<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;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let filteredColumns = tableColumns;
|
// Columns with custom renderers
|
||||||
if (extraColumns && extraColumns.length > 0) {
|
const enhancedColumns = useSelector(state =>
|
||||||
filteredColumns = showExtraColumns
|
selectEnhancedColumns(
|
||||||
? tableColumns
|
state,
|
||||||
: tableColumns.filter(col => !extraColumns.includes(col.field));
|
enableStatusActions,
|
||||||
}
|
extraColumns,
|
||||||
|
showExtraColumns,
|
||||||
|
localRows,
|
||||||
|
handleStatusChange
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ width: "calc(100vw - 300px)", overflowX: "hidden" }}>
|
<>
|
||||||
<Stack
|
{status === "loading" && <Spinner size="small" color="#fff" />}
|
||||||
direction="row"
|
{status === "failed" && (
|
||||||
justifyContent="space-between"
|
<Alert severity="error">
|
||||||
alignItems="center"
|
{errorMessage || "Failed to load transactions."}
|
||||||
p={2}
|
</Alert>
|
||||||
flexWrap="wrap"
|
)}
|
||||||
gap={2}
|
<Paper sx={{ width: "100%", overflowX: "hidden" }}>
|
||||||
>
|
<DataTableHeader
|
||||||
<TextField
|
extraColumns={extraColumns}
|
||||||
label="Search"
|
showExtraColumns={showExtraColumns}
|
||||||
variant="outlined"
|
onToggleExtraColumns={() => setShowExtraColumns(prev => !prev)}
|
||||||
size="small"
|
onOpenExport={() => {}}
|
||||||
onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
|
|
||||||
sx={{ width: 300 }}
|
|
||||||
/>
|
/>
|
||||||
<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={{ width: "100%", overflowX: "auto" }}>
|
||||||
<Box sx={{ minWidth: 1200 }}>
|
<Box sx={{ minWidth: 1200 }}>
|
||||||
<DataGrid
|
<DataGrid
|
||||||
rows={tableRows}
|
rows={localRows}
|
||||||
columns={getColumnsWithDropdown(filteredColumns)}
|
columns={enhancedColumns}
|
||||||
initialState={{
|
pageSizeOptions={[10, 25, 50, 100]}
|
||||||
pagination: { paginationModel: { pageSize: 50 } },
|
sx={{
|
||||||
}}
|
border: 0,
|
||||||
pageSizeOptions={[50, 100]}
|
cursor: "pointer",
|
||||||
sx={{
|
"& .MuiDataGrid-cell": {
|
||||||
border: 0,
|
py: 1,
|
||||||
cursor: "pointer",
|
textAlign: "center",
|
||||||
"& .MuiDataGrid-cell": {
|
justifyContent: "center",
|
||||||
py: 1,
|
display: "flex",
|
||||||
textAlign: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
},
|
||||||
display: "flex",
|
"& .MuiDataGrid-columnHeader": {
|
||||||
alignItems: "center",
|
textAlign: "center",
|
||||||
},
|
justifyContent: "center",
|
||||||
"& .MuiDataGrid-columnHeader": {
|
},
|
||||||
textAlign: "center",
|
}}
|
||||||
justifyContent: "center",
|
/>
|
||||||
},
|
</Box>
|
||||||
}}
|
|
||||||
onCellClick={params => {
|
|
||||||
if (params.field !== "actions") {
|
|
||||||
handleClickField(params.field, params.value as string);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<StatusChangeDialog
|
<StatusChangeDialog
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
newStatus={newStatus}
|
newStatus={pendingStatus}
|
||||||
reason={reason}
|
reason={reason}
|
||||||
setReason={setReason}
|
setReason={setReason}
|
||||||
handleClose={() => setModalOpen(false)}
|
handleClose={() => {
|
||||||
handleSave={handleStatusSave}
|
setModalOpen(false);
|
||||||
/>
|
setReason("");
|
||||||
|
setPendingStatus("");
|
||||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
setStatusUpdateError(null);
|
||||||
<DialogTitle>Export Transactions</DialogTitle>
|
setSelectedRowId(null);
|
||||||
<DialogContent>
|
}}
|
||||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
handleSave={handleStatusSave}
|
||||||
<Select
|
isSubmitting={isUpdatingStatus}
|
||||||
value={fileType}
|
errorMessage={statusUpdateError}
|
||||||
onChange={e =>
|
/>
|
||||||
setFileType(e.target.value as "csv" | "xls" | "xlsx")
|
</Paper>
|
||||||
}
|
</>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DataTable;
|
// Memoize to avoid unnecessary re-renders
|
||||||
|
export default React.memo(DataTable);
|
||||||
|
|||||||
51
app/features/DataTable/DataTableHeader.tsx
Normal file
51
app/features/DataTable/DataTableHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,8 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
Button,
|
Button,
|
||||||
TextField,
|
TextField,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
@ -15,6 +17,8 @@ interface StatusChangeDialogProps {
|
|||||||
setReason: React.Dispatch<React.SetStateAction<string>>;
|
setReason: React.Dispatch<React.SetStateAction<string>>;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
handleSave: () => void;
|
handleSave: () => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
errorMessage?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusChangeDialog = ({
|
const StatusChangeDialog = ({
|
||||||
@ -24,17 +28,19 @@ const StatusChangeDialog = ({
|
|||||||
setReason,
|
setReason,
|
||||||
handleClose,
|
handleClose,
|
||||||
handleSave,
|
handleSave,
|
||||||
|
isSubmitting = false,
|
||||||
|
errorMessage,
|
||||||
}: StatusChangeDialogProps) => {
|
}: StatusChangeDialogProps) => {
|
||||||
const [isValid, setIsValid] = useState(false);
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const noSpaces = reason.replace(/\s/g, ""); // remove all spaces
|
const noSpaces = reason.replace(/\s/g, "");
|
||||||
const length = noSpaces.length;
|
const length = noSpaces.length;
|
||||||
setIsValid(length >= 12 && length <= 400);
|
setIsValid(length >= 12 && length <= 400);
|
||||||
}, [reason]);
|
}, [reason]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose}>
|
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>Change Status</DialogTitle>
|
<DialogTitle>Change Status</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
You want to change the status to <b>{newStatus}</b>. Please provide a
|
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"
|
helperText="Reason must be between 12 and 400 characters"
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
/>
|
/>
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleClose}>Cancel</Button>
|
<Button onClick={handleClose} disabled={isSubmitting}>
|
||||||
<Button variant="contained" onClick={handleSave} disabled={!isValid}>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
startIcon={isSubmitting ? <CircularProgress size={18} /> : undefined}
|
||||||
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|||||||
37
app/features/DataTable/constants.ts
Normal file
37
app/features/DataTable/constants.ts
Normal 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" },
|
||||||
|
];
|
||||||
238
app/features/DataTable/re-selectors/index.tsx
Normal file
238
app/features/DataTable/re-selectors/index.tsx
Normal 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[];
|
||||||
|
}
|
||||||
|
);
|
||||||
@ -11,3 +11,9 @@ export interface IDataTable<TRow, TColumn> {
|
|||||||
tableSearchLabels: ISearchLabel[];
|
tableSearchLabels: ISearchLabel[];
|
||||||
extraColumns: string[];
|
extraColumns: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DataRowBase {
|
||||||
|
id: number;
|
||||||
|
status?: string;
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|||||||
@ -161,7 +161,7 @@ export default function UserRoleCard({ user }: Props) {
|
|||||||
<Modal
|
<Modal
|
||||||
open={showConfirmModal}
|
open={showConfirmModal}
|
||||||
onClose={() => setShowConfirmModal(false)}
|
onClose={() => setShowConfirmModal(false)}
|
||||||
title="Reset Password"
|
title={`Reset Password - ${user.first_name}`}
|
||||||
>
|
>
|
||||||
{newPassword && (
|
{newPassword && (
|
||||||
<div className="reset-password__content">
|
<div className="reset-password__content">
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import PageLinks from "../../../../components/PageLinks/PageLinks";
|
import PageLinks from "../../../../components/PageLinks/PageLinks";
|
||||||
import { SidebarItem } from "@/app/redux/metadata/metadataSlice";
|
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
|
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
|
||||||
import "./DropDown.scss";
|
import "./DropDown.scss";
|
||||||
@ -24,8 +23,6 @@ export default function SidebarDropdown({ onChange }: Props) {
|
|||||||
onChange?.(event);
|
onChange?.(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("sidebar", sidebar);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl fullWidth variant="outlined" sx={{ minWidth: 200 }}>
|
<FormControl fullWidth variant="outlined" sx={{ minWidth: 200 }}>
|
||||||
<InputLabel id="sidebar-dropdown-label">Navigate To</InputLabel>
|
<InputLabel id="sidebar-dropdown-label">Navigate To</InputLabel>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
|||||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
import { selectNavigationSidebar } from "@/app/redux/metadata/selectors";
|
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 { resolveIcon } from "@/app/utils/iconMap";
|
||||||
import "./sideBar.scss";
|
import "./sideBar.scss";
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
|
const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
|
||||||
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
|
||||||
const sidebar = useSelector(selectNavigationSidebar)?.links;
|
const sidebar = useSelector(selectNavigationSidebar);
|
||||||
const toggleMenu = (title: string) => {
|
const toggleMenu = (title: string) => {
|
||||||
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
|
setOpenMenus(prev => ({ ...prev, [title]: !prev[title] }));
|
||||||
};
|
};
|
||||||
@ -32,12 +32,12 @@ const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
|
|||||||
</button>
|
</button>
|
||||||
<div className="sidebar__header">
|
<div className="sidebar__header">
|
||||||
<span>
|
<span>
|
||||||
Betrise cashier
|
Cashier
|
||||||
<DashboardIcon fontSize="small" className="sidebar__icon-spacing" />
|
<DashboardIcon fontSize="small" className="sidebar__icon-spacing" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sidebar?.map((link: SidebarItem) => {
|
{sidebar?.map((link: SidebarLink) => {
|
||||||
if (link.children) {
|
if (link.children) {
|
||||||
const Icon = resolveIcon(link.icon as string);
|
const Icon = resolveIcon(link.icon as string);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -10,13 +10,22 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
border-right: 1px solid #333;
|
border-right: 1px solid #835454;
|
||||||
transition: transform 0.3s ease-in-out;
|
transition: transform 0.3s ease-in-out;
|
||||||
overflow: hidden;
|
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 {
|
&--collapsed {
|
||||||
transform: translateX(-210px); // Hide 90% (210px out of 240px)
|
transform: translateX(-210px); // Hide 90% (210px out of 240px)
|
||||||
|
|
||||||
.sidebar__header,
|
.sidebar__header,
|
||||||
.sidebar__dropdown-button,
|
.sidebar__dropdown-button,
|
||||||
.sidebar__submenu,
|
.sidebar__submenu,
|
||||||
@ -153,3 +162,9 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Track (background behind the thumb) */
|
||||||
|
.scrollable-div::-webkit-scrollbar-track {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,53 +1,106 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit";
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
interface AdvancedSearchState {
|
// Filter value can be a simple string or an object with operator and value
|
||||||
keyword: string;
|
export interface FilterValue {
|
||||||
transactionID: string;
|
operator: string;
|
||||||
transactionReferenceId: string;
|
value: string;
|
||||||
user: string;
|
}
|
||||||
currency: string;
|
|
||||||
state: string;
|
export type FilterField = string | FilterValue;
|
||||||
statusDescription: string;
|
|
||||||
transactionType: string;
|
export interface AdvancedSearchFilters {
|
||||||
paymentMethod: string;
|
[field: string]: FilterField | undefined;
|
||||||
psps: string;
|
}
|
||||||
initialPsps: string;
|
|
||||||
merchants: string;
|
export type FetchStatus = "idle" | "loading" | "succeeded" | "failed";
|
||||||
startDate: null | string;
|
|
||||||
endDate: null | string;
|
export interface AdvancedSearchState {
|
||||||
lastUpdatedFrom: null | string;
|
filters: AdvancedSearchFilters;
|
||||||
lastUpdatedTo: null | string;
|
pagination: {
|
||||||
minAmount: string;
|
page: number;
|
||||||
maxAmount: string;
|
limit: number;
|
||||||
channel: string;
|
};
|
||||||
|
sort?: {
|
||||||
|
field: string;
|
||||||
|
order: "asc" | "desc";
|
||||||
|
};
|
||||||
|
status: FetchStatus;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: AdvancedSearchState = {
|
const initialState: AdvancedSearchState = {
|
||||||
keyword: "",
|
filters: {},
|
||||||
transactionID: "",
|
pagination: {
|
||||||
transactionReferenceId: "",
|
page: 1,
|
||||||
user: "",
|
limit: 10,
|
||||||
currency: "",
|
},
|
||||||
state: "",
|
status: "idle",
|
||||||
statusDescription: "",
|
error: null,
|
||||||
transactionType: "",
|
|
||||||
paymentMethod: "",
|
|
||||||
psps: "",
|
|
||||||
initialPsps: "",
|
|
||||||
merchants: "",
|
|
||||||
startDate: null,
|
|
||||||
endDate: null,
|
|
||||||
lastUpdatedFrom: null,
|
|
||||||
lastUpdatedTo: null,
|
|
||||||
minAmount: "",
|
|
||||||
maxAmount: "",
|
|
||||||
channel: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const advancedSearchSlice = createSlice({
|
const advancedSearchSlice = createSlice({
|
||||||
name: "advancedSearch",
|
name: "advancedSearch",
|
||||||
initialState,
|
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;
|
export default advancedSearchSlice.reducer;
|
||||||
|
|||||||
24
app/redux/advanedSearch/selectors.ts
Normal file
24
app/redux/advanedSearch/selectors.ts
Normal 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;
|
||||||
@ -1,21 +1,30 @@
|
|||||||
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
|
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
export interface SidebarItem {
|
export interface SidebarLink {
|
||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
path: string;
|
path: string;
|
||||||
icon?: string; // icon name from backend; map client-side if needed
|
icon?: string;
|
||||||
permissions?: string[]; // required permissions for visibility
|
groups?: string[];
|
||||||
children?: SidebarItem[];
|
children?: SidebarLink[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SidebarPayload {
|
||||||
|
links: SidebarLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldGroupMap = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
export interface AppMetadata {
|
export interface AppMetadata {
|
||||||
groups?: string[];
|
|
||||||
job_titles?: string[];
|
|
||||||
merchants?: string[];
|
|
||||||
message?: string;
|
|
||||||
sidebar?: SidebarItem[];
|
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
field_names?: FieldGroupMap;
|
||||||
|
job_titles?: string[];
|
||||||
|
groups?: string[];
|
||||||
|
merchants?: string[];
|
||||||
|
countries?: string[];
|
||||||
|
sidebar?: SidebarPayload;
|
||||||
|
transaction_status?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetadataState {
|
interface MetadataState {
|
||||||
|
|||||||
@ -1,7 +1,35 @@
|
|||||||
import { RootState } from "../store";
|
import { RootState } from "../store";
|
||||||
|
import { FieldGroupMap, SidebarLink } from "./metadataSlice";
|
||||||
|
|
||||||
// Selectors
|
|
||||||
export const selectMetadataState = (state: RootState) => state.metadata;
|
export const selectMetadataState = (state: RootState) => state.metadata;
|
||||||
export const selectAppMetadata = (state: RootState) => state.metadata.data;
|
|
||||||
export const selectNavigationSidebar = (state: RootState) =>
|
export const selectMetadataStatus = (state: RootState) =>
|
||||||
state.metadata.data?.sidebar || [];
|
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 ?? [];
|
||||||
|
|||||||
@ -7,6 +7,25 @@ export type AppDispatch = typeof store.dispatch;
|
|||||||
export type ThunkSuccess<T extends object = object> = { message: string } & T;
|
export type ThunkSuccess<T extends object = object> = { message: string } & T;
|
||||||
export type ThunkError = string;
|
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 {
|
export interface IUserResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
token: string;
|
token: string;
|
||||||
@ -17,6 +36,8 @@ export interface IUserResponse {
|
|||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
merchantId?: number | null;
|
merchantId?: number | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
|
groups?: TGroupName[];
|
||||||
|
merchants?: TMerchantName[];
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
jobTitle?: string | null;
|
jobTitle?: string | null;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@ -1,18 +1,41 @@
|
|||||||
export async function getAudits({ query }: { query: string }) {
|
interface GetAuditsParams {
|
||||||
const res = await fetch(
|
limit?: number;
|
||||||
`http://localhost:4000/api/dashboard/audits?${query}`,
|
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",
|
cache: "no-store",
|
||||||
|
signal,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!response.ok) {
|
||||||
// Handle error from the API
|
const errorData = await response
|
||||||
const errorData = await res
|
|
||||||
.json()
|
.json()
|
||||||
.catch(() => ({ message: "Unknown error" }));
|
.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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,9 +18,16 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
|||||||
import HistoryIcon from "@mui/icons-material/History";
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import FactCheckIcon from "@mui/icons-material/FactCheck";
|
import FactCheckIcon from "@mui/icons-material/FactCheck";
|
||||||
import WidgetsIcon from "@mui/icons-material/Widgets"; // fallback
|
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 IconMap = {
|
||||||
const iconRegistry: Record<string, ElementType> = {
|
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
AccountBalanceWalletIcon,
|
AccountBalanceWalletIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -37,6 +44,21 @@ const iconRegistry: Record<string, ElementType> = {
|
|||||||
ArrowUpwardIcon,
|
ArrowUpwardIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
FactCheckIcon,
|
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(
|
export function resolveIcon(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user