From 277c7e15b90a750758cb62cb0e9d2f6e580702c6 Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Thu, 27 Nov 2025 09:09:43 +0100 Subject: [PATCH 01/12] Changed Audits to use SSR and SWR --- app/api/auth/change-password/route.ts | 8 +- app/api/auth/login/route.tsx | 6 +- app/api/auth/logout/route.tsx | 8 +- app/api/auth/register/route.ts | 6 +- app/api/auth/reset-password/[id]/route.ts | 6 +- app/api/auth/validate/route.ts | 6 +- app/api/dashboard/admin/groups/route.ts | 6 +- app/api/dashboard/admin/permissions/route.ts | 6 +- app/api/dashboard/admin/sessions/route.ts | 6 +- app/api/dashboard/admin/users/[id]/route.ts | 8 +- app/api/dashboard/admin/users/route.ts | 7 +- app/api/dashboard/audits/route.ts | 19 +- app/api/dashboard/transactions/[id]/route.ts | 7 +- .../dashboard/transactions/deposits/route.ts | 5 +- app/api/dashboard/transactions/route.ts | 9 +- .../transactions/withdrawal/mockData.ts | 243 ---------- .../transactions/withdrawal/route.ts | 76 +-- .../transactions/withdrawals/route.ts | 5 +- app/api/metadata/route.ts | 2 +- app/dashboard/admin/page.tsx | 10 - app/dashboard/audits/AuditTableClient.tsx | 214 +++++++++ app/dashboard/audits/auditTransforms.ts | 137 ++++++ app/dashboard/audits/page.tsx | 374 +++------------ app/dashboard/transactions/deposits/page.tsx | 2 +- .../AdvancedSearch/AdvancedSearch.tsx | 1 - app/features/DataTable/DataTable.tsx | 84 +--- app/features/DataTable/StatusChangeDialog.tsx | 87 +++- app/services/audits.ts | 42 +- app/services/constants copy.ts | 0 app/services/constants.ts | 5 + app/services/types.ts | 8 + middleware.ts | 9 +- package-lock.json | 153 ++++++ package.json | 1 + yarn.lock | 454 +++--------------- 35 files changed, 804 insertions(+), 1216 deletions(-) delete mode 100644 app/api/dashboard/transactions/withdrawal/mockData.ts delete mode 100644 app/dashboard/admin/page.tsx create mode 100644 app/dashboard/audits/AuditTableClient.tsx create mode 100644 app/dashboard/audits/auditTransforms.ts create mode 100644 app/services/constants copy.ts create mode 100644 app/services/constants.ts create mode 100644 app/services/types.ts diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts index 399aaa4..4bf1712 100644 --- a/app/api/auth/change-password/route.ts +++ b/app/api/auth/change-password/route.ts @@ -1,9 +1,7 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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(); @@ -11,7 +9,7 @@ export async function POST(request: Request) { // Get the auth token from cookies first const { cookies } = await import("next/headers"); const cookieStore = cookies(); - const token = (await cookieStore).get(COOKIE_NAME)?.value; + const token = (await cookieStore).get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -99,7 +97,7 @@ export async function POST(request: Request) { } catch {} response.cookies.set({ - name: COOKIE_NAME, + name: AUTH_COOKIE_NAME, value: newToken, httpOnly: true, secure: process.env.NODE_ENV === "production", diff --git a/app/api/auth/login/route.tsx b/app/api/auth/login/route.tsx index de83096..3615eed 100644 --- a/app/api/auth/login/route.tsx +++ b/app/api/auth/login/route.tsx @@ -1,10 +1,8 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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(); @@ -59,7 +57,7 @@ export async function POST(request: Request) { // Set the cookie const cookieStore = await cookies(); - cookieStore.set(COOKIE_NAME, token, { + cookieStore.set(AUTH_COOKIE_NAME, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", diff --git a/app/api/auth/logout/route.tsx b/app/api/auth/logout/route.tsx index 0ba3c09..002d1e0 100644 --- a/app/api/auth/logout/route.tsx +++ b/app/api/auth/logout/route.tsx @@ -1,12 +1,10 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (token) { try { @@ -23,6 +21,6 @@ export async function DELETE() { } } - cookieStore.delete(COOKIE_NAME); + cookieStore.delete(AUTH_COOKIE_NAME); return NextResponse.json({ success: true, message: "Logged out" }); } diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 31982d3..6a0c0c8 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,9 +1,7 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; @@ -23,7 +21,7 @@ export async function POST(request: Request) { // Get the auth token from cookies const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/auth/reset-password/[id]/route.ts b/app/api/auth/reset-password/[id]/route.ts index 75c63bd..3aecaa4 100644 --- a/app/api/auth/reset-password/[id]/route.ts +++ b/app/api/auth/reset-password/[id]/route.ts @@ -1,11 +1,9 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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 }> } @@ -21,7 +19,7 @@ export async function PUT( } const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/auth/validate/route.ts b/app/api/auth/validate/route.ts index ea6b10b..bf002c1 100644 --- a/app/api/auth/validate/route.ts +++ b/app/api/auth/validate/route.ts @@ -1,12 +1,10 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json({ valid: false }, { status: 401 }); diff --git a/app/api/dashboard/admin/groups/route.ts b/app/api/dashboard/admin/groups/route.ts index a7c6daa..d888cb8 100644 --- a/app/api/dashboard/admin/groups/route.ts +++ b/app/api/dashboard/admin/groups/route.ts @@ -1,14 +1,12 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/dashboard/admin/permissions/route.ts b/app/api/dashboard/admin/permissions/route.ts index 8fb217d..52ef89b 100644 --- a/app/api/dashboard/admin/permissions/route.ts +++ b/app/api/dashboard/admin/permissions/route.ts @@ -1,14 +1,12 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/dashboard/admin/sessions/route.ts b/app/api/dashboard/admin/sessions/route.ts index 17049c6..5b993f1 100644 --- a/app/api/dashboard/admin/sessions/route.ts +++ b/app/api/dashboard/admin/sessions/route.ts @@ -1,14 +1,12 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/dashboard/admin/users/[id]/route.ts b/app/api/dashboard/admin/users/[id]/route.ts index 9a9c5df..6950f2e 100644 --- a/app/api/dashboard/admin/users/[id]/route.ts +++ b/app/api/dashboard/admin/users/[id]/route.ts @@ -1,9 +1,7 @@ // app/api/users/[id]/route.ts +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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 = { @@ -75,7 +73,7 @@ export async function PUT( // Get the auth token from cookies const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -114,7 +112,7 @@ export async function DELETE( const { id } = await context.params; const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/dashboard/admin/users/route.ts b/app/api/dashboard/admin/users/route.ts index 759225a..ee5764a 100644 --- a/app/api/dashboard/admin/users/route.ts +++ b/app/api/dashboard/admin/users/route.ts @@ -1,13 +1,11 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -29,6 +27,7 @@ export async function GET(request: Request) { }); 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); diff --git a/app/api/dashboard/audits/route.ts b/app/api/dashboard/audits/route.ts index dfe76b8..93f0336 100644 --- a/app/api/dashboard/audits/route.ts +++ b/app/api/dashboard/audits/route.ts @@ -1,11 +1,6 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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"; @@ -13,7 +8,7 @@ export async function GET(request: NextRequest) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -38,7 +33,7 @@ export async function GET(request: NextRequest) { proxiedParams.set("page", DEFAULT_PAGE); } - const backendUrl = `${AUDITS_BASE_URL}/api/v1/audit${ + const backendUrl = `${BE_BASE_URL}/api/v1/audit${ proxiedParams.size ? `?${proxiedParams.toString()}` : "" }`; @@ -48,7 +43,10 @@ export async function GET(request: NextRequest) { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - cache: "no-store", + next: { + revalidate: 60, + tags: ["audits"], + }, }); if (!response.ok) { @@ -65,11 +63,8 @@ export async function GET(request: NextRequest) { } 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( diff --git a/app/api/dashboard/transactions/[id]/route.ts b/app/api/dashboard/transactions/[id]/route.ts index 2702f90..adfd133 100644 --- a/app/api/dashboard/transactions/[id]/route.ts +++ b/app/api/dashboard/transactions/[id]/route.ts @@ -1,8 +1,6 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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 }> } @@ -11,7 +9,7 @@ export async function PUT( const { id } = await context.params; const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -32,7 +30,6 @@ export async function PUT( }); 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"; diff --git a/app/api/dashboard/transactions/deposits/route.ts b/app/api/dashboard/transactions/deposits/route.ts index d294c7c..110fb44 100644 --- a/app/api/dashboard/transactions/deposits/route.ts +++ b/app/api/dashboard/transactions/deposits/route.ts @@ -1,6 +1,5 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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 @@ -13,7 +12,7 @@ export async function POST(request: NextRequest) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/dashboard/transactions/route.ts b/app/api/dashboard/transactions/route.ts index c297e66..19e55a1 100644 --- a/app/api/dashboard/transactions/route.ts +++ b/app/api/dashboard/transactions/route.ts @@ -1,13 +1,11 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( @@ -100,8 +98,6 @@ export async function POST(request: NextRequest) { 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: { @@ -125,7 +121,6 @@ export async function POST(request: NextRequest) { } const data = await response.json(); - console.log("[DEBUG] [TRANSACTIONS] Response data:", data); return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { diff --git a/app/api/dashboard/transactions/withdrawal/mockData.ts b/app/api/dashboard/transactions/withdrawal/mockData.ts deleted file mode 100644 index 48c8fc8..0000000 --- a/app/api/dashboard/transactions/withdrawal/mockData.ts +++ /dev/null @@ -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" }, -]; diff --git a/app/api/dashboard/transactions/withdrawal/route.ts b/app/api/dashboard/transactions/withdrawal/route.ts index 52c8d3c..fa61a38 100644 --- a/app/api/dashboard/transactions/withdrawal/route.ts +++ b/app/api/dashboard/transactions/withdrawal/route.ts @@ -1,67 +1,13 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - withdrawalTransactionDummyData, - withdrawalTransactionsColumns, - withdrawalTransactionsSearchLabels, -} from "./mockData"; +import { NextResponse } from "next/server"; -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, - }); +/** + * Placeholder Whitdrawal 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 } + ); } diff --git a/app/api/dashboard/transactions/withdrawals/route.ts b/app/api/dashboard/transactions/withdrawals/route.ts index eafbad8..19bc5ed 100644 --- a/app/api/dashboard/transactions/withdrawals/route.ts +++ b/app/api/dashboard/transactions/withdrawals/route.ts @@ -1,6 +1,5 @@ +import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; 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 @@ -13,7 +12,7 @@ export async function POST(request: NextRequest) { try { const { cookies } = await import("next/headers"); const cookieStore = await cookies(); - const token = cookieStore.get(COOKIE_NAME)?.value; + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; if (!token) { return NextResponse.json( diff --git a/app/api/metadata/route.ts b/app/api/metadata/route.ts index 113b8f4..911581f 100644 --- a/app/api/metadata/route.ts +++ b/app/api/metadata/route.ts @@ -1,3 +1,4 @@ +import { BE_BASE_URL } from "@/app/services/constants"; import { NextResponse } from "next/server"; // Proxy to backend metadata endpoint. Assumes BACKEND_BASE_URL is set. @@ -5,7 +6,6 @@ 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( diff --git a/app/dashboard/admin/page.tsx b/app/dashboard/admin/page.tsx deleted file mode 100644 index 4e7a55d..0000000 --- a/app/dashboard/admin/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; - -export default function BackOfficeUsersPage() { - return ( -
- {/* This page will now be rendered on the client-side */} - hello -
- ); -} diff --git a/app/dashboard/audits/AuditTableClient.tsx b/app/dashboard/audits/AuditTableClient.tsx new file mode 100644 index 0000000..6bc3c73 --- /dev/null +++ b/app/dashboard/audits/AuditTableClient.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridSortModel, +} from "@mui/x-data-grid"; +import TextField from "@mui/material/TextField"; +import { Box, debounce } from "@mui/material"; +import useSWR from "swr"; +import { getAudits } from "@/app/services/audits"; +import { + AuditQueryResult, + AuditRow, + DEFAULT_PAGE_SIZE, +} from "./auditTransforms"; + +const FALLBACK_COLUMNS: GridColDef[] = [ + { + field: "placeholder", + headerName: "Audit Data", + flex: 1, + sortable: false, + filterable: false, + }, +]; + +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, + })); +}; + +interface AuditTableClientProps { + initialData: AuditQueryResult; +} + +const ENTITY_PREFIX = "LIKE/"; + +const buildSortParam = (sortModel: GridSortModel) => + sortModel.length && sortModel[0].field && sortModel[0].sort + ? `${sortModel[0].field}:${sortModel[0].sort}` + : undefined; + +const buildEntityParam = (entitySearch: string) => + entitySearch.trim() ? `${ENTITY_PREFIX}${entitySearch.trim()}` : undefined; + +export default function AuditTableClient({ + initialData, +}: AuditTableClientProps) { + const [paginationModel, setPaginationModel] = useState({ + page: initialData.pageIndex ?? 0, + pageSize: DEFAULT_PAGE_SIZE, + }); + const [sortModel, setSortModel] = useState([]); + const [entitySearch, setEntitySearch] = useState(""); + const [entitySearchInput, setEntitySearchInput] = useState(""); + + const [columns, setColumns] = useState( + initialData.rows.length ? deriveColumns(initialData.rows) : FALLBACK_COLUMNS + ); + + const debouncedSetEntitySearch = useMemo( + () => + debounce((value: string) => { + setEntitySearch(value); + setPaginationModel(prev => ({ ...prev, page: 0 })); + }, 500), + [] + ); + + useEffect(() => { + return () => { + debouncedSetEntitySearch.clear(); + }; + }, [debouncedSetEntitySearch]); + + const sortParam = useMemo(() => buildSortParam(sortModel), [sortModel]); + const entityParam = useMemo( + () => buildEntityParam(entitySearch), + [entitySearch] + ); + + const { data, error, isLoading, isValidating } = useSWR( + [ + "audits", + paginationModel.page, + paginationModel.pageSize, + sortParam ?? "", + entityParam ?? "", + ], + () => + getAudits({ + limit: paginationModel.pageSize, + page: paginationModel.page + 1, + sort: sortParam, + entity: entityParam, + }), + { + keepPreviousData: true, + revalidateOnFocus: true, + revalidateOnReconnect: true, + fallbackData: initialData, + } + ); + + const rows = data?.rows ?? initialData.rows; + const rowCount = data?.total ?? initialData.total ?? rows.length; + const loading = isLoading || (isValidating && !rows.length); + const pageTitle = useMemo( + () => + sortModel.length && sortModel[0].field + ? `Audit Logs · sorted by ${toTitle(sortModel[0].field)}` + : "Audit Logs", + [sortModel] + ); + + useEffect(() => { + setColumns(prev => + rows.length ? deriveColumns(rows) : prev.length ? prev : FALLBACK_COLUMNS + ); + }, [rows]); + + const handlePaginationChange = (model: GridPaginationModel) => { + setPaginationModel(model); + }; + + const handleSortModelChange = (model: GridSortModel) => { + setSortModel(model); + setPaginationModel(prev => ({ ...prev, page: 0 })); + }; + + const handleEntitySearchChange = (value: string) => { + setEntitySearchInput(value); + debouncedSetEntitySearch(value); + }; + + const errorMessage = + error instanceof Error + ? error.message + : error + ? "Failed to load audits" + : null; + + return ( +
+ + handleEntitySearchChange(e.target.value)} + sx={{ width: 300, backgroundColor: "#f0f0f0" }} + /> + +

{pageTitle}

+ {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
+
+ +
+
+
+
+ ); +} diff --git a/app/dashboard/audits/auditTransforms.ts b/app/dashboard/audits/auditTransforms.ts new file mode 100644 index 0000000..77cbf9f --- /dev/null +++ b/app/dashboard/audits/auditTransforms.ts @@ -0,0 +1,137 @@ +export type AuditRow = Record & { id: string | number }; + +export 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 }; +} + +export interface AuditQueryResult { + rows: AuditRow[]; + total: number; + payload: AuditApiResponse; + pageIndex: number; +} + +export const DEFAULT_PAGE_SIZE = 25; +const CANDIDATE_ARRAY_KEYS: (keyof AuditApiResponse)[] = [ + "items", + "audits", + "logs", + "results", + "records", +]; + +export 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); +}; + +export 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) + : 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 []; +}; + +export 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 + ); +}; + +export const normalizeRows = ( + entries: unknown[], + pageIndex: number +): AuditRow[] => + entries.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return { + id: `${pageIndex}-${index}`, + value: normalizeValue(entry), + }; + } + + const record = entry as Record; + + const normalized: Record = {}; + Object.entries(record).forEach(([key, value]) => { + normalized[key] = normalizeValue(value); + }); + + const identifier = + record.id ?? + record.audit_id ?? + record.log_id ?? + record._id ?? + `${pageIndex}-${index}`; + + return { + id: (identifier as string | number) ?? `${pageIndex}-${index}`, + ...normalized, + }; + }); diff --git a/app/dashboard/audits/page.tsx b/app/dashboard/audits/page.tsx index 5217065..b672cca 100644 --- a/app/dashboard/audits/page.tsx +++ b/app/dashboard/audits/page.tsx @@ -1,332 +1,70 @@ -"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 & { id: string | number }; +import AuditTableClient from "./AuditTableClient"; +import { + AuditApiResponse, + AuditQueryResult, + DEFAULT_PAGE_SIZE, + extractArray, + normalizeRows, + resolveTotal, +} from "./auditTransforms"; +import { cookies } from "next/headers"; +import { + AUDIT_CACHE_TAG, + AUTH_COOKIE_NAME, + BE_BASE_URL, + REVALIDATE_SECONDS, +} from "@/app/services/constants"; -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 }; -} +async function fetchInitialAudits(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; -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 (!token) { + throw new Error("Missing auth token"); } - if (typeof value === "string" || typeof value === "number") { - return value; - } + const params = new URLSearchParams(); + params.set("limit", DEFAULT_PAGE_SIZE.toString()); + params.set("page", "1"); - if (typeof value === "boolean") { - return value ? "true" : "false"; - } + const backendUrl = `${BE_BASE_URL}/api/v1/audit${ + params.size ? `?${params.toString()}` : "" + }`; - 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) - : 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; - - const normalized: Record = {}; - 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, - }; + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + next: { + revalidate: REVALIDATE_SECONDS, + tags: [AUDIT_CACHE_TAG], + }, }); -export default function AuditPage() { - const [rows, setRows] = useState([]); - const [columns, setColumns] = useState([]); - const [rowCount, setRowCount] = useState(0); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [paginationModel, setPaginationModel] = useState({ - page: 0, - pageSize: DEFAULT_PAGE_SIZE, - }); - const [sortModel, setSortModel] = useState([]); - const [entitySearch, setEntitySearch] = useState(""); - const [entitySearchInput, setEntitySearchInput] = useState(""); + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch audits" })); + throw new Error(errorData?.message || "Failed to fetch audits"); + } - useEffect(() => { - const controller = new AbortController(); + const payload = (await response.json()) as AuditApiResponse; + const rows = normalizeRows(extractArray(payload), 0); + const total = resolveTotal(payload, rows.length); - 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); + return { + rows, + total, + payload, + pageIndex: 0, }; - - 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 ( -
- - handleEntitySearchChange(e.target.value)} - sx={{ width: 300, backgroundColor: "#f0f0f0" }} - /> - -

{pageTitle}

- {error && ( -
- {error} -
- )} -
-
-
- -
-
-
-
- ); +} + +export default async function AuditPage() { + const initialData = await fetchInitialAudits(); + return ; } diff --git a/app/dashboard/transactions/deposits/page.tsx b/app/dashboard/transactions/deposits/page.tsx index 97d269f..8c660e5 100644 --- a/app/dashboard/transactions/deposits/page.tsx +++ b/app/dashboard/transactions/deposits/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import DataTable from "@/app/features/DataTable/DataTable"; import { useDispatch, useSelector } from "react-redux"; import { AppDispatch } from "@/app/redux/store"; @@ -12,7 +13,6 @@ 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() { diff --git a/app/features/AdvancedSearch/AdvancedSearch.tsx b/app/features/AdvancedSearch/AdvancedSearch.tsx index 97088f1..16b2453 100644 --- a/app/features/AdvancedSearch/AdvancedSearch.tsx +++ b/app/features/AdvancedSearch/AdvancedSearch.tsx @@ -45,7 +45,6 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) { const [operators, setOperators] = useState>({}); const conditionOperators = useSelector(selectConditionOperators); - console.log("[conditionOperators]", conditionOperators); // ----------------------------------------------------- // SYNC REDUX FILTERS TO LOCAL STATE ON LOAD // ----------------------------------------------------- diff --git a/app/features/DataTable/DataTable.tsx b/app/features/DataTable/DataTable.tsx index 1816a8b..9a22f26 100644 --- a/app/features/DataTable/DataTable.tsx +++ b/app/features/DataTable/DataTable.tsx @@ -33,14 +33,10 @@ const DataTable = ({ }: DataTableProps) => { const dispatch = useDispatch(); const [showExtraColumns, setShowExtraColumns] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - const [selectedRowId, setSelectedRowId] = useState(null); - const [pendingStatus, setPendingStatus] = useState(""); - const [reason, setReason] = useState(""); - const [statusUpdateError, setStatusUpdateError] = useState( - null - ); - const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const [statusDialogData, setStatusDialogData] = useState<{ + rowId: number; + newStatus: string; + } | null>(null); const status = useSelector(selectStatus); const errorMessage = useSelector(selectError); @@ -49,7 +45,6 @@ const DataTable = ({ const handlePaginationModelChange = useCallback( (model: GridPaginationModel) => { - console.log("model", model); const nextPage = model.page + 1; const nextLimit = model.pageSize; @@ -61,57 +56,12 @@ const DataTable = ({ ); const handleStatusChange = useCallback((rowId: number, newStatus: string) => { - setSelectedRowId(rowId); - setPendingStatus(newStatus); - setModalOpen(true); + setStatusDialogData({ rowId, newStatus }); }, []); - 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 handleDialogClose = useCallback(() => { + setStatusDialogData(null); + }, []); const selectEnhancedColumns = useMemo(makeSelectEnhancedColumns, []); @@ -170,20 +120,10 @@ const DataTable = ({ { - setModalOpen(false); - setReason(""); - setPendingStatus(""); - setStatusUpdateError(null); - setSelectedRowId(null); - }} - handleSave={handleStatusSave} - isSubmitting={isUpdatingStatus} - errorMessage={statusUpdateError} + open={Boolean(statusDialogData)} + transactionId={statusDialogData?.rowId} + newStatus={statusDialogData?.newStatus ?? ""} + onClose={handleDialogClose} /> diff --git a/app/features/DataTable/StatusChangeDialog.tsx b/app/features/DataTable/StatusChangeDialog.tsx index aa66c8e..c75d4bb 100644 --- a/app/features/DataTable/StatusChangeDialog.tsx +++ b/app/features/DataTable/StatusChangeDialog.tsx @@ -8,39 +8,90 @@ import { Alert, CircularProgress, } from "@mui/material"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; interface StatusChangeDialogProps { open: boolean; + transactionId?: number | null; newStatus: string; - reason: string; - setReason: React.Dispatch>; - handleClose: () => void; - handleSave: () => void; - isSubmitting?: boolean; - errorMessage?: string | null; + onClose: () => void; + onStatusUpdated?: () => void; } const StatusChangeDialog = ({ open, + transactionId, newStatus, - reason, - setReason, - handleClose, - handleSave, - isSubmitting = false, - errorMessage, + onClose, + onStatusUpdated, }: StatusChangeDialogProps) => { - const [isValid, setIsValid] = useState(false); + const [reason, setReason] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { + if (!open) { + setReason(""); + setErrorMessage(null); + setIsSubmitting(false); + } + }, [open]); + + const isValid = useMemo(() => { const noSpaces = reason.replace(/\s/g, ""); const length = noSpaces.length; - setIsValid(length >= 12 && length <= 400); + return length >= 12 && length <= 400; }, [reason]); + const handleSave = async () => { + if (!transactionId || !newStatus || !isValid) return; + + setErrorMessage(null); + setIsSubmitting(true); + + try { + const payload = { + data: { + status: newStatus, + notes: reason.trim(), + }, + fields: ["Status", "Notes"], + }; + + const response = await fetch( + `/api/dashboard/transactions/${transactionId}`, + { + 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" + ); + } + + setReason(""); + setErrorMessage(null); + onStatusUpdated?.(); + onClose(); + } catch (err) { + setErrorMessage( + err instanceof Error ? err.message : "Failed to update transaction" + ); + } finally { + setIsSubmitting(false); + } + }; + return ( - + Change Status You want to change the status to {newStatus}. Please provide a @@ -63,13 +114,13 @@ const StatusChangeDialog = ({ )} - @@ -30,11 +101,12 @@ export const TransactionsOverView = () => { - {/* Chart and Table */} - - - - + {overviewData && ( + + + + + )} ); }; diff --git a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx index dd16547..d66cf8c 100644 --- a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx +++ b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx @@ -12,14 +12,27 @@ import { import "./TransactionsOverViewTable.scss"; -const data1 = [ +interface ITableData { + state: string; + count: number; + percentage: string; + color?: string; +} + +interface ITransactionsOverViewTableProps { + data?: ITableData[]; +} + +const defaultData: ITableData[] = [ { 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 = () => { +export const TransactionsOverViewTable = ({ + data = defaultData, +}: ITransactionsOverViewTableProps) => { return ( @@ -32,8 +45,8 @@ export const TransactionsOverViewTable = () => { - {data1.map((row, i) => ( - + {data.map((row, i) => ( + { + const normalizedState = state.toLowerCase(); + + switch (normalizedState) { + case "success": + case "completed": + case "successful": + return "#4caf50"; // green + case "pending": + case "waiting": + return "#ff9800"; // orange + case "failed": + case "error": + return "#f44336"; // red + case "cancelled": + case "canceled": + return "#9e9e9e"; // gray + default: + return "#9e9e9e"; // gray + } +}; + +/** + * Transform flat API overview response to array format + */ +export const transformOverviewResponse = (data: { + cancelled?: number; + failed?: number; + successful?: number; + waiting?: number; +}): Array<{ + state: string; + count: number; +}> => { + const states = [ + { key: "successful", label: "Successful" }, + { key: "waiting", label: "Waiting" }, + { key: "failed", label: "Failed" }, + { key: "cancelled", label: "Cancelled" }, + ]; + + return states + .map(({ key, label }) => ({ + state: label, + count: data[key as keyof typeof data] || 0, + })) + .filter(item => item.count > 0); // Only include states with counts > 0 +}; + +/** + * Calculate percentage for each state + */ +export const calculatePercentages = ( + items: Array<{ state: string; count: number }> +): Array<{ + state: string; + count: number; + percentage: string; +}> => { + const total = items.reduce((sum, item) => sum + item.count, 0); + + if (total === 0) { + return items.map(item => ({ + ...item, + percentage: "0%", + })); + } + + return items.map(item => ({ + ...item, + percentage: `${Math.round((item.count / total) * 100)}%`, + })); +}; + +/** + * Transform API overview data to include colors if missing + */ +export const enrichOverviewData = ( + data: Array<{ + state: string; + count: number; + percentage: string; + color?: string; + }> +): Array<{ + state: string; + count: number; + percentage: string; + color: string; +}> => { + return data.map(item => ({ + ...item, + color: item.color || getStateColor(item.state), + })); +}; diff --git a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx index 34ddc01..1f1d092 100644 --- a/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx +++ b/app/features/TransactionsWaitingApproval/TransactionsWaitingApproval.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { useEffect, useState } from "react"; import { Box, Button, @@ -13,110 +16,71 @@ import { } from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CancelIcon from "@mui/icons-material/Cancel"; - import MoreVertIcon from "@mui/icons-material/MoreVert"; -import "./TransactionsWaitingApproval.scss"; -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", - }, - { - 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", - }, - { - 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", - }, -]; +import { dashboardService } from "@/app/services/dashboardService"; +import { formatToDateTimeString } from "@/app/utils/formatDate"; + +import "./TransactionsWaitingApproval.scss"; +import { + type IReviewTransactionsData, + type IReviewTransaction, +} from "@/app/services/types"; + +interface ITransactionsWaitingApprovalProps { + initialReviewTransactions?: IReviewTransactionsData | null; +} + +export const TransactionsWaitingApproval = ({ + initialReviewTransactions = null, +}: ITransactionsWaitingApprovalProps) => { + const [reviewTransactions, setReviewTransactions] = + useState( + initialReviewTransactions || + dashboardService.getCurrentDashboardData()?.reviewTransactions || + null + ); + + /** + * Subscribe to dashboard data changes + */ + useEffect(() => { + const subscription = dashboardService + .getDashboardData$() + .subscribe(data => { + if (data?.reviewTransactions) { + setReviewTransactions(data.reviewTransactions); + } + }); + + // Cleanup subscription on unmount + return () => subscription.unsubscribe(); + }, []); + + /** + * Format transaction for display + */ + const formatTransaction = (tx: IReviewTransaction) => { + const createdDate = tx.created || tx.modified || ""; + const formattedDate = createdDate + ? formatToDateTimeString(createdDate) + : ""; + + return { + id: tx.id?.toString() || tx.external_id || "", + user: tx.customer || "", + created: formattedDate, + type: tx.type || "", + amount: tx.amount + ? `${tx.amount < 0 ? "-" : ""}${Math.abs(tx.amount).toFixed(2)} ${tx.currency || ""}` + : "", + psp: tx.psp_id || "", + }; + }; + + const transactions = reviewTransactions?.transactions || []; + const displayTransactions = transactions.map(formatTransaction); -export const TransactionsWaitingApproval = () => { return ( @@ -128,65 +92,77 @@ export const TransactionsWaitingApproval = () => { - {" "} + - -
- - - - ID - - - User - - - Created - - - Type - - - Amount - - - PSP - - - Action - - - - - {transactions.map((tx, i) => ( - - {tx.id} - {tx.user} - {tx.created} - {tx.type} - {tx.amount} - {tx.psp} + {reviewTransactions && ( + +
+ + - - - - - - + ID + + + User + + + Created + + + Type + + + Amount + + + PSP + + + Action - ))} - -
-
+ + + {displayTransactions.length > 0 ? ( + displayTransactions.map((tx, i) => ( + + {tx.id} + {tx.user} + {tx.created} + {tx.type} + {tx.amount} + {tx.psp} + + + + + + + + + + )) + ) : ( + + + + No transactions waiting for approval + + + + )} + + + + )} ); diff --git a/app/hooks/useDebouncedDateRange.ts b/app/hooks/useDebouncedDateRange.ts new file mode 100644 index 0000000..53ecf4a --- /dev/null +++ b/app/hooks/useDebouncedDateRange.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { Range } from "react-date-range"; + +interface IUseDebouncedDateRangeOptions { + initialDateRange?: Range[]; + debounceMs?: number; + onDateRangeChange?: (range: Range[]) => void | Promise; + skipInitialFetch?: boolean; +} + +export const useDebouncedDateRange = ({ + initialDateRange, + debounceMs = 1000, + onDateRangeChange, + skipInitialFetch = false, +}: IUseDebouncedDateRangeOptions = {}) => { + const [dateRange, setDateRange] = useState(initialDateRange ?? []); + const debounceTimeoutRef = useRef(null); + const isFirstMount = useRef(true); + + const handleDateRangeChange = useCallback( + (newRange: Range[]) => { + // Update state immediately for UI responsiveness + setDateRange(newRange); + + // Clear any existing debounce timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + // Skip fetch on first mount if requested + if (isFirstMount.current) { + isFirstMount.current = false; + if (skipInitialFetch) { + return; + } + } + + const currentRange = newRange[0]; + if (!currentRange?.startDate || !currentRange?.endDate) return; + + // Debounce the callback + debounceTimeoutRef.current = setTimeout(() => { + onDateRangeChange?.(newRange); + }, debounceMs); + }, + [onDateRangeChange, debounceMs, skipInitialFetch] + ); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, []); + + return { + dateRange, + handleDateRangeChange, + }; +}; diff --git a/app/services/dashboardService.ts b/app/services/dashboardService.ts new file mode 100644 index 0000000..84906c2 --- /dev/null +++ b/app/services/dashboardService.ts @@ -0,0 +1,42 @@ +import { BehaviorSubject, Observable } from "rxjs"; +import { IDashboardData } from "./types"; +import { getDashboardData } from "./transactions"; + +class DashboardService { + private dashboardData$ = new BehaviorSubject(null); + + /** + * Get observable for dashboard data + */ + getDashboardData$(): Observable { + return this.dashboardData$.asObservable(); + } + + /** + * Get current dashboard data + */ + getCurrentDashboardData(): IDashboardData | null { + return this.dashboardData$.getValue(); + } + + /** + * Update dashboard data (called when fetching new data) + */ + updateDashboardData(data: IDashboardData): void { + this.dashboardData$.next(data); + } + + /** + * Fetch and update dashboard data + */ + async fetchDashboardData(params: { + dateStart?: string; + dateEnd?: string; + }): Promise { + const data = await getDashboardData(params); + this.updateDashboardData(data); + return data; + } +} + +export const dashboardService = new DashboardService(); diff --git a/app/services/health.ts b/app/services/health.ts index 45f8f02..31ece41 100644 --- a/app/services/health.ts +++ b/app/services/health.ts @@ -7,31 +7,23 @@ import { REVALIDATE_SECONDS, HEALTH_CACHE_TAG, } from "./constants"; +import { + type IDashboardData, + type IFetchHealthDataParams, + type IHealthData, + type IReviewTransactionsData, + type ITransactionsOverviewData, +} from "./types"; -export interface IHealthData { - success?: boolean; - message?: string; - total?: number; - successful?: number; - acceptance_rate?: number; - amount?: number; - atv?: number; - stats?: Array<{ - label: string; - value: string | number; - change: string; - }>; -} - -export interface IFetchHealthDataParams { - dateStart?: string; - dateEnd?: string; -} - -export async function fetchHealthDataService({ +/** + * Fetch both health and overview data concurrently + * This is optimized for initial page load + * Always includes overview data with the provided date range + */ +export async function fetchDashboardDataService({ dateStart, dateEnd, -}: IFetchHealthDataParams = {}): Promise { +}: IFetchHealthDataParams): Promise { const cookieStore = await cookies(); const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; @@ -53,11 +45,8 @@ export async function fetchHealthDataService({ } const queryString = queryParts.join("&"); - const backendUrl = `${BE_BASE_URL}/api/v1/transactions/health${ - queryString ? `?${queryString}` : "" - }`; - const response = await fetch(backendUrl, { + const fetchConfig = { method: "GET", headers: { "Content-Type": "application/json", @@ -67,17 +56,78 @@ export async function fetchHealthDataService({ revalidate: REVALIDATE_SECONDS, tags: [HEALTH_CACHE_TAG], }, - }); + }; - if (!response.ok) { - const errorData = await response + // Fetch all three endpoints concurrently + const [healthResponse, overviewResponse, reviewResponse] = await Promise.all([ + fetch( + `${BE_BASE_URL}/api/v1/transactions/health${ + queryString ? `?${queryString}` : "" + }`, + fetchConfig + ), + fetch( + `${BE_BASE_URL}/api/v1/transactions/overview${ + queryString ? `?${queryString}` : "" + }`, + fetchConfig + ), + fetch( + `${BE_BASE_URL}/api/v1/transactions?limit=1000${ + queryString ? `&${queryString}` : "" + }&Status==/review`, + fetchConfig + ), + ]); + + // Handle health data response + if (!healthResponse.ok) { + const errorData = await healthResponse .json() .catch(() => ({ message: "Failed to fetch health data" })); throw new Error(errorData?.message || "Failed to fetch health data"); } - const data = (await response.json()) as IHealthData; + const healthData = (await healthResponse.json()) as IHealthData; - console.log("[data]", data.stats); - return data; + // Handle overview data response + let overviewData: ITransactionsOverviewData = { + success: false, + cancelled: 0, + failed: 0, + successful: 0, + waiting: 0, + }; + + if (!overviewResponse.ok) { + // Don't fail the whole request if overview fails, just log it + console.error("Failed to fetch transactions overview"); + } else { + overviewData = (await overviewResponse.json()) as ITransactionsOverviewData; + } + + // Handle review transactions response + let reviewTransactions: IReviewTransactionsData = { + success: false, + transactions: [], + total: 0, + }; + + if (!reviewResponse.ok) { + // Don't fail the whole request if review transactions fail, just log it + console.error("Failed to fetch review transactions"); + } else { + const reviewData = (await reviewResponse.json()) as IReviewTransactionsData; + reviewTransactions = { + success: reviewData.success ?? true, + transactions: reviewData.transactions || [], + total: reviewData.total || 0, + }; + } + + return { + healthData, + overviewData, + reviewTransactions, + }; } diff --git a/app/services/transactions.ts b/app/services/transactions.ts index 3747652..f3d5d67 100644 --- a/app/services/transactions.ts +++ b/app/services/transactions.ts @@ -1,5 +1,5 @@ import { getBaseUrl } from "./constants"; -import { IHealthData, IFetchHealthDataParams } from "./health"; +import { IFetchHealthDataParams, IHealthData, IDashboardData } from "./types"; export async function getTransactions({ transactionType, @@ -27,12 +27,13 @@ export async function getTransactions({ } /** - * Client-side function to fetch health data via the /api/dashboard proxy + * Client-side function to fetch dashboard data (health + overview) via the /api/dashboard proxy + * This function calls a single endpoint that returns both health and overview data */ -export async function getHealthData({ +export async function getDashboardData({ dateStart, dateEnd, -}: IFetchHealthDataParams = {}): Promise { +}: IFetchHealthDataParams = {}): Promise { const params = new URLSearchParams(); if (dateStart) params.set("dateStart", dateStart); if (dateEnd) params.set("dateEnd", dateEnd); @@ -52,5 +53,24 @@ export async function getHealthData({ throw new Error(errorData.message || `HTTP error! status: ${res.status}`); } - return res.json(); + const data = await res.json(); + + // Extract overviewData and reviewTransactions from the response + const { overviewData, reviewTransactions, ...healthDataWithStats } = data; + + return { + healthData: healthDataWithStats as IHealthData, + overviewData: overviewData || { + success: false, + cancelled: 0, + failed: 0, + successful: 0, + waiting: 0, + }, + reviewTransactions: reviewTransactions || { + success: false, + transactions: [], + total: 0, + }, + }; } diff --git a/app/services/types.ts b/app/services/types.ts new file mode 100644 index 0000000..82c6c11 --- /dev/null +++ b/app/services/types.ts @@ -0,0 +1,63 @@ +export interface IHealthData { + success?: boolean; + message?: string; + total?: number; + successful?: number; + acceptance_rate?: number; + amount?: number; + atv?: number; + stats?: Array<{ + label: string; + value: string | number; + change: string; + }>; +} + +export interface IFetchHealthDataParams { + dateStart?: string; + dateEnd?: string; +} + +export interface ITransactionsOverviewData { + success?: boolean; + message?: string; + cancelled?: number; + failed?: number; + successful?: number; + waiting?: number; + data?: Array<{ + state: string; + count: number; + percentage: string; + color?: string; + }>; + total?: number; +} + +export interface IDashboardData { + healthData: IHealthData; + overviewData: ITransactionsOverviewData; + reviewTransactions: IReviewTransactionsData; +} + +export interface IReviewTransaction { + 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; +} + +export interface IReviewTransactionsData { + success?: boolean; + message?: string; + transactions?: IReviewTransaction[]; + total?: number; +} diff --git a/app/utils/formatDate.ts b/app/utils/formatDate.ts index bd1a494..a399f4d 100644 --- a/app/utils/formatDate.ts +++ b/app/utils/formatDate.ts @@ -21,3 +21,29 @@ export const getDefaultDateRange = () => { dateEnd: endDate.toISOString(), }; }; + +/** + * Normalize date range for API calls + * - Start date is set to beginning of day (00:00:00.000) + * - End date is set to end of day (23:59:59.999) + * This ensures same-day selections include the entire day + */ +export const normalizeDateRangeForAPI = ( + startDate: Date, + endDate: Date +): { dateStart: string; dateEnd: string } => { + // Clone dates to avoid mutating originals + const normalizedStart = new Date(startDate); + const normalizedEnd = new Date(endDate); + + // Set start date to beginning of day + normalizedStart.setHours(0, 0, 0, 0); + + // Set end date to end of day + normalizedEnd.setHours(23, 59, 59, 999); + + return { + dateStart: normalizedStart.toISOString(), + dateEnd: normalizedEnd.toISOString(), + }; +}; diff --git a/services/roles.services.ts b/services/roles.services.ts index 718180a..20b8d3d 100644 --- a/services/roles.services.ts +++ b/services/roles.services.ts @@ -8,7 +8,6 @@ export async function editUser(id: string, data: IEditUserForm) { }); if (!res.ok) { - console.log("[editUser] - FAILING", id, data); throw new Error("Failed to update user"); } -- 2.39.5 From ce4c977efcdc0ce211c3d564a402ef00228e21e8 Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Mon, 29 Dec 2025 19:54:42 +0100 Subject: [PATCH 08/12] Pushing to update server --- .../admin/matcher/MatcherPageClient.tsx | 6 ++--- app/dashboard/audits/page.tsx | 13 ++++----- .../GeneralHealthCard/GeneralHealthCard.tsx | 2 +- app/features/Pages/Approve/Approve.tsx | 7 +++-- .../DashboardHomePage/DashboardHomePage.tsx | 8 +++--- .../TransactionsWaitingApproval.tsx | 27 ++++++++++++++++--- app/services/health.ts | 2 +- 7 files changed, 42 insertions(+), 23 deletions(-) diff --git a/app/dashboard/admin/matcher/MatcherPageClient.tsx b/app/dashboard/admin/matcher/MatcherPageClient.tsx index 6485371..35ef9cb 100644 --- a/app/dashboard/admin/matcher/MatcherPageClient.tsx +++ b/app/dashboard/admin/matcher/MatcherPageClient.tsx @@ -31,8 +31,6 @@ export default function MatcherPageClient({ }: MatcherPageClientProps) { const router = useRouter(); const [matchType, setMatchType] = useState(initialMatchType); - const [sourceItems, setSourceItems] = useState(initialSourceItems); - const [targetItems, setTargetItems] = useState(initialTargetItems); const currentConfig = MATCH_CONFIGS[matchType] || initialConfig; @@ -99,8 +97,8 @@ export default function MatcherPageClient({ diff --git a/app/dashboard/audits/page.tsx b/app/dashboard/audits/page.tsx index edc1de2..c4a3309 100644 --- a/app/dashboard/audits/page.tsx +++ b/app/dashboard/audits/page.tsx @@ -9,7 +9,7 @@ import { } from "@/app/dashboard/audits/auditConstants"; type AuditPageProps = { - searchParams?: Record; + searchParams?: Promise>; }; const toSingleValue = (value?: string | string[]): string | undefined => { @@ -24,11 +24,12 @@ const clampNumber = (value: number, min: number, max?: number) => { return value; }; -export default async function AuditPage({ searchParams = {} }: AuditPageProps) { - const pageParam = toSingleValue(searchParams.page); - const limitParam = toSingleValue(searchParams.limit); - const sortParam = toSingleValue(searchParams.sort) || undefined; - const entityQuery = toSingleValue(searchParams.entity)?.trim() || ""; +export default async function AuditPage({ searchParams }: AuditPageProps) { + const params = searchParams ? await searchParams : {}; + const pageParam = toSingleValue(params?.page); + const limitParam = toSingleValue(params?.limit); + const sortParam = toSingleValue(params?.sort) || undefined; + const entityQuery = toSingleValue(params?.entity)?.trim() || ""; const page = clampNumber(parseInt(pageParam || "1", 10), 1); const parsedLimit = parseInt(limitParam || String(DEFAULT_PAGE_SIZE), 10); diff --git a/app/features/GeneralHealthCard/GeneralHealthCard.tsx b/app/features/GeneralHealthCard/GeneralHealthCard.tsx index a317e23..01ab30e 100644 --- a/app/features/GeneralHealthCard/GeneralHealthCard.tsx +++ b/app/features/GeneralHealthCard/GeneralHealthCard.tsx @@ -17,7 +17,7 @@ import { Range } from "react-date-range"; import { DateRangePicker } from "../DateRangePicker/DateRangePicker"; import { StatItem } from "./components/StatItem"; import { DEFAULT_DATE_RANGE } from "./constants"; -import { IHealthData } from "@/app/services/health"; +import { type IHealthData } from "@/app/services/types"; import { useDebouncedDateRange } from "@/app/hooks/useDebouncedDateRange"; import { dashboardService } from "@/app/services/dashboardService"; import { normalizeDateRangeForAPI } from "@/app/utils/formatDate"; diff --git a/app/features/Pages/Approve/Approve.tsx b/app/features/Pages/Approve/Approve.tsx index f031973..f18fd35 100644 --- a/app/features/Pages/Approve/Approve.tsx +++ b/app/features/Pages/Approve/Approve.tsx @@ -336,11 +336,10 @@ export function ApproveTable({ setModalOpen(false)} - handleSave={handleStatusSave} + onClose={() => setModalOpen(false)} + onStatusUpdated={handleStatusSave} /> (null); /** * Subscribe to dashboard data changes @@ -67,7 +72,7 @@ export const TransactionsWaitingApproval = ({ : ""; return { - id: tx.id?.toString() || tx.external_id || "", + id: tx.id ?? tx.external_id ?? "", user: tx.customer || "", created: formattedDate, type: tx.type || "", @@ -81,6 +86,10 @@ export const TransactionsWaitingApproval = ({ const transactions = reviewTransactions?.transactions || []; const displayTransactions = transactions.map(formatTransaction); + const handleStatusChange = (rowId: number, newStatus: string) => { + setStatusDialogData({ rowId, newStatus }); + }; + return ( @@ -141,10 +150,16 @@ export const TransactionsWaitingApproval = ({ {tx.amount} {tx.psp} - + handleStatusChange(tx.id, "approved")} + > - + handleStatusChange(tx.id, "declined")} + > @@ -164,6 +179,12 @@ export const TransactionsWaitingApproval = ({ )} + setStatusDialogData(null)} + /> ); }; diff --git a/app/services/health.ts b/app/services/health.ts index 31ece41..7fb9f8c 100644 --- a/app/services/health.ts +++ b/app/services/health.ts @@ -73,7 +73,7 @@ export async function fetchDashboardDataService({ fetchConfig ), fetch( - `${BE_BASE_URL}/api/v1/transactions?limit=1000${ + `${BE_BASE_URL}/api/v1/transactions?limit=1000&page=1${ queryString ? `&${queryString}` : "" }&Status==/review`, fetchConfig -- 2.39.5 From d7d4dfd73fc23f0c6c62972be91aca63ded485cd Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Mon, 29 Dec 2025 20:36:11 +0100 Subject: [PATCH 09/12] Renamed folder file sue to build error --- app/features/Pages/DashboardHomePage/DashboardHomePage.tsx | 7 ++----- app/features/TransactionsOverview/TransactionsOverview.tsx | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx b/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx index d0f113d..7e88123 100644 --- a/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx +++ b/app/features/Pages/DashboardHomePage/DashboardHomePage.tsx @@ -4,7 +4,7 @@ import { Range } from "react-date-range"; import { Box } from "@mui/material"; import { GeneralHealthCard } from "../../GeneralHealthCard/GeneralHealthCard"; import { TransactionsWaitingApproval } from "../../TransactionsWaitingApproval/TransactionsWaitingApproval"; -import { TransactionsOverView } from "../../TransactionsOverView/TransactionsOverview"; +import { TransactionsOverview } from "../../TransactionsOverview/TransactionsOverview"; import { type ITransactionsOverviewData, type IReviewTransactionsData, @@ -59,14 +59,11 @@ export const DashboardHomePage = ({ initialDateRange={initialDateRange} /> - + {/* */} - {/* - - */} ); }; diff --git a/app/features/TransactionsOverview/TransactionsOverview.tsx b/app/features/TransactionsOverview/TransactionsOverview.tsx index 67e0d61..84e9b2b 100644 --- a/app/features/TransactionsOverview/TransactionsOverview.tsx +++ b/app/features/TransactionsOverview/TransactionsOverview.tsx @@ -6,22 +6,22 @@ import { Box, Button, IconButton, Paper, Typography } from "@mui/material"; import { PieCharts } from "../PieCharts/PieCharts"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable"; import { type ITransactionsOverviewData } from "@/app/services/types"; import { dashboardService } from "@/app/services/dashboardService"; import { transformOverviewResponse, calculatePercentages, enrichOverviewData, -} from "./utils"; +} from "../TransactionsOverview/utils"; import "./TransactionsOverView.scss"; +import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable"; interface ITransactionsOverviewProps { initialOverviewData?: ITransactionsOverviewData | null; } -export const TransactionsOverView = ({ +export const TransactionsOverview = ({ initialOverviewData = null, }: ITransactionsOverviewProps) => { const router = useRouter(); -- 2.39.5 From 0d7f8aef068d363b2e12431b7aec3e06cf223a2e Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Mon, 29 Dec 2025 20:44:14 +0100 Subject: [PATCH 10/12] Renamed folder file sue to build error --- app/features/PieCharts/PieCharts.tsx | 4 ++-- app/features/TransactionsOverview/TransactionsOverview.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/features/PieCharts/PieCharts.tsx b/app/features/PieCharts/PieCharts.tsx index 9a2d766..4abe27b 100644 --- a/app/features/PieCharts/PieCharts.tsx +++ b/app/features/PieCharts/PieCharts.tsx @@ -47,7 +47,7 @@ const renderCustomizedLabel = ({ ); }; -export const PieCharts = ({ data = defaultData }: IPieChartsProps) => { +export default function PieCharts({ data = defaultData }: IPieChartsProps) { return ( @@ -73,4 +73,4 @@ export const PieCharts = ({ data = defaultData }: IPieChartsProps) => { ); -}; +} diff --git a/app/features/TransactionsOverview/TransactionsOverview.tsx b/app/features/TransactionsOverview/TransactionsOverview.tsx index 84e9b2b..ec755b5 100644 --- a/app/features/TransactionsOverview/TransactionsOverview.tsx +++ b/app/features/TransactionsOverview/TransactionsOverview.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Box, Button, IconButton, Paper, Typography } from "@mui/material"; -import { PieCharts } from "../PieCharts/PieCharts"; +import PieCharts from "../PieCharts/PieCharts"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { type ITransactionsOverviewData } from "@/app/services/types"; -- 2.39.5 From 3c8b99776d33634ce3d561a6d1b9e1a2878c91e0 Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Mon, 29 Dec 2025 20:53:17 +0100 Subject: [PATCH 11/12] Renamed folder file sue to build error --- app/features/{PieCharts => Charts}/PieCharts.scss | 0 app/features/{PieCharts => Charts}/PieCharts.tsx | 0 app/features/TransactionsOverview/TransactionsOverview.tsx | 5 ++--- 3 files changed, 2 insertions(+), 3 deletions(-) rename app/features/{PieCharts => Charts}/PieCharts.scss (100%) rename app/features/{PieCharts => Charts}/PieCharts.tsx (100%) diff --git a/app/features/PieCharts/PieCharts.scss b/app/features/Charts/PieCharts.scss similarity index 100% rename from app/features/PieCharts/PieCharts.scss rename to app/features/Charts/PieCharts.scss diff --git a/app/features/PieCharts/PieCharts.tsx b/app/features/Charts/PieCharts.tsx similarity index 100% rename from app/features/PieCharts/PieCharts.tsx rename to app/features/Charts/PieCharts.tsx diff --git a/app/features/TransactionsOverview/TransactionsOverview.tsx b/app/features/TransactionsOverview/TransactionsOverview.tsx index ec755b5..8a5b601 100644 --- a/app/features/TransactionsOverview/TransactionsOverview.tsx +++ b/app/features/TransactionsOverview/TransactionsOverview.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; 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 { type ITransactionsOverviewData } from "@/app/services/types"; @@ -13,9 +12,9 @@ import { calculatePercentages, enrichOverviewData, } from "../TransactionsOverview/utils"; - -import "./TransactionsOverView.scss"; import { TransactionsOverViewTable } from "./components/TransactionsOverViewTable"; +import PieCharts from "../Charts/PieCharts"; +import "./TransactionsOverView.scss"; interface ITransactionsOverviewProps { initialOverviewData?: ITransactionsOverviewData | null; -- 2.39.5 From fabb507851af956e192a8edc64fb6299b2ec7b63 Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Wed, 7 Jan 2026 15:41:36 +0100 Subject: [PATCH 12/12] Added more to entity modification --- .../dashboard/admin/[resource]/[id]/route.ts | 143 +++++ .../admin/[resource]/create/route.ts | 79 +++ .../admin/{groups => [resource]}/route.ts | 57 +- app/api/dashboard/admin/permissions/route.ts | 4 +- app/api/dashboard/admin/sessions/route.ts | 4 +- app/api/dashboard/admin/users/[id]/route.ts | 10 +- app/api/dashboard/route.ts | 21 +- app/api/dashboard/utils/dashboard.ts | 172 +++++ app/components/AddModal/AddModal.tsx | 148 +++++ app/components/AddModal/types.ts | 22 + app/components/DeleteModal/DeleteModal.tsx | 65 ++ app/components/DeleteModal/types.ts | 14 + app/dashboard/admin/constants.ts | 53 ++ app/dashboard/admin/currencies/page.tsx | 32 + app/dashboard/admin/groups/page.tsx | 20 +- app/dashboard/admin/merchants/page.tsx | 17 + app/dashboard/admin/permissions/page.tsx | 22 +- app/dashboard/page.tsx | 11 +- app/features/AdminList/AdminResourceList.scss | 109 ++++ app/features/AdminList/AdminResourceList.tsx | 595 +++++++++++++++--- .../AdminList/AdminResourceList.utils.ts | 146 +++++ app/features/GeneralHealthCard/utils.ts | 56 -- app/features/Pages/Admin/Users/users.tsx | 2 - .../TransactionsOverview.tsx | 36 +- .../components/TransactionsOverViewTable.tsx | 2 + app/features/TransactionsOverview/utils.ts | 27 - app/features/UserRoles/EditUser/EditUser.tsx | 36 +- app/layout.tsx | 2 +- .../advanedSearch/advancedSearchSlice.ts | 2 +- app/redux/advanedSearch/selectors.ts | 2 +- app/redux/user/userSlice.ts | 2 - app/services/adminResources.ts | 71 +++ app/services/constants.ts | 5 + app/services/health.ts | 8 +- app/services/transactions.ts | 8 +- app/services/types.ts | 12 +- app/utils/formatCurrency.ts | 15 + 37 files changed, 1771 insertions(+), 259 deletions(-) create mode 100644 app/api/dashboard/admin/[resource]/[id]/route.ts create mode 100644 app/api/dashboard/admin/[resource]/create/route.ts rename app/api/dashboard/admin/{groups => [resource]}/route.ts (54%) create mode 100644 app/api/dashboard/utils/dashboard.ts create mode 100644 app/components/AddModal/AddModal.tsx create mode 100644 app/components/AddModal/types.ts create mode 100644 app/components/DeleteModal/DeleteModal.tsx create mode 100644 app/components/DeleteModal/types.ts create mode 100644 app/dashboard/admin/constants.ts create mode 100644 app/dashboard/admin/currencies/page.tsx create mode 100644 app/dashboard/admin/merchants/page.tsx create mode 100644 app/features/AdminList/AdminResourceList.scss create mode 100644 app/features/AdminList/AdminResourceList.utils.ts create mode 100644 app/services/adminResources.ts create mode 100644 app/utils/formatCurrency.ts diff --git a/app/api/dashboard/admin/[resource]/[id]/route.ts b/app/api/dashboard/admin/[resource]/[id]/route.ts new file mode 100644 index 0000000..dad5d49 --- /dev/null +++ b/app/api/dashboard/admin/[resource]/[id]/route.ts @@ -0,0 +1,143 @@ +import { + AUTH_COOKIE_NAME, + BE_BASE_URL, + getAdminResourceCacheTag, +} from "@/app/services/constants"; +import { NextRequest, NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; + +const ALLOWED_RESOURCES = [ + "groups", + "currencies", + "permissions", + "merchants", + "sessions", + "users", +]; + +export async function PUT( + request: NextRequest, + context: { params: Promise<{ resource: string; id: string }> } +) { + try { + const { resource, id } = await context.params; + + if (!ALLOWED_RESOURCES.includes(resource)) { + return NextResponse.json( + { message: `Resource '${resource}' is not allowed` }, + { status: 400 } + ); + } + + const body = await request.json(); + + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const response = await fetch(`${BE_BASE_URL}/api/v1/${resource}/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + // Revalidate the cache for this resource after successful update + if (response.ok) { + revalidateTag(getAdminResourceCacheTag(resource)); + } + + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + let resourceName = "resource"; + try { + const { resource } = await context.params; + resourceName = resource; + } catch { + // If we can't get resource, use default + } + console.error(`Proxy PUT /api/v1/${resourceName}/{id} error:`, err); + 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<{ resource: string; id: string }> } +) { + try { + const { resource, id } = await context.params; + + if (!ALLOWED_RESOURCES.includes(resource)) { + return NextResponse.json( + { message: `Resource '${resource}' is not allowed` }, + { status: 400 } + ); + } + + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const response = await fetch(`${BE_BASE_URL}/api/v1/${resource}/${id}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + let data: unknown = null; + try { + data = await response.json(); + } catch { + data = { success: response.ok }; + } + + // Revalidate the cache for this resource after successful deletion + if (response.ok) { + revalidateTag(getAdminResourceCacheTag(resource)); + } + + return NextResponse.json(data ?? { success: response.ok }, { + status: response.status, + }); + } catch (err: unknown) { + let resourceName = "resource"; + try { + const { resource } = await context.params; + resourceName = resource; + } catch { + // If we can't get resource, use default + } + console.error(`Proxy DELETE /api/v1/${resourceName}/{id} error:`, err); + const errorMessage = + err instanceof Error ? err.message : "Unknown error occurred"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/[resource]/create/route.ts b/app/api/dashboard/admin/[resource]/create/route.ts new file mode 100644 index 0000000..4286bde --- /dev/null +++ b/app/api/dashboard/admin/[resource]/create/route.ts @@ -0,0 +1,79 @@ +import { + AUTH_COOKIE_NAME, + BE_BASE_URL, + getAdminResourceCacheTag, +} from "@/app/services/constants"; +import { NextResponse } from "next/server"; +import { NextRequest } from "next/server"; +import { revalidateTag } from "next/cache"; + +const ALLOWED_RESOURCES = [ + "groups", + "currencies", + "permissions", + "merchants", + "sessions", + "users", +]; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ resource: string }> } +) { + try { + const { resource } = await context.params; + + if (!ALLOWED_RESOURCES.includes(resource)) { + return NextResponse.json( + { message: `Resource '${resource}' is not allowed` }, + { status: 400 } + ); + } + + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + + const response = await fetch(`${BE_BASE_URL}/api/v1/${resource}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + // Revalidate the cache for this resource after successful creation + if (response.ok) { + revalidateTag(getAdminResourceCacheTag(resource)); + } + + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + let resourceName = "resource"; + try { + const { resource } = await context.params; + resourceName = resource; + } catch { + // If we can't get resource, use default + } + console.error(`Proxy POST /api/v1/${resourceName} error:`, err); + const errorMessage = + err instanceof Error ? err.message : "Unknown error occurred"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/groups/route.ts b/app/api/dashboard/admin/[resource]/route.ts similarity index 54% rename from app/api/dashboard/admin/groups/route.ts rename to app/api/dashboard/admin/[resource]/route.ts index d888cb8..4db9d26 100644 --- a/app/api/dashboard/admin/groups/route.ts +++ b/app/api/dashboard/admin/[resource]/route.ts @@ -1,9 +1,35 @@ -import { AUTH_COOKIE_NAME, BE_BASE_URL } from "@/app/services/constants"; +import { + AUTH_COOKIE_NAME, + BE_BASE_URL, + REVALIDATE_SECONDS, + getAdminResourceCacheTag, +} from "@/app/services/constants"; import { NextRequest, NextResponse } from "next/server"; import { buildFilterParam } from "../utils"; -export async function POST(request: NextRequest) { +const ALLOWED_RESOURCES = [ + "groups", + "currencies", + "permissions", + "merchants", + "sessions", + "users", +]; + +export async function POST( + request: NextRequest, + context: { params: Promise<{ resource: string }> } +) { try { + const { resource } = await context.params; + + if (!ALLOWED_RESOURCES.includes(resource)) { + return NextResponse.json( + { message: `Resource '${resource}' is not allowed` }, + { status: 400 } + ); + } + const { cookies } = await import("next/headers"); const cookieStore = await cookies(); const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; @@ -16,10 +42,10 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body; const queryParams = new URLSearchParams(); - queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("limit", String(pagination.limit ?? 100)); queryParams.set("page", String(pagination.page ?? 1)); if (sort?.field && sort?.order) { @@ -31,7 +57,7 @@ export async function POST(request: NextRequest) { queryParams.set("filter", filterParam); } - const backendUrl = `${BE_BASE_URL}/api/v1/groups${ + const backendUrl = `${BE_BASE_URL}/api/v1/${resource}${ queryParams.size ? `?${queryParams.toString()}` : "" }`; @@ -41,18 +67,21 @@ export async function POST(request: NextRequest) { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - cache: "no-store", + next: { + revalidate: REVALIDATE_SECONDS, + tags: [getAdminResourceCacheTag(resource)], + }, }); if (!response.ok) { const errorData = await response .json() - .catch(() => ({ message: "Failed to fetch groups" })); + .catch(() => ({ message: `Failed to fetch ${resource}` })); return NextResponse.json( { success: false, - message: errorData?.message || "Failed to fetch groups", + message: errorData?.message || `Failed to fetch ${resource}`, }, { status: response.status } ); @@ -61,7 +90,17 @@ export async function POST(request: NextRequest) { 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); + let resourceName = "resource"; + try { + const { resource } = await context.params; + resourceName = resource; + } catch { + // If we can't get resource, use default + } + console.error( + `Proxy POST /api/dashboard/admin/${resourceName} error:`, + err + ); const errorMessage = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json( { message: "Internal server error", error: errorMessage }, diff --git a/app/api/dashboard/admin/permissions/route.ts b/app/api/dashboard/admin/permissions/route.ts index 52ef89b..ae1114a 100644 --- a/app/api/dashboard/admin/permissions/route.ts +++ b/app/api/dashboard/admin/permissions/route.ts @@ -16,10 +16,10 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body; const queryParams = new URLSearchParams(); - queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("limit", String(pagination.limit ?? 100)); queryParams.set("page", String(pagination.page ?? 1)); if (sort?.field && sort?.order) { diff --git a/app/api/dashboard/admin/sessions/route.ts b/app/api/dashboard/admin/sessions/route.ts index 5b993f1..bb6bfad 100644 --- a/app/api/dashboard/admin/sessions/route.ts +++ b/app/api/dashboard/admin/sessions/route.ts @@ -16,10 +16,10 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + const { filters = {}, pagination = { page: 1, limit: 100 }, sort } = body; const queryParams = new URLSearchParams(); - queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("limit", String(pagination.limit ?? 100)); queryParams.set("page", String(pagination.page ?? 1)); if (sort?.field && sort?.order) { diff --git a/app/api/dashboard/admin/users/[id]/route.ts b/app/api/dashboard/admin/users/[id]/route.ts index 4ed8d5b..0f44858 100644 --- a/app/api/dashboard/admin/users/[id]/route.ts +++ b/app/api/dashboard/admin/users/[id]/route.ts @@ -89,21 +89,27 @@ export async function PUT( } // According to swagger: /api/v1/users/{id} - const response = await fetch(`${BE_BASE_URL}/api/v1/users/${id}`, { + const requestUrl = `${BE_BASE_URL}/api/v1/users/${id}`; + const requestConfig = { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(transformedBody), - }); + }; + console.log("request", { url: requestUrl, ...requestConfig }); + + const response = await fetch(requestUrl, requestConfig); const data = await response.json(); + console.log("response", { data, status: response.status }); if (response.ok) { revalidateTag(USERS_CACHE_TAG); } return NextResponse.json(data, { status: response.status }); } catch (err: unknown) { + console.error("Proxy PUT /api/v1/users/{id} error:", err); const errorMessage = err instanceof Error ? err.message : "Unknown error occurred"; return NextResponse.json( diff --git a/app/api/dashboard/route.ts b/app/api/dashboard/route.ts index 116d251..6e84482 100644 --- a/app/api/dashboard/route.ts +++ b/app/api/dashboard/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { fetchDashboardDataService } from "@/app/services/health"; -import { transformHealthDataToStats } from "@/app/features/GeneralHealthCard/utils"; +import { + transformHealthDataToStats, + transformOverviewResponse, +} from "./utils/dashboard"; export async function GET(request: NextRequest) { try { @@ -13,15 +16,19 @@ export async function GET(request: NextRequest) { dateStart, dateEnd, }); - + const { healthData, overviewData, reviewTransactions } = dashboardData; // Transform health data to stats format using shared util - const stats = transformHealthDataToStats(dashboardData.healthData); - + const stats = transformHealthDataToStats(healthData); + const transformedOverviewData = transformOverviewResponse(overviewData); + // console.log("[TransformedOverviewData] - Route", transformedOverviewData); const response = { - ...dashboardData.healthData, + ...healthData, stats, - overviewData: dashboardData.overviewData, - reviewTransactions: dashboardData.reviewTransactions, + overviewData: { + ...overviewData, + data: transformedOverviewData, + }, + reviewTransactions: reviewTransactions, }; return NextResponse.json(response, { status: 200 }); diff --git a/app/api/dashboard/utils/dashboard.ts b/app/api/dashboard/utils/dashboard.ts new file mode 100644 index 0000000..a36aefe --- /dev/null +++ b/app/api/dashboard/utils/dashboard.ts @@ -0,0 +1,172 @@ +import "server-only"; + +import { formatCurrency, formatPercentage } from "@/app/utils/formatCurrency"; + +interface IHealthData { + total?: number; + successful?: number; + acceptance_rate?: number; + amount?: number; + atv?: number; +} + +interface IStatItem { + label: string; + value: string | number; + change: string; +} + +export const transformHealthDataToStats = ( + healthData: IHealthData | null +): IStatItem[] => { + if (!healthData) { + return [ + { label: "TOTAL", value: 0, change: "0%" }, + { label: "SUCCESSFUL", value: 0, change: "0%" }, + { label: "ACCEPTANCE RATE", value: "0%", change: "0%" }, + { label: "AMOUNT", value: "€0.00", change: "0%" }, + { label: "ATV", value: "€0.00", change: "0%" }, + ]; + } + + return [ + { + label: "TOTAL", + value: healthData.total ?? 0, + change: "0%", + }, + { + label: "SUCCESSFUL", + value: healthData.successful ?? 0, + change: "0%", + }, + { + label: "ACCEPTANCE RATE", + value: formatPercentage(healthData.acceptance_rate), + change: "0%", + }, + { + label: "AMOUNT", + value: formatCurrency(healthData.amount), + change: "0%", + }, + { + label: "ATV", + value: formatCurrency(healthData.atv), + change: "0%", + }, + ]; +}; + +/** + * Map transaction state to color + */ +const getStateColor = (state: string): string => { + const normalizedState = state.toLowerCase(); + + switch (normalizedState) { + case "success": + case "completed": + case "successful": + return "#4caf50"; // green + case "pending": + case "waiting": + return "#ff9800"; // orange + case "failed": + case "error": + return "#f44336"; // red + case "cancelled": + case "canceled": + return "#9e9e9e"; // gray + default: + return "#9e9e9e"; // gray + } +}; + +/** + * Calculate percentage for each state + */ +const calculatePercentages = ( + items: Array<{ state: string; count: number }> +): Array<{ + state: string; + count: number; + percentage: string; +}> => { + const total = items.reduce((sum, item) => sum + item.count, 0); + + if (total === 0) { + return items.map(item => ({ + ...item, + percentage: "0%", + })); + } + + return items.map(item => ({ + ...item, + percentage: `${Math.round((item.count / total) * 100)}%`, + })); +}; + +/** + * Transform API overview data to include colors + */ +const enrichOverviewData = ( + data: Array<{ + state: string; + count: number; + percentage: string; + color?: string; + }> +): Array<{ + state: string; + count: number; + percentage: string; + color: string; +}> => { + return data.map(item => ({ + ...item, + color: item.color || getStateColor(item.state), + })); +}; + +/** + * Transform flat API overview response to enriched array format with percentages and colors + */ +export const transformOverviewResponse = ( + data: + | { + cancelled_count?: number; + failed_count?: number; + successful_count?: number; + waiting_count?: number; + } + | null + | undefined +): Array<{ + state: string; + count: number; + percentage: string; + color: string; +}> => { + if (!data) { + return []; + } + + const states = [ + { key: "successful_count", label: "Successful" }, + { key: "waiting_count", label: "Waiting" }, + { key: "failed_count", label: "Failed" }, + { key: "cancelled_count", label: "Cancelled" }, + ]; + + const transformed = states + .map(({ key, label }) => ({ + state: label, + count: data[key as keyof typeof data] || 0, + })) + .filter(item => item.count > 0); // Only include states with counts > 0 + + const withPercentages = calculatePercentages(transformed); + return enrichOverviewData(withPercentages); +}; diff --git a/app/components/AddModal/AddModal.tsx b/app/components/AddModal/AddModal.tsx new file mode 100644 index 0000000..917b829 --- /dev/null +++ b/app/components/AddModal/AddModal.tsx @@ -0,0 +1,148 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Stack, Typography, Button, Alert, TextField } from "@mui/material"; +import Modal from "@/app/components/Modal/Modal"; +import Spinner from "@/app/components/Spinner/Spinner"; +import { IAddModalProps } from "./types"; + +const AddModal: React.FC = ({ + open, + onClose, + onConfirm, + resourceType = "item", + fields, + isLoading = false, + error = null, +}) => { + const [formData, setFormData] = useState>({}); + const [validationErrors, setValidationErrors] = useState< + Record + >({}); + + useEffect(() => { + if (open) { + const initialData: Record = {}; + fields.forEach(field => { + initialData[field.name] = field.defaultValue ?? ""; + }); + setFormData(initialData); + setValidationErrors({}); + } + }, [open, fields]); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + if (validationErrors[name]) { + setValidationErrors(prev => { + const next = { ...prev }; + delete next[name]; + return next; + }); + } + }; + + const validateForm = (): boolean => { + const errors: Record = {}; + + fields.forEach(field => { + const value = formData[field.name]; + const isEmpty = + value === undefined || value === null || String(value).trim() === ""; + + if (field.required && isEmpty) { + errors[field.name] = `${field.label} is required`; + } + + if (field.type === "email" && value && !isEmpty) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(String(value))) { + errors[field.name] = "Please enter a valid email address"; + } + } + }); + + setValidationErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + await Promise.resolve(onConfirm(formData)); + }; + + const handleClose = () => { + if (!isLoading) { + setFormData({}); + setValidationErrors({}); + onClose(); + } + }; + + const title = `Add ${resourceType.charAt(0).toUpperCase() + resourceType.slice(1)}`; + + return ( + +
+ + {fields.map(field => ( + + ))} + + {error && ( + + {error} + + )} + + + + + + +
+
+ ); +}; + +export default AddModal; diff --git a/app/components/AddModal/types.ts b/app/components/AddModal/types.ts new file mode 100644 index 0000000..feb3753 --- /dev/null +++ b/app/components/AddModal/types.ts @@ -0,0 +1,22 @@ +export type TAddModalFieldType = "text" | "email" | "number" | "textarea"; + +export interface IAddModalField { + name: string; + label: string; + type?: TAddModalFieldType; + required?: boolean; + placeholder?: string; + defaultValue?: string | number; + multiline?: boolean; + rows?: number; +} + +export interface IAddModalProps { + open: boolean; + onClose: () => void; + onConfirm: (data: Record) => Promise | void; + resourceType?: string; + fields: IAddModalField[]; + isLoading?: boolean; + error?: string | null; +} diff --git a/app/components/DeleteModal/DeleteModal.tsx b/app/components/DeleteModal/DeleteModal.tsx new file mode 100644 index 0000000..2ae1c72 --- /dev/null +++ b/app/components/DeleteModal/DeleteModal.tsx @@ -0,0 +1,65 @@ +"use client"; + +import React from "react"; +import { Stack, Typography, Button, Alert } from "@mui/material"; +import Modal from "@/app/components/Modal/Modal"; +import Spinner from "@/app/components/Spinner/Spinner"; +import { IDeleteModalProps } from "./types"; + +const DeleteModal: React.FC = ({ + open, + onClose, + onConfirm, + resource, + resourceType = "item", + isLoading = false, + error = null, +}) => { + const handleConfirm = async () => { + if (!resource?.id) return; + await Promise.resolve(onConfirm(resource.id)); + }; + + const handleClose = () => { + if (!isLoading) { + onClose(); + } + }; + + const resourceLabel = resource?.label || `this ${resourceType}`; + const title = `Delete ${resourceType.charAt(0).toUpperCase() + resourceType.slice(1)}`; + + return ( + + + + Are you sure you want to delete {resourceLabel}? This + action cannot be undone. + + + {error && ( + + {error} + + )} + + + + + + + + ); +}; + +export default DeleteModal; diff --git a/app/components/DeleteModal/types.ts b/app/components/DeleteModal/types.ts new file mode 100644 index 0000000..233c3b4 --- /dev/null +++ b/app/components/DeleteModal/types.ts @@ -0,0 +1,14 @@ +export type TDeleteModalResource = { + id: number | string; + label: string; +}; + +export interface IDeleteModalProps { + open: boolean; + onClose: () => void; + onConfirm: (id: number | string) => Promise | void; + resource: TDeleteModalResource | null; + resourceType?: string; + isLoading?: boolean; + error?: string | null; +} diff --git a/app/dashboard/admin/constants.ts b/app/dashboard/admin/constants.ts new file mode 100644 index 0000000..d41badc --- /dev/null +++ b/app/dashboard/admin/constants.ts @@ -0,0 +1,53 @@ +export const MERCHANT_FIELDS = [ + { + name: "name", + label: "Name", + type: "text", + required: true, + placeholder: "Enter merchant name", + }, +]; + +export const CURRENCY_FIELDS = [ + { + name: "code", + label: "Code", + type: "text", + required: true, + placeholder: "Enter currency code (e.g. USD)", + }, + { + name: "name", + label: "Name", + type: "text", + required: true, + placeholder: "Enter currency name", + }, + { + name: "rate", + label: "Rate", + type: "number", + required: false, + placeholder: "Enter exchange rate", + }, +]; + +export const GROUP_FIELDS = [ + { + name: "name", + label: "Name", + type: "text", + required: true, + placeholder: "Enter group name", + }, +]; + +export const PERMISSION_FIELDS = [ + { + name: "name", + label: "Name", + type: "text", + required: true, + placeholder: "Enter permission name", + }, +]; diff --git a/app/dashboard/admin/currencies/page.tsx b/app/dashboard/admin/currencies/page.tsx new file mode 100644 index 0000000..7cf927b --- /dev/null +++ b/app/dashboard/admin/currencies/page.tsx @@ -0,0 +1,32 @@ +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; +import { IAddModalField } from "@/app/components/AddModal/types"; +import { CURRENCY_FIELDS } from "../constants"; +import { fetchAdminResource } from "@/app/services/adminResources"; + +export default async function CurrenciesPage() { + let initialData = null; + + try { + initialData = await fetchAdminResource({ + resource: "currencies", + pagination: { page: 1, limit: 100 }, + }); + } catch (error) { + console.error("Failed to fetch currencies server-side:", error); + // Continue without initial data - component will fetch client-side + } + + return ( + + ); +} diff --git a/app/dashboard/admin/groups/page.tsx b/app/dashboard/admin/groups/page.tsx index f2e668d..dfbbfb7 100644 --- a/app/dashboard/admin/groups/page.tsx +++ b/app/dashboard/admin/groups/page.tsx @@ -1,6 +1,21 @@ import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; +import { IAddModalField } from "@/app/components/AddModal/types"; +import { GROUP_FIELDS } from "../constants"; +import { fetchAdminResource } from "@/app/services/adminResources"; + +export default async function GroupsPage() { + let initialData = null; + + try { + initialData = await fetchAdminResource({ + resource: "groups", + pagination: { page: 1, limit: 100 }, + }); + } catch (error) { + console.error("Failed to fetch groups:", error); + // Continue without initial data - component will fetch client-side + } -export default function GroupsPage() { return ( ); } diff --git a/app/dashboard/admin/merchants/page.tsx b/app/dashboard/admin/merchants/page.tsx new file mode 100644 index 0000000..18954b5 --- /dev/null +++ b/app/dashboard/admin/merchants/page.tsx @@ -0,0 +1,17 @@ +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; +import { MERCHANT_FIELDS } from "../constants"; +import { IAddModalField } from "@/app/components/AddModal/types"; + +export default function GroupsPage() { + return ( + + ); +} diff --git a/app/dashboard/admin/permissions/page.tsx b/app/dashboard/admin/permissions/page.tsx index cde9367..13a6818 100644 --- a/app/dashboard/admin/permissions/page.tsx +++ b/app/dashboard/admin/permissions/page.tsx @@ -1,8 +1,21 @@ -"use client"; - import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; +import { IAddModalField } from "@/app/components/AddModal/types"; +import { PERMISSION_FIELDS } from "../constants"; +import { fetchAdminResource } from "@/app/services/adminResources"; + +export default async function PermissionsPage() { + let initialData = null; + + try { + initialData = await fetchAdminResource({ + resource: "permissions", + pagination: { page: 1, limit: 100 }, + }); + } catch (error) { + console.error("Failed to fetch permissions server-side:", error); + // Continue without initial data - component will fetch client-side + } -export default function PermissionsPage() { return ( ); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index f3ec067..ad53ec8 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,6 +1,7 @@ +import { transformOverviewResponse } from "../api/dashboard/utils/dashboard"; import { DashboardHomePage } from "../features/Pages/DashboardHomePage/DashboardHomePage"; -import { transformHealthDataToStats } from "../features/GeneralHealthCard/utils"; import { fetchDashboardDataService } from "../services/health"; +import { ITransactionsOverviewData } from "../services/types"; import { getDefaultDateRange } from "../utils/formatDate"; export default async function DashboardPage() { @@ -18,9 +19,13 @@ export default async function DashboardPage() { }); const { healthData, overviewData, reviewTransactions } = dashboardData; + initialHealthData = healthData; - initialStats = healthData.stats ?? transformHealthDataToStats(healthData); - initialOverviewData = overviewData; + initialStats = healthData.stats ?? null; + initialOverviewData = { + data: transformOverviewResponse(overviewData), + ...overviewData, + } as ITransactionsOverviewData; initialReviewTransactions = reviewTransactions; } catch (_error: unknown) { // If fetch fails, component will handle it client-side diff --git a/app/features/AdminList/AdminResourceList.scss b/app/features/AdminList/AdminResourceList.scss new file mode 100644 index 0000000..055d11b --- /dev/null +++ b/app/features/AdminList/AdminResourceList.scss @@ -0,0 +1,109 @@ +.admin-resource-list { + padding: 24px; + width: 100%; + max-width: 900px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + &__title { + margin: 0; + } + + &__actions { + display: flex; + align-items: center; + gap: 8px; + } + + &__loading { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 16px; + } + + &__error { + margin-bottom: 16px; + } + + &__empty { + color: rgba(0, 0, 0, 0.6); + } + + &__list { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + &__row { + &-content { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 16px; + } + + &-container { + flex: 1; + min-width: 0; + } + + &-header { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + &-title { + font-weight: 600; + } + + &-id { + color: rgba(0, 0, 0, 0.6); + font-size: 0.75rem; + } + + &-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 8px 0; + } + + &-secondary { + color: rgba(0, 0, 0, 0.6); + font-size: 0.875rem; + } + } + + &__divider { + height: 1px; + background-color: rgba(0, 0, 0, 0.12); + margin: 0 16px; + } + + &__secondary-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + &__enabled-label { + color: rgba(0, 0, 0, 0.6); + font-size: 0.875rem; + } + + &__edit-field { + min-width: 200px; + } +} diff --git a/app/features/AdminList/AdminResourceList.tsx b/app/features/AdminList/AdminResourceList.tsx index 4695d26..beddd07 100644 --- a/app/features/AdminList/AdminResourceList.tsx +++ b/app/features/AdminList/AdminResourceList.tsx @@ -1,32 +1,43 @@ "use client"; import Spinner from "@/app/components/Spinner/Spinner"; +import DeleteModal from "@/app/components/DeleteModal/DeleteModal"; +import { TDeleteModalResource } from "@/app/components/DeleteModal/types"; +import AddModal from "@/app/components/AddModal/AddModal"; +import { IAddModalField } from "@/app/components/AddModal/types"; 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, + Button, Chip, - Divider, - List, - ListItem, + IconButton, + Switch, + TextField, + Tooltip, Typography, } from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import EditIcon from "@mui/icons-material/Edit"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; import { useEffect, useMemo, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; -type ResourceRow = DataRowBase & Record; +import { + createResourceApi, + deleteResourceApi, + updateResourceApi, +} from "./AdminResourceList.utils"; +import "./AdminResourceList.scss"; + +type ResourceRow = DataRowBase & { + identifier?: string | number; +} & Record; type FilterValue = | string @@ -43,6 +54,10 @@ interface AdminResourceListProps { chipKeys?: string[]; excludeKeys?: string[]; filterOverrides?: Record; + addModalFields?: IAddModalField[]; + showEnabledToggle?: boolean; + idField?: string; + initialData?: Record; } const DEFAULT_COLLECTION_KEYS = ["data", "items"]; @@ -57,13 +72,14 @@ const ensureRowId = ( return row as ResourceRow; } + const identifier = currentId; const numericId = Number(currentId); if (!Number.isNaN(numericId) && numericId !== 0) { - return { ...row, id: numericId } as ResourceRow; + return { ...row, id: numericId, identifier } as ResourceRow; } - return { ...row, id: fallbackId } as ResourceRow; + return { ...row, id: fallbackId, identifier } as ResourceRow; }; const resolveCollection = ( @@ -91,18 +107,50 @@ const AdminResourceList = ({ primaryLabelKeys, chipKeys = [], excludeKeys = [], + addModalFields, + showEnabledToggle = false, + idField, + initialData, }: AdminResourceListProps) => { - const dispatch = useDispatch(); + // Keep Redux for shared state (filters, pagination, sort) const filters = useSelector(selectFilters); const pagination = useSelector(selectPagination); const sort = useSelector(selectSort); - const status = useSelector(selectStatus); - const errorMessage = useSelector(selectError); - const [rows, setRows] = useState([]); + // Use local state for component-specific status/error + const [localStatus, setLocalStatus] = useState< + "idle" | "loading" | "succeeded" | "failed" + >(initialData ? "succeeded" : "idle"); + const [localError, setLocalError] = useState(null); + + const [refreshCounter, setRefreshCounter] = useState(0); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [resourceToDelete, setResourceToDelete] = + useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const [addModalOpen, setAddModalOpen] = useState(false); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(null); + const [editingRowId, setEditingRowId] = useState( + null + ); + const [editingName, setEditingName] = useState(""); + const [editingRate, setEditingRate] = useState(""); + const [isSavingName, setIsSavingName] = useState(false); + const [updatingEnabled, setUpdatingEnabled] = useState>( + new Set() + ); const normalizedTitle = title.toLowerCase(); + const getRowIdentifier = (row: ResourceRow): number | string => { + if (idField && row[idField] !== undefined) { + return row[idField] as number | string; + } + return (row.identifier as string | number | undefined) ?? row.id; + }; + const excludedKeys = useMemo(() => { const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]); excludeKeys.forEach(key => baseExcluded.add(key)); @@ -134,10 +182,48 @@ const AdminResourceList = ({ [responseCollectionKeys] ); + // Initialize rows synchronously from initialData if available + const getInitialRows = (): ResourceRow[] => { + if (initialData) { + const collection = resolveCollection(initialData, resolvedCollectionKeys); + return collection.map((item, index) => { + const row = ensureRowId(item, index + 1); + // If idField is specified, use that field as identifier + if (idField && row[idField] !== undefined) { + return { ...row, identifier: row[idField] } as ResourceRow; + } + return row; + }); + } + return []; + }; + + const [rows, setRows] = useState(() => getInitialRows()); + useEffect(() => { + // Skip initial fetch if we have server-rendered data and no filters/pagination/sort changes + // Always fetch when filters/pagination/sort change or after mutations (refreshCounter > 0) + const hasFilters = Object.keys(filters).length > 0; + const hasSort = sort?.field && sort?.order; + + // Only skip if we have initialData AND it's the first render (refreshCounter === 0) AND no filters/sort + // Also check if pagination is at default values (page 1, limit 100) + const isDefaultPagination = + pagination.page === 1 && pagination.limit === 100; + const shouldSkipInitialFetch = + initialData && + refreshCounter === 0 && + !hasFilters && + !hasSort && + isDefaultPagination; + + if (shouldSkipInitialFetch) { + return; + } + const fetchResources = async () => { - dispatch(setStatus("loading")); - dispatch(setAdvancedSearchError(null)); + setLocalStatus("loading"); + setLocalError(null); try { const response = await fetch(endpoint, { @@ -151,10 +237,9 @@ const AdminResourceList = ({ }); if (!response.ok) { - dispatch( - setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`) - ); + setLocalError(`Failed to fetch ${normalizedTitle}`); setRows([]); + setLocalStatus("failed"); return; } @@ -164,98 +249,334 @@ const AdminResourceList = ({ resolvedCollectionKeys ); - const nextRows = collection.map((item, index) => - ensureRowId(item, index + 1) - ); + const nextRows = collection.map((item, index) => { + const row = ensureRowId(item, index + 1); + // If idField is specified, use that field as identifier + if (idField && row[idField] !== undefined) { + return { ...row, identifier: row[idField] } as ResourceRow; + } + return row; + }); setRows(nextRows); - dispatch(setStatus("succeeded")); + setLocalStatus("succeeded"); } catch (error) { - dispatch( - setAdvancedSearchError( - error instanceof Error ? error.message : "Unknown error" - ) - ); + setLocalError(error instanceof Error ? error.message : "Unknown error"); setRows([]); + setLocalStatus("failed"); } }; fetchResources(); }, [ - dispatch, endpoint, filters, pagination, sort, resolvedCollectionKeys, normalizedTitle, + refreshCounter, + idField, + initialData, ]); - return ( - - - {title} - + const handleAddClick = () => { + if (!addModalFields) { + return; + } + setAddModalOpen(true); + setAddError(null); + }; - {status === "loading" && ( - + const handleAddConfirm = async (data: Record) => { + if (!addModalFields) { + return; + } + + setIsAdding(true); + setAddError(null); + + try { + await createResourceApi(endpoint, data, normalizedTitle); + setAddModalOpen(false); + setRefreshCounter(current => current + 1); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : `Failed to create ${normalizedTitle}`; + setAddError(errorMessage); + setLocalError(errorMessage); + } finally { + setIsAdding(false); + } + }; + + const handleAddModalClose = () => { + if (!isAdding) { + setAddModalOpen(false); + setAddError(null); + } + }; + + const handleEditClick = (row: ResourceRow) => { + if (!addModalFields || !row.id) { + return; + } + const currentName = getPrimaryLabel(row); + const currentRate = row.rate !== undefined ? String(row.rate) : ""; + setEditingRowId(row.id); + setEditingName(currentName); + setEditingRate(currentRate); + }; + + const handleEditCancel = () => { + setEditingRowId(null); + setEditingName(""); + setEditingRate(""); + }; + + const handleEditSave = async (row: ResourceRow) => { + if (!addModalFields || !editingName.trim()) { + handleEditCancel(); + return; + } + + const identifier = getRowIdentifier(row); + + setIsSavingName(true); + + try { + const updatePayload: Record = { + name: editingName.trim(), + }; + + // Include rate if it exists and has been modified + if (row.rate !== undefined && editingRate !== "") { + const rateValue = parseFloat(editingRate); + if (!Number.isNaN(rateValue)) { + updatePayload.rate = rateValue; + } + } + + await updateResourceApi( + endpoint, + identifier, + updatePayload, + normalizedTitle + ); + setEditingRowId(null); + setEditingName(""); + setEditingRate(""); + setRefreshCounter(current => current + 1); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : `Failed to update ${normalizedTitle}`; + setLocalError(errorMessage); + } finally { + setIsSavingName(false); + } + }; + + const handleEditKeyDown = (e: React.KeyboardEvent, row: ResourceRow) => { + if (e.key === "Enter") { + e.preventDefault(); + handleEditSave(row); + } else if (e.key === "Escape") { + e.preventDefault(); + handleEditCancel(); + } + }; + + const handleDeleteClick = (row: ResourceRow) => { + if (!addModalFields) { + return; + } + + const identifier = getRowIdentifier(row); + if (!identifier) { + return; + } + + setResourceToDelete({ + id: identifier, + label: getPrimaryLabel(row), + }); + setDeleteModalOpen(true); + setDeleteError(null); + }; + + const handleDeleteConfirm = async (id: number | string) => { + if (!addModalFields) { + return; + } + + setIsDeleting(true); + setDeleteError(null); + + try { + await deleteResourceApi(endpoint, id, normalizedTitle); + setDeleteModalOpen(false); + setResourceToDelete(null); + setRefreshCounter(current => current + 1); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : `Failed to delete ${normalizedTitle}`; + setDeleteError(errorMessage); + setLocalError(errorMessage); + } finally { + setIsDeleting(false); + } + }; + + const handleDeleteModalClose = () => { + if (!isDeleting) { + setDeleteModalOpen(false); + setResourceToDelete(null); + setDeleteError(null); + } + }; + + const handleEnabledToggle = async (row: ResourceRow, newEnabled: boolean) => { + if (!showEnabledToggle) { + return; + } + + const identifier = getRowIdentifier(row); + + setUpdatingEnabled(prev => new Set(prev).add(identifier)); + + try { + await updateResourceApi( + endpoint, + identifier, + { enabled: newEnabled }, + normalizedTitle + ); + setRefreshCounter(current => current + 1); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : `Failed to update ${normalizedTitle}`; + setLocalError(errorMessage); + } finally { + setUpdatingEnabled(prev => { + const next = new Set(prev); + next.delete(identifier); + return next; + }); + } + }; + + return ( +
+
+ + {title} + + + {addModalFields && ( + + )} +
+ + {localStatus === "loading" && ( +
{`Loading ${normalizedTitle}...`} - +
)} - {status === "failed" && ( - - {errorMessage || `Failed to load ${normalizedTitle}`} + {localStatus === "failed" && ( + + {localError || `Failed to load ${normalizedTitle}`} )} - {!rows.length && status === "succeeded" && ( - + {!rows.length && localStatus === "succeeded" && ( + {`No ${normalizedTitle} found.`} )} {rows.length > 0 && ( - +
{rows.map(row => { const chips = getMetaChips(row); const secondary = getSecondaryDetails(row); return ( - - - - - - {getPrimaryLabel(row)} - +
+
+
+
+ {editingRowId === row.id ? ( +
+ setEditingName(e.target.value)} + onKeyDown={e => handleEditKeyDown(e, row)} + autoFocus + disabled={isSavingName} + size="small" + className="admin-resource-list__edit-field" + variant="standard" + label="Name" + /> + {row.rate !== undefined && ( + setEditingRate(e.target.value)} + onKeyDown={e => handleEditKeyDown(e, row)} + disabled={isSavingName} + size="small" + className="admin-resource-list__edit-field" + variant="standard" + label="Rate" + type="number" + /> + )} +
+ ) : ( + + {getPrimaryLabel(row)} + + )} - + ID: {row.id} - +
- {chips.length > 0 && ( - + {chips.length > 0 && editingRowId !== row.id && ( +
{chips.map(chip => ( ))} - +
)} {secondary.length > 0 && ( - + {secondary .map(([key, value]) => `${key}: ${String(value)}`) .join(" • ")} )} -
- - - +
+ + {(addModalFields || showEnabledToggle) && ( +
+ {showEnabledToggle && ( + <> + + Enabled + + + handleEnabledToggle(row, e.target.checked) + } + disabled={updatingEnabled.has( + (row.identifier as string | number | undefined) ?? + row.id + )} + size="small" + /> + + )} + {addModalFields && ( + <> + {editingRowId === row.id ? ( + <> + + handleEditSave(row)} + disabled={isSavingName} + size="small" + color="primary" + > + {isSavingName ? ( + + ) : ( + + )} + + + + + + + + + ) : ( + + handleEditClick(row)} + size="small" + > + + + + )} + + handleDeleteClick(row)} + size="small" + > + + + + + )} +
+ )} +
+
+
); })} - +
)} -
+ + {addModalFields && ( + <> + + + + )} +
); }; diff --git a/app/features/AdminList/AdminResourceList.utils.ts b/app/features/AdminList/AdminResourceList.utils.ts new file mode 100644 index 0000000..576cdfc --- /dev/null +++ b/app/features/AdminList/AdminResourceList.utils.ts @@ -0,0 +1,146 @@ +export type TCreateResourcePayload = Record; + +/** + * Transforms create payload to convert string numbers to actual numbers + * for fields that should be numeric (e.g., rate, limit, etc.) + */ +function transformCreatePayload( + payload: TCreateResourcePayload +): TCreateResourcePayload { + const transformed: TCreateResourcePayload = { ...payload }; + + for (const [key, value] of Object.entries(transformed)) { + // Convert string numbers to actual numbers for common numeric fields + if (typeof value === "string" && value.trim() !== "") { + const numericValue = Number(value); + // Only convert if it's a valid number and the key suggests it should be numeric + if ( + !Number.isNaN(numericValue) && + (key.toLowerCase().includes("rate") || + key.toLowerCase().includes("price") || + key.toLowerCase().includes("amount") || + key.toLowerCase().includes("limit") || + key.toLowerCase().includes("count")) + ) { + transformed[key] = numericValue; + } + } + } + + return transformed; +} + +export async function createResourceApi( + endpointBase: string, + payload: TCreateResourcePayload, + resourceName: string +) { + // Transform the payload to convert string numbers to actual numbers + const transformedPayload = transformCreatePayload(payload); + + const response = await fetch(`${endpointBase}/create`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(transformedPayload), + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: `Failed to create ${resourceName}` })); + throw new Error(errorData?.message || `Failed to create ${resourceName}`); + } + + return response.json(); +} + +export async function deleteResourceApi( + endpointBase: string, + id: number | string, + resourceName: string +) { + const response = await fetch(`${endpointBase}/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: `Failed to delete ${resourceName}` })); + throw new Error(errorData?.message || `Failed to delete ${resourceName}`); + } + + try { + return await response.json(); + } catch { + return { success: true }; + } +} + +export type TUpdateResourcePayload = Record; + +/** + * Converts a key to PascalCase (e.g., "enabled" -> "Enabled", "first_name" -> "FirstName") + */ +function toPascalCase(key: string): string { + return key + .split("_") + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); +} + +/** + * Transforms frontend data to backend format + * - data object uses lowercase keys (matching API response) + * - fields array uses PascalCase (required by backend) + */ +function transformResourceUpdateData(updates: Record): { + data: Record; + fields: string[]; +} { + const data: Record = {}; + const fields: string[] = []; + + for (const [key, value] of Object.entries(updates)) { + // Skip undefined/null values + if (value === undefined || value === null) { + continue; + } + + // Use the key as-is for data (matching API response casing) + data[key] = value; + // Convert to PascalCase for fields array (required by backend) + fields.push(toPascalCase(key)); + } + + return { data, fields }; +} + +export async function updateResourceApi( + endpointBase: string, + id: number | string, + payload: TUpdateResourcePayload, + resourceName: string +) { + // Transform the payload to match backend format + const transformedPayload = transformResourceUpdateData(payload); + + const response = await fetch(`${endpointBase}/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(transformedPayload), + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: `Failed to update ${resourceName}` })); + throw new Error(errorData?.message || `Failed to update ${resourceName}`); + } + + return response.json(); +} diff --git a/app/features/GeneralHealthCard/utils.ts b/app/features/GeneralHealthCard/utils.ts index bac015e..7f6478c 100644 --- a/app/features/GeneralHealthCard/utils.ts +++ b/app/features/GeneralHealthCard/utils.ts @@ -13,59 +13,3 @@ export const formatPercentage = ( if (isNaN(numValue)) return "0%"; return `${numValue.toFixed(2)}%`; }; - -interface IHealthData { - total?: number; - successful?: number; - acceptance_rate?: number; - amount?: number; - atv?: number; -} - -interface IStatItem { - label: string; - value: string | number; - change: string; -} - -export const transformHealthDataToStats = ( - healthData: IHealthData | null -): IStatItem[] => { - if (!healthData) { - return [ - { label: "TOTAL", value: 0, change: "0%" }, - { label: "SUCCESSFUL", value: 0, change: "0%" }, - { label: "ACCEPTANCE RATE", value: "0%", change: "0%" }, - { label: "AMOUNT", value: "€0.00", change: "0%" }, - { label: "ATV", value: "€0.00", change: "0%" }, - ]; - } - - return [ - { - label: "TOTAL", - value: healthData.total ?? 0, - change: "0%", - }, - { - label: "SUCCESSFUL", - value: healthData.successful ?? 0, - change: "0%", - }, - { - label: "ACCEPTANCE RATE", - value: formatPercentage(healthData.acceptance_rate), - change: "0%", - }, - { - label: "AMOUNT", - value: formatCurrency(healthData.amount), - change: "0%", - }, - { - label: "ATV", - value: formatCurrency(healthData.atv), - change: "0%", - }, - ]; -}; diff --git a/app/features/Pages/Admin/Users/users.tsx b/app/features/Pages/Admin/Users/users.tsx index 0c6f3e2..396977f 100644 --- a/app/features/Pages/Admin/Users/users.tsx +++ b/app/features/Pages/Admin/Users/users.tsx @@ -17,8 +17,6 @@ const Users: React.FC = ({ users }) => { const [showAddUser, setShowAddUser] = useState(false); const dispatch = useDispatch(); - // console.log("[Users] - users", users); - return (
{ + // console.log("[data]", data); if (data?.overviewData) { + console.log("[OverViewData] - Subscribe", data); setOverviewData(data.overviewData); } }); @@ -47,25 +44,11 @@ export const TransactionsOverview = ({ return () => subscription.unsubscribe(); }, []); + console.log("[OverviewData] - Component", overviewData); /** - * Transform overview data from flat object to array format + * Use transformed data from API (already includes percentages and colors) */ - const transformedData = overviewData - ? transformOverviewResponse({ - cancelled: overviewData.cancelled, - failed: overviewData.failed, - successful: overviewData.successful, - waiting: overviewData.waiting, - }) - : []; - - /** - * Calculate percentages and enrich with colors - */ - const enrichedData = - transformedData.length > 0 - ? enrichOverviewData(calculatePercentages(transformedData)) - : []; + const enrichedData = overviewData?.data || []; /** * Transform overview data for PieCharts (expects { name, value }[]) @@ -75,11 +58,6 @@ export const TransactionsOverview = ({ value: item.count, })); - /** - * Transform overview data for table (expects { state, count, percentage, color }[]) - */ - const tableData = enrichedData; - return ( @@ -103,7 +81,7 @@ export const TransactionsOverview = ({ {overviewData && ( - + )} diff --git a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx index d66cf8c..a552a15 100644 --- a/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx +++ b/app/features/TransactionsOverview/components/TransactionsOverViewTable.tsx @@ -33,6 +33,8 @@ const defaultData: ITableData[] = [ export const TransactionsOverViewTable = ({ data = defaultData, }: ITransactionsOverViewTableProps) => { + console.log("data", data); + return ( diff --git a/app/features/TransactionsOverview/utils.ts b/app/features/TransactionsOverview/utils.ts index 073d002..333a90b 100644 --- a/app/features/TransactionsOverview/utils.ts +++ b/app/features/TransactionsOverview/utils.ts @@ -23,33 +23,6 @@ export const getStateColor = (state: string): string => { } }; -/** - * Transform flat API overview response to array format - */ -export const transformOverviewResponse = (data: { - cancelled?: number; - failed?: number; - successful?: number; - waiting?: number; -}): Array<{ - state: string; - count: number; -}> => { - const states = [ - { key: "successful", label: "Successful" }, - { key: "waiting", label: "Waiting" }, - { key: "failed", label: "Failed" }, - { key: "cancelled", label: "Cancelled" }, - ]; - - return states - .map(({ key, label }) => ({ - state: label, - count: data[key as keyof typeof data] || 0, - })) - .filter(item => item.count > 0); // Only include states with counts > 0 -}; - /** * Calculate percentage for each state */ diff --git a/app/features/UserRoles/EditUser/EditUser.tsx b/app/features/UserRoles/EditUser/EditUser.tsx index 1a65572..e10ad98 100644 --- a/app/features/UserRoles/EditUser/EditUser.tsx +++ b/app/features/UserRoles/EditUser/EditUser.tsx @@ -89,23 +89,28 @@ const EditUser = ({ user }: { user: IUser }) => { // Check if this was a toggle-only update const isToggle = !e || ("enabled" in e && Object.keys(e).length === 1); - const updates: Record = {}; + let updates: Record = {}; - // Compare form fields vs original user object - Object.entries(form).forEach(([key, value]) => { - const originalValue = (user as unknown as Record)[key]; - // Only include changed values and skip empty ones - if ( - value !== "" && - JSON.stringify(value) !== JSON.stringify(originalValue) - ) { - updates[key] = value; - } - }); - - // Handle enabled toggle separately + // If toggle, send entire user object with updated enabled field (excluding id) if (isToggle) { - updates.enabled = !(enabled ?? true); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...rest } = user; + updates = { + ...rest, + enabled: !(enabled ?? true), + }; + } else { + // Compare form fields vs original user object + Object.entries(form).forEach(([key, value]) => { + const originalValue = (user as unknown as Record)[key]; + // Only include changed values and skip empty ones + if ( + value !== "" && + JSON.stringify(value) !== JSON.stringify(originalValue) + ) { + updates[key] = value; + } + }); } // Nothing changed — no need to call API @@ -114,7 +119,6 @@ const EditUser = ({ user }: { user: IUser }) => { return; } - console.log("[handleUpdate] - updates", updates); try { const resultAction = await dispatch( updateUserDetails({ id: user.id, updates }) diff --git a/app/layout.tsx b/app/layout.tsx index cfa4e7b..52a7859 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,7 +8,7 @@ import "../styles/globals.scss"; import Modals from "./modals"; export const metadata: Metadata = { - title: "Your App", + title: "Cashier BO", description: "Generated by Next.js", }; diff --git a/app/redux/advanedSearch/advancedSearchSlice.ts b/app/redux/advanedSearch/advancedSearchSlice.ts index 46f04ce..2b22dbb 100644 --- a/app/redux/advanedSearch/advancedSearchSlice.ts +++ b/app/redux/advanedSearch/advancedSearchSlice.ts @@ -32,7 +32,7 @@ const initialState: AdvancedSearchState = { filters: {}, pagination: { page: 1, - limit: 10, + limit: 100, }, status: "idle", error: null, diff --git a/app/redux/advanedSearch/selectors.ts b/app/redux/advanedSearch/selectors.ts index eecb2e6..7879a76 100644 --- a/app/redux/advanedSearch/selectors.ts +++ b/app/redux/advanedSearch/selectors.ts @@ -16,7 +16,7 @@ export const selectPaginationModel = createSelector( [selectPagination], pagination => ({ page: Math.max(0, (pagination.page ?? 1) - 1), - pageSize: pagination.limit ?? 10, + pageSize: pagination.limit ?? 100, }) ); diff --git a/app/redux/user/userSlice.ts b/app/redux/user/userSlice.ts index 70e05e5..f50fa8b 100644 --- a/app/redux/user/userSlice.ts +++ b/app/redux/user/userSlice.ts @@ -37,8 +37,6 @@ export const addUser = createAsyncThunk< const data = await res.json(); - console.log("[DEBUG] [ADD-USER] [data]: ", data); - if (!res.ok) { return rejectWithValue(data.message || "Failed to create user"); } diff --git a/app/services/adminResources.ts b/app/services/adminResources.ts new file mode 100644 index 0000000..acae750 --- /dev/null +++ b/app/services/adminResources.ts @@ -0,0 +1,71 @@ +"use server"; + +import { cookies } from "next/headers"; +import { + AUTH_COOKIE_NAME, + BE_BASE_URL, + REVALIDATE_SECONDS, + getAdminResourceCacheTag, +} from "./constants"; +import { buildFilterParam } from "@/app/api/dashboard/admin/utils"; + +export interface IFetchAdminResourceParams { + resource: string; + filters?: Record; + pagination?: { page: number; limit: number }; + sort?: { field: string; order: "asc" | "desc" }; +} + +export async function fetchAdminResource({ + resource, + filters = {}, + pagination = { page: 1, limit: 100 }, + sort, +}: IFetchAdminResourceParams) { + const cookieStore = await cookies(); + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value; + + if (!token) { + throw new Error("Missing auth token"); + } + + const queryParams = new URLSearchParams(); + queryParams.set("limit", String(pagination.limit ?? 100)); + queryParams.set("page", String(pagination.page ?? 1)); + + if (sort?.field && sort?.order) { + queryParams.set("sort", `${sort.field}:${sort.order}`); + } + + const filterParam = buildFilterParam( + filters as Record + ); + if (filterParam) { + queryParams.set("filter", filterParam); + } + + const backendUrl = `${BE_BASE_URL}/api/v1/${resource}${ + queryParams.size ? `?${queryParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + next: { + revalidate: REVALIDATE_SECONDS, + tags: [getAdminResourceCacheTag(resource)], + }, + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: `Failed to fetch ${resource}` })); + throw new Error(errorData?.message || `Failed to fetch ${resource}`); + } + + return response.json(); +} diff --git a/app/services/constants.ts b/app/services/constants.ts index 90042c4..969c350 100644 --- a/app/services/constants.ts +++ b/app/services/constants.ts @@ -3,6 +3,11 @@ export const USERS_CACHE_TAG = "users"; export const HEALTH_CACHE_TAG = "health"; export const REVALIDATE_SECONDS = 100; +// Admin resource cache tags +export const ADMIN_RESOURCE_CACHE_TAG_PREFIX = "admin-resource"; +export const getAdminResourceCacheTag = (resource: string) => + `${ADMIN_RESOURCE_CACHE_TAG_PREFIX}-${resource}`; + export const BE_BASE_URL = process.env.BE_BASE_URL || ""; export const AUTH_COOKIE_NAME = "auth_token"; diff --git a/app/services/health.ts b/app/services/health.ts index 7fb9f8c..826bee7 100644 --- a/app/services/health.ts +++ b/app/services/health.ts @@ -93,10 +93,10 @@ export async function fetchDashboardDataService({ // Handle overview data response let overviewData: ITransactionsOverviewData = { success: false, - cancelled: 0, - failed: 0, - successful: 0, - waiting: 0, + successful_count: 0, + waiting_count: 0, + failed_count: 0, + cancelled_count: 0, }; if (!overviewResponse.ok) { diff --git a/app/services/transactions.ts b/app/services/transactions.ts index f3d5d67..6124e27 100644 --- a/app/services/transactions.ts +++ b/app/services/transactions.ts @@ -62,10 +62,10 @@ export async function getDashboardData({ healthData: healthDataWithStats as IHealthData, overviewData: overviewData || { success: false, - cancelled: 0, - failed: 0, - successful: 0, - waiting: 0, + successful_count: 0, + waiting_count: 0, + failed_count: 0, + cancelled_count: 0, }, reviewTransactions: reviewTransactions || { success: false, diff --git a/app/services/types.ts b/app/services/types.ts index 82c6c11..ed0f1f1 100644 --- a/app/services/types.ts +++ b/app/services/types.ts @@ -21,10 +21,14 @@ export interface IFetchHealthDataParams { export interface ITransactionsOverviewData { success?: boolean; message?: string; - cancelled?: number; - failed?: number; - successful?: number; - waiting?: number; + successful_count?: number; + waiting_count?: number; + failed_count?: number; + cancelled_count?: number; + successful_ratio?: number; + waiting_ratio?: number; + failed_ratio?: number; + cancelled_ratio?: number; data?: Array<{ state: string; count: number; diff --git a/app/utils/formatCurrency.ts b/app/utils/formatCurrency.ts new file mode 100644 index 0000000..7f6478c --- /dev/null +++ b/app/utils/formatCurrency.ts @@ -0,0 +1,15 @@ +export const formatCurrency = (value: number | string | undefined): string => { + if (value === undefined || value === null) return "€0.00"; + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (isNaN(numValue)) return "€0.00"; + return `€${numValue.toFixed(2)}`; +}; + +export const formatPercentage = ( + value: number | string | undefined +): string => { + if (value === undefined || value === null) return "0%"; + const numValue = typeof value === "string" ? parseFloat(value) : value; + if (isNaN(numValue)) return "0%"; + return `${numValue.toFixed(2)}%`; +}; -- 2.39.5