Compare commits

..

1 Commits

Author SHA1 Message Date
Mitchell Magro
3d4f0e86da Added more to table filtering 2025-07-14 19:25:10 +02:00
207 changed files with 8414 additions and 12449 deletions

View File

@ -1 +0,0 @@
npx lint-staged

1
.nvmrc
View File

@ -1 +0,0 @@
v22.16.0

View File

@ -1,45 +0,0 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Production builds
.next/
out/
build/
dist/
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Package manager files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Generated files
*.tsbuildinfo
next-env.d.ts
# Public assets
public/
# MSW
mockServiceWorker.js
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db

View File

@ -1,15 +0,0 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"proseWrap": "preserve"
}

View File

@ -19,9 +19,9 @@ A modern backoffice admin panel built with [Next.js](https://nextjs.org/) and [R
## 📦 Getting Started
```bash
git clone https://git.luckyigaming.com/Mitchell/payment-backoffice.git
cd backoffice
yarn run dev
```

View File

@ -1,40 +0,0 @@
// app/AuthBootstrap.tsx
"use client";
import { useEffect, useRef } from "react";
import { useDispatch } from "react-redux";
import { AppDispatch } from "@/app/redux/types";
import { validateAuth } from "./redux/auth/authSlice";
export function AuthBootstrap() {
const dispatch = useDispatch<AppDispatch>();
const startedRef = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
// Guard against React StrictMode double-invoke in dev
if (startedRef.current) return;
startedRef.current = true;
// Initial validate on mount
dispatch(validateAuth());
// dispatch(fetchMetadata());
// Refresh on window focus (nice UX)
const onFocus = () => dispatch(validateAuth());
window.addEventListener("focus", onFocus);
// Optional: periodic validation every 5 min
intervalRef.current = setInterval(
() => {
dispatch(validateAuth());
},
5 * 60 * 1000
);
return () => {
window.removeEventListener("focus", onFocus);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [dispatch]);
return null;
}

View File

@ -1,20 +0,0 @@
"use client";
import React from "react";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { store, persistor } from "./redux/store";
export default function ReduxProvider({
children,
}: {
children: React.ReactNode;
}) {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
{children}
</PersistGate>
</Provider>
);
}

View File

@ -1,120 +0,0 @@
import { NextResponse } from "next/server";
import { decodeJwt } from "jose";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
export async function POST(request: Request) {
try {
const { email, currentPassword, newPassword } = await request.json();
// Get the auth token from cookies first
const { cookies } = await import("next/headers");
const cookieStore = cookies();
const token = (await cookieStore).get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: "No authentication token found" },
{ status: 401 }
);
}
// Check MustChangePassword flag from current token
let mustChangePassword = false;
try {
const payload = decodeJwt(token);
const mustChangeClaim = payload.MustChangePassword;
if (typeof mustChangeClaim === "boolean") {
mustChangePassword = mustChangeClaim;
} else if (typeof mustChangeClaim === "string") {
mustChangePassword = mustChangeClaim.toLowerCase() === "true";
} else {
mustChangePassword = false;
}
} catch (err) {
console.error("❌ Failed to decode current JWT:", err);
}
// Skip currentPassword validation if MustChangePassword is true
if (!email || !newPassword || (!mustChangePassword && !currentPassword)) {
return NextResponse.json(
{
success: false,
message: "Email, current password, and new password are required",
},
{ status: 400 }
);
}
// 🔁 Call backend API with the correct payload
const requestBody: {
email: string;
new_password: string;
current_password?: string;
} = {
email,
new_password: newPassword,
};
// Only include current_password if MustChangePassword is false
if (!mustChangePassword && currentPassword) {
requestBody.current_password = currentPassword;
}
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
const data = await resp.json();
if (!resp.ok) {
return NextResponse.json(
{ success: false, message: data?.message || "Password change failed" },
{ status: resp.status }
);
}
// ✅ Handle new token from backend
const newToken = data?.token;
const response = NextResponse.json({
success: true,
message: data?.message || "Password changed successfully",
});
if (newToken) {
// Derive maxAge from JWT exp if available; fallback to 12h
let maxAge = 60 * 60 * 12;
try {
const payload = decodeJwt(newToken);
if (payload?.exp) {
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
if (secondsLeft > 0) maxAge = secondsLeft;
}
} catch {}
response.cookies.set({
name: COOKIE_NAME,
value: newToken,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge,
});
}
return response;
} catch (error) {
console.error("❌ Change password proxy error:", error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -1,94 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { decodeJwt } from "jose";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
export async function POST(request: Request) {
try {
const { email, password } = await request.json();
// Call backend login
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
console.log("[LOGIN] resp", resp);
if (!resp.ok) {
const errJson = await safeJson(resp);
return NextResponse.json(
{ success: false, message: errJson?.message || "Login failed" },
{ status: resp.status }
);
}
const data = await resp.json();
const token: string | undefined = data?.token;
if (!token) {
return NextResponse.json(
{ success: false, message: "No token returned from backend" },
{ status: 502 }
);
}
// Decode JWT token to extract MustChangePassword
let mustChangePassword = false;
let maxAge = 60 * 60 * 12; // fallback to 12h
try {
const payload = decodeJwt(token);
// Extract exp if present
if (payload?.exp) {
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
if (secondsLeft > 0) maxAge = secondsLeft;
}
// Extract MustChangePassword flag if it exists
if (typeof payload?.MustChangePassword === "boolean") {
mustChangePassword = payload.MustChangePassword;
}
} catch (err) {
console.warn("Failed to decode JWT:", err);
}
// Set the cookie
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge,
});
return NextResponse.json(
{
user: data.user,
success: true,
message: "Login successful",
must_change_password: mustChangePassword,
},
{ status: 200 }
);
} catch (error) {
console.error("Login proxy error:", error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 }
);
}
}
async function safeJson(resp: Response) {
try {
return await resp.json();
} catch {
return null;
}
}

View File

@ -1,28 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:3000";
const COOKIE_NAME = "auth_token";
export async function DELETE() {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (token) {
try {
await fetch(`${BE_BASE_URL}/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`, // satisfy requireJwt
},
body: JSON.stringify({ token }), // satisfy body check
});
} catch (err) {
console.error("BE /logout failed:", err);
}
}
cookieStore.delete(COOKIE_NAME);
return NextResponse.json({ success: true, message: "Logged out" });
}

View File

@ -1,91 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
// Interface matching the backend RegisterRequest and frontend IEditUserForm
interface RegisterRequest {
creator: string;
email: string;
first_name: string;
groups: string[];
job_title: string;
last_name: string;
merchants: string[];
phone: string;
username: string;
}
export async function POST(request: Request) {
try {
const body: RegisterRequest = await request.json();
// Get the auth token from cookies
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{
success: false,
message: "No authentication token found",
},
{ status: 401 }
);
}
// Call backend registration endpoint
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
// Handle backend response
if (!resp.ok) {
const errorData = await safeJson(resp);
console.error("Registration proxy error:", errorData);
return NextResponse.json(
{
success: false,
message: errorData?.message || "Registration failed",
error: errorData?.error || "Unknown error",
},
{ status: resp.status }
);
}
const data = await resp.json();
return NextResponse.json(
{
success: true,
message: "Registration successful",
user: data.user || null,
},
{ status: 201 }
);
} catch (error) {
console.error("Registration proxy error:", error);
return NextResponse.json(
{
success: false,
message: "Internal server error during registration",
},
{ status: 500 }
);
}
}
// Helper function to safely parse JSON responses
async function safeJson(resp: Response) {
try {
return await resp.json();
} catch {
return null;
}
}

View File

@ -1,61 +0,0 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { NextResponse, type NextRequest } from "next/server";
import { cookies } from "next/headers";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
if (!id) {
return NextResponse.json(
{ success: false, message: "User ID is required" },
{ status: 400 }
);
}
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json(
{ success: false, message: "No authentication token found" },
{ status: 401 }
);
}
const resp = await fetch(
`${BE_BASE_URL}/api/v1/auth/reset-password/${encodeURIComponent(id)}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}
);
let data;
try {
data = await resp.json();
} catch {
data = { success: resp.ok };
}
return NextResponse.json(data, {
status: resp.status,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ success: false, message: "Internal server error", error: message },
{ status: 500 }
);
}
}

View File

@ -1,76 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
validateToken,
isTokenExpired,
getTimeUntilExpiration,
} from "@/app/utils/auth";
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (!token) {
return NextResponse.json(
{
isAuthenticated: false,
message: "No authentication token found",
},
{ status: 401 }
);
}
// Validate the token
const payload = await validateToken(token);
if (!payload) {
return NextResponse.json(
{
isAuthenticated: false,
message: "Invalid authentication token",
},
{ status: 401 }
);
}
if (isTokenExpired(payload)) {
// Clear the expired cookie
cookieStore.delete("auth_token");
return NextResponse.json(
{
isAuthenticated: false,
message: "Authentication token has expired",
},
{ status: 401 }
);
}
// Token is valid and not expired
const timeUntilExpiration = getTimeUntilExpiration(payload);
return NextResponse.json({
isAuthenticated: true,
user: {
email: payload.email,
role: payload.role,
},
tokenInfo: {
issuedAt: new Date(payload.iat * 1000).toISOString(),
expiresAt: new Date(payload.exp * 1000).toISOString(),
timeUntilExpiration: timeUntilExpiration, // in seconds
expiresInHours: Math.floor(timeUntilExpiration / 3600),
},
});
} catch (error) {
console.error("Auth status check error:", error);
return NextResponse.json(
{
isAuthenticated: false,
message: "Internal server error",
},
{ status: 500 }
);
}
}

View File

@ -1,34 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583";
const COOKIE_NAME = "auth_token";
export async function POST() {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ valid: false }, { status: 401 });
}
try {
const resp = await fetch(`${BE_BASE_URL}/api/v1/auth/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ token }),
});
const data = await resp.json();
return NextResponse.json(data, { status: resp.status });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ valid: false, message: "Validation failed", error: message },
{ status: 500 }
);
}
}

View File

