Compare commits
1 Commits
main
...
table-filt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d4f0e86da |
@ -1 +0,0 @@
|
||||
npx lint-staged
|
||||
@ -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
|
||||
15
.prettierrc
15
.prettierrc
@ -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"
|
||||
}
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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" });
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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" },
|
||||
];
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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" },
|
||||
];
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
];
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
25
app/api/transactions/route.ts
Normal file
25
app/api/transactions/route.ts
Normal 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);
|
||||
}
|
||||
26
app/components/AccountIQ/AccountIQ.tsx
Normal file
26
app/components/AccountIQ/AccountIQ.tsx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
18
app/components/Documentation/Documentation.tsx
Normal file
18
app/components/Documentation/Documentation.tsx
Normal 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' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
155
app/components/FetchReports/FetchReports.tsx
Normal file
155
app/components/FetchReports/FetchReports.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
66
app/components/GeneralHealthCard/GeneralHealthCard.tsx
Normal file
66
app/components/GeneralHealthCard/GeneralHealthCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{title && <h2 className="modal__title">{title}</h2>}
|
||||
<div className={"modal__body"}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
69
app/components/PieCharts/PieCharts.tsx
Normal file
69
app/components/PieCharts/PieCharts.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
|
||||
37
app/components/SectionCard/SectionCard.tsx
Normal file
37
app/components/SectionCard/SectionCard.tsx
Normal 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>
|
||||
);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
50
app/components/TransactionsOverview/TransactionsOverview.tsx
Normal file
50
app/components/TransactionsOverview/TransactionsOverview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
17
app/components/WhatsNew/WhatsNew.tsx
Normal file
17
app/components/WhatsNew/WhatsNew.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { SectionCard } from "../SectionCard/SectionCard";
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
|
||||
export const WhatsNew = () => {
|
||||
return (
|
||||
<SectionCard
|
||||
title="What’s 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' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
42
app/components/dashboard/header/DropDown.tsx
Normal file
42
app/components/dashboard/header/DropDown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
app/components/dashboard/header/Header.tsx
Normal file
48
app/components/dashboard/header/Header.tsx
Normal 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;
|
||||
8
app/components/dashboard/layout/layoutWrapper.ts
Normal file
8
app/components/dashboard/layout/layoutWrapper.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { styled } from '@mui/system';
|
||||
|
||||
export const LayoutWrapper = styled('div')({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
// overflow: 'hidden',
|
||||
});
|
||||
8
app/components/dashboard/layout/mainContent.ts
Normal file
8
app/components/dashboard/layout/mainContent.ts
Normal 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)',
|
||||
}));
|
||||
41
app/components/dashboard/sidebar/SideBarLink.tsx
Normal file
41
app/components/dashboard/sidebar/SideBarLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/components/dashboard/sidebar/Sidebar.tsx
Normal file
55
app/components/dashboard/sidebar/Sidebar.tsx
Normal 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;
|
||||
162
app/components/pages/Approve/Approve.tsx
Normal file
162
app/components/pages/Approve/Approve.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
26
app/components/pages/DashboardHomePage/DashboardHomePage.tsx
Normal file
26
app/components/pages/DashboardHomePage/DashboardHomePage.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
110
app/components/pages/transactions/Transactions.tsx
Normal file
110
app/components/pages/transactions/Transactions.tsx
Normal 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',
|
||||
}));
|
||||
80
app/components/pages/transactions/constants.ts
Normal file
80
app/components/pages/transactions/constants.ts
Normal 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 },
|
||||
];
|
||||
2134
app/components/pages/transactions/mockData.ts
Normal file
2134
app/components/pages/transactions/mockData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
41
app/components/sidebar/SideBarLink.tsx
Normal file
41
app/components/sidebar/SideBarLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
app/components/test/test1.tsx
Normal file
103
app/components/test/test1.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
app/components/test/test2.tsx
Normal file
189
app/components/test/test2.tsx
Normal 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,
|
||||
}
|
||||
];
|
||||
@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export default async function HistoryTransactionPage() {
|
||||
return <div>History Transactions Page</div>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
13
app/dashboard/transactions/page.tsx
Normal file
13
app/dashboard/transactions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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.)
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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">
|
||||
You’re 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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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" },
|
||||
];
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user