From 2b6df3899bd16f52811c35ef5cf854bcd6e95077 Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Tue, 18 Nov 2025 19:27:12 +0100 Subject: [PATCH] Added more to sidebar, added more to transaction tables --- app/api/dashboard/audits/mockData.ts | 88 ----- app/api/dashboard/audits/route.ts | 125 +++---- app/api/dashboard/transactions/[id]/route.ts | 44 +++ .../transactions/deposits/mockData.ts | 264 --------------- .../dashboard/transactions/deposits/route.ts | 164 ++++++---- app/dashboard/audits/page.scss | 42 +++ app/dashboard/audits/page.tsx | 305 +++++++++++++++++- app/dashboard/transactions/all/page.tsx | 36 +-- app/dashboard/transactions/deposits/page.tsx | 108 +++++-- app/dashboard/transactions/interface.ts | 29 ++ app/features/DataTable/DataTable.tsx | 221 ++++++------- app/features/DataTable/StatusChangeDialog.tsx | 26 +- app/features/DataTable/re-selectors/index.tsx | 238 ++++++++++++++ app/features/DataTable/types.ts | 6 + .../dashboard/header/dropDown/DropDown.tsx | 1 - app/features/dashboard/sidebar/Sidebar.tsx | 8 +- app/features/dashboard/sidebar/sideBar.scss | 19 +- app/redux/metadata/metadataSlice.ts | 27 +- app/redux/metadata/selectors.ts | 36 ++- app/services/audits.ts | 39 ++- app/utils/iconMap.ts | 26 +- 21 files changed, 1148 insertions(+), 704 deletions(-) delete mode 100644 app/api/dashboard/audits/mockData.ts create mode 100644 app/api/dashboard/transactions/[id]/route.ts delete mode 100644 app/api/dashboard/transactions/deposits/mockData.ts create mode 100644 app/dashboard/audits/page.scss create mode 100644 app/dashboard/transactions/interface.ts create mode 100644 app/features/DataTable/re-selectors/index.tsx diff --git a/app/api/dashboard/audits/mockData.ts b/app/api/dashboard/audits/mockData.ts deleted file mode 100644 index 596c5cd..0000000 --- a/app/api/dashboard/audits/mockData.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { GridColDef } from "@mui/x-data-grid"; - -export const AuditColumns: GridColDef[] = [ - { field: "actionType", headerName: "Action Type", width: 130 }, - { - field: "timeStampOfTheAction", - headerName: "Timestamp of the action", - width: 130, - }, - { field: "adminUsername", headerName: "Admin username", width: 130 }, - { field: "adminId", headerName: "Admin ID", width: 130 }, - { field: "affectedUserId", headerName: "Affected user ID", width: 130 }, - { field: "adminIPAddress", headerName: "Admin IP address", width: 130 }, - { field: "reasonNote", headerName: "Reason/Note", width: 130 }, -]; - -export const AuditData = [ - { - id: "1", - actionType: "Create", - timeStampOfTheAction: "2023-03-01T12:00:00", - adminUsername: "admin1", - adminId: "12345", - affectedUserId: "67890", - adminIPAddress: "192.168.1.1", - reasonNote: "New user created", - }, - { - id: "2", - actionType: "Update", - timeStampOfTheAction: "2023-03-02T12:00:00", - adminUsername: "admin2", - adminId: "54321", - affectedUserId: "09876", - adminIPAddress: "192.168.2.2", - reasonNote: "User details updated", - }, - { - id: "3", - actionType: "Delete", - timeStampOfTheAction: "2023-03-03T12:00:00", - adminUsername: "admin3", - adminId: "98765", - affectedUserId: "45678", - adminIPAddress: "192.168.3.3", - reasonNote: "User deleted", - }, - { - id: "4", - actionType: "Create", - timeStampOfTheAction: "2023-03-04T12:00:00", - adminUsername: "admin4", - adminId: "98765", - affectedUserId: "45678", - adminIPAddress: "192.168.3.3", - reasonNote: "New user created", - }, - { - id: "5", - actionType: "Update", - timeStampOfTheAction: "2023-03-05T12:00:00", - adminUsername: "admin2", - adminId: "98765", - affectedUserId: "45678", - adminIPAddress: "192.168.3.3", - reasonNote: "User details updated", - }, -]; - -export const AuditSearchLabels = [ - { label: "Action Type", field: "actionType", type: "text" }, - { label: "Date / Time", field: "dateTime", type: "date" }, - { - label: "affectedUserId", - field: "Affected user ID", - type: "text", - }, - { - label: "Admin ID", - field: "adminId", - type: "text", - }, - { - label: "Admin username", - field: "adminUsername", - type: "text", - }, -]; diff --git a/app/api/dashboard/audits/route.ts b/app/api/dashboard/audits/route.ts index afac8c9..dfe76b8 100644 --- a/app/api/dashboard/audits/route.ts +++ b/app/api/dashboard/audits/route.ts @@ -1,69 +1,80 @@ import { NextRequest, NextResponse } from "next/server"; -import { AuditColumns, AuditData, AuditSearchLabels } from "./mockData"; + +const AUDITS_BASE_URL = + process.env.AUDITS_BASE_URL || + process.env.BE_BASE_URL || + "http://localhost:8583"; +const COOKIE_NAME = "auth_token"; + +const DEFAULT_LIMIT = "25"; +const DEFAULT_PAGE = "1"; export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; - const actionType = searchParams.get("actionType"); - const affectedUserId = searchParams.get("affectedUserId"); - const adminId = searchParams.get("adminId"); - const adminUsername = searchParams.get("adminUsername"); - - const dateTimeStart = searchParams.get("dateTime_start"); - const dateTimeEnd = searchParams.get("dateTime_end"); - - let filteredRows = [...AuditData]; - - if (actionType) { - filteredRows = filteredRows.filter( - tx => tx.actionType.toLocaleLowerCase() === actionType.toLocaleLowerCase() - ); - } - - if (affectedUserId) { - filteredRows = filteredRows.filter( - tx => tx.affectedUserId.toLowerCase() === affectedUserId.toLowerCase() - ); - } - - if (adminId) { - filteredRows = filteredRows.filter(tx => tx.adminId === adminId); - } - if (adminUsername) { - filteredRows = filteredRows.filter( - tx => tx.adminUsername === adminUsername - ); - } - - if (dateTimeStart && dateTimeEnd) { - const start = new Date(dateTimeStart); - const end = new Date(dateTimeEnd); - - // Validate the date range to ensure it’s correct - if (isNaN(start.getTime()) || isNaN(end.getTime())) { + if (!token) { return NextResponse.json( - { - error: "Invalid date range", - }, - { status: 400 } + { message: "Missing Authorization header" }, + { status: 401 } ); } - filteredRows = filteredRows.filter(tx => { - const txDate = new Date(tx.timeStampOfTheAction); + const { searchParams } = new URL(request.url); + const proxiedParams = new URLSearchParams(); - // Validate if the timestamp is a valid date - if (isNaN(txDate.getTime())) { - return false; // Skip invalid dates - } - - return txDate >= start && txDate <= end; + // Forward provided params + searchParams.forEach((value, key) => { + if (value == null || value === "") return; + proxiedParams.append(key, value); }); - } - return NextResponse.json({ - tableRows: filteredRows, - tableColumns: AuditColumns, - tableSearchLabels: AuditSearchLabels, - }); + if (!proxiedParams.has("limit")) { + proxiedParams.set("limit", DEFAULT_LIMIT); + } + if (!proxiedParams.has("page")) { + proxiedParams.set("page", DEFAULT_PAGE); + } + + const backendUrl = `${AUDITS_BASE_URL}/api/v1/audit${ + proxiedParams.size ? `?${proxiedParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch audits" })); + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch audits", + }, + { status: response.status } + ); + } + + const data = await response.json(); + console.log("[AUDITS] data:", data); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.log("[AUDITS] error:", err); + + console.error("Proxy GET /api/v1/audits error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } } diff --git a/app/api/dashboard/transactions/[id]/route.ts b/app/api/dashboard/transactions/[id]/route.ts new file mode 100644 index 0000000..bca9543 --- /dev/null +++ b/app/api/dashboard/transactions/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = await params; + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const payload = await request.json(); + + const upstream = await fetch(`${BE_BASE_URL}/api/v1/transactions/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + + const data = await upstream.json(); + console.log("[DEBUG] [TRANSACTIONS] [PUT] Response data:", data); + return NextResponse.json(data, { status: upstream.status }); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/transactions/deposits/mockData.ts b/app/api/dashboard/transactions/deposits/mockData.ts deleted file mode 100644 index 2ba30c8..0000000 --- a/app/api/dashboard/transactions/deposits/mockData.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { GridColDef } from "@mui/x-data-grid"; - -export const depositTransactionDummyData = [ - { - id: 1, - userId: 17, - merchandId: 100987998, - transactionId: 1049131973, - depositMethod: "Card", - status: "Completed", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-18 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 2, - userId: 17, - merchandId: 100987998, - transactionId: 1049131973, - depositMethod: "Card", - status: "Completed", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-18 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 3, - userId: 17, - merchandId: 100987997, - transactionId: 1049131973, - depositMethod: "Card", - status: "Completed", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-18 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 4, - userId: 19, - merchandId: 100987997, - transactionId: 1049136973, - depositMethod: "Card", - status: "Completed", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-18 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 5, - userId: 19, - merchandId: 100987998, - transactionId: 1049131973, - depositMethod: "Card", - status: "Completed", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-18 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 6, - userId: 27, - merchandId: 100987997, - transactionId: 1049131973, - depositMethod: "Card", - status: "Pending", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-18 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 7, - userId: 175, - merchandId: 100987938, - transactionId: 1049136973, - depositMethod: "Card", - status: "Pending", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-18 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 8, - userId: 172, - merchandId: 100987938, - transactionId: 1049131973, - depositMethod: "Card", - status: "Pending", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-12 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 9, - userId: 174, - merchandId: 100987938, - transactionId: 1049131973, - depositMethod: "Bank Transfer", - status: "Inprogress", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-17 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 10, - userId: 7, - merchandId: 100987998, - transactionId: 1049131973, - depositMethod: "Bank Transfer", - status: "Inprogress", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-17 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, - { - id: 11, - userId: 1, - merchandId: 100987998, - transactionId: 1049131973, - depositMethod: "Bank Transfer", - status: "Error", - options: [ - { value: "Pending", label: "Pending" }, - { value: "Completed", label: "Completed" }, - { value: "Inprogress", label: "Inprogress" }, - { value: "Error", label: "Error" }, - ], - amount: 4000, - currency: "EUR", - dateTime: "2025-06-17 10:10:30", - errorInfo: "-", - fraudScore: "frad score 1234", - }, -]; - -export const depositTransactionsColumns: GridColDef[] = [ - { field: "userId", headerName: "User ID", width: 130 }, - { field: "merchandId", headerName: "Merchant ID", width: 130 }, - { field: "transactionId", headerName: "Transaction ID", width: 130 }, - { field: "depositMethod", headerName: "Deposit Method", width: 130 }, - { field: "status", headerName: "Status", width: 130 }, - { field: "actions", headerName: "Actions", width: 150 }, - { field: "amount", headerName: "Amount", width: 130 }, - { field: "currency", headerName: "Currency", width: 130 }, - { field: "dateTime", headerName: "Date / Time", width: 130 }, - { field: "errorInfo", headerName: "Error Info", width: 130 }, - { field: "fraudScore", headerName: "Fraud Score", width: 130 }, -]; - -export const depositTransactionsExtraColumns: GridColDef[] = [ - { field: "currency", headerName: "Currency", width: 130 }, - { field: "errorInfo", headerName: "Error Info", width: 130 }, - { field: "fraudScore", headerName: "Fraud Score", width: 130 }, -]; - -// export const extraColumns = ["currency", "errorInfo", "fraudScore"] - -export const depositTransactionsSearchLabels = [ - { label: "User", field: "userId", type: "text" }, - { label: "Transaction ID", field: "transactionId", type: "text" }, - { - label: "Transaction Reference ID", - field: "transactionReferenceId", - type: "text", - }, - { - label: "Currency", - field: "currency", - type: "select", - options: ["USD", "EUR", "GBP"], - }, - { - label: "Status", - field: "status", - type: "select", - options: ["Pending", "Inprogress", "Completed", "Failed"], - }, - { - label: "Payment Method", - field: "depositMethod", - type: "select", - options: ["Card", "Bank Transfer"], - }, - { label: "Date / Time", field: "dateTime", type: "date" }, -]; diff --git a/app/api/dashboard/transactions/deposits/route.ts b/app/api/dashboard/transactions/deposits/route.ts index 818f3f8..d294c7c 100644 --- a/app/api/dashboard/transactions/deposits/route.ts +++ b/app/api/dashboard/transactions/deposits/route.ts @@ -1,82 +1,108 @@ import { NextRequest, NextResponse } from "next/server"; -import { - depositTransactionDummyData, - depositTransactionsColumns, - depositTransactionsSearchLabels, - // extraColumns -} from "./mockData"; -// import { formatToDateTimeString } from "@/app/utils/formatDate"; +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); +type FilterValue = + | string + | { + operator?: string; + value: string; + }; - const status = searchParams.get("status"); - const userId = searchParams.get("userId"); - const depositMethod = searchParams.get("depositMethod"); - const merchandId = searchParams.get("merchandId"); - const transactionId = searchParams.get("transactionId"); - // const dateTime = searchParams.get("dateTime"); +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; - const dateTimeStart = searchParams.get("dateTime_start"); - const dateTimeEnd = searchParams.get("dateTime_end"); - - let filteredTransactions = [...depositTransactionDummyData]; - - if (userId) { - filteredTransactions = filteredTransactions.filter( - tx => tx.userId.toString() === userId - ); - } - - if (status) { - filteredTransactions = filteredTransactions.filter( - tx => tx.status.toLowerCase() === status.toLowerCase() - ); - } - - if (depositMethod) { - filteredTransactions = filteredTransactions.filter( - tx => tx.depositMethod.toLowerCase() === depositMethod.toLowerCase() - ); - } - if (merchandId) { - filteredTransactions = filteredTransactions.filter( - tx => tx.merchandId.toString() === merchandId - ); - } - if (transactionId) { - filteredTransactions = filteredTransactions.filter( - tx => tx.transactionId.toString() === transactionId - ); - } - - if (dateTimeStart && dateTimeEnd) { - const start = new Date(dateTimeStart); - const end = new Date(dateTimeEnd); - - if (isNaN(start.getTime()) || isNaN(end.getTime())) { + if (!token) { return NextResponse.json( - { - error: "Invalid date range", - }, - { status: 400 } + { message: "Missing Authorization header" }, + { status: 401 } ); } - filteredTransactions = filteredTransactions.filter(tx => { - const txDate = new Date(tx.dateTime); + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; - if (isNaN(txDate.getTime())) { - return false; + // Force deposits filter while allowing other filters to stack + const mergedFilters: Record = { + ...filters, + Type: { + operator: "==", + value: "deposit", + }, + }; + + const queryParts: string[] = []; + queryParts.push(`limit=${pagination.limit}`); + queryParts.push(`page=${pagination.page}`); + + if (sort) { + queryParts.push(`sort=${sort.field}:${sort.order}`); + } + + for (const [key, filterValue] of Object.entries(mergedFilters)) { + if (!filterValue) continue; + + let operator: string; + let value: string; + + if (typeof filterValue === "string") { + operator = "=="; + value = filterValue; + } else { + operator = filterValue.operator || "=="; + value = filterValue.value; } - return txDate >= start && txDate <= end; - }); - } + if (!value) continue; - return NextResponse.json({ - tableRows: filteredTransactions, - tableSearchLabels: depositTransactionsSearchLabels, - tableColumns: depositTransactionsColumns, - }); + const encodedValue = encodeURIComponent(value); + const needsEqualsPrefix = /^[A-Za-z]/.test(operator); + const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator; + + queryParts.push(`${key}${operatorSegment}/${encodedValue}`); + } + + const queryString = queryParts.join("&"); + const backendUrl = `${BE_BASE_URL}/api/v1/transactions${ + queryString ? `?${queryString}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch deposits" })); + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch deposits", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error( + "Proxy POST /api/dashboard/transactions/deposits error:", + err + ); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } } diff --git a/app/dashboard/audits/page.scss b/app/dashboard/audits/page.scss new file mode 100644 index 0000000..e8f142e --- /dev/null +++ b/app/dashboard/audits/page.scss @@ -0,0 +1,42 @@ +.audits-page { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + + .page-title { + font-size: 1.5rem; + font-weight: 500; + margin: 0; + } + + .error-alert { + margin-bottom: 8px; + padding: 12px 16px; + background-color: #fee; + color: #c62828; + border-radius: 4px; + border: 1px solid #ffcdd2; + } + + .table-container { + width: 100%; + background-color: #fff; + border-radius: 4px; + box-shadow: + 0px 2px 1px -1px rgba(0, 0, 0, 0.2), + 0px 1px 1px 0px rgba(0, 0, 0, 0.14), + 0px 1px 3px 0px rgba(0, 0, 0, 0.12); + overflow: hidden; + + .scroll-wrapper { + width: 100dvw; + overflow-x: auto; + overflow-y: hidden; + + .table-inner { + min-width: 1200px; + } + } + } +} diff --git a/app/dashboard/audits/page.tsx b/app/dashboard/audits/page.tsx index 9bec2b5..10a804f 100644 --- a/app/dashboard/audits/page.tsx +++ b/app/dashboard/audits/page.tsx @@ -1,22 +1,293 @@ -import DataTable from "@/app/features/DataTable/DataTable"; -import { getAudits } from "@/app/services/audits"; +"use client"; -export default async function AuditPage({ - searchParams, -}: { - searchParams: Promise>; -}) { - // Await searchParams before processing - const params = await searchParams; - // Create a safe query string by filtering only string values - const safeParams: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (typeof value === "string") { - safeParams[key] = value; +import { useEffect, useMemo, useState } from "react"; +import { + DataGrid, + GridColDef, + GridPaginationModel, + GridSortModel, +} from "@mui/x-data-grid"; +import { getAudits } from "@/app/services/audits"; +import "./page.scss"; + +type AuditRow = Record & { id: string | number }; + +interface AuditApiResponse { + total?: number; + limit?: number; + page?: number; + data?: unknown; + items?: unknown[]; + audits?: unknown[]; + logs?: unknown[]; + results?: unknown[]; + records?: unknown[]; + meta?: { total?: number }; + pagination?: { total?: number }; +} + +const DEFAULT_PAGE_SIZE = 25; + +const FALLBACK_COLUMNS: GridColDef[] = [ + { + field: "placeholder", + headerName: "Audit Data", + flex: 1, + sortable: false, + filterable: false, + }, +]; + +const CANDIDATE_ARRAY_KEYS: (keyof AuditApiResponse)[] = [ + "items", + "audits", + "logs", + "results", + "records", +]; + +const normalizeValue = (value: unknown): string | number => { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "string" || typeof value === "number") { + return value; + } + + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + + return JSON.stringify(value); +}; + +const toTitle = (field: string) => + field + .replace(/_/g, " ") + .replace(/-/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\s+/g, " ") + .trim() + .replace(/^\w/g, char => char.toUpperCase()); + +const deriveColumns = (rows: AuditRow[]): GridColDef[] => { + if (!rows.length) return []; + + return Object.keys(rows[0]).map(field => ({ + field, + headerName: toTitle(field), + flex: field === "id" ? 0 : 1, + minWidth: field === "id" ? 140 : 200, + sortable: true, + })); +}; + +const extractArray = (payload: AuditApiResponse): unknown[] => { + if (Array.isArray(payload)) { + return payload; + } + + for (const key of CANDIDATE_ARRAY_KEYS) { + const candidate = payload[key]; + if (Array.isArray(candidate)) { + return candidate; } } - const query = new URLSearchParams(safeParams).toString(); - const data = await getAudits({ query }); - return ; + 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, + }; + }); + +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([]); + + useEffect(() => { + const controller = new AbortController(); + + const fetchAudits = async () => { + setLoading(true); + setError(null); + + const sortParam = + sortModel.length && sortModel[0].field && sortModel[0].sort + ? `${sortModel[0].field}:${sortModel[0].sort}` + : undefined; + + try { + const payload = (await getAudits({ + limit: paginationModel.pageSize, + page: paginationModel.page + 1, + sort: sortParam, + signal: controller.signal, + })) as AuditApiResponse; + + const auditEntries = extractArray(payload); + const normalized = normalizeRows(auditEntries, paginationModel.page); + + setColumns(prev => + normalized.length + ? deriveColumns(normalized) + : prev.length + ? prev + : FALLBACK_COLUMNS + ); + + setRows(normalized); + setRowCount(resolveTotal(payload, normalized.length)); + } catch (err) { + if (controller.signal.aborted) return; + const message = + err instanceof Error ? err.message : "Failed to load audits"; + setError(message); + setRows([]); + setColumns(prev => (prev.length ? prev : FALLBACK_COLUMNS)); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + }; + + fetchAudits(); + + return () => controller.abort(); + }, [paginationModel, sortModel]); + + const handlePaginationChange = (model: GridPaginationModel) => { + setPaginationModel(model); + }; + + const handleSortModelChange = (model: GridSortModel) => { + setSortModel(model); + setPaginationModel(prev => ({ ...prev, page: 0 })); + }; + + const pageTitle = useMemo( + () => + sortModel.length && sortModel[0].field + ? `Audit Logs · sorted by ${toTitle(sortModel[0].field)}` + : "Audit Logs", + [sortModel] + ); + + return ( +
+

{pageTitle}

+ {error && ( +
+ {error} +
+ )} +
+
+
+ +
+
+
+
+ ); } diff --git a/app/dashboard/transactions/all/page.tsx b/app/dashboard/transactions/all/page.tsx index 7e6165e..1e1745a 100644 --- a/app/dashboard/transactions/all/page.tsx +++ b/app/dashboard/transactions/all/page.tsx @@ -7,36 +7,10 @@ import { selectFilters, selectPagination, selectSort, - selectStatus, - selectError, } from "@/app/redux/advanedSearch/selectors"; -import { GridColDef } from "@mui/x-data-grid"; -import Spinner from "@/app/components/Spinner/Spinner"; import { AppDispatch } from "@/app/redux/store"; -import { - setStatus, - setError as setAdvancedSearchError, -} from "@/app/redux/advanedSearch/advancedSearchSlice"; - -import { - TABLE_COLUMNS, - TABLE_SEARCH_LABELS, -} from "@/app/features/DataTable/constants"; - -interface TransactionRow { - id: number; - userId?: string; - transactionId: string; - type?: string; - currency?: string; - amount?: number; - status?: string; - dateTime?: string; - merchantId?: string; - pspId?: string; - methodId?: string; - modified?: string; -} +import { setError as setAdvancedSearchError } from "@/app/redux/advanedSearch/advancedSearchSlice"; +import { TransactionRow, BackendTransaction } from "../interface"; export default function AllTransactionPage() { const dispatch = useDispatch(); @@ -53,7 +27,6 @@ export default function AllTransactionPage() { // Fetch data when filters, pagination, or sort changes useEffect(() => { const fetchData = async () => { - dispatch(setStatus("loading")); dispatch(setAdvancedSearchError(null)); try { const response = await fetch("/api/dashboard/transactions", { @@ -71,8 +44,8 @@ export default function AllTransactionPage() { const backendData = await response.json(); const transactions = backendData.transactions || []; - const rows = transactions.map((tx: any) => ({ - id: tx.id, + const rows = transactions.map((tx: BackendTransaction) => ({ + id: tx.id || 0, userId: tx.customer, transactionId: tx.external_id || tx.id, type: tx.type, @@ -87,7 +60,6 @@ export default function AllTransactionPage() { })); setTableRows(rows); - dispatch(setStatus("succeeded")); } catch (error) { dispatch( setAdvancedSearchError( diff --git a/app/dashboard/transactions/deposits/page.tsx b/app/dashboard/transactions/deposits/page.tsx index aa589d0..eb59da3 100644 --- a/app/dashboard/transactions/deposits/page.tsx +++ b/app/dashboard/transactions/deposits/page.tsx @@ -1,23 +1,93 @@ +"use client"; + import DataTable from "@/app/features/DataTable/DataTable"; -import { getTransactions } from "@/app/services/transactions"; +import { useDispatch, useSelector } from "react-redux"; +import { AppDispatch } from "@/app/redux/store"; +import { + selectFilters, + selectPagination, + selectSort, +} from "@/app/redux/advanedSearch/selectors"; +import { + setStatus, + setError as setAdvancedSearchError, +} from "@/app/redux/advanedSearch/advancedSearchSlice"; +import { useEffect, useMemo, useState } from "react"; +import { TransactionRow, BackendTransaction } from "../interface"; -export default async function DepositTransactionPage({ - searchParams, -}: { - searchParams: Promise>; -}) { - // Await searchParams before processing - const params = await searchParams; - // Create a safe query string by filtering only string values - const safeParams: Record = {}; - for (const [key, value] of Object.entries(params)) { - if (typeof value === "string") { - safeParams[key] = value; - } - } - const query = new URLSearchParams(safeParams).toString(); - const transactionType = "deposits"; - const data = await getTransactions({ transactionType, query }); +export default function DepositTransactionPage() { + const dispatch = useDispatch(); + const filters = useSelector(selectFilters); + const pagination = useSelector(selectPagination); + const sort = useSelector(selectSort); + const [tableRows, setTableRows] = useState([]); - return ; + const memoizedRows = useMemo(() => tableRows, [tableRows]); + + const depositFilters = useMemo(() => { + return { + ...filters, + Type: { + operator: "==", + value: "deposit", + }, + }; + }, [filters]); + + useEffect(() => { + const fetchDeposits = async () => { + dispatch(setStatus("loading")); + dispatch(setAdvancedSearchError(null)); + try { + const response = await fetch("/api/dashboard/transactions/deposits", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filters: depositFilters, + pagination, + sort, + }), + }); + + if (!response.ok) { + dispatch(setAdvancedSearchError("Failed to fetch deposits")); + setTableRows([]); + return; + } + + const backendData = await response.json(); + const transactions: BackendTransaction[] = + backendData.transactions || []; + + const rows: TransactionRow[] = transactions.map(tx => ({ + id: tx.id, + userId: tx.customer, + transactionId: String(tx.external_id ?? tx.id), + type: tx.type, + currency: tx.currency, + amount: tx.amount, + status: tx.status, + dateTime: tx.created || tx.modified, + merchantId: tx.merchant_id, + pspId: tx.psp_id, + methodId: tx.method_id, + modified: tx.modified, + })); + + setTableRows(rows); + dispatch(setStatus("succeeded")); + } catch (error) { + dispatch( + setAdvancedSearchError( + error instanceof Error ? error.message : "Unknown error" + ) + ); + setTableRows([]); + } + }; + + fetchDeposits(); + }, [dispatch, depositFilters, pagination, sort]); + + return ; } diff --git a/app/dashboard/transactions/interface.ts b/app/dashboard/transactions/interface.ts new file mode 100644 index 0000000..4392876 --- /dev/null +++ b/app/dashboard/transactions/interface.ts @@ -0,0 +1,29 @@ +export interface TransactionRow { + id: number; + userId?: string; + transactionId: string; + type?: string; + currency?: string; + amount?: number; + status?: string; + dateTime?: string; + merchantId?: string; + pspId?: string; + methodId?: string; + modified?: string; +} + +export interface BackendTransaction { + id: number; + customer?: string; + external_id?: string; + type?: string; + currency?: string; + amount?: number; + status?: string; + created?: string; + modified?: string; + merchant_id?: string; + psp_id?: string; + method_id?: string; +} diff --git a/app/features/DataTable/DataTable.tsx b/app/features/DataTable/DataTable.tsx index e897677..0f755f9 100644 --- a/app/features/DataTable/DataTable.tsx +++ b/app/features/DataTable/DataTable.tsx @@ -1,148 +1,113 @@ "use client"; -import React, { useState, useMemo } from "react"; -import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; -import { Box, Paper, IconButton, Alert } from "@mui/material"; -import OpenInNewIcon from "@mui/icons-material/OpenInNew"; -import StatusChangeDialog from "./StatusChangeDialog"; +import React, { useState, useEffect, useCallback } from "react"; +import { DataGrid } from "@mui/x-data-grid"; +import { Box, Paper, Alert } from "@mui/material"; import DataTableHeader from "./DataTableHeader"; -import { TABLE_COLUMNS } from "./constants"; +import StatusChangeDialog from "./StatusChangeDialog"; import Spinner from "@/app/components/Spinner/Spinner"; +import { selectStatus, selectError } from "@/app/redux/advanedSearch/selectors"; +import { selectEnhancedColumns } from "./re-selectors"; import { useSelector } from "react-redux"; -import { selectStatus, selectError } from "@/app/redux/auth/selectors"; +import { DataRowBase } from "./types"; -interface IDataTableProps { +interface DataTableProps { rows: TRow[]; extraColumns?: string[]; + enableStatusActions?: boolean; } -const DataTable = ({ +const DataTable = ({ rows, extraColumns, -}: IDataTableProps) => { + enableStatusActions = false, +}: DataTableProps) => { + const [showExtraColumns, setShowExtraColumns] = useState(false); + const [localRows, setLocalRows] = useState(rows); const [modalOpen, setModalOpen] = useState(false); const [selectedRowId, setSelectedRowId] = useState(null); - const [newStatus, setNewStatus] = useState(""); + const [pendingStatus, setPendingStatus] = useState(""); const [reason, setReason] = useState(""); - const [showExtraColumns, setShowExtraColumns] = useState(false); + const [statusUpdateError, setStatusUpdateError] = useState( + null + ); + const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const status = useSelector(selectStatus); const errorMessage = useSelector(selectError); + useEffect(() => { + setLocalRows(rows); + }, [rows]); - // Open status modal - const handleStatusChange = (id: number, status: string) => { - setSelectedRowId(id); - setNewStatus(status); + const handleStatusChange = useCallback((rowId: number, newStatus: string) => { + setSelectedRowId(rowId); + setPendingStatus(newStatus); setModalOpen(true); - }; + }, []); - const handleStatusSave = () => { - setModalOpen(false); - setReason(""); - // rows update should happen in parent component - }; + const handleStatusSave = async () => { + if (!selectedRowId || !pendingStatus) return; + setStatusUpdateError(null); + setIsUpdatingStatus(true); - // Columns filtered by extraColumns toggle - const visibleColumns = useMemo(() => { - if (!extraColumns || extraColumns.length === 0) return TABLE_COLUMNS; - return showExtraColumns - ? TABLE_COLUMNS - : TABLE_COLUMNS.filter(col => !extraColumns.includes(col.field)); - }, [extraColumns, showExtraColumns]); + 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" + ); + } + + setLocalRows(prev => + prev.map(row => + row.id === selectedRowId ? { ...row, status: pendingStatus } : row + ) + ); + setModalOpen(false); + setReason(""); + setPendingStatus(""); + setStatusUpdateError(null); + setSelectedRowId(null); + } catch (err) { + setStatusUpdateError( + err instanceof Error ? err.message : "Failed to update transaction" + ); + } finally { + setIsUpdatingStatus(false); + } + }; // Columns with custom renderers - const enhancedColumns = useMemo(() => { - return visibleColumns.map(col => { - if (col.field === "status") { - return { - ...col, - renderCell: (params: GridRenderCellParams) => { - const value = params.value?.toLowerCase(); - let bgColor = "#e0e0e0"; - let textColor = "#000"; - switch (value) { - case "completed": - bgColor = "#d0f0c0"; - textColor = "#1b5e20"; - break; - case "pending": - bgColor = "#fff4cc"; - textColor = "#9e7700"; - break; - case "inprogress": - bgColor = "#cce5ff"; - textColor = "#004085"; - break; - case "error": - bgColor = "#ffcdd2"; - textColor = "#c62828"; - break; - } - return ( - - {params.value} - - ); - }, - }; - } - - if (col.field === "userId") { - return { - ...col, - headerAlign: "center", - align: "center", - renderCell: (params: GridRenderCellParams) => ( - e.stopPropagation()} - > - - {params.value} - - e.stopPropagation()} - > - - - - ), - }; - } - - return col; - }); - }, [visibleColumns]); + const enhancedColumns = useSelector(state => + selectEnhancedColumns( + state, + enableStatusActions, + extraColumns, + showExtraColumns, + localRows, + handleStatusChange + ) + ); return ( <> @@ -163,7 +128,7 @@ const DataTable = ({ ({ setModalOpen(false)} + handleClose={() => { + setModalOpen(false); + setReason(""); + setPendingStatus(""); + setStatusUpdateError(null); + setSelectedRowId(null); + }} handleSave={handleStatusSave} + isSubmitting={isUpdatingStatus} + errorMessage={statusUpdateError} /> diff --git a/app/features/DataTable/StatusChangeDialog.tsx b/app/features/DataTable/StatusChangeDialog.tsx index 2ffd269..aa66c8e 100644 --- a/app/features/DataTable/StatusChangeDialog.tsx +++ b/app/features/DataTable/StatusChangeDialog.tsx @@ -5,6 +5,8 @@ import { DialogActions, Button, TextField, + Alert, + CircularProgress, } from "@mui/material"; import { useState, useEffect } from "react"; @@ -15,6 +17,8 @@ interface StatusChangeDialogProps { setReason: React.Dispatch>; handleClose: () => void; handleSave: () => void; + isSubmitting?: boolean; + errorMessage?: string | null; } const StatusChangeDialog = ({ @@ -24,17 +28,19 @@ const StatusChangeDialog = ({ setReason, handleClose, handleSave, + isSubmitting = false, + errorMessage, }: StatusChangeDialogProps) => { const [isValid, setIsValid] = useState(false); useEffect(() => { - const noSpaces = reason.replace(/\s/g, ""); // remove all spaces + const noSpaces = reason.replace(/\s/g, ""); const length = noSpaces.length; setIsValid(length >= 12 && length <= 400); }, [reason]); return ( - + Change Status You want to change the status to {newStatus}. Please provide a @@ -50,10 +56,22 @@ const StatusChangeDialog = ({ helperText="Reason must be between 12 and 400 characters" sx={{ mt: 2 }} /> + {errorMessage && ( + + {errorMessage} + + )} - - + diff --git a/app/features/DataTable/re-selectors/index.tsx b/app/features/DataTable/re-selectors/index.tsx new file mode 100644 index 0000000..2ac907c --- /dev/null +++ b/app/features/DataTable/re-selectors/index.tsx @@ -0,0 +1,238 @@ +import { createSelector } from "@reduxjs/toolkit"; +import { Box, IconButton, MenuItem, Select } from "@mui/material"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { RootState } from "@/app/redux/store"; +import { TABLE_COLUMNS } from "../constants"; +import { selectTransactionStatuses } from "@/app/redux/metadata/selectors"; +import { DataRowBase } from "../types"; + +const TRANSACTION_STATUS_FALLBACK: string[] = [ + "pending", + "completed", + "failed", + "inprogress", + "error", +]; + +type StatusChangeHandler = (rowId: number, newStatus: string) => void; + +const selectEnableStatusActions = ( + _state: RootState, + enableStatusActions: boolean +) => enableStatusActions; + +const selectExtraColumns = ( + _state: RootState, + _enableStatusActions: boolean, + extraColumns?: string[] | null +) => extraColumns ?? null; + +const selectShowExtraColumns = ( + _state: RootState, + _enableStatusActions: boolean, + _extraColumns?: string[] | null, + showExtraColumns = false +) => showExtraColumns; + +const selectLocalRows = ( + _state: RootState, + _enableStatusActions: boolean, + _extraColumns?: string[] | null, + _showExtraColumns?: boolean, + localRows?: DataRowBase[] +) => localRows ?? []; + +const noopStatusChangeHandler: StatusChangeHandler = () => {}; + +const selectStatusChangeHandler = ( + _state: RootState, + _enableStatusActions: boolean, + _extraColumns?: string[] | null, + _showExtraColumns?: boolean, + _localRows?: DataRowBase[], + handleStatusChange?: StatusChangeHandler +) => handleStatusChange ?? noopStatusChangeHandler; + +export const selectBaseColumns = createSelector( + [selectEnableStatusActions], + enableStatusActions => { + if (!enableStatusActions) { + return TABLE_COLUMNS; + } + + return [ + ...TABLE_COLUMNS, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + } as GridColDef, + ]; + } +); + +export const selectVisibleColumns = createSelector( + [selectBaseColumns, selectExtraColumns, selectShowExtraColumns], + (baseColumns, extraColumns, showExtraColumns) => { + if (!extraColumns || extraColumns.length === 0) { + return baseColumns; + } + + return showExtraColumns + ? baseColumns + : baseColumns.filter(col => !extraColumns.includes(col.field)); + } +); + +export const selectResolvedTransactionStatuses = createSelector( + [selectTransactionStatuses], + statuses => (statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK) +); + +export const selectEnhancedColumns = createSelector( + [ + selectVisibleColumns, + selectLocalRows, + selectStatusChangeHandler, + selectResolvedTransactionStatuses, + ], + ( + visibleColumns, + localRows, + handleStatusChange, + resolvedStatusOptions + ): GridColDef[] => { + return visibleColumns.map(col => { + if (col.field === "status") { + return { + ...col, + renderCell: (params: GridRenderCellParams) => { + const value = params.value?.toLowerCase(); + let bgColor = "#e0e0e0"; + let textColor = "#000"; + switch (value) { + case "completed": + bgColor = "#d0f0c0"; + textColor = "#1b5e20"; + break; + case "pending": + bgColor = "#fff4cc"; + textColor = "#9e7700"; + break; + case "inprogress": + bgColor = "#cce5ff"; + textColor = "#004085"; + break; + case "error": + bgColor = "#ffcdd2"; + textColor = "#c62828"; + break; + } + return ( + + {params.value} + + ); + }, + }; + } + + if (col.field === "userId") { + return { + ...col, + headerAlign: "center", + align: "center", + renderCell: (params: GridRenderCellParams) => ( + e.stopPropagation()} + > + + {params.value} + + e.stopPropagation()} + > + + + + ), + }; + } + + if (col.field === "actions") { + return { + ...col, + renderCell: (params: GridRenderCellParams) => { + const currentRow = localRows.find(row => row.id === params.id); + const options = + currentRow?.options?.map(option => option.value) ?? + resolvedStatusOptions; + const uniqueOptions: string[] = Array.from(new Set(options)); + + return ( + + value={currentRow?.status ?? ""} + onChange={e => + handleStatusChange( + params.id as number, + e.target.value as string + ) + } + size="small" + fullWidth + displayEmpty + sx={{ + "& .MuiOutlinedInput-notchedOutline": { border: "none" }, + "& .MuiSelect-select": { py: 0.5 }, + }} + onClick={e => e.stopPropagation()} + > + {uniqueOptions.map(option => ( + + {option} + + ))} + + ); + }, + }; + } + + return col; + }) as GridColDef[]; + } +); diff --git a/app/features/DataTable/types.ts b/app/features/DataTable/types.ts index a104fa4..5cc2efe 100644 --- a/app/features/DataTable/types.ts +++ b/app/features/DataTable/types.ts @@ -11,3 +11,9 @@ export interface IDataTable { tableSearchLabels: ISearchLabel[]; extraColumns: string[]; } + +export interface DataRowBase { + id: number; + status?: string; + options?: { value: string; label: string }[]; +} diff --git a/app/features/dashboard/header/dropDown/DropDown.tsx b/app/features/dashboard/header/dropDown/DropDown.tsx index 773f0a9..f81b600 100644 --- a/app/features/dashboard/header/dropDown/DropDown.tsx +++ b/app/features/dashboard/header/dropDown/DropDown.tsx @@ -7,7 +7,6 @@ import { SelectChangeEvent, } from "@mui/material"; import PageLinks from "../../../../components/PageLinks/PageLinks"; -import { SidebarItem } from "@/app/redux/metadata/metadataSlice"; import { useSelector } from "react-redux"; import { selectNavigationSidebar } from "@/app/redux/metadata/selectors"; import "./DropDown.scss"; diff --git a/app/features/dashboard/sidebar/Sidebar.tsx b/app/features/dashboard/sidebar/Sidebar.tsx index 0114135..7d1edbe 100644 --- a/app/features/dashboard/sidebar/Sidebar.tsx +++ b/app/features/dashboard/sidebar/Sidebar.tsx @@ -9,7 +9,7 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { selectNavigationSidebar } from "@/app/redux/metadata/selectors"; -import { SidebarItem } from "@/app/redux/metadata/metadataSlice"; +import { SidebarLink } from "@/app/redux/metadata/metadataSlice"; import { resolveIcon } from "@/app/utils/iconMap"; import "./sideBar.scss"; @@ -20,7 +20,7 @@ interface SidebarProps { const SideBar = ({ isOpen = true, onClose }: SidebarProps) => { const [openMenus, setOpenMenus] = useState>({}); - const sidebar = useSelector(selectNavigationSidebar)?.links; + const sidebar = useSelector(selectNavigationSidebar); const toggleMenu = (title: string) => { setOpenMenus(prev => ({ ...prev, [title]: !prev[title] })); }; @@ -32,12 +32,12 @@ const SideBar = ({ isOpen = true, onClose }: SidebarProps) => {
- Betrise cashier + Cashier
- {sidebar?.map((link: SidebarItem) => { + {sidebar?.map((link: SidebarLink) => { if (link.children) { const Icon = resolveIcon(link.icon as string); return ( diff --git a/app/features/dashboard/sidebar/sideBar.scss b/app/features/dashboard/sidebar/sideBar.scss index b7113a9..b51770b 100644 --- a/app/features/dashboard/sidebar/sideBar.scss +++ b/app/features/dashboard/sidebar/sideBar.scss @@ -10,13 +10,22 @@ flex-direction: column; padding: 16px; z-index: 1100; - border-right: 1px solid #333; + border-right: 1px solid #835454; transition: transform 0.3s ease-in-out; overflow: hidden; + .sidebar__submenu { + overflow: hidden; + max-height: 200px; + transition: max-height 0.3s ease-in-out; + overflow-y: auto; + &:hover { + max-height: 300px; + } + } + &--collapsed { transform: translateX(-210px); // Hide 90% (210px out of 240px) - .sidebar__header, .sidebar__dropdown-button, .sidebar__submenu, @@ -153,3 +162,9 @@ display: none; } } + +/* Track (background behind the thumb) */ +.scrollable-div::-webkit-scrollbar-track { + background: #f0f0f0; + border-radius: 5px; +} diff --git a/app/redux/metadata/metadataSlice.ts b/app/redux/metadata/metadataSlice.ts index fbc3518..496c652 100644 --- a/app/redux/metadata/metadataSlice.ts +++ b/app/redux/metadata/metadataSlice.ts @@ -1,21 +1,30 @@ import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; -export interface SidebarItem { +export interface SidebarLink { id?: string; title: string; path: string; - icon?: string; // icon name from backend; map client-side if needed - permissions?: string[]; // required permissions for visibility - children?: SidebarItem[]; + icon?: string; + groups?: string[]; + children?: SidebarLink[]; } +export interface SidebarPayload { + links: SidebarLink[]; +} + +export type FieldGroupMap = Record>; + export interface AppMetadata { - groups?: string[]; - job_titles?: string[]; - merchants?: string[]; - message?: string; - sidebar?: SidebarItem[]; success: boolean; + message?: string; + field_names?: FieldGroupMap; + job_titles?: string[]; + groups?: string[]; + merchants?: string[]; + countries?: string[]; + sidebar?: SidebarPayload; + transaction_status?: string[]; } interface MetadataState { diff --git a/app/redux/metadata/selectors.ts b/app/redux/metadata/selectors.ts index 36adaaf..b70e490 100644 --- a/app/redux/metadata/selectors.ts +++ b/app/redux/metadata/selectors.ts @@ -1,7 +1,35 @@ import { RootState } from "../store"; +import { FieldGroupMap, SidebarLink } from "./metadataSlice"; -// Selectors export const selectMetadataState = (state: RootState) => state.metadata; -export const selectAppMetadata = (state: RootState) => state.metadata.data; -export const selectNavigationSidebar = (state: RootState) => - state.metadata.data?.sidebar || []; + +export const selectMetadataStatus = (state: RootState) => + state.metadata?.status; + +export const selectMetadataError = (state: RootState) => state.metadata?.error; + +export const selectAppMetadata = (state: RootState) => state.metadata?.data; + +export const selectFieldNames = (state: RootState): FieldGroupMap | undefined => + state.metadata.data?.field_names; + +export const selectSidebarLinks = (state: RootState): SidebarLink[] => + state.metadata.data?.sidebar?.links ?? []; + +export const selectJobTitles = (state: RootState): string[] => + state.metadata.data?.job_titles ?? []; + +export const selectGroups = (state: RootState): string[] => + state.metadata.data?.groups ?? []; + +export const selectMerchants = (state: RootState): string[] => + state.metadata.data?.merchants ?? []; + +export const selectCountries = (state: RootState): string[] => + state.metadata.data?.countries ?? []; + +export const selectTransactionStatuses = (state: RootState): string[] => + state.metadata.data?.transaction_status ?? []; + +export const selectNavigationSidebar = (state: RootState): SidebarLink[] => + state.metadata.data?.sidebar?.links ?? []; diff --git a/app/services/audits.ts b/app/services/audits.ts index ff8a81b..9723e26 100644 --- a/app/services/audits.ts +++ b/app/services/audits.ts @@ -1,18 +1,41 @@ -export async function getAudits({ query }: { query: string }) { - const res = await fetch( - `http://localhost:4000/api/dashboard/audits?${query}`, +interface GetAuditsParams { + limit?: number; + page?: number; + sort?: string; + filter?: string; + signal?: AbortSignal; +} + +export async function getAudits({ + limit, + page, + sort, + filter, + signal, +}: GetAuditsParams = {}) { + const params = new URLSearchParams(); + + if (limit) params.set("limit", String(limit)); + if (page) params.set("page", String(page)); + if (sort) params.set("sort", sort); + if (filter) params.set("filter", filter); + + const queryString = params.toString(); + const response = await fetch( + `/api/dashboard/audits${queryString ? `?${queryString}` : ""}`, { + method: "GET", cache: "no-store", + signal, } ); - if (!res.ok) { - // Handle error from the API - const errorData = await res + if (!response.ok) { + const errorData = await response .json() .catch(() => ({ message: "Unknown error" })); - throw new Error(errorData.message || `HTTP error! status: ${res.status}`); + throw new Error(errorData.message || "Failed to fetch audits"); } - return res.json(); + return response.json(); } diff --git a/app/utils/iconMap.ts b/app/utils/iconMap.ts index f91ce5a..014b96c 100644 --- a/app/utils/iconMap.ts +++ b/app/utils/iconMap.ts @@ -18,9 +18,16 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import HistoryIcon from "@mui/icons-material/History"; import FactCheckIcon from "@mui/icons-material/FactCheck"; import WidgetsIcon from "@mui/icons-material/Widgets"; // fallback +import GroupIcon from "@mui/icons-material/Group"; +import SecurityIcon from "@mui/icons-material/Security"; +import TimerIcon from "@mui/icons-material/Timer"; +import StoreIcon from "@mui/icons-material/Store"; +import AccountBalanceIcon from "@mui/icons-material/AccountBalance"; +import PaymentIcon from "@mui/icons-material/Payment"; +import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange"; +import ViewSidebarIcon from "@mui/icons-material/ViewSidebar"; -// Map string keys from backend to actual Icon components -const iconRegistry: Record = { +const IconMap = { HomeIcon, AccountBalanceWalletIcon, CheckCircleIcon, @@ -37,6 +44,21 @@ const iconRegistry: Record = { ArrowUpwardIcon, HistoryIcon, FactCheckIcon, + WidgetsIcon, + GroupIcon, + SecurityIcon, + TimerIcon, + StoreIcon, + AccountBalanceIcon, + PaymentIcon, + CurrencyExchangeIcon, + ViewSidebarIcon, +}; + +export default IconMap; +// Map string keys from backend to actual Icon components +const iconRegistry: Record = { + ...IconMap, }; export function resolveIcon(