@ -1,73 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { buildFilterParam } from "../utils";
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 }
);
}
const body = await request.json();
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
const queryParams = new URLSearchParams();
queryParams.set("limit", String(pagination.limit ?? 10));
queryParams.set("page", String(pagination.page ?? 1));
if (sort?.field && sort?.order) {
queryParams.set("sort", `${sort.field}:${sort.order}`);
}
const filterParam = buildFilterParam(filters);
if (filterParam) {
queryParams.set("filter", filterParam);
}
const backendUrl = `${BE_BASE_URL}/api/v1/groups${
queryParams.size ? `?${queryParams.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 groups" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch groups",
},
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error("Proxy POST /api/dashboard/admin/groups error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,73 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { buildFilterParam } from "../utils";
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 }
);
}
const body = await request.json();
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
const queryParams = new URLSearchParams();
queryParams.set("limit", String(pagination.limit ?? 10));
queryParams.set("page", String(pagination.page ?? 1));
if (sort?.field && sort?.order) {
queryParams.set("sort", `${sort.field}:${sort.order}`);
}
const filterParam = buildFilterParam(filters);
if (filterParam) {
queryParams.set("filter", filterParam);
}
const backendUrl = `${BE_BASE_URL}/api/v1/permissions${
queryParams.size ? `?${queryParams.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 permissions" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch permissions",
},
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error("Proxy POST /api/dashboard/admin/permissions error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,73 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { buildFilterParam } from "../utils";
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 }
);
}
const body = await request.json();
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
const queryParams = new URLSearchParams();
queryParams.set("limit", String(pagination.limit ?? 10));
queryParams.set("page", String(pagination.page ?? 1));
if (sort?.field && sort?.order) {
queryParams.set("sort", `${sort.field}:${sort.order}`);
}
const filterParam = buildFilterParam(filters);
if (filterParam) {
queryParams.set("filter", filterParam);
}
const backendUrl = `${BE_BASE_URL}/api/v1/sessions${
queryParams.size ? `?${queryParams.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 sessions" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch sessions",
},
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error("Proxy POST /api/dashboard/admin/sessions error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,154 +0,0 @@
// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
const COOKIE_NAME = "auth_token";
// Field mapping: snake_case input -> { snake_case for data, PascalCase for fields }
// Matches API metadata field_names.users mapping
const FIELD_MAPPING: Record<string, { dataKey: string; fieldName: string }> = {
id: { dataKey: "id", fieldName: "ID" },
email: { dataKey: "email", fieldName: "Email" },
first_name: { dataKey: "first_name", fieldName: "FirstName" },
last_name: { dataKey: "last_name", fieldName: "LastName" },
username: { dataKey: "username", fieldName: "Username" },
phone: { dataKey: "phone", fieldName: "Phone" },
job_title: { dataKey: "job_title", fieldName: "JobTitle" },
password: { dataKey: "password", fieldName: "Password" },
temp_link: { dataKey: "temp_link", fieldName: "TempLink" },
temp_password: { dataKey: "temp_password", fieldName: "TempPassword" },
temp_expiry: { dataKey: "temp_expiry", fieldName: "TempExpiry" },
groups: { dataKey: "groups", fieldName: "Groups" },
merchants: { dataKey: "merchants", fieldName: "Merchants" },
enabled: { dataKey: "enabled", fieldName: "Enabled" },
};
/**
* Transforms frontend snake_case data to backend format
* with data (snake_case) and fields (PascalCase) arrays
*/
function transformUserUpdateData(updates: Record<string, unknown>): {
data: Record<string, unknown>;
fields: string[];
} {
const data: Record<string, unknown> = {};
const fields: string[] = [];
for (const [key, value] of Object.entries(updates)) {
// Skip undefined/null values
if (value === undefined || value === null) {
continue;
}
const mapping = FIELD_MAPPING[key];
if (mapping) {
// Use the dataKey for the data object (snake_case)
data[mapping.dataKey] = value;
// Use the fieldName for the fields array (PascalCase)
fields.push(mapping.fieldName);
} else {
// If no mapping exists, use the key as-is (for backwards compatibility)
data[key] = value;
// Convert snake_case to PascalCase for fields
const pascalCase = key
.split("_")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
fields.push(pascalCase);
}
}
return { data, fields };
}
export async function PUT(
request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const body = await request.json();
// Transform the request body to match backend format
const transformedBody = transformUserUpdateData(body);
// Get the auth token from cookies
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 }
);
}
// According to swagger: /api/v1/users/{id}
const response = await fetch(`${BE_BASE_URL}/api/v1/users/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(transformedBody),
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
const errorMessage =
err instanceof Error ? err.message : "Unknown error occurred";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.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 }
);
}
// According to swagger: /api/v1/users/{id}
const response = await fetch(`${BE_BASE_URL}/api/v1/users/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
});
// Some backends return empty body for DELETE; handle safely
let data: unknown = null;
try {
data = await response.json();
} catch {
data = { success: response.ok };
}
return NextResponse.json(data ?? { success: response.ok }, {
status: response.status,
});
} catch (err: unknown) {
console.error("Proxy DELETE /api/v1/users/{id} error:", err);
const errorMessage =
err instanceof Error ? err.message : "Unknown error occurred";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,41 +0,0 @@
import { NextResponse } from "next/server";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
const COOKIE_NAME = "auth_token";
export async function GET(request: Request) {
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 }
);
}
// Preserve query string when proxying
const url = new URL(request.url);
const queryString = url.search ? url.search : "";
const response = await fetch(`${BE_BASE_URL}/api/v1/users${queryString}`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error("Proxy GET /api/v1/users/list error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,34 +0,0 @@
export type FilterValue =
| string
| {
operator?: string;
value: string;
};
export const buildFilterParam = (filters: Record<string, FilterValue>) => {
const filterExpressions: string[] = [];
for (const [key, filterValue] of Object.entries(filters)) {
if (!filterValue) continue;
let operator = "==";
let value: string;
if (typeof filterValue === "string") {
value = filterValue;
} else {
operator = filterValue.operator || "==";
value = filterValue.value;
}
if (!value) continue;
const encodedValue = encodeURIComponent(value);
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
filterExpressions.push(`${key}${operatorSegment}/${encodedValue}`);
}
return filterExpressions.length > 0 ? filterExpressions.join(",") : undefined;
};

View File

@ -1,176 +0,0 @@
import { TableColumn } from "@/app/features/Pages/Approve/Approve";
type UserRow = {
id: number;
merchantId: string;
txId: string;
userEmail: string;
kycStatus: string;
amount: number;
paymentMethod: string;
currency: string;
status: string;
};
export const approveRows: UserRow[] = [
{
id: 17,
merchantId: "100987998",
txId: "1049078821",
userEmail: "dhkheni1@yopmail.com",
kycStatus: "N/A",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 12,
merchantId: "100987998",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 11,
merchantId: "100232399",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 10,
merchantId: "100232399",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 1,
merchantId: "100232399",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 2,
merchantId: "101907999",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 3,
merchantId: "101907999",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 4,
merchantId: "10552342",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 5,
merchantId: "10552342",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 6,
merchantId: "10552342",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 7,
merchantId: "10552342",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
{
id: 8,
merchantId: "10552342",
txId: "1049078822",
userEmail: "dhkheni2@yopmail.com",
kycStatus: "Pending",
amount: 1000,
paymentMethod: "Credit Card",
currency: "USD",
status: "Pending",
},
];
export const approveColumns: TableColumn<UserRow>[] = [
{ field: "id", headerName: "User ID" },
{ field: "merchantId", headerName: "Merchant ID" },
{ field: "txId", headerName: "Transaction ID" },
{ field: "userEmail", headerName: "User Email" },
{ field: "kycStatus", headerName: "KYC Status" },
{ field: "amount", headerName: "Amount" },
{ field: "paymentMethod", headerName: "Payment Method" },
{ field: "currency", headerName: "Currency" },
{ field: "status", headerName: "Status" },
];
export const approveActions = [
{ value: "approve", label: "Approve" },
{ value: "reject", label: "Reject" },
{ value: "forceSuccessful", label: "Force Successful" },
{ value: "forceFiled", label: "Force Field" },
{ value: "forceInconsistent", label: "Force Inconsistent" },
{ value: "forceStatus", label: "Force Status" },
{ value: "partialCapture", label: "Partial Capture" },
{ value: "capture", label: "Capture" },
{ value: "extendedAuth", label: "Extended uth" },
{ value: "partialRefund", label: "Partial Refund" },
{ value: "refund", label: "Refund" },
{ value: "void", label: "Void" },
{ value: "registerCorrection", label: "Register Correction" },
];

View File

@ -1,20 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { approveRows, approveColumns, approveActions } from "./mockData";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const merchantId = searchParams.get("merchantId");
let filteredApproveRows = [...approveRows];
if (merchantId) {
filteredApproveRows = filteredApproveRows.filter(tx =>
tx.merchantId.toString().includes(merchantId)
);
}
return NextResponse.json({
rows: filteredApproveRows,
columns: approveColumns,
actions: approveActions,
});
}

View File

@ -1,80 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
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) {
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 }
);
}
const { searchParams } = new URL(request.url);
const proxiedParams = new URLSearchParams();
// Forward provided params
searchParams.forEach((value, key) => {
if (value == null || value === "") return;
proxiedParams.append(key, value);
});
if (!proxiedParams.has("limit")) {
proxiedParams.set("limit", DEFAULT_LIMIT);
}
if (!proxiedParams.has("page")) {
proxiedParams.set("page", DEFAULT_PAGE);
}
const backendUrl = `${AUDITS_BASE_URL}/api/v1/audit${
proxiedParams.size ? `?${proxiedParams.toString()}` : ""
}`;
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to fetch audits" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch audits",
},
{ status: response.status }
);
}
const data = await response.json();
console.log("[AUDITS] data:", data);
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.log("[AUDITS] error:", err);
console.error("Proxy GET /api/v1/audits error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

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

View File

@ -1,108 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
const COOKIE_NAME = "auth_token";
type FilterValue =
| string
| {
operator?: string;
value: string;
};
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 }
);
}
const body = await request.json();
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
// Force deposits filter while allowing other filters to stack
const mergedFilters: Record<string, FilterValue> = {
...filters,
Type: {
operator: "==",
value: "deposit",
},
};
const queryParts: string[] = [];
queryParts.push(`limit=${pagination.limit}`);
queryParts.push(`page=${pagination.page}`);
if (sort) {
queryParts.push(`sort=${sort.field}:${sort.order}`);
}
for (const [key, filterValue] of Object.entries(mergedFilters)) {
if (!filterValue) continue;
let operator: string;
let value: string;
if (typeof filterValue === "string") {
operator = "==";
value = filterValue;
} else {
operator = filterValue.operator || "==";
value = filterValue.value;
}
if (!value) continue;
const encodedValue = encodeURIComponent(value);
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
queryParts.push(`${key}${operatorSegment}/${encodedValue}`);
}
const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${
queryString ? `?${queryString}` : ""
}`;
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to fetch deposits" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch deposits",
},
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error(
"Proxy POST /api/dashboard/transactions/deposits error:",
err
);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,139 +0,0 @@
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}`);
}
// Track date ranges separately so we can emit BETWEEN/>/< syntax
const dateRanges: Record<string, { start?: string; end?: string }> = {};
// Process filters - convert FilterValue objects to operator/value format
for (const [key, filterValue] of Object.entries(filters)) {
if (!filterValue) continue;
// Handle date range helpers (e.g. Created_start / Created_end)
if (/_start$|_end$/.test(key)) {
const baseField = key.replace(/_(start|end)$/, "");
if (!dateRanges[baseField]) {
dateRanges[baseField] = {};
}
const targetKey = key.endsWith("_start") ? "start" : "end";
const stringValue =
typeof filterValue === "string"
? filterValue
: (filterValue as { value?: string }).value;
if (stringValue) {
dateRanges[baseField][targetKey] = stringValue;
}
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}`);
}
// Emit date range filters using backend format
for (const [field, { start, end }] of Object.entries(dateRanges)) {
if (start && end) {
queryParts.push(
`${field}=BETWEEN/${encodeURIComponent(start)}/${encodeURIComponent(
end
)}`
);
continue;
}
if (start) {
queryParts.push(`${field}=>/${encodeURIComponent(start)}`);
} else if (end) {
queryParts.push(`${field}=</${encodeURIComponent(end)}`);
}
}
const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
console.log("[DEBUG] [TRANSACTIONS] Backend URL:", backendUrl);
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to fetch transactions" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch transactions",
},
{ status: response.status }
);
}
const data = await response.json();
console.log("[DEBUG] [TRANSACTIONS] Response data:", data);
return NextResponse.json(data, { status: response.status });
} catch (err: unknown) {
console.error("Proxy GET /api/v1/transactions error:", err);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,243 +0,0 @@
import { GridColDef } from "@mui/x-data-grid";
export const withdrawalTransactionDummyData = [
{
id: 1,
userId: 17,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
manualCorrectionFlag: "-",
informationWhoApproved: "-",
},
{
id: 2,
userId: 17,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
manualCorrectionFlag: "-",
informationWhoApproved: "-",
},
{
id: 3,
userId: 17,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Completed",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 4,
userId: 19,
transactionId: 1049136973,
withdrawalMethod: "Bank Transfer",
status: "Completed",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-18 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 5,
userId: 19,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
manualCorrectionFlag: "-",
informationWhoApproved: "-",
},
{
id: 6,
userId: 27,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
manualCorrectionFlag: "-",
informationWhoApproved: "-",
},
{
id: 7,
userId: 1,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
manualCorrectionFlag: "-",
informationWhoApproved: "-",
},
{
id: 8,
userId: 172,
transactionId: 1049131973,
withdrawalMethod: "Card",
status: "Pending",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-12 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 9,
userId: 174,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Inprogress",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
},
{
id: 10,
userId: 1,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
manualCorrectionFlag: "-",
informationWhoApproved: "-",
},
{
id: 11,
userId: 1,
transactionId: 1049131973,
withdrawalMethod: "Bank Transfer",
status: "Error",
options: [
{ value: "Pending", label: "Pending" },
{ value: "Completed", label: "Completed" },
{ value: "Inprogress", label: "Inprogress" },
{ value: "Error", label: "Error" },
],
amount: 4000,
dateTime: "2025-06-17 10:10:30",
errorInfo: "-",
fraudScore: "frad score 1234",
manualCorrectionFlag: "-",
informationWhoApproved: "-",
},
];
export const withdrawalTransactionsColumns: GridColDef[] = [
{ field: "userId", headerName: "User ID", width: 130 },
{ field: "transactionId", headerName: "Transaction ID", width: 130 },
{ field: "withdrawalMethod", headerName: "Withdrawal Method", width: 130 },
{ field: "status", headerName: "Status", width: 130 },
{ field: "actions", headerName: "Actions", width: 150 },
{ field: "amount", headerName: "Amount", width: 130 },
{ field: "dateTime", headerName: "Date / Time", width: 130 },
{ field: "errorInfo", headerName: "Error Info", width: 130 },
{ field: "fraudScore", headerName: "Fraud Score", width: 130 },
{
field: "manualCorrectionFlag",
headerName: "Manual Correction Flag",
width: 130,
},
{
field: "informationWhoApproved",
headerName: "Information who approved",
width: 130,
},
];
export const withdrawalTransactionsSearchLabels = [
{
label: "Status",
field: "status",
type: "select",
options: ["Pending", "Inprogress", "Completed", "Failed"],
},
{
label: "Payment Method",
field: "depositMethod",
type: "select",
options: ["Card", "Bank Transfer"],
},
{ label: "Date / Time", field: "dateTime", type: "date" },
];

View File

@ -1,67 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import {
withdrawalTransactionDummyData,
withdrawalTransactionsColumns,
withdrawalTransactionsSearchLabels,
} from "./mockData";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
const status = searchParams.get("status");
const withdrawalMethod = searchParams.get("withdrawalMethod");
const dateTimeStart = searchParams.get("dateTime_start");
const dateTimeEnd = searchParams.get("dateTime_end");
let filteredTransactions = [...withdrawalTransactionDummyData];
if (userId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.userId.toString() === userId
);
}
if (status) {
filteredTransactions = filteredTransactions.filter(
tx => tx.status.toLowerCase() === status.toLowerCase()
);
}
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;
});
}
if (withdrawalMethod) {
filteredTransactions = filteredTransactions.filter(
tx => tx.withdrawalMethod.toLowerCase() === withdrawalMethod.toLowerCase()
);
}
return NextResponse.json({
tableRows: filteredTransactions,
tableSearchLabels: withdrawalTransactionsSearchLabels,
tableColumns: withdrawalTransactionsColumns,
});
}

View File

@ -1,108 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
const COOKIE_NAME = "auth_token";
type FilterValue =
| string
| {
operator?: string;
value: string;
};
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 }
);
}
const body = await request.json();
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
// Force withdrawals filter while allowing other filters to stack
const mergedFilters: Record<string, FilterValue> = {
...filters,
Type: {
operator: "==",
value: "withdrawal",
},
};
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;
}
if (!value) continue;
const encodedValue = encodeURIComponent(value);
const needsEqualsPrefix = /^[A-Za-z]/.test(operator);
const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator;
queryParts.push(`${key}${operatorSegment}/${encodedValue}`);
}
const queryString = queryParts.join("&");
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${
queryString ? `?${queryString}` : ""
}`;
const response = await fetch(backendUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
if (!response.ok) {
const errorData = await response
.json()
.catch(() => ({ message: "Failed to fetch withdrawals" }));
return NextResponse.json(
{
success: false,
message: errorData?.message || "Failed to fetch withdrawals",
},
{ 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/withdrawals error:",
err
);
const errorMessage = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ message: "Internal server error", error: errorMessage },
{ status: 500 }
);
}
}

View File

@ -1,83 +0,0 @@
interface Transaction {
userId: number | string;
date: string;
method: string;
amount: number | string;
status: string;
}
export const deposits: Transaction[] = [
{
userId: 17,
date: "2025-08-01 10:10",
method: "CC",
amount: 120,
status: "approved",
},
{
userId: 17,
date: "2025-07-28 14:35",
method: "Bank Transfer",
amount: 250,
status: "approved",
},
{
userId: 17,
date: "2025-07-20 09:05",
method: "PayPal",
amount: 75,
status: "pending",
},
{
userId: 17,
date: "2025-07-11 17:12",
method: "CC",
amount: 300,
status: "rejected",
},
{
userId: 17,
date: "2025-07-01 12:42",
method: "CC",
amount: 180,
status: "approved",
},
];
export const withdrawals: Transaction[] = [
{
userId: 17,
date: "2025-08-02 11:20",
method: "Crypto",
amount: 95,
status: "processing",
},
{
userId: 17,
date: "2025-07-29 16:45",
method: "Bank Transfer",
amount: 220,
status: "approved",
},
{
userId: 17,
date: "2025-07-21 15:10",
method: "eWallet",
amount: 60,
status: "pending",
},
{
userId: 17,
date: "2025-07-12 13:33",
method: "Crypto",
amount: 120,
status: "approved",
},
{
userId: 17,
date: "2025-07-03 08:50",
method: "Bank Transfer",
amount: 150,
status: "rejected",
},
];

View File

@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { deposits, withdrawals } from "./mockData";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
let filteredDeposits = [...deposits];
let filteredwithdrawals = [...withdrawals];
if (userId) {
filteredDeposits = filteredDeposits.filter(
item => item.userId.toString() === userId.toString()
);
filteredwithdrawals = filteredwithdrawals.filter(
item => item.userId.toString() === userId.toString()
);
}
return NextResponse.json({
deposits: filteredDeposits,
withdrawals: filteredwithdrawals,
});
}

View File

@ -1,46 +0,0 @@
import { NextResponse } from "next/server";
// Proxy to backend metadata endpoint. Assumes BACKEND_BASE_URL is set.
export async function GET() {
const { cookies } = await import("next/headers");
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
if (!token) {
return NextResponse.json(
{ success: false, message: "No token found" },
{ status: 401 }
);
}
const url = `${BE_BASE_URL.replace(/\/$/, "")}/api/v1/metadata`;
try {
const res = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
const data = await res.json();
if (!res.ok) {
return NextResponse.json(
{
success: false,
message: data?.message || "Failed to fetch metadata",
},
{ status: res.status }
);
}
return NextResponse.json(data);
} catch (err) {
const message = (err as Error)?.message || "Metadata proxy error";
return NextResponse.json({ success: false, message }, { status: 500 });
}
}

View File

@ -1,13 +0,0 @@
import { NextResponse } from "next/server";
/**
* Placeholder Settings API route.
* Keeps the module valid while the real implementation
* is being built, and makes the intent obvious to clients.
*/
export async function GET() {
return NextResponse.json(
{ message: "Settings endpoint not implemented" },
{ status: 501 }
);
}

View File

@ -0,0 +1,25 @@
import { transactionDummyData } from '@/app/features/Pages/transactions/mockData';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const state = searchParams.get('state');
const user = searchParams.get('user');
let filteredTransactions = [...transactionDummyData];
if (user) {
filteredTransactions = filteredTransactions.filter(
tx => tx.user.toString() === user
);
}
if (state) {
filteredTransactions = filteredTransactions.filter(
tx => tx.state.toLowerCase() === state.toLowerCase()
);
}
return NextResponse.json(filteredTransactions);
}

View File

@ -0,0 +1,26 @@
import { styled } from "@mui/material"
import { SectionCard } from "../SectionCard/SectionCard"
const AccountIQIcon = styled('div')(({ theme }) => ({
fontWeight: 'bold',
color: '#4ecdc4',
fontSize: '1rem',
marginRight: theme.spacing(1),
}));
export const AccountIQ = () => {
return (
<SectionCard
title="AccountIQ"
icon={<AccountIQIcon>AIQ</AccountIQIcon>
}
items={[
{ title: 'Automatically reconcile your transactions' },
{ title: 'Live wallet balances from providers' },
{ title: 'Gaming provider financial overviews' },
{ title: 'Learn more' },
]}
/>
)
}

View File

@ -1,49 +0,0 @@
import React from "react";
import { Stack, Typography, Button } from "@mui/material";
interface ConfirmProps {
onSubmit: () => void | Promise<void>;
onClose: () => void;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
disabled?: boolean;
}
/**
* Simple confirmation content to be rendered inside the shared Modal.
* Shows an "Are you sure?" message and calls the parent's onSubmit when confirmed.
*/
const Confirm: React.FC<ConfirmProps> = ({
onSubmit,
onClose,
message = "Are you sure you want to continue?",
confirmLabel = "Yes, continue",
cancelLabel = "Cancel",
disabled = false,
}) => {
const handleConfirm = async () => {
await Promise.resolve(onSubmit());
};
return (
<Stack spacing={3}>
<Typography variant="body1">{message}</Typography>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={onClose} disabled={disabled}>
{cancelLabel}
</Button>
<Button
color="primary"
variant="contained"
onClick={handleConfirm}
disabled={disabled}
>
{confirmLabel}
</Button>
</Stack>
</Stack>
);
};
export default Confirm;

View File

@ -0,0 +1,18 @@
import React from 'react';
import DescriptionIcon from '@mui/icons-material/Description';
import { SectionCard } from '../SectionCard/SectionCard';
export const Documentation = () => {
return (
<SectionCard
title="Documentation"
icon={<DescriptionIcon fontSize="small" />}
items={[
{ title: 'Provider Integration Overview' },
{ title: 'APIs Introduction' },
{ title: 'Documentation Overview' },
{ title: 'How-Tos' },
]}
/>
);
};

View File

@ -0,0 +1,155 @@
import React, { useState } from 'react';
import {
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
Stack,
Box,
Paper,
IconButton,
} from '@mui/material';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import MoreVertIcon from '@mui/icons-material/MoreVert';
export const FetchReport = () => {
const [state, setState] = useState('');
const [psp, setPsp] = useState('');
const [reportType, setReportType] = useState('');
const handleDownload = () => {
// Download logic goes here
alert('Report downloaded');
};
const isDownloadEnabled = state && psp && reportType;
return (
<Paper elevation={3} sx={{ padding: 2, margin: 2, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6" fontWeight="bold">
Fetch Report
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarTodayIcon fontSize="small" />
<Typography variant="body2">Last 30 days</Typography>
<IconButton size="small">
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Stack spacing={2}>
<FormControl fullWidth>
<InputLabel>Select state (defaults to All)</InputLabel>
<Select value={state} onChange={(e) => setState(e.target.value)} label="Select state (defaults to All)">
<MenuItem value="successful">Successful</MenuItem>
<MenuItem value="failed">Failed</MenuItem>
<MenuItem value="canceled">Canceled</MenuItem>
{/* Add more states */}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Select PSPs (defaults to All)</InputLabel>
<Select value={psp} onChange={(e) => setPsp(e.target.value)} label="Select PSPs (defaults to All)">
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
<MenuItem value="a1">A1</MenuItem>
<MenuItem value="ahub">AHUB</MenuItem>
<MenuItem value="aibms">AIBMS</MenuItem>
{/* Add more PSPs */}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Select report type</InputLabel>
<Select value={reportType} onChange={(e) => setReportType(e.target.value)} label="Select report type">
<MenuItem value="allTransactionsReport">All Transactions Report</MenuItem>
<MenuItem value="depositReport">Deposit Report</MenuItem>
<MenuItem value="widthdrawReport">WithDraw Report</MenuItem>
{/* Add more types */}
</Select>
</FormControl>
<Box textAlign="center" mt={2}>
<Button
variant="outlined"
onClick={handleDownload}
disabled={!isDownloadEnabled}
sx={{ minWidth: 200 }}
>
Download Report
</Button>
</Box>
</Stack>
</Paper>
);
};

View File

@ -0,0 +1,66 @@
import {
Box,
Card,
CardContent,
Typography,
IconButton,
Divider,
} from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
// import { ArrowDropUp } from '@mui/icons-material';
const stats = [
{ label: 'TOTAL', value: 5, change: '-84.85%' },
{ label: 'SUCCESSFUL', value: 10, change: '100%' },
{ label: 'ACCEPTANCE RATE', value: '0%', change: '-100%' },
{ label: 'AMOUNT', value: '€0.00', change: '-100%' },
{ label: 'ATV', value: '€0.00', change: '-100%' },
];
const StatItem = ({ label, value, change }: { label: string, value: string | number, change: string }) => (
<Box sx={{ textAlign: 'center', px: 2 }}>
<Typography variant="body2" fontWeight="bold" color="text.secondary">
{label}
</Typography>
<Typography variant="h6" fontWeight="bold" mt={0.5}>
{value}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'error.main' }}>
<ArrowDropDownIcon fontSize="small" />
{/* <ArrowDropUp fontSize='small' /> */}
<Typography variant="caption">{change}</Typography>
</Box>
</Box>
);
export const GeneralHealthCard = () => {
return (
<Card sx={{ borderRadius: 3, p: 2 }}>
<CardContent sx={{ pb: '16px !important' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="bold">
General Health
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarTodayIcon fontSize="small" />
<Typography variant="body2">Last 24h</Typography>
<IconButton size="small">
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Divider />
<Box sx={{ display: 'flex', justifyContent: 'space-around', mt: 2 }}>
{stats.map((item) => (
<StatItem key={item.label} {...item} />
))}
</Box>
</CardContent>
</Card>
);
}

View File

@ -1,230 +0,0 @@
import * as React from "react";
import {
Dialog,
DialogTitle,
DialogContent,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
Paper,
Box,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
interface Transaction {
date: string;
method: string;
amount: number | string;
status: string;
}
interface TransactionHistoryModalProps {
open: boolean;
onClose: () => void;
deposits?: Transaction[];
withdrawals?: Transaction[];
}
/**
* TransactionHistoryModal
*
* Props:
* - open: boolean
* - onClose: () => void
* - deposits: Array<{ date: string; method: string; amount: number | string; status: string }>
* - withdrawals: Array<{ date: string; method: string; amount: number | string; status: string }>
*/
export function HistoryModal({
open,
onClose,
deposits = [],
withdrawals = [],
}: TransactionHistoryModalProps) {
const fmt = (n: number | string): string =>
typeof n === "number"
? n.toLocaleString(undefined, { style: "currency", currency: "EUR" })
: n;
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={{ p: 2, pr: 6 }}>
<Typography variant="h6" fontWeight={700} sx={{ textAlign: "center" }}>
History
</Typography>
<IconButton
aria-label="close"
onClick={onClose}
sx={{ position: "absolute", right: 8, top: 8 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ pt: 0 }}>
<Box component={Paper} variant="outlined" sx={{ p: { xs: 1, sm: 2 } }}>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Typography
variant="h6"
sx={{ mb: 1, fontWeight: 700, textAlign: "center" }}
>
Last 5 Deposits
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date/Time</TableCell>
<TableCell>Deposit Method</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{deposits.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>
<Typography variant="body2" color="text.secondary">
No deposits yet
</Typography>
</TableCell>
</TableRow>
) : (
deposits.slice(0, 5).map((row, idx) => (
<TableRow key={idx}>
<TableCell>{row.date}</TableCell>
<TableCell>{row.method}</TableCell>
<TableCell>{fmt(row.amount)}</TableCell>
<TableCell>{row.status}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="h6"
sx={{ mb: 1, fontWeight: 700, textAlign: "center" }}
>
Last 5 Withdrawals
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date/Time</TableCell>
<TableCell>Withdrawal Method</TableCell>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{withdrawals.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>
<Typography variant="body2" color="text.secondary">
No withdrawals yet
</Typography>
</TableCell>
</TableRow>
) : (
withdrawals.slice(0, 5).map((row, idx) => (
<TableRow key={idx}>
<TableCell>{row.date}</TableCell>
<TableCell>{row.method}</TableCell>
<TableCell>{fmt(row.amount)}</TableCell>
<TableCell>{row.status}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
</Box>
</DialogContent>
</Dialog>
);
}
export default function TransactionHistoryModalDemo() {
const [open, setOpen] = React.useState(false);
const deposits: Transaction[] = [
{ date: "2025-08-01 10:10", method: "CC", amount: 120, status: "approved" },
{
date: "2025-07-28 14:35",
method: "Bank Transfer",
amount: 250,
status: "approved",
},
{
date: "2025-07-20 09:05",
method: "PayPal",
amount: 75,
status: "pending",
},
{ date: "2025-07-11 17:12", method: "CC", amount: 300, status: "rejected" },
{ date: "2025-07-01 12:42", method: "CC", amount: 180, status: "approved" },
];
const withdrawals: Transaction[] = [
{
date: "2025-08-02 11:20",
method: "Crypto",
amount: 95,
status: "processing",
},
{
date: "2025-07-29 16:45",
method: "Bank Transfer",
amount: 220,
status: "approved",
},
{
date: "2025-07-21 15:10",
method: "eWallet",
amount: 60,
status: "pending",
},
{
date: "2025-07-12 13:33",
method: "Crypto",
amount: 120,
status: "approved",
},
{
date: "2025-07-03 08:50",
method: "Bank Transfer",
amount: 150,
status: "rejected",
},
];
return (
<div>
<button onClick={() => setOpen(true)}>Open Transaction History</button>
<HistoryModal
open={open}
onClose={() => setOpen(false)}
deposits={deposits}
withdrawals={withdrawals}
/>
</div>
);
}

View File

@ -1,53 +0,0 @@
.modal__overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
position: relative;
min-width: 320px;
max-width: 65vw;
max-height: 90vh;
overflow: auto;
padding: 2rem 1.5rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
}
.modal__close {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
color: #888;
transition: color 0.2s;
&:hover,
&:focus {
color: #333;
outline: none;
}
}
.modal__body {
// Example element block for modal content
margin-top: 1rem;
font-size: 1rem;
color: #222;
min-width: 450px;
}

View File

@ -1,48 +0,0 @@
import React from "react";
import "./Modal.scss";
interface ModalProps {
open: boolean;
onClose?: () => void;
children: React.ReactNode;
className?: string;
overlayClassName?: string;
title?: string;
}
const Modal: React.FC<ModalProps> = ({
open,
onClose,
children,
title,
className = "",
}) => {
if (!open) return null;
return (
<div
className={"modal__overlay"}
onClick={onClose}
data-testid="modal-overlay"
>
<div
className={`modal${className ? " " + className : ""}`}
onClick={e => e.stopPropagation()}
data-testid="modal-content"
>
<button
className="modal__close"
onClick={onClose}
aria-label="Close"
type="button"
>
&times;
</button>
{title && <h2 className="modal__title">{title}</h2>}
<div className={"modal__body"}>{children}</div>
</div>
</div>
);
};
export default Modal;

View File

@ -1,20 +1,20 @@
.page-link__container {
display: flex;
align-items: center;
padding: 12px 1px;
border-radius: 4px;
color: var(--text-tertiary);
text-decoration: none;
transition: background 0.2s ease-in-out;
&:hover {
color: #fff;
background-color: var(--hover-color);
cursor: pointer;
}
.page-link__text {
display: flex;
align-items: center;
padding: 12px 1px;
border-radius: 4px;
color: var(--text-tertiary);
margin-left: 12px;
font-weight: 500;
}
text-decoration: none;
transition: background 0.2s ease-in-out;
&:hover {
color: #fff;
background-color: var(--hover-color);
cursor: pointer;
}
.page-link__text {
color: var(--text-tertiary);
margin-left: 12px;
font-weight: 500;
}
}

View File

@ -1,25 +1,25 @@
"use client";
import Link from "next/link";
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces";
import clsx from "clsx"; // Utility to merge class names
import "./PageLinks.scss";
import { ISidebarLink } from "@/app/features/dashboard/sidebar/SidebarLink.interfaces"; // Keep this import
import { resolveIcon } from "@/app/utils/iconMap";
import "./PageLinks.scss"; // Keep this import
// Define the props interface for your PageLinks component
// It now extends ISidebarLink and includes isShowIcon
interface IPageLinksProps extends ISidebarLink {
isShowIcon?: boolean;
}
// PageLinks component
export default function PageLinks({ title, path, icon }: IPageLinksProps) {
const Icon = resolveIcon(icon);
console.log("Icon", Icon);
export default function PageLinks({
title,
path,
icon: Icon,
}: IPageLinksProps) {
return (
<Link href={path} className={clsx("page-link", "page-link__container")}>
{Icon && <Icon />}
<span className="page-link__text">{title}</span>
<Link href={path} passHref legacyBehavior className="page-link">
<a className={clsx("page-link__container")}>
{Icon && <Icon />}
<span className="page-link__text">{title}</span>
</a>
</Link>
);
}

View File

@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Box } from "@mui/material";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
const data = [
{ name: "Group A", value: 100 },
{ name: "Group B", value: 200 },
{ name: "Group C", value: 400 },
{ name: "Group D", value: 300 }
];
const COLORS = ["#4caf50", "#ff9800", "#f44336", "#9e9e9e"];
const RADIAN = Math.PI / 180;
const renderCustomizedLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
percent,
// index
}: any) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
export const PieCharts = () => {
return (
<Box sx={{
width: {
xs: '100%',
md: '60%'
}, height: '300px'
}}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={renderCustomizedLabel}
outerRadius="80%" // Percentage-based radius
fill="#8884d8"
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</Box >
);
}

View File

@ -0,0 +1,37 @@
import { CardContent, Typography, Divider, List, ListItem, ListItemText, Paper, Box, IconButton } from "@mui/material";
import MoreVertIcon from '@mui/icons-material/MoreVert';
export const SectionCard = ({ title, icon, items }) => (
<Paper elevation={3} sx={{ padding: 2, margin: 2, display: 'flex', flexDirection: 'column' }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex' }}>
{icon}
<Typography variant="subtitle1" fontWeight="bold">{title}</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small">
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
</Box >
<Divider />
<List dense disablePadding>
{items.map((item, index) => (
<ListItem key={index} disableGutters>
<ListItemText
primary={item.title}
secondary={item.date}
primaryTypographyProps={{ fontSize: 14 }}
secondaryTypographyProps={{ fontSize: 12 }}
/>
</ListItem>
))}
</List>
</CardContent>
</Paper>
);

View File

@ -1,48 +0,0 @@
.spinner {
display: inline-block;
position: relative;
border-radius: 50%;
border: 2px solid transparent;
border-top: 2px solid #1976d2;
animation: spin 1s linear infinite;
&--small {
width: 16px;
height: 16px;
border-width: 1px;
}
&--medium {
width: 20px;
height: 20px;
border-width: 2px;
}
&--large {
width: 32px;
height: 32px;
border-width: 3px;
}
&__inner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60%;
height: 60%;
border-radius: 50%;
border: 1px solid transparent;
border-top: 1px solid currentColor;
animation: spin 0.8s linear infinite reverse;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -1,23 +0,0 @@
import React from "react";
import "./Spinner.scss";
interface SpinnerProps {
size?: "small" | "medium" | "large";
color?: string;
}
const Spinner: React.FC<SpinnerProps> = ({
size = "medium",
color = "#1976d2",
}) => {
return (
<div
className={`spinner spinner--${size}`}
style={{ borderTopColor: color }}
>
<div className="spinner__inner"></div>
</div>
);
};
export default Spinner;

View File

@ -1,42 +0,0 @@
"use client";
import React from "react";
import { useDispatch } from "react-redux";
import { Button, Box, Typography, Stack } from "@mui/material";
import { AppDispatch } from "@/app/redux/types";
import { autoLogout, validateAuth } from "@/app/redux/auth/authSlice";
export default function TestTokenExpiration() {
const dispatch = useDispatch<AppDispatch>();
const handleTestExpiration = () => {
dispatch(autoLogout("Manual test expiration"));
};
const handleRefreshAuth = () => {
dispatch(validateAuth());
};
return (
<Box sx={{ p: 2, border: "1px solid #ccc", borderRadius: 1, mb: 2 }}>
<Typography variant="h6" gutterBottom>
Test Token Expiration
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Test authentication functionality and token management.
</Typography>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
color="warning"
onClick={handleTestExpiration}
>
Test Auto-Logout
</Button>
<Button variant="outlined" color="primary" onClick={handleRefreshAuth}>
Refresh Auth Status
</Button>
</Stack>
</Box>
);
}

View File

@ -1,54 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import {
selectExpiresInHours,
selectTimeUntilExpiration,
} from "@/app/redux/auth/selectors";
import { Alert, AlertTitle, Box, Typography } from "@mui/material";
export default function TokenExpirationInfo() {
const expiresInHours = useSelector(selectExpiresInHours);
const timeUntilExpiration = useSelector(selectTimeUntilExpiration);
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
// Show warning when token expires in less than 1 hour
if (expiresInHours > 0 && expiresInHours <= 1) {
setShowWarning(true);
} else {
setShowWarning(false);
}
}, [expiresInHours]);
if (expiresInHours <= 0) {
return null; // Don't show anything if not logged in or no token info
}
const formatTime = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
return (
<Box sx={{ mb: 2 }}>
{showWarning ? (
<Alert severity="warning" sx={{ mb: 2 }}>
<AlertTitle>Session Expiring Soon</AlertTitle>
Your session will expire in {formatTime(timeUntilExpiration)}. Please
save your work and log in again if needed.
</Alert>
) : (
<Typography variant="caption" color="text.secondary">
Session expires in {formatTime(timeUntilExpiration)}
</Typography>
)}
</Box>
);
}

View File

@ -0,0 +1,71 @@
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Box,
Button
} from '@mui/material';
const data1 = [
{ state: 'Success', count: 120, percentage: '60%', color: 'green' },
{ state: 'Pending', count: 50, percentage: '25%', color: 'orange' },
{ state: 'Failed', count: 20, percentage: '10%', color: 'red' },
{ state: 'Other', count: 10, percentage: '5%', color: 'gray' }
];
export const TransactionsOverviewTable = () => {
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell align="center">State</TableCell>
<TableCell align="center">Count</TableCell>
<TableCell align="center">Percentage</TableCell>
<TableCell align="center">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data1.map((row) => (
<TableRow key={row.state}>
<TableCell align="center">
<Box
sx={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
mx: 'auto', // center the flexbox itself
width: '73px' // consistent width for alignment
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: row.color,
mr: 1
}}
/>
{row.state}
</Box>
</TableCell>
<TableCell align="center">{row.count}</TableCell>
<TableCell align="center">{row.percentage}</TableCell>
<TableCell align="center">
<Button variant="outlined" size="small">
View
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};

View File

@ -0,0 +1,50 @@
import { useRouter } from 'next/navigation';
import { Box, Button, IconButton, Paper, Typography } from "@mui/material"
import { PieCharts } from "../PieCharts/PieCharts"
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { TransactionsOverviewTable } from "./TransactionsOverViewTable";
export const TransactionsOverview = () => {
const router = useRouter();
return (
<Paper elevation={3} sx={{ padding: 2, margin: 2, display: 'flex', flexDirection: 'column' }}>
{/* Title and All Transactions Button */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}>
<Typography variant="h6">
Transactions Overview (Last 24h)
</Typography>
<Box>
<Button variant="contained" color="primary" onClick={() => router.push('dashboard/transactions')}>
All Transactions
</Button>
<IconButton size="small">
<MoreVertIcon fontSize="small" />
</IconButton>
</Box>
</Box>
{/* Chart and Table */}
<Box
sx={{
padding: 2,
margin: 2,
display: 'flex',
flexDirection: 'row',
flexWrap: {
xs: 'wrap', // Wrap on small screens
md: 'nowrap' // No wrap on medium and up
},
gap: {
xs: 4, // Add spacing on small screens
md: 0 // No spacing on larger screens
}
}}
>
<PieCharts />
<TransactionsOverviewTable />
</Box>
</Paper>
)
}

View File

@ -0,0 +1,111 @@
import {
Box,
Button,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography
} from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import MoreVertIcon from '@mui/icons-material/MoreVert';
const transactions = [
{
id: '1049078821',
user: '17',
created: '2025-06-17 16:45',
type: 'BestPayWithdrawal',
amount: '-787.49 TRY',
psp: 'BestPay'
},
{
id: '1049078822',
user: '17',
created: '2025-06-17 16:45',
type: 'BestPayWithdrawal',
amount: '-787.49 TRY',
psp: 'BestPay'
},
{
id: '1049078823',
user: '17',
created: '2025-06-17 16:45',
type: 'BestPayWithdrawal',
amount: '-787.49 TRY',
psp: 'BestPay'
},
{
id: '1049078824',
user: '17',
created: '2025-06-17 16:45',
type: 'BestPayWithdrawal',
amount: '-787.49 TRY',
psp: 'BestPay'
}
];
export const TransactionsWaitingApproval = () => {
return (
<Paper elevation={3} sx={{ padding: 2, margin: 2, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6" fontWeight="bold">
Transactions Waiting for Approval
</Typography>
<Box>
<Button variant="outlined">All Pending Withdrawals</Button>
<IconButton size="small">
<MoreVertIcon fontSize="small" />
</IconButton> </Box>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell><strong>ID</strong></TableCell>
<TableCell><strong>User</strong></TableCell>
<TableCell><strong>Created</strong></TableCell>
<TableCell><strong>Type</strong></TableCell>
<TableCell><strong>Amount</strong></TableCell>
<TableCell><strong>PSP</strong></TableCell>
<TableCell><strong>Action</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{transactions.map((tx) => (
<TableRow key={tx.id}>
<TableCell>{tx.id}</TableCell>
<TableCell>{tx.user}</TableCell>
<TableCell>{tx.created}</TableCell>
<TableCell>{tx.type}</TableCell>
<TableCell>{tx.amount}</TableCell>
<TableCell>{tx.psp}</TableCell>
<TableCell>
<IconButton color="success">
<CheckCircleIcon />
</IconButton>
<IconButton color="error">
<CancelIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
</Paper>
);
}

View File

@ -0,0 +1,17 @@
import { SectionCard } from "../SectionCard/SectionCard";
import WifiIcon from '@mui/icons-material/Wifi';
export const WhatsNew = () => {
return (
<SectionCard
title="Whats New"
icon={<WifiIcon fontSize="small" />}
items={[
{ title: 'Sneak Peek Discover the New Rules Hub Feature', date: '13 May 2025' },
{ title: 'New security measures for anonymizing sensitive configuration values, effective December 2nd', date: '31 Oct 2024' },
{ title: 'Introducing Our New Transactions and Rule Views', date: '23 Oct 2024' },
{ title: 'Introducing Our New Status Page', date: '09 Sept 2024' },
]}
/>
);
};

View File

@ -0,0 +1,42 @@
import React from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
ListItemIcon,
ListItemText,
} from '@mui/material';
import { SIDEBAR_LINKS } from '@/constants/SidebarLink.constants';
interface Props {
onChange?: (event: SelectChangeEvent<string>) => void;
}
export default function SidebarDropdown({ onChange }: Props) {
const [value, setValue] = React.useState('');
const handleChange = (event: SelectChangeEvent<string>) => {
setValue(event.target.value);
onChange?.(event);
};
return (
<FormControl fullWidth variant="outlined" sx={{ maxWidth: 200 }}>
<InputLabel id="sidebar-dropdown-label">Navigate To</InputLabel>
<Select
labelId="sidebar-dropdown-label"
value={value}
onChange={handleChange}
label="Navigate To"
>
{SIDEBAR_LINKS.map((link:any) => (
<MenuItem key={link.path} value={link.path}>
<ListItemText primary={link.title} />
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { AppBar, Toolbar, IconButton, Menu, MenuItem, Button } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import Dropdown from './DropDown';
const Header = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
// Handle menu open
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
// Handle menu close
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleChange = (e:any) => {
};
return (
<AppBar position="sticky" color="transparent" elevation={0} sx={{ borderBottom: '1px solid #22242626' }}>
<Toolbar>
{/* Burger Menu */}
<IconButton edge="start" color="inherit" aria-label="menu">
<MenuIcon />
</IconButton>
{/* Dropdown Button */}
<Dropdown onChange={handleChange}/>
{/* Dropdown Menu */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleMenuClose}>Option 1</MenuItem>
<MenuItem onClick={handleMenuClose}>Option 2</MenuItem>
<MenuItem onClick={handleMenuClose}>Option 3</MenuItem>
</Menu>
</Toolbar>
</AppBar>
);
};
export default Header;

View File

@ -0,0 +1,8 @@
import { styled } from '@mui/system';
export const LayoutWrapper = styled('div')({
display: 'flex',
width: '100%',
height: '100vh',
// overflow: 'hidden',
});

View File

@ -0,0 +1,8 @@
import { styled } from '@mui/system';
export const MainContent = styled('div')(({ theme }) => ({
marginLeft: '240px',
padding: theme.spacing(3),
minHeight: '100vh',
width: 'calc(100% - 240px)',
}));

View File

@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { styled } from '@mui/system';
import { ISidebarLink } from '@/interfaces/SidebarLink.interfaces';
const LinkContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
padding: '12px 1px',
borderRadius: '4px',
color: theme.palette.text.tertiary,
textDecoration: 'none',
transition: 'background 0.2s ease-in-out',
'&:hover': {
color: 'rgb(255, 255, 255)',
background: 'rgba(255, 255, 255, 0.08)',
backgroundColor: theme.palette.action.hover,
cursor: 'pointer',
},
}));
const LinkText = styled('span')(({ theme }) => ({
color: theme.palette.text.tertiary,
marginLeft: '12px',
fontWeight: 500,
}));
export default function SidebarLink({ title, path, icon: Icon }: ISidebarLink) {
return (
<Link href={path} passHref legacyBehavior>
<a style={{ textDecoration: 'none' }}>
<LinkContainer>
{Icon && <Icon />}
<LinkText>{title}</LinkText>
</LinkContainer>
</a>
</Link>
);
}

View File

@ -0,0 +1,55 @@
'use client';
import React from 'react';
import DashboardIcon from '@mui/icons-material/Dashboard';
import { styled } from '@mui/system';
import { SIDEBAR_LINKS } from '@/constants/SidebarLink.constants';
import SidebarLink from './SideBarLink';
const SideBarContainer = styled('aside')(({ theme }) => ({
position: 'fixed',
top: 0,
left: 0,
width: 240,
height: '100vh',
backgroundColor: theme.palette.background.primary,
color: theme.palette.text.primary,
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2),
zIndex: 1100,
borderRight: `1px solid ${theme.palette.divider}`,
}));
const SidebarHeader = styled('div')(({ theme }) => ({
fontSize: '20px',
fontWeight: 600,
marginBottom: theme.spacing(3),
display: 'flex',
alignItems: 'center',
color: theme.palette.text.primary,
}));
const IconSpacing = styled(DashboardIcon)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
const SideBar = () => {
return (
<SideBarContainer>
<SidebarHeader>
<span style={{color: '#fff'}}>Betrise cashir <IconSpacing fontSize="small" /></span>
</SidebarHeader>
{SIDEBAR_LINKS.map((link) => (
<SidebarLink
key={link.path}
title={link.title}
path={link.path}
icon={link.icon}
/>
))}
</SideBarContainer>
);
};
export default SideBar;

View File

@ -0,0 +1,162 @@
import React, { useState } from 'react';
import {
Box,
TextField,
IconButton,
InputAdornment,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Checkbox,
Paper,
MenuItem,
InputLabel,
Select,
FormControl,
SelectChangeEvent
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
const rows = [
{
merchantId: '100987998',
txId: '1049078821',
userId: 17,
userEmail: 'dhkheni1@yopmail.com',
kycStatus: 'N/A',
},
{
merchantId: '100987998',
txId: '1049078821',
userId: 18,
userEmail: 'dhkheni1@yopmail.com',
kycStatus: 'N/A',
},
{
merchantId: '100987998',
txId: '1049078821',
userId: 19,
userEmail: 'dhkheni1@yopmail.com',
kycStatus: 'N/A',
},
];
export const Approve = () => {
const [age, setAge] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const handleCheckboxChange = (userId: number) => (event: React.ChangeEvent<HTMLInputElement>) => {
const isChecked = event.target.checked;
setSelectedRows((prevSelected: number[]) =>
isChecked
? [...prevSelected, userId]
: prevSelected.filter((id) => id !== userId)
);
console.log('Selected IDs:', isChecked
? [...selectedRows, userId]
: selectedRows.filter((id) => id !== userId));
};
const handleChangeAge = (event: SelectChangeEvent) => {
setAge(event.target.value as string);
};
return (
<Box p={2}>
<Box mb={2} display="flex" justifyContent="space-between" alignItems="center">
<TextField
variant="outlined"
placeholder="Filter by tags or search by keyword"
size="small"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton>
<SearchIcon />
</IconButton>
</InputAdornment>
),
}}
/>
<Box sx={{ width: '100px' }}>
{/* <IconButton onClick={handleMenuOpen}> */}
{/* <MoreVertIcon /> */}
{/* </IconButton> */}
{/* <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}> */}
{/* <MenuItem onClick={handleMenuClose}>Action 1</MenuItem> */}
{/* <MenuItem onClick={handleMenuClose}>Action 2</MenuItem> */}
{/* </Menu> */}
<FormControl fullWidth>
<InputLabel id="demo-simple-select-label">Action</InputLabel>
<Select
labelId="demo-simple-select-label"
id="demo-simple-select"
value={age}
label="Age"
onChange={handleChangeAge}
>
<MenuItem value={10}>Ten</MenuItem>
<MenuItem value={20}>Twenty</MenuItem>
<MenuItem value={30}>Thirty</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox"><Checkbox /></TableCell>
<TableCell>Merchant-id</TableCell>
<TableCell>Tx-id</TableCell>
<TableCell>User</TableCell>
<TableCell>User email</TableCell>
<TableCell>KYC Status</TableCell>
<TableCell>KYC PSP</TableCell>
<TableCell>KYC PSP status</TableCell>
<TableCell>KYC ID status</TableCell>
<TableCell>KYC address status</TableCell>
<TableCell>KYC liveness status</TableCell>
<TableCell>KYC age status</TableCell>
<TableCell>KYC peps and sanctions</TableCell>
<TableCell>Suspected</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, idx) => (
<TableRow key={idx}>
<TableCell padding="checkbox">
<Checkbox checked={selectedRows.includes(row.userId)}
onChange={handleCheckboxChange(row.userId)} /></TableCell>
<TableCell>{row.merchantId}</TableCell>
<TableCell>{row.txId}</TableCell>
<TableCell>
<a href={`/user/${row.userId}`} target="_blank" rel="noopener noreferrer">
{row.userId}
</a>
</TableCell>
<TableCell>{row.userEmail}</TableCell>
<TableCell>{row.kycStatus}</TableCell>
<TableCell />
<TableCell />
<TableCell />
<TableCell />
<TableCell />
<TableCell />
<TableCell />
<TableCell />
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}

View File

@ -0,0 +1,26 @@
import { Box } from "@mui/material"
import { GeneralHealthCard } from "../../GeneralHealthCard/GeneralHealthCard"
import { TransactionsOverview } from "../../TransactionsOverview/TransactionsOverview"
import { TransactionsWaitingApproval } from "../../TransactionsWaitingApproval/TransactionsWaitingApproval"
import { FetchReport } from "../../FetchReports/FetchReports"
import { Documentation } from "../../Documentation/Documentation"
import { AccountIQ } from "../../AccountIQ/AccountIQ"
import { WhatsNew } from "../../WhatsNew/WhatsNew"
export const DashboardHomePage = () => {
return (
<>
<Box sx={{ p: 2 }}>
<GeneralHealthCard />
</Box>
<TransactionsOverview />
<TransactionsWaitingApproval />
<FetchReport />
<Documentation />
<AccountIQ />
<WhatsNew />
</>
)
}

View File

@ -0,0 +1,110 @@
'use client';
import { useState } from 'react';
import {
Button, Dialog, DialogTitle, DialogContent, DialogActions,
FormControl, Select, MenuItem, FormControlLabel, Checkbox,
Stack, Paper, styled,
TextField
} from '@mui/material';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
import { DataGrid } from '@mui/x-data-grid';
import { columns } from './constants';
import { rows } from './mockData';
const paginationModel = { page: 0, pageSize: 50 };
export default function TransactionTable() {
const [open, setOpen] = useState(false);
const [fileType, setFileType] = useState<'csv' | 'xls' | 'xlsx'>('csv');
const [onlyCurrentTable, setOnlyCurrentTable] = useState(false);
const handleExport = () => {
const exportRows = onlyCurrentTable ? rows.slice(0, 5) : rows;
const exportData = [
columns.map(col => col.headerName),
// @ts-expect-error - Dynamic field access from DataGrid columns
...exportRows.map(row => columns.map(col => row[col.field] ?? '')),
];
const worksheet = XLSX.utils.aoa_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Transactions');
if (fileType === 'csv') {
const csv = XLSX.utils.sheet_to_csv(worksheet);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, 'transactions.csv');
} else {
XLSX.writeFile(workbook, `transactions.${fileType}`, { bookType: fileType });
}
setOpen(false);
};
return (
<StyledPaper>
<Stack direction="row" justifyContent="space-between" alignItems="center" p={2}>
<TextField
label="Search"
variant="outlined"
size="small"
// value={'searchQuery'}
onChange={(e) => console.log(`setSearchQuery(${e.target.value})`)}
sx={{ width: 300 }}
/>
<Button
variant="outlined"
startIcon={<FileUploadIcon />}
onClick={() => setOpen(true)}
>
Export
</Button>
</Stack>
<DataGrid
rows={rows}
columns={columns}
initialState={{ pagination: { paginationModel } }}
pageSizeOptions={[50 , 100]}
sx={{ border: 0 }}
/>
{/* Export Dialog */}
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle>Export Transactions</DialogTitle>
<DialogContent>
<FormControl fullWidth sx={{ mt: 2 }}>
<Select
value={fileType}
onChange={(e) => setFileType(e.target.value as 'csv' | 'xls' | 'xlsx')}
>
<MenuItem value="csv">CSV</MenuItem>
<MenuItem value="xls">XLS</MenuItem>
<MenuItem value="xlsx">XLSX</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
checked={onlyCurrentTable}
onChange={(e) => setOnlyCurrentTable(e.target.checked)}
/>
}
label="Only export the results in the current table"
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleExport}>Export</Button>
</DialogActions>
</Dialog>
</StyledPaper>
);
}
const StyledPaper = styled(Paper)(() => ({
height: '90vh',
}));

View File

@ -0,0 +1,80 @@
import { GridColDef } from "@mui/x-data-grid";
export const columns: GridColDef[] = [
{ field: 'merchandId', headerName: 'Merchant ID', width: 130 },
{ field: 'transactionID', headerName: 'Transaction ID', width: 130 },
{ field: 'user', headerName: 'User', width: 75 },
{ field: 'created', headerName: 'Created', type: 'number', width: 130 },
{ field: 'state', headerName: 'State', type: 'number', width: 130 },
{ field: 'statusDescription', headerName: 'Status Description', type: 'number', width: 130 },
{ field: 'pspStatusCode', headerName: 'PSP Status Code', type: 'number', width: 130 },
{ field: 'pspStatusMessage', headerName: 'PSP Status Message', type: 'number', width: 90 },
{ field: 'psp', headerName: 'PSP', type: 'number', width: 90 },
{ field: 'pspAccount', headerName: 'PSP Account', type: 'number', width: 90 },
{ field: 'initPSP', headerName: 'Init PSP', type: 'number', width: 90 },
{ field: 'initPSPAccout', headerName: 'Init PSP Account', type: 'number', width: 90 },
{ field: 'pspService', headerName: 'PSP Service', type: 'number', width: 90 },
{ field: 'transactionType', headerName: 'Transaction Type', type: 'number', width: 90 },
{ field: 'paymentMethod', headerName: 'Payment Method', type: 'number', width: 90 },
{ field: 'rules', headerName: 'Rules', type: 'number', width: 90 },
{ field: 'amount', headerName: 'Amount', type: 'number', width: 90 },
{ field: 'fee', headerName: 'Fee', type: 'number', width: 90 },
{ field: 'transactionAmount', headerName: 'Transaction Amount', type: 'number', width: 90 },
{ field: 'baseAmount', headerName: 'Base Amount', type: 'number', width: 90 },
{ field: 'baseFee', headerName: 'Base Fee', type: 'number', width: 90 },
{ field: 'baseTransaction', headerName: 'Base Transaction', type: 'number', width: 90 },
{ field: 'pspFee', headerName: 'PSP Fee', type: 'number', width: 90 },
{ field: 'basePspFee', headerName: 'Base PSP Fee', type: 'number', width: 90 },
{ field: 'authAmount', headerName: 'Auth Amount', type: 'number', width: 90 },
{ field: 'baseAuthAmount', headerName: 'Base Auth Amount', type: 'number', width: 90 },
{ field: 'userBalance', headerName: 'User Balance', type: 'number', width: 90 },
{ field: 'updated', headerName: 'Updated', type: 'number', width: 90 },
{ field: 'userIp', headerName: 'User IP', type: 'number', width: 90 },
{ field: 'channel', headerName: 'Channel', type: 'number', width: 90 },
{ field: 'depositType', headerName: 'Deposit Type', type: 'number', width: 90 },
{ field: 'userEmal', headerName: 'User Emal', type: 'number', width: 90 },
{ field: 'userCategory', headerName: 'User Category', type: 'number', width: 90 },
{ field: 'userCountry', headerName: 'User Country', type: 'number', width: 90 },
{ field: 'userAccount', headerName: 'User Account', type: 'number', width: 90 },
{ field: 'bankName', headerName: 'Bank Name', type: 'number', width: 90 },
{ field: 'pspUserReference', headerName: 'PSP User Reference', type: 'number', width: 90 },
{ field: 'pspFraudScore', headerName: 'PSP Fraud Score', type: 'number', width: 90 },
{ field: 'fraudStatus', headerName: 'FraudStatus', type: 'number', width: 90 },
{ field: 'blocked', headerName: 'Blocked', type: 'number', width: 90 },
{ field: 'abuse', headerName: 'Abuse', type: 'number', width: 90 },
{ field: 'kycStatus', headerName: 'KYC Status', type: 'number', width: 90 },
{ field: 'kycPSPName', headerName: 'KYC PSP Name', type: 'number', width: 90 },
{ field: 'kycPSPStatus', headerName: 'KYC PSP Status', type: 'number', width: 90 },
{ field: 'kycIdStatus', headerName: 'KYC ID Status', type: 'number', width: 90 },
{ field: 'kycAddressStatus', headerName: 'KYC Address Status', type: 'number', width: 90 },
{ field: 'kycAgeStatus', headerName: 'KYC Age Status', type: 'number', width: 90 },
{ field: 'kycPEPAndSanction', headerName: 'KYC PEP And Sanction', type: 'number', width: 90 },
{ field: 'pspReferenceId', headerName: 'PSPReferenceID', type: 'number', width: 90 },
{ field: 'siteReferenceId', headerName: 'Site Reference ID', type: 'number', width: 90 },
{ field: 'info', headerName: 'Info', type: 'number', width: 90 },
{ field: 'accountHolder', headerName: 'Account Holder', type: 'number', width: 90 },
{ field: 'firstName', headerName: 'First Name', type: 'number', width: 90 },
{ field: 'lastName', headerName: 'Last Name', type: 'number', width: 90 },
{ field: 'street', headerName: 'Street', type: 'number', width: 90 },
{ field: 'city', headerName: 'City', type: 'number', width: 90 },
{ field: 'zip', headerName: 'ZIP', type: 'number', width: 90 },
{ field: 'dob', headerName: 'DOB', type: 'number', width: 90 },
{ field: 'mobile', headerName: 'Mobile', type: 'number', width: 90 },
{ field: 'lastUpdatedBy', headerName: 'Last Updated By', type: 'number', width: 90 },
{ field: 'ipCity', headerName: 'IP City', type: 'number', width: 90 },
{ field: 'ipRegion', headerName: 'IP Region', type: 'number', width: 90 },
{ field: 'ipCountry', headerName: 'IP Country', type: 'number', width: 90 },
{ field: 'cardIssuerCountry', headerName: 'Card Issuer Country', type: 'number', width: 90 },
{ field: 'cardBand', headerName: 'Card Band', type: 'number', width: 90 },
{ field: 'cardCategory', headerName: 'Card Category', type: 'number', width: 90 },
{ field: 'cardIssuerName', headerName: 'Card Issuer Name', type: 'number', width: 90 },
{ field: 'inn', headerName: 'INN', type: 'number', width: 90 },
{ field: 'cardType', headerName: 'Card Type', type: 'number', width: 90 },
{ field: 'firstAttempt', headerName: 'First Attempt', type: 'number', width: 90 },
{ field: 'firstSuccessful', headerName: 'First Successful', type: 'number', width: 90 },
{ field: 'firstTransaction', headerName: 'First Transaction', type: 'number', width: 90 },
{ field: 'firstPspAcountAttempt', headerName: 'First PSP Acount Attempt', type: 'number', width: 90 },
{ field: 'firstPspAcountSuccessful', headerName: 'First PSP Acount Successful', type: 'number', width: 90 },
{ field: 'originTransactionId', headerName: 'Origin Transaction ID', type: 'number', width: 90 },
{ field: 'transactionReferenceId', headerName: 'Transaction Reference ID', type: 'number', width: 90 },
];

File diff suppressed because it is too large Load Diff

View File

@ -1,43 +0,0 @@
.search-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 16px;
}
.chip {
display: inline-flex;
align-items: center;
background-color: #e0e0e0;
border-radius: 16px;
padding: 4px 8px;
margin: 4px;
font-size: 14px;
}
.chip-label {
margin-right: 8px;
}
.chip-label.bold {
font-weight: bold;
}
.chip-delete {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: #333;
}
.clear-all {
margin-left: 8px;
text-decoration: underline;
background: none;
border: none;
color: black;
cursor: pointer;
font-size: 14px;
}

View File

@ -1,79 +1,36 @@
"use client";
import React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import "./SearchFilters.scss";
// components/SearchFilters.js
import React from 'react';
import { Box, Chip, Typography, Button } from '@mui/material';
interface SearchFiltersProps {
filters: Record<string, string>;
}
const SearchFilters = ({ filters }: SearchFiltersProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const filterLabels: Record<string, string> = {
userId: "User",
state: "State",
dateRange: "Date Range",
};
const handleDeleteFilter = (key: string) => {
const params = new URLSearchParams(searchParams.toString());
if (key === "dateRange") {
params.delete("dateTime_start");
params.delete("dateTime_end");
} else {
params.delete(key);
}
router.push(`?${params.toString()}`);
};
const onClearAll = () => {
router.push("?");
};
const renderChip = (label: string, value: string, key: string) => (
<div className="chip" key={key}>
<span className={`chip-label ${key === "state" ? "bold" : ""}`}>
{label}: {value}
</span>
<button className="chip-delete" onClick={() => handleDeleteFilter(key)}>
×
</button>
</div>
const SearchFilters = ({ filters, onDeleteFilter, onClearAll }) => {
const renderChip = (label, value, key) => (
<Chip
key={key}
label={
<Typography variant="body2" sx={{ fontWeight: key === 'state' ? 'bold' : 'normal' }}>
{label} {value}
</Typography>
}
onDelete={() => onDeleteFilter(key)}
sx={{ mr: 1, mb: 1 }}
/>
);
const formatDate = (dateStr: string) =>
new Date(dateStr).toISOString().split("T")[0];
const hasDateRange = filters.dateTime_start && filters.dateTime_end;
const allFilters = [
...Object.entries(filters).filter(
([key]) => key !== "dateTime_start" && key !== "dateTime_end"
),
...(hasDateRange
? [
[
"dateRange",
`${formatDate(filters.dateTime_start)} - ${formatDate(filters.dateTime_end)}`,
] as [string, string],
]
: []),
];
return (
<div className="search-filters">
{allFilters.map(([key, value]) =>
value ? renderChip(filterLabels[key] ?? key, value, key) : null
)}
<Box display="flex" alignItems="center" flexWrap="wrap" sx={{ p: 2 }}>
{filters.user && renderChip('User', filters.user, 'user')}
{filters.state && renderChip('State', filters.state, 'state')}
{filters.startDate && renderChip('Start Date', filters.startDate, 'startDate')}
{Object.values(filters).some(Boolean) && (
<button className="clear-all" onClick={onClearAll}>
<Button
onClick={onClearAll}
sx={{ ml: 1, textDecoration: 'underline', color: 'black' }}
>
Clear All
</button>
</Button>
)}
</div>
</Box>
);
};

View File

@ -0,0 +1,41 @@
'use client';
import Link from 'next/link';
import { styled } from '@mui/system';
import { ISidebarLink } from '@/interfaces/SidebarLink.interfaces';
const LinkContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
padding: '12px 1px',
borderRadius: '4px',
color: theme.palette.text.tertiary,
textDecoration: 'none',
transition: 'background 0.2s ease-in-out',
'&:hover': {
color: 'rgb(255, 255, 255)',
background: 'rgba(255, 255, 255, 0.08)',
backgroundColor: theme.palette.action.hover,
cursor: 'pointer',
},
}));
const LinkText = styled('span')(({ theme }) => ({
color: theme.palette.text.tertiary,
marginLeft: '12px',
fontWeight: 500,
}));
export default function SidebarLink({ title, path, icon: Icon }: ISidebarLink) {
return (
<Link href={path} passHref legacyBehavior>
<a style={{ textDecoration: 'none' }}>
<LinkContainer>
{Icon && <Icon />}
<LinkText>{title}</LinkText>
</LinkContainer>
</a>
</Link>
);
}

View File

@ -0,0 +1,103 @@
import * as React from "react";
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import Button from "@mui/material/Button";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import InboxIcon from "@mui/icons-material/MoveToInbox";
import MailIcon from "@mui/icons-material/Mail";
import SearchIcon from "@mui/icons-material/Search";
export default function RightTemporaryDrawer() {
const [open, setOpen] = React.useState(false);
const toggleDrawer =
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (
event.type === "keydown" &&
((event as React.KeyboardEvent).key === "Tab" ||
(event as React.KeyboardEvent).key === "Shift")
) {
return;
}
setOpen(open);
};
const list = () => (
<Box
sx={{ width: 400 }}
role="presentation"
onClick={toggleDrawer(false)}
onKeyDown={toggleDrawer(false)}
>
<List>
{["Inbox", "Starred", "Send email", "Drafts"].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
{["All mail", "Trash", "Spam"].map((text, index) => (
<ListItem key={text} disablePadding>
<ListItemButton>
<ListItemIcon>
{index % 2 === 0 ? <InboxIcon /> : <MailIcon />}
</ListItemIcon>
<ListItemText primary={text} />
</ListItemButton>
</ListItem>
))}
</List>
</Box>
);
return (
<div>
<Button
sx={{
borderRadius: "8px",
textTransform: "none",
backgroundColor: "#f5f5f5",
color: "#555",
padding: "6px 12px",
boxShadow: "inset 0 0 0 1px #ddd",
fontWeight: 400,
fontSize: "16px",
justifyContent: "flex-start",
"& .MuiButton-startIcon": {
marginRight: "12px",
backgroundColor: "#eee",
padding: "8px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
"&:hover": {
backgroundColor: "#e0e0e0",
},
}}
startIcon={<SearchIcon />}
onClick={toggleDrawer(true)}
>
Advanced Search
</Button>
{/* <Button onClick={toggleDrawer(true)}>Open Right Drawer</Button> */}
<Drawer anchor="right" open={open} onClose={toggleDrawer(false)}>
{list()}
</Drawer>
</div>
);
}

View File

@ -0,0 +1,189 @@
// app/transactions/page.tsx
'use client';
import { useState } from 'react';
export default function TransactionsPage() {
const [userId, setUserId] = useState('');
const [state, setState] = useState('');
const [statusCode, setStatusCode] = useState('');
const [transactions, setTransactions] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const fetchTransactions = async () => {
setLoading(true);
try {
const url = new URL('https://api.example.com/transactions');
if (userId) url.searchParams.append('userId', userId);
if (state) url.searchParams.append('state', state);
if (statusCode) url.searchParams.append('statusCode', statusCode);
const response = await fetch(url.toString());
const data = await response.json();
setTransactions(data.transactions);
} catch (error) {
console.error('Error fetching transactions:', error);
} finally {
setLoading(false);
}
};
return (
<div className="p-4">
<h1 className="text-xl font-bold mb-4">Transaction Search</h1>
<div className="flex gap-4 mb-4">
<div>
<label className="block text-sm mb-1">User ID</label>
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
className="border p-2 rounded text-sm"
placeholder="Filter by user ID"
/>
</div>
<div>
<label className="block text-sm mb-1">State</label>
<input
type="text"
value={state}
onChange={(e) => setState(e.target.value)}
className="border p-2 rounded text-sm"
placeholder="Filter by state"
/>
</div>
<div>
<label className="block text-sm mb-1">Status Code</label>
<input
type="text"
value={statusCode}
onChange={(e) => setStatusCode(e.target.value)}
className="border p-2 rounded text-sm"
placeholder="Filter by status code"
/>
</div>
<div className="flex items-end">
<button
onClick={fetchTransactions}
disabled={loading}
className="bg-blue-500 text-white px-4 py-2 rounded text-sm"
>
{loading ? 'Loading...' : 'Search'}
</button>
</div>
</div>
{transactions.length > 0 ? (
<div className="border rounded overflow-hidden">
<table className="min-w-full">
<thead className="bg-gray-100">
<tr>
<th className="py-2 px-4 text-left text-sm">ID</th>
<th className="py-2 px-4 text-left text-sm">User</th>
<th className="py-2 px-4 text-left text-sm">State</th>
<th className="py-2 px-4 text-left text-sm">Status Code</th>
<th className="py-2 px-4 text-left text-sm">Created</th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr key={tx.id} className="border-t">
<td className="py-2 px-4 text-sm">{tx.id}</td>
<td className="py-2 px-4 text-sm">{tx.user}</td>
<td className="py-2 px-4 text-sm">{tx.state}</td>
<td className="py-2 px-4 text-sm">{tx.pspStatusCode}</td>
<td className="py-2 px-4 text-sm">{tx.created}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-4 text-sm">
{loading ? 'Loading transactions...' : 'No transactions found'}
</div>
)}
</div>
);
}
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { transactionDummyData } from './transactionData';
export const handlers = [
http.get('https://api.example.com/transactions', ({ request }) => {
const url = new URL(request.url);
// Get query parameters
const userId = url.searchParams.get('userId');
const state = url.searchParams.get('state');
const statusCode = url.searchParams.get('statusCode');
// Filter transactions based on query parameters
let filteredTransactions = [...transactionDummyData];
if (userId) {
filteredTransactions = filteredTransactions.filter(
tx => tx.user.toString() === userId
);
}
if (state) {
filteredTransactions = filteredTransactions.filter(
tx => tx.state.toLowerCase() === state.toLowerCase()
);
}
if (statusCode) {
filteredTransactions = filteredTransactions.filter(
tx => tx.pspStatusCode.toString() === statusCode
);
}
return HttpResponse.json({
transactions: filteredTransactions,
count: filteredTransactions.length
});
}),
];
// mocks/transactionData.ts
export const transactionDummyData = [
{
id: 1,
merchandId: 100987998,
transactionID: 1049131973,
user: 1,
created: "2025-06-18 10:10:30",
state: "FAILED",
statusDescription: "ERR_ABOVE_LIMIT",
pspStatusCode: 100501,
},
{
id: 2,
merchandId: 100987998,
transactionID: 1049131973,
user: 2,
created: "2025-06-18 10:10:30",
state: "FAILED",
statusDescription: "ERR_ABOVE_LIMIT",
pspStatusCode: 100501,
},
{
id: 3,
merchandId: 100987998,
transactionID: 1049131973,
user: 3,
created: "2025-06-18 10:10:30",
state: "FAILED",
statusDescription: "ERR_ABOVE_LIMIT",
pspStatusCode: 100501,
}
];

View File

@ -1,13 +0,0 @@
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
export default function GroupsPage() {
return (
<AdminResourceList
title="Groups"
endpoint="/api/dashboard/admin/groups"
responseCollectionKeys={["groups", "data", "items"]}
primaryLabelKeys={["name", "groupName", "title"]}
chipKeys={["status", "role", "permissions"]}
/>
);
}

View File

@ -1,5 +1,8 @@
// This ensures this component is rendered only on the client side
"use client";
import { Approve } from "@/app/features/Pages/Approve/Approve";
export default function BackOfficeUsersPage() {
return (
<div>

View File

@ -1,15 +0,0 @@
"use client";
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
export default function PermissionsPage() {
return (
<AdminResourceList
title="Permissions"
endpoint="/api/dashboard/admin/permissions"
responseCollectionKeys={["permissions", "data", "items"]}
primaryLabelKeys={["name", "permissionName", "title", "description"]}
chipKeys={["status", "scope", "category"]}
/>
);
}

View File

@ -1,13 +0,0 @@
import AdminResourceList from "@/app/features/AdminList/AdminResourceList";
export default function SessionsPage() {
return (
<AdminResourceList
title="Sessions"
endpoint="/api/dashboard/admin/sessions"
responseCollectionKeys={["sessions", "data", "items"]}
primaryLabelKeys={["sessionId", "id", "userId", "name", "title"]}
chipKeys={["status", "channel", "platform"]}
/>
);
}

View File

@ -1,59 +1,12 @@
// This ensures this component is rendered only on the client side
"use client";
import Users from "@/app/features/Pages/Admin/Users/users";
import { cookies } from "next/headers";
export default async function BackOfficeUsersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// Await searchParams and extract pagination
const params = await searchParams;
const limit = params.limit || "10";
const page = params.page || "1";
// Build absolute URL for server-side fetch
// In server components, fetch requires absolute URLs
const port = process.env.PORT || "3000";
const baseUrl =
process.env.NEXT_PUBLIC_BASE_URL ||
(process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: `http://localhost:${port}`);
const url = new URL(`${baseUrl}/api/dashboard/admin/users`);
url.searchParams.set("limit", typeof limit === "string" ? limit : limit[0]);
url.searchParams.set("page", typeof page === "string" ? page : page[0]);
// Forward cookies for auth when calling the internal API route
const cookieStore = await cookies();
const cookieHeader = cookieStore
.getAll()
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
.join("; ");
const res = await fetch(url.toString(), {
headers: {
Cookie: cookieHeader,
},
cache: "no-store",
});
if (!res.ok) {
console.error("Failed to fetch users:", res.status, res.statusText);
return (
<div>
<Users users={[]} />
</div>
);
}
const data = await res.json();
// Handle different response structures: could be array directly, or wrapped in data/users property
const users = Array.isArray(data) ? data : data.users || data.data || [];
export default function BackOfficeUsersPage() {
return (
<div>
<Users users={users} />
<Users />
</div>
);
}

View File

@ -1,22 +1,13 @@
import { ApproveTable } from "@/app/features/Pages/Approve/Approve";
import { getApproves } from "@/app/services/approve";
// This ensures this component is rendered only on the client side
"use client";
export default async function Approve({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
// Await searchParams before processing
const params = await searchParams;
// Create a safe query string by filtering only string values
const safeParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string") {
safeParams[key] = value;
}
}
const query = new URLSearchParams(safeParams).toString();
const data = await getApproves({ query });
import { Approve } from "@/app/features/Pages/Approve/Approve";
return <ApproveTable data={data} />;
export default function ApprovePage() {
return (
<div>
{/* This page will now be rendered on the client-side */}
<Approve />
</div>
);
}

View File

@ -1,42 +0,0 @@
.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: 85dvw;
overflow-x: auto;
overflow-y: hidden;
.table-inner {
min-width: 1200px;
}
}
}
}

View File

@ -1,332 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
DataGrid,
GridColDef,
GridPaginationModel,
GridSortModel,
} from "@mui/x-data-grid";
import { getAudits } from "@/app/services/audits";
import "./page.scss";
import TextField from "@mui/material/TextField";
import { Box, debounce } from "@mui/material";
type AuditRow = Record<string, unknown> & { id: string | number };
interface AuditApiResponse {
total?: number;
limit?: number;
page?: number;
data?: unknown;
items?: unknown[];
audits?: unknown[];
logs?: unknown[];
results?: unknown[];
records?: unknown[];
meta?: { total?: number };
pagination?: { total?: number };
}
const DEFAULT_PAGE_SIZE = 25;
const FALLBACK_COLUMNS: GridColDef[] = [
{
field: "placeholder",
headerName: "Audit Data",
flex: 1,
sortable: false,
filterable: false,
},
];
const CANDIDATE_ARRAY_KEYS: (keyof AuditApiResponse)[] = [
"items",
"audits",
"logs",
"results",
"records",
];
const normalizeValue = (value: unknown): string | number => {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string" || typeof value === "number") {
return value;
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return JSON.stringify(value);
};
const toTitle = (field: string) =>
field
.replace(/_/g, " ")
.replace(/-/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim()
.replace(/^\w/g, char => char.toUpperCase());
const deriveColumns = (rows: AuditRow[]): GridColDef[] => {
if (!rows.length) return [];
return Object.keys(rows[0]).map(field => ({
field,
headerName: toTitle(field),
flex: field === "id" ? 0 : 1,
minWidth: field === "id" ? 140 : 200,
sortable: true,
}));
};
const extractArray = (payload: AuditApiResponse): unknown[] => {
if (Array.isArray(payload)) {
return payload;
}
for (const key of CANDIDATE_ARRAY_KEYS) {
const candidate = payload[key];
if (Array.isArray(candidate)) {
return candidate;
}
}
const 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>([]);
const [entitySearch, setEntitySearch] = useState<string>("");
const [entitySearchInput, setEntitySearchInput] = useState<string>("");
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;
const entityParam = entitySearch.trim()
? `LIKE/${entitySearch.trim()}`
: undefined;
try {
const payload = (await getAudits({
limit: paginationModel.pageSize,
page: paginationModel.page + 1,
sort: sortParam,
entity: entityParam,
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, entitySearch]);
const handlePaginationChange = (model: GridPaginationModel) => {
setPaginationModel(model);
};
const handleSortModelChange = (model: GridSortModel) => {
setSortModel(model);
setPaginationModel(prev => ({ ...prev, page: 0 }));
};
const debouncedSetEntitySearch = useMemo(
() =>
debounce((value: string) => {
setEntitySearch(value);
setPaginationModel(prev => ({ ...prev, page: 0 }));
}, 500),
[]
);
useEffect(() => {
return () => {
debouncedSetEntitySearch.clear();
};
}, [debouncedSetEntitySearch]);
const handleEntitySearchChange = (value: string) => {
setEntitySearchInput(value);
debouncedSetEntitySearch(value);
};
const pageTitle = useMemo(
() =>
sortModel.length && sortModel[0].field
? `Audit Logs · sorted by ${toTitle(sortModel[0].field)}`
: "Audit Logs",
[sortModel]
);
return (
<div className="audits-page">
<Box sx={{ display: "flex", gap: 2, mt: 5 }}>
<TextField
label="Search by Entity"
variant="outlined"
size="small"
value={entitySearchInput}
onChange={e => handleEntitySearchChange(e.target.value)}
sx={{ width: 300, backgroundColor: "#f0f0f0" }}
/>
</Box>
<h1 className="page-title">{pageTitle}</h1>
{error && (
<div className="error-alert" role="alert">
{error}
</div>
)}
<div className="table-container">
<div className="scroll-wrapper">
<div
className="table-inner"
style={{ minWidth: `${columns.length * 200}px` }}
>
<DataGrid
rows={rows}
columns={columns.length ? columns : FALLBACK_COLUMNS}
loading={loading}
paginationMode="server"
sortingMode="server"
paginationModel={paginationModel}
onPaginationModelChange={handlePaginationChange}
rowCount={rowCount}
sortModel={sortModel}
onSortModelChange={handleSortModelChange}
pageSizeOptions={[10, 25, 50, 100]}
disableRowSelectionOnClick
sx={{
border: 0,
minHeight: 500,
"& .MuiDataGrid-cell": {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
},
}}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
// This ensures this component is rendered only on the client side
"use client";
'use client';
import React from "react";
import React from 'react';
export default function InvestigatePage() {
return (

View File

@ -1,36 +1,28 @@
"use client";
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import React, { useEffect } from "react";
import { LayoutWrapper } from "../features/dashboard/layout/layoutWrapper";
import { MainContent } from "../features/dashboard/layout/mainContent";
import SideBar from "../features/dashboard/sidebar/Sidebar";
import Header from "../features/dashboard/header/Header";
import { useTokenExpiration } from "../hooks/useTokenExpiration";
import TokenExpirationInfo from "../components/TokenExpirationInfo";
import { toggleSidebar } from "../redux/ui/uiSlice";
import { RootState } from "../redux/types";
const DashboardLayout: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
// Monitor token expiration and auto-logout
useTokenExpiration();
const dispatch = useDispatch();
const isSidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen);
const handleToggleSidebar = () => {
dispatch(toggleSidebar());
};
useEffect(() => {
// if (process.env.NODE_ENV === "development") {
import("../../mock/browser").then(({ worker }) => {
worker.start();
});
// }
}, []);
return (
<LayoutWrapper>
<SideBar isOpen={isSidebarOpen} onClose={handleToggleSidebar} />
<SideBar />
<div style={{ flexGrow: 1, display: "flex", flexDirection: "column" }}>
<MainContent>
<Header />
<TokenExpirationInfo />
{children}
</MainContent>
</div>

View File

@ -1,21 +0,0 @@
import SettingsPageClient from "@/app/features/Pages/Settings/SettingsPageClient";
// We can enable this if we want to fetch the user from the database by the id but it's not necessary since we already have the user in the redux store
// async function getUser() {
// const token = (await cookies()).get("auth_token")?.value;
// const payload = token ? await validateToken(token) : null;
// const userId = payload?.id; // requires JWT to include id
// if (!userId) throw new Error("No user id in token");
// const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/dashboard/admin/users/${userId}`, {
// headers: { Authorization: `Bearer ${token}` },
// cache: "no-store",
// });
// if (!res.ok) throw new Error("Failed to fetch user");
// return res.json();
// }
export default async function SettingsPage() {
// const user = await getUser();
return <SettingsPageClient />;
}

View File

@ -1,89 +0,0 @@
"use client";
import DataTable from "@/app/features/DataTable/DataTable";
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 function AllTransactionPage() {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
const [tableData, setTableData] = useState<{
transactions: TransactionRow[];
total: number;
}>({ transactions: [], total: 0 });
const extraColumns: string[] = []; // static for now
// Memoize rows to avoid new reference each render
const memoizedRows = useMemo(
() => tableData.transactions,
[tableData.transactions]
);
// 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"));
setTableData({ transactions: [], total: 0 });
return;
}
const data = await response.json();
const transactions = data.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,
}));
setTableData({ transactions: rows, total: data?.total });
} catch (error) {
dispatch(
setAdvancedSearchError(
error instanceof Error ? error.message : "Unknown error"
)
);
setTableData({ transactions: [], total: 0 });
}
};
fetchData();
}, [dispatch, filters, pagination, sort]);
return (
<DataTable
rows={memoizedRows}
extraColumns={extraColumns}
totalRows={tableData.total}
/>
);
}

View File

@ -1,97 +0,0 @@
"use client";
import DataTable from "@/app/features/DataTable/DataTable";
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 function DepositTransactionPage() {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
const [rowCount, setRowCount] = useState(0);
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);
setRowCount(100);
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 totalRows={rowCount} />
);
}

View File

@ -1,3 +0,0 @@
export default async function HistoryTransactionPage() {
return <div>History Transactions Page</div>;
}

View File

@ -1,29 +0,0 @@
export interface TransactionRow {
id: number;
userId?: string;
transactionId: string;
type?: string;
currency?: string;
amount?: number;
status?: string;
dateTime?: string;
merchantId?: string;
pspId?: string;
methodId?: string;
modified?: string;
}
export interface BackendTransaction {
id: number;
customer?: string;
external_id?: string;
type?: string;
currency?: string;
amount?: number;
status?: string;
created?: string;
modified?: string;
merchant_id?: string;
psp_id?: string;
method_id?: string;
}

View File

@ -0,0 +1,13 @@
// This ensures this component is rendered only on the client side
import TransactionTable from "@/app/features/Pages/Transactions/Transactions";
export default function TransactionPage() {
return (
<div style={{ width: "70%" }}>
{/* This page will now be rendered on the client-side */}
<TransactionTable />
</div>
);
}

View File

@ -1,100 +0,0 @@
"use client";
import DataTable from "@/app/features/DataTable/DataTable";
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 function WithdrawalTransactionPage() {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
const [rowCount, setRowCount] = useState(0);
const memoizedRows = useMemo(() => tableRows, [tableRows]);
const withdrawalFilters = useMemo(() => {
return {
...filters,
Type: {
operator: "==",
value: "withdrawal",
},
};
}, [filters]);
useEffect(() => {
const fetchWithdrawals = async () => {
dispatch(setStatus("loading"));
dispatch(setAdvancedSearchError(null));
try {
const response = await fetch(
"/api/dashboard/transactions/withdrawals",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters: withdrawalFilters,
pagination,
sort,
}),
}
);
if (!response.ok) {
dispatch(setAdvancedSearchError("Failed to fetch withdrawals"));
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);
setRowCount(100);
dispatch(setStatus("succeeded"));
} catch (error) {
dispatch(
setAdvancedSearchError(
error instanceof Error ? error.message : "Unknown error"
)
);
setTableRows([]);
}
};
fetchWithdrawals();
}, [dispatch, withdrawalFilters, pagination, sort]);
return (
<DataTable rows={memoizedRows} enableStatusActions totalRows={rowCount} />
);
}

View File

@ -1,7 +1,7 @@
.account-iq {
.account-iq__icon {
font-weight: bold;
color: #4ecdc4;
margin-top: 4px;
}
.account-iq__icon {
font-weight: bold;
color: #4ecdc4;
margin-top: 4px;
}
}

View File

@ -1,290 +0,0 @@
"use client";
import Spinner from "@/app/components/Spinner/Spinner";
import { DataRowBase } from "@/app/features/DataTable/types";
import {
setError as setAdvancedSearchError,
setStatus,
} from "@/app/redux/advanedSearch/advancedSearchSlice";
import {
selectError,
selectFilters,
selectPagination,
selectSort,
selectStatus,
} from "@/app/redux/advanedSearch/selectors";
import { AppDispatch } from "@/app/redux/store";
import {
Alert,
Box,
Chip,
Divider,
List,
ListItem,
Typography,
} from "@mui/material";
import { useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
type ResourceRow = DataRowBase & Record<string, unknown>;
type FilterValue =
| string
| {
operator?: string;
value: string;
};
interface AdminResourceListProps {
title: string;
endpoint: string;
responseCollectionKeys?: string[];
primaryLabelKeys: string[];
chipKeys?: string[];
excludeKeys?: string[];
filterOverrides?: Record<string, FilterValue>;
}
const DEFAULT_COLLECTION_KEYS = ["data", "items"];
const ensureRowId = (
row: Record<string, unknown>,
fallbackId: number
): ResourceRow => {
const currentId = row.id;
if (typeof currentId === "number") {
return row as ResourceRow;
}
const numericId = Number(currentId);
if (!Number.isNaN(numericId) && numericId !== 0) {
return { ...row, id: numericId } as ResourceRow;
}
return { ...row, id: fallbackId } as ResourceRow;
};
const resolveCollection = (
payload: Record<string, unknown>,
preferredKeys: string[] = []
) => {
for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) {
const maybeCollection = payload?.[key];
if (Array.isArray(maybeCollection)) {
return maybeCollection as Record<string, unknown>[];
}
}
if (Array.isArray(payload)) {
return payload as Record<string, unknown>[];
}
return [];
};
const AdminResourceList = ({
title,
endpoint,
responseCollectionKeys = [],
primaryLabelKeys,
chipKeys = [],
excludeKeys = [],
}: AdminResourceListProps) => {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const pagination = useSelector(selectPagination);
const sort = useSelector(selectSort);
const status = useSelector(selectStatus);
const errorMessage = useSelector(selectError);
const [rows, setRows] = useState<ResourceRow[]>([]);
const normalizedTitle = title.toLowerCase();
const excludedKeys = useMemo(() => {
const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]);
excludeKeys.forEach(key => baseExcluded.add(key));
return Array.from(baseExcluded);
}, [primaryLabelKeys, chipKeys, excludeKeys]);
const getPrimaryLabel = (row: ResourceRow) => {
for (const key of primaryLabelKeys) {
if (row[key]) {
return String(row[key]);
}
}
return `${title} #${row.id}`;
};
const getMetaChips = (row: ResourceRow) =>
chipKeys
.filter(key => row[key])
.map(key => ({
key,
value: String(row[key]),
}));
const getSecondaryDetails = (row: ResourceRow) =>
Object.entries(row).filter(([key]) => !excludedKeys.includes(key));
const resolvedCollectionKeys = useMemo(
() => [...responseCollectionKeys],
[responseCollectionKeys]
);
useEffect(() => {
const fetchResources = async () => {
dispatch(setStatus("loading"));
dispatch(setAdvancedSearchError(null));
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filters,
pagination,
sort,
}),
});
if (!response.ok) {
dispatch(
setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`)
);
setRows([]);
return;
}
const backendData = await response.json();
const collection = resolveCollection(
backendData,
resolvedCollectionKeys
);
const nextRows = collection.map((item, index) =>
ensureRowId(item, index + 1)
);
setRows(nextRows);
dispatch(setStatus("succeeded"));
} catch (error) {
dispatch(
setAdvancedSearchError(
error instanceof Error ? error.message : "Unknown error"
)
);
setRows([]);
}
};
fetchResources();
}, [
dispatch,
endpoint,
filters,
pagination,
sort,
resolvedCollectionKeys,
normalizedTitle,
]);
return (
<Box sx={{ p: 3, width: "100%", maxWidth: 900 }}>
<Typography variant="h5" sx={{ mb: 2 }}>
{title}
</Typography>
{status === "loading" && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mb: 2 }}>
<Spinner size="small" color="#000" />
<Typography variant="body2">
{`Loading ${normalizedTitle}...`}
</Typography>
</Box>
)}
{status === "failed" && (
<Alert severity="error" sx={{ mb: 2 }}>
{errorMessage || `Failed to load ${normalizedTitle}`}
</Alert>
)}
{!rows.length && status === "succeeded" && (
<Typography variant="body2" color="text.secondary">
{`No ${normalizedTitle} found.`}
</Typography>
)}
{rows.length > 0 && (
<List
sx={{ bgcolor: "background.paper", borderRadius: 2, boxShadow: 1 }}
>
{rows.map(row => {
const chips = getMetaChips(row);
const secondary = getSecondaryDetails(row);
return (
<Box key={row.id}>
<ListItem alignItems="flex-start">
<Box sx={{ width: "100%" }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 1,
}}
>
<Typography variant="subtitle1" fontWeight={600}>
{getPrimaryLabel(row)}
</Typography>
<Typography variant="caption" color="text.secondary">
ID: {row.id}
</Typography>
</Box>
{chips.length > 0 && (
<Box
sx={{
display: "flex",
gap: 1,
flexWrap: "wrap",
my: 1,
}}
>
{chips.map(chip => (
<Chip
key={`${row.id}-${chip.key}`}
label={`${chip.key}: ${chip.value}`}
size="small"
color="primary"
variant="outlined"
/>
))}
</Box>
)}
{secondary.length > 0 && (
<Typography variant="body2" color="text.secondary">
{secondary
.map(([key, value]) => `${key}: ${String(value)}`)
.join(" • ")}
</Typography>
)}
</Box>
</ListItem>
<Divider component="li" />
</Box>
);
})}
</List>
)}
</Box>
);
};
export default AdminResourceList;

View File

@ -1,5 +1,4 @@
"use client";
import { useState } from "react";
import {
Box,
TextField,
@ -10,170 +9,170 @@ import {
Select,
Typography,
Stack,
debounce,
} from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import SearchIcon from "@mui/icons-material/Search";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useDispatch, useSelector } from "react-redux";
import { useState, useEffect, useMemo } from "react";
import { ISearchLabel } from "../DataTable/types";
import { AppDispatch } from "@/app/redux/store";
import {
updateFilter,
clearFilters,
FilterValue,
} from "@/app/redux/advanedSearch/advancedSearchSlice";
import { selectFilters } from "@/app/redux/advanedSearch/selectors";
import { normalizeValue, defaultOperatorForField } from "./utils/utils";
import { selectConditionOperators } from "@/app/redux/metadata/selectors";
// -----------------------------------------------------
// COMPONENT
// -----------------------------------------------------
export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
const dispatch = useDispatch<AppDispatch>();
const filters = useSelector(selectFilters);
const currencies = ["USD", "EUR", "GBP"];
const states = ["Pending", "Completed", "Failed"];
const transactionTypes = ["Credit", "Debit"];
const paymentMethods = ["Card", "Bank Transfer"];
export default function AdvancedSearch({ setForm, form, resetForm }) {
const [open, setOpen] = useState(false);
// Local form state for UI (synced with Redux)
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [operators, setOperators] = useState<Record<string, string>>({});
const conditionOperators = useSelector(selectConditionOperators);
const handleChange = (field: string, value: any) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
console.log("[conditionOperators]", conditionOperators);
// -----------------------------------------------------
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
// -----------------------------------------------------
useEffect(() => {
const values: Record<string, string> = {};
const ops: Record<string, string> = {};
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;
}
const toggleDrawer =
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (
event.type === "keydown" &&
((event as React.KeyboardEvent).key === "Tab" ||
(event as React.KeyboardEvent).key === "Shift")
) {
return;
}
// Handle date ranges
const startKey = `${field}_start`;
const endKey = `${field}_end`;
const startFilter = filters[startKey];
const endFilter = filters[endKey];
setOpen(open);
};
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(
(field: string, value: string | undefined, operator?: string) => {
if (!value || value === "") {
dispatch(updateFilter({ field, value: undefined }));
return;
}
const safeValue = normalizeValue(value);
if (!safeValue) {
dispatch(updateFilter({ field, value: undefined }));
return;
}
// For text/select fields, use FilterValue with operator
const filterValue: FilterValue = {
operator: operator ?? defaultOperatorForField(field, "text"),
value: safeValue,
};
dispatch(updateFilter({ field, value: filterValue }));
},
300
),
[dispatch]
const list = () => (
<Box sx={{ width: 400 }} role="presentation">
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Box p={2}>
<Box sx={{ display: "flex", gap: "60px" }}>
<Typography variant="h6" gutterBottom>
Search
</Typography>
{/* Buttons */}
<Box display="flex" justifyContent="flex-end" gap={2}>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={() => console.log("Apply Filter", form)}
sx={{ "& .span": { margin: "0px", padding: "0px" } }}
>
Apply Filter
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon sx={{ margin: "0px" }} />}
onClick={resetForm}
sx={{ "& span": { margin: "0px", padding: "0px" } }}
></Button>
</Box>
</Box>
<Stack spacing={2}>
{[
{ label: "Keyword", field: "keyword", type: "text" },
{ label: "Transaction ID", field: "transactionID", type: "text" },
{
label: "Transaction Reference ID",
field: "transactionReferenceId",
type: "text",
},
{ label: "User", field: "user", type: "text" },
{
label: "Currency",
field: "currency",
type: "select",
options: currencies,
},
{
label: "State",
field: "state",
type: "select",
options: states,
},
{
label: "Status Description",
field: "statusDescription",
type: "text",
},
{
label: "Transaction Type",
field: "transactionType",
type: "select",
options: transactionTypes,
},
{
label: "Payment Method",
field: "paymentMethod",
type: "select",
options: paymentMethods,
},
{ label: "PSPs", field: "psps", type: "text" },
{ label: "Initial PSPs", field: "initialPsps", type: "text" },
{ label: "Merchants", field: "merchants", type: "text" },
{ label: "Start Date", field: "startDate", type: "date" },
{ label: "End Date", field: "endDate", type: "date" },
{
label: "Last Updated From",
field: "lastUpdatedFrom",
type: "date",
},
{
label: "Last Updated To",
field: "lastUpdatedTo",
type: "date",
},
{ label: "Min Amount", field: "minAmount", type: "text" },
{ label: "Max Amount", field: "maxAmount", type: "text" },
{ label: "Channel", field: "channel", type: "text" },
].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={form[field]}
onChange={(e) => handleChange(field, e.target.value)}
/>
)}
{type === "select" && (
<FormControl fullWidth size="small">
<Select
value={form[field]}
onChange={(e) => handleChange(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" && (
<DatePicker
value={form[field]}
onChange={(newValue) => handleChange(field, newValue)}
renderInput={(params) => (
<TextField fullWidth size="small" {...params} />
)}
/>
)}
</Box>
))}
</Stack>
</Box>
</LocalizationProvider>
</Box>
);
// -----------------------------------------------------
// handlers
// -----------------------------------------------------
const updateField = (field: string, value: string) => {
setFormValues(prev => ({ ...prev, [field]: value }));
const operator = operators[field] ?? defaultOperatorForField(field, "text");
debouncedUpdateFilter(field, value, operator);
};
const updateOperator = (field: string, op: string) => {
setOperators(prev => ({ ...prev, [field]: op }));
// If value exists, update filter immediately with new operator
const currentValue = formValues[field];
if (currentValue) {
const safeValue = normalizeValue(currentValue);
if (safeValue) {
dispatch(
updateFilter({
field,
value: { operator: op, value: safeValue },
})
);
}
}
};
const updateDateRange = (
field: string,
start: string | undefined,
end: string | undefined
) => {
if (start) {
dispatch(updateFilter({ field: `${field}_start`, value: start }));
} else {
dispatch(updateFilter({ field: `${field}_start`, value: undefined }));
}
if (end) {
dispatch(updateFilter({ field: `${field}_end`, value: end }));
} else {
dispatch(updateFilter({ field: `${field}_end`, value: undefined }));
}
};
const resetForm = () => {
setFormValues({});
setOperators({});
dispatch(clearFilters());
};
// -----------------------------------------------------
// render
// -----------------------------------------------------
return (
<Box sx={{ width: "185px" }}>
<Box sx={{ width: '185px' }}>
<Button
sx={{
borderRadius: "8px",
@ -184,216 +183,26 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
boxShadow: "inset 0 0 0 1px #ddd",
fontWeight: 400,
fontSize: "16px",
"&:hover": { backgroundColor: "#e0e0e0" },
justifyContent: "flex-start",
"& .MuiButton-startIcon": {
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
"&:hover": {
backgroundColor: "#e0e0e0",
},
}}
startIcon={<SearchIcon />}
onClick={() => setOpen(true)}
onClick={toggleDrawer(true)}
>
Advanced Search
</Button>
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
<Box sx={{ width: 400 }} p={2}>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Box
sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}
>
<Typography variant="h6">Search</Typography>
<Box display="flex" gap={2}>
<Button
variant="contained"
startIcon={<SearchIcon />}
onClick={() => {
// Apply all current form values to Redux
labels.forEach(({ field, type }) => {
const val = formValues[field];
if (!val) return;
if (type === "select" || type === "text") {
const operator =
operators[field] ??
defaultOperatorForField(field, type);
const safeValue = normalizeValue(val);
if (safeValue) {
dispatch(
updateFilter({
field,
value: { operator, value: safeValue },
})
);
}
}
});
}}
>
Apply
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={resetForm}
/>
</Box>
</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)
}
>
{Object.entries(conditionOperators ?? {}).map(
([key, value]) => (
<MenuItem key={key} value={value}>
{key.replace(/_/g, " ")}{" "}
{/* Optional: make it readable */}
</MenuItem>
)
)}
</Select>
</FormControl>
<TextField
fullWidth
size="small"
value={formValues[field] ?? ""}
onChange={e => updateField(field, e.target.value)}
placeholder="Enter amount"
/>
</Box>
) : (
<TextField
fullWidth
size="small"
value={formValues[field] ?? ""}
onChange={e => updateField(field, e.target.value)}
/>
)}
</>
)}
{/* SELECTS */}
{type === "select" && (
<FormControl fullWidth size="small">
<Select
value={formValues[field] ?? ""}
onChange={e => updateField(field, e.target.value)}
displayEmpty
>
<MenuItem value="">
<em>{label}</em>
</MenuItem>
{options?.map(opt => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
)}
{/* DATE RANGE */}
{type === "date" && (
<Stack spacing={2}>
<DatePicker
label="Start Date"
value={
formValues[`${field}_start`]
? new Date(formValues[`${field}_start`])
: null
}
onChange={value => {
if (!value) {
updateDateRange(
field,
undefined,
formValues[`${field}_end`]
);
setFormValues(prev => ({
...prev,
[`${field}_start`]: "",
}));
return;
}
const v = new Date(value);
v.setHours(0, 0, 0, 0);
const isoString = v.toISOString();
setFormValues(prev => ({
...prev,
[`${field}_start`]: isoString,
}));
updateDateRange(
field,
isoString,
formValues[`${field}_end`]
);
}}
slotProps={{
textField: { fullWidth: true, size: "small" },
}}
/>
<DatePicker
label="End Date"
value={
formValues[`${field}_end`]
? new Date(formValues[`${field}_end`])
: null
}
onChange={value => {
if (!value) {
updateDateRange(
field,
formValues[`${field}_start`],
undefined
);
setFormValues(prev => ({
...prev,
[`${field}_end`]: "",
}));
return;
}
const v = new Date(value);
v.setHours(23, 59, 59, 999);
const isoString = v.toISOString();
setFormValues(prev => ({
...prev,
[`${field}_end`]: isoString,
}));
updateDateRange(
field,
formValues[`${field}_start`],
isoString
);
}}
slotProps={{
textField: { fullWidth: true, size: "small" },
}}
/>
</Stack>
)}
</Box>
))}
</Stack>
</LocalizationProvider>
</Box>
{/* <Button onClick={toggleDrawer(true)}>Open Right Drawer</Button> */}
<Drawer anchor="right" open={open} onClose={toggleDrawer(false)}>
{list()}
</Drawer>
</Box>
);
}

View File

@ -1,25 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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 "";
};
// Default operator based on field and type
export const defaultOperatorForField = (
field: string,
type: string
): string => {
if (field === "Amount") return ">="; // numeric field
if (type === "text") return "LIKE"; // string/text search
return "=="; // everything else (select, etc.)
};

View File

@ -1,208 +0,0 @@
/* app/styles/LoginModal.scss (BEM Methodology) */
@use "sass:color";
// Variables for consistent styling
$primary-color: #2563eb; // Blue-600 equivalent
$primary-hover-color: #1d4ed8; // Blue-700 equivalent
$success-color: #16a34a; // Green-600 equivalent
$error-color: #dc2626; // Red-600 equivalent
$text-color-dark: #1f2937; // Gray-800 equivalent
$text-color-medium: #4b5563; // Gray-700 equivalent
$text-color-light: #6b7280; // Gray-600 equivalent
$border-color: #d1d5db; // Gray-300 equivalent
$bg-color-light: #f3f4f6; // Gray-100 equivalent
$bg-color-white: #ffffff;
/* --- Login Modal Block (.login-modal) --- */
.login-modal__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(17, 24, 39, 0.75); // Gray-900 75% opacity
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem; // p-4
}
.login-modal__content {
background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl
padding: 2rem; // p-8
width: 100%;
max-width: 28rem; // max-w-md
transform: scale(1);
opacity: 1;
transition: all 0.3s ease-in-out; // transition-all duration-300
}
.login-modal__title {
font-size: 1.875rem; // text-3xl
font-weight: 700; // font-bold
color: $text-color-dark;
margin-bottom: 1.5rem; // mb-6
text-align: center;
}
/* --- Login Form Block (.login-form) --- */
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem; // space-y-6
}
.login-form__group {
// No specific styles needed here, just a container for label/input
}
.login-form__label {
display: block;
font-size: 0.875rem; // text-sm
font-weight: 500; // font-medium
color: $text-color-medium;
margin-bottom: 0.25rem; // mb-1
}
.login-form__input {
display: block;
width: 100%;
padding: 0.5rem 1rem; // px-4 py-2
border: 1px solid $border-color;
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
font-size: 0.875rem; // sm:text-sm
&:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: $primary-color; // focus:border-blue-500
box-shadow:
0 0 0 1px $primary-color,
0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.login-form__message {
text-align: center;
font-size: 0.875rem; // text-sm
font-weight: 500; // font-medium
}
.login-form__message--success {
color: $success-color;
}
.login-form__message--error {
color: $error-color;
}
.login-form__button {
width: 100%;
display: flex;
justify-content: center;
padding: 0.75rem 1rem; // py-3 px-4
border: 1px solid transparent;
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
font-size: 1.125rem; // text-lg
font-weight: 600; // font-semibold
color: $bg-color-white;
background-color: $primary-color;
cursor: pointer;
transition:
background-color 0.3s ease-in-out,
box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
&:hover {
background-color: color.adjust(
$primary-color,
$lightness: -5%
); // blue-700 equivalent
}
&:focus {
outline: none;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.5),
0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.login-form__spinner {
animation: spin 1s linear infinite;
height: 1.25rem; // h-5
width: 1.25rem; // w-5
color: white;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* --- Page Container Block (.page-container) --- */
.page-container {
min-height: 100vh;
background-color: $bg-color-light;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: "Inter", sans-serif; // Assuming Inter font is used
padding: 1rem;
}
.page-container__content {
width: 100%;
max-width: 56rem; // max-w-4xl
background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
padding: 2rem; // p-8
text-align: center;
}
.page-container__title {
font-size: 2.25rem; // text-4xl
font-weight: 700; // font-bold
color: $text-color-dark;
margin-bottom: 1.5rem; // mb-6
}
.page-container__message--logged-in {
font-size: 1.25rem; // text-xl
color: $success-color;
margin-bottom: 1rem; // mb-4
}
.page-container__text {
color: $text-color-medium;
margin-bottom: 1.5rem; // mb-6
}
.page-container__button--logout {
padding: 0.75rem 1.5rem; // px-6 py-3
background-color: $error-color;
color: $bg-color-white;
font-weight: 600; // font-semibold
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
transition: background-color 0.3s ease-in-out;
}

View File

@ -1,157 +0,0 @@
// components/ChangePasswordModal.tsx
import React, { useState, useEffect } from "react";
import { Typography, TextField, Button, Box } from "@mui/material";
import toast from "react-hot-toast";
interface Props {
open: boolean;
onClose: () => void;
onSubmit: (passwordData: {
currentPassword?: string;
newPassword: string;
}) => void;
isUserChange?: boolean;
}
export const ChangePassword: React.FC<Props> = ({
open,
onSubmit,
isUserChange = false,
}) => {
const [currentPassword, setCurrentPassword] = useState("");
const [password, setPassword] = useState<string>("");
const [confirm, setConfirm] = useState<string>("");
const [errors, setErrors] = useState<string[]>([]);
const [isValid, setIsValid] = useState(false);
// Validate password rules
useEffect(() => {
if (!password) {
setErrors([]);
setIsValid(false);
return;
}
const newErrors: string[] = [];
if (password.length < 8) newErrors.push("At least 8 characters");
if (!/[A-Z]/.test(password)) newErrors.push("One uppercase letter");
if (!/[a-z]/.test(password)) newErrors.push("One lowercase letter");
if (!/[0-9]/.test(password)) newErrors.push("One number");
if (!/[!@#$%^&*(),.?\":{}|<>]/.test(password))
newErrors.push("One special character");
if (password !== confirm) newErrors.push("Passwords do not match");
// If user change mode, require current password
if (isUserChange && !currentPassword)
newErrors.push("Current password required");
setErrors(newErrors);
setIsValid(newErrors.length === 0);
}, [confirm, password, currentPassword, isUserChange]);
const handleSubmit = () => {
if (!isValid) {
toast.error("Please fix the validation errors before continuing");
return;
}
onSubmit({
...(isUserChange ? { currentPassword } : {}),
newPassword: password,
});
};
return (
<Box
sx={{
backgroundColor: "white",
p: 3,
borderRadius: 2,
width: 400,
display: open ? "flex" : "none",
flexDirection: "column",
gap: 2,
boxShadow: 3,
}}
>
{isUserChange ? (
<Typography variant="body2" color="text.secondary">
Please enter your current password and choose a new one below.
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
Youre currently logged in with a temporary password. Please set a new
one to continue.
</Typography>
)}
{isUserChange && (
<TextField
label="Current Password"
type="password"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
fullWidth
error={isUserChange && !currentPassword}
helperText={
isUserChange && !currentPassword
? "Current password is required"
: ""
}
/>
)}
<TextField
label="New Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
fullWidth
error={!isValid && password.length > 0}
/>
<TextField
label="Confirm Password"
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
fullWidth
error={confirm.length > 0 && password !== confirm}
/>
<Box sx={{ height: "80px" }}>
<Typography
variant="caption"
color={isValid ? "success.main" : "error.main"}
sx={{
fontWeight: 500,
visibility: password.length > 0 ? "visible" : "hidden",
}}
>
<p>
{isValid
? "✓ Password meets all requirements"
: "Password must contain:"}
</p>
</Typography>
{!isValid && (
<Typography
variant="caption"
sx={{ color: "error.main", fontSize: "0.8rem" }}
>
{errors.join(", ")}
</Typography>
)}
</Box>
<Button
variant="contained"
sx={{ width: "100%", height: 40, fontSize: 16 }}
onClick={handleSubmit}
disabled={!isValid}
>
Update Password
</Button>
</Box>
);
};

View File

@ -1,208 +0,0 @@
/* app/styles/LoginModal.scss (BEM Methodology) */
@use "sass:color";
// Variables for consistent styling
$primary-color: #2563eb; // Blue-600 equivalent
$primary-hover-color: #1d4ed8; // Blue-700 equivalent
$success-color: #16a34a; // Green-600 equivalent
$error-color: #dc2626; // Red-600 equivalent
$text-color-dark: #1f2937; // Gray-800 equivalent
$text-color-medium: #4b5563; // Gray-700 equivalent
$text-color-light: #6b7280; // Gray-600 equivalent
$border-color: #d1d5db; // Gray-300 equivalent
$bg-color-light: #f3f4f6; // Gray-100 equivalent
$bg-color-white: #ffffff;
/* --- Login Modal Block (.login-modal) --- */
.login-modal__overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(17, 24, 39, 0.75); // Gray-900 75% opacity
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem; // p-4
}
.login-modal__content {
background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04); // shadow-2xl
padding: 2rem; // p-8
width: 100%;
max-width: 28rem; // max-w-md
transform: scale(1);
opacity: 1;
transition: all 0.3s ease-in-out; // transition-all duration-300
}
.login-modal__title {
font-size: 1.875rem; // text-3xl
font-weight: 700; // font-bold
color: $text-color-dark;
margin-bottom: 1.5rem; // mb-6
text-align: center;
}
/* --- Login Form Block (.login-form) --- */
.login-form {
display: flex;
flex-direction: column;
gap: 1.5rem; // space-y-6
}
.login-form__group {
// No specific styles needed here, just a container for label/input
}
.login-form__label {
display: block;
font-size: 0.875rem; // text-sm
font-weight: 500; // font-medium
color: $text-color-medium;
margin-bottom: 0.25rem; // mb-1
}
.login-form__input {
display: block;
width: 100%;
padding: 0.5rem 1rem; // px-4 py-2
border: 1px solid $border-color;
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
font-size: 0.875rem; // sm:text-sm
&:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: $primary-color; // focus:border-blue-500
box-shadow:
0 0 0 1px $primary-color,
0 0 0 3px rgba($primary-color, 0.5); // focus:ring-blue-500
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.login-form__message {
text-align: center;
font-size: 0.875rem; // text-sm
font-weight: 500; // font-medium
}
.login-form__message--success {
color: $success-color;
}
.login-form__message--error {
color: $error-color;
}
.login-form__button {
width: 100%;
display: flex;
justify-content: center;
padding: 0.75rem 1rem; // py-3 px-4
border: 1px solid transparent;
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-sm
font-size: 1.125rem; // text-lg
font-weight: 600; // font-semibold
color: $bg-color-white;
background-color: $primary-color;
cursor: pointer;
transition:
background-color 0.3s ease-in-out,
box-shadow 0.3s ease-in-out; // transition duration-300 ease-in-out
&:hover {
background-color: color.adjust(
$primary-color,
$lightness: -5%
); // blue-700 equivalent
}
&:focus {
outline: none;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.5),
0 0 0 4px rgba($primary-color, 0.5); // focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.login-form__spinner {
animation: spin 1s linear infinite;
height: 1.25rem; // h-5
width: 1.25rem; // w-5
color: white;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* --- Page Container Block (.page-container) --- */
.page-container {
min-height: 100vh;
background-color: $bg-color-light;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: "Inter", sans-serif; // Assuming Inter font is used
padding: 1rem;
}
.page-container__content {
width: 100%;
max-width: 56rem; // max-w-4xl
background-color: $bg-color-white;
border-radius: 0.75rem; // rounded-xl
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05); // shadow-lg
padding: 2rem; // p-8
text-align: center;
}
.page-container__title {
font-size: 2.25rem; // text-4xl
font-weight: 700; // font-bold
color: $text-color-dark;
margin-bottom: 1.5rem; // mb-6
}
.page-container__message--logged-in {
font-size: 1.25rem; // text-xl
color: $success-color;
margin-bottom: 1rem; // mb-4
}
.page-container__text {
color: $text-color-medium;
margin-bottom: 1.5rem; // mb-6
}
.page-container__button--logout {
padding: 0.75rem 1.5rem; // px-6 py-3
background-color: $error-color;
color: $bg-color-white;
font-weight: 600; // font-semibold
border-radius: 0.5rem; // rounded-lg
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); // shadow-md
transition: background-color 0.3s ease-in-out;
}

View File

@ -1,104 +0,0 @@
"use client"; // This MUST be the very first line of the file
import React, { useState, useEffect } from "react";
import "./LoginModal.scss"; // Adjust path based on your actual structure
import { clearAuthMessage } from "@/app/redux/auth/authSlice";
// Define the props interface for LoginModal
type LoginModalProps = {
onLogin: (email: string, password: string) => Promise<boolean>;
authMessage: string;
clearAuthMessage: () => void;
};
// LoginModal component
export default function LoginModal({ onLogin }: LoginModalProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
console.log("LoginModal rendered"); // Debugging log to check if the component renders
// Effect to clear authentication messages when email or password inputs change
useEffect(() => {
clearAuthMessage();
}, [email, password]); // Dependency array ensures effect runs when these change
// Handler for form submission
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // Prevent default form submission behavior
setIsLoading(true); // Set loading state to true
await onLogin(email, password); // Call the passed onLogin function (now uncommented)
setIsLoading(false); // Set loading state back to false after login attempt
};
return (
// The content of the login modal, without the modal overlay/wrapper
<form onSubmit={handleSubmit} className="login-form">
{/* Email input field */}
<div className="login-form__group">
<label htmlFor="email" className="login-form__label">
Email Address
</label>
<input
type="email"
id="email"
className="login-form__input"
placeholder="admin@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
{/* Password input field */}
<div className="login-form__group">
<label htmlFor="password" className="login-form__label">
Password
</label>
<input
type="password"
id="password"
className="login-form__input"
placeholder="password123"
value={password}
onChange={e => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<button
type="submit"
className="login-form__button"
disabled={isLoading} // Disable button while loading
>
{isLoading ? (
<svg
className="login-form__spinner"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
{" "}
{/* BEM class name */}
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
"Login" // Button text for normal state
)}
</button>
</form>
);
}

View File

@ -1,194 +0,0 @@
"use client";
import React, { useState, useCallback, useMemo } from "react";
import { DataGrid, GridPaginationModel } 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,
selectPagination,
selectPaginationModel,
} from "@/app/redux/advanedSearch/selectors";
import { makeSelectEnhancedColumns } from "./re-selectors";
import { useDispatch, useSelector } from "react-redux";
import { DataRowBase } from "./types";
import { setPagination } from "@/app/redux/advanedSearch/advancedSearchSlice";
import { AppDispatch } from "@/app/redux/store";
interface DataTableProps<TRow extends DataRowBase> {
rows: TRow[];
extraColumns?: string[];
enableStatusActions?: boolean;
totalRows?: number;
}
const DataTable = <TRow extends DataRowBase>({
rows: localRows,
extraColumns,
enableStatusActions = false,
totalRows: totalRows,
}: DataTableProps<TRow>) => {
const dispatch = useDispatch<AppDispatch>();
const [showExtraColumns, setShowExtraColumns] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
const [pendingStatus, setPendingStatus] = useState<string>("");
const [reason, setReason] = useState<string>("");
const [statusUpdateError, setStatusUpdateError] = useState<string | null>(
null
);
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const status = useSelector(selectStatus);
const errorMessage = useSelector(selectError);
const pagination = useSelector(selectPagination);
const paginationModel = useSelector(selectPaginationModel);
const handlePaginationModelChange = useCallback(
(model: GridPaginationModel) => {
console.log("model", model);
const nextPage = model.page + 1;
const nextLimit = model.pageSize;
if (nextPage !== pagination.page || nextLimit !== pagination.limit) {
dispatch(setPagination({ page: nextPage, limit: nextLimit }));
}
},
[dispatch, pagination.page, pagination.limit]
);
const handleStatusChange = useCallback((rowId: number, newStatus: string) => {
setSelectedRowId(rowId);
setPendingStatus(newStatus);
setModalOpen(true);
}, []);
const handleStatusSave = async () => {
if (!selectedRowId || !pendingStatus) return;
setStatusUpdateError(null);
setIsUpdatingStatus(true);
try {
const payload = {
data: {
status: pendingStatus,
notes: reason.trim(),
},
fields: ["Status", "Notes"],
};
const response = await fetch(
`/api/dashboard/transactions/${selectedRowId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}
);
const result = await response.json();
if (!response.ok) {
throw new Error(
result?.message || result?.error || "Failed to update transaction"
);
}
setModalOpen(false);
setReason("");
setPendingStatus("");
setStatusUpdateError(null);
setSelectedRowId(null);
} catch (err) {
setStatusUpdateError(
err instanceof Error ? err.message : "Failed to update transaction"
);
} finally {
setIsUpdatingStatus(false);
}
};
const selectEnhancedColumns = useMemo(makeSelectEnhancedColumns, []);
const enhancedColumns = useSelector(state =>
selectEnhancedColumns(state, {
enableStatusActions,
extraColumns,
showExtraColumns,
localRows,
handleStatusChange,
})
);
return (
<>
{status === "loading" && <Spinner size="small" color="#fff" />}
{status === "failed" && (
<Alert severity="error">
{errorMessage || "Failed to load transactions."}
</Alert>
)}
<Paper sx={{ width: "100%", overflowX: "hidden" }}>
<DataTableHeader
extraColumns={extraColumns}
showExtraColumns={showExtraColumns}
onToggleExtraColumns={() => setShowExtraColumns(prev => !prev)}
onOpenExport={() => {}}
/>
<Box sx={{ width: "85vw" }}>
<Box sx={{ minWidth: 1200 }}>
<DataGrid
rows={localRows}
columns={enhancedColumns}
paginationModel={paginationModel}
onPaginationModelChange={handlePaginationModelChange}
paginationMode={totalRows ? "server" : "client"}
rowCount={totalRows}
pageSizeOptions={[10, 25, 50, 100]}
sx={{
border: 0,
cursor: "pointer",
"& .MuiDataGrid-cell": {
py: 1,
textAlign: "center",
justifyContent: "center",
display: "flex",
alignItems: "center",
},
"& .MuiDataGrid-columnHeader": {
textAlign: "center",
justifyContent: "center",
},
}}
/>
</Box>
</Box>
<StatusChangeDialog
open={modalOpen}
newStatus={pendingStatus}
reason={reason}
setReason={setReason}
handleClose={() => {
setModalOpen(false);
setReason("");
setPendingStatus("");
setStatusUpdateError(null);
setSelectedRowId(null);
}}
handleSave={handleStatusSave}
isSubmitting={isUpdatingStatus}
errorMessage={statusUpdateError}
/>
</Paper>
</>
);
};
// Memoize to avoid unnecessary re-renders
export default React.memo(DataTable);

View File

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

View File

@ -1,82 +0,0 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Alert,
CircularProgress,
} from "@mui/material";
import { useState, useEffect } from "react";
interface StatusChangeDialogProps {
open: boolean;
newStatus: string;
reason: string;
setReason: React.Dispatch<React.SetStateAction<string>>;
handleClose: () => void;
handleSave: () => void;
isSubmitting?: boolean;
errorMessage?: string | null;
}
const StatusChangeDialog = ({
open,
newStatus,
reason,
setReason,
handleClose,
handleSave,
isSubmitting = false,
errorMessage,
}: StatusChangeDialogProps) => {
const [isValid, setIsValid] = useState(false);
useEffect(() => {
const noSpaces = reason.replace(/\s/g, "");
const length = noSpaces.length;
setIsValid(length >= 12 && length <= 400);
}, [reason]);
return (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="sm">
<DialogTitle>Change Status</DialogTitle>
<DialogContent>
You want to change the status to <b>{newStatus}</b>. Please provide a
reason for the change.
<TextField
label="Reason for change"
variant="outlined"
fullWidth
multiline
rows={4}
value={reason}
onChange={e => setReason(e.target.value)}
helperText="Reason must be between 12 and 400 characters"
sx={{ mt: 2 }}
/>
{errorMessage && (
<Alert severity="error" sx={{ mt: 2 }}>
{errorMessage}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={!isValid || isSubmitting}
startIcon={isSubmitting ? <CircularProgress size={18} /> : undefined}
>
Save
</Button>
</DialogActions>
</Dialog>
);
};
export default StatusChangeDialog;

View File

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

View File

@ -1,333 +0,0 @@
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 SelectorProps = {
enableStatusActions: boolean;
extraColumns?: string[] | null;
showExtraColumns?: boolean;
localRows: DataRowBase[];
handleStatusChange: (rowId: number, newStatus: string) => void;
};
// -------------------------
// Basic Selectors (props-driven)
// -------------------------
const propsEnableStatusActions = (_: RootState, props: SelectorProps) =>
props.enableStatusActions;
const propsExtraColumns = (_: RootState, props: SelectorProps) =>
props.extraColumns ?? null;
const propsShowExtraColumns = (_: RootState, props: SelectorProps) =>
props.showExtraColumns ?? false;
const propsLocalRows = (_: RootState, props: SelectorProps) =>
props.localRows ?? [];
const propsStatusChangeHandler = (_: RootState, props: SelectorProps) =>
props.handleStatusChange;
// -------------------------
// Helper: Format field name to header name
// -------------------------
/**
* Converts a field name to a readable header name
* e.g., "userId" -> "User ID", "transactionId" -> "Transaction ID"
*/
const formatFieldNameToHeader = (fieldName: string): string => {
// Handle camelCase: insert space before capital letters and capitalize first letter
return fieldName
.replace(/([A-Z])/g, " $1") // Add space before capital letters
.replace(/^./, str => str.toUpperCase()) // Capitalize first letter
.trim();
};
// -------------------------
// Dynamic Columns from Row Data
// -------------------------
const makeSelectDynamicColumns = () =>
createSelector([propsLocalRows], (localRows): GridColDef[] => {
// If no rows, fall back to static columns
if (!localRows || localRows.length === 0) {
return TABLE_COLUMNS;
}
// Get all unique field names from the row data
const fieldSet = new Set<string>();
localRows.forEach(row => {
Object.keys(row).forEach(key => {
if (key !== "options") {
// Exclude internal fields
fieldSet.add(key);
}
});
});
// Build columns from actual row data fields
const dynamicColumns: GridColDef[] = Array.from(fieldSet).map(field => {
// Format field name to readable header
const headerName = formatFieldNameToHeader(field);
// Set default widths based on field type
let width = 150;
if (field.includes("id") || field.includes("Id")) {
width = 180;
} else if (field === "amount" || field === "currency") {
width = 120;
} else if (field === "status") {
width = 120;
} else if (
field.includes("date") ||
field.includes("Date") ||
field === "dateTime" ||
field === "created" ||
field === "modified"
) {
width = 180;
}
return {
field,
headerName,
width,
sortable: true,
filterable: true,
} as GridColDef;
});
return dynamicColumns;
});
// -------------------------
// Base Columns
// -------------------------
const makeSelectBaseColumns = () =>
createSelector(
[makeSelectDynamicColumns(), propsEnableStatusActions],
(dynamicColumns, enableStatusActions) => {
const baseColumns = dynamicColumns;
if (!enableStatusActions) return baseColumns;
return [
...baseColumns,
{
field: "actions",
headerName: "Actions",
width: 160,
sortable: false,
filterable: false,
} as GridColDef,
];
}
);
// -------------------------
// Visible Columns
// -------------------------
const makeSelectVisibleColumns = () =>
createSelector(
[makeSelectBaseColumns(), propsExtraColumns, propsShowExtraColumns],
(baseColumns, extraColumns, showExtraColumns) => {
// Columns are already built from row data, so they're all valid
if (!extraColumns || extraColumns.length === 0) return baseColumns;
const visibleColumns = showExtraColumns
? baseColumns
: baseColumns.filter(col => !extraColumns.includes(col.field));
console.log("visibleColumns", visibleColumns);
return visibleColumns;
}
);
// -------------------------
// Resolved Statuses (STATE-based)
// -------------------------
const makeSelectResolvedStatuses = () =>
createSelector([selectTransactionStatuses], statuses =>
statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK
);
// -------------------------
// Enhanced Columns
// -------------------------
export const makeSelectEnhancedColumns = () =>
createSelector(
[
makeSelectVisibleColumns(),
propsLocalRows,
propsStatusChangeHandler,
makeSelectResolvedStatuses(),
],
(
visibleColumns,
localRows,
handleStatusChange,
resolvedStatusOptions
): GridColDef[] => {
console.log("visibleColumns", visibleColumns);
return visibleColumns.map(col => {
// --------------------------------
// 1. STATUS COLUMN RENDERER
// --------------------------------
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>
);
},
};
}
// --------------------------------
// 2. USER ID COLUMN
// --------------------------------
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>
),
};
}
// --------------------------------
// 3. ACTIONS COLUMN
// --------------------------------
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 = 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;
});
}
);

Some files were not shown because too many files have changed in this diff Show More