diff --git a/app/api/auth/change-password/route.ts b/app/api/auth/change-password/route.ts index 559d50c..399aaa4 100644 --- a/app/api/auth/change-password/route.ts +++ b/app/api/auth/change-password/route.ts @@ -24,7 +24,14 @@ export async function POST(request: Request) { let mustChangePassword = false; try { const payload = decodeJwt(token); - mustChangePassword = payload.MustChangePassword || false; + const mustChangeClaim = payload.MustChangePassword; + if (typeof mustChangeClaim === "boolean") { + mustChangePassword = mustChangeClaim; + } else if (typeof mustChangeClaim === "string") { + mustChangePassword = mustChangeClaim.toLowerCase() === "true"; + } else { + mustChangePassword = false; + } } catch (err) { console.error("โŒ Failed to decode current JWT:", err); } diff --git a/app/api/auth/login/route.tsx b/app/api/auth/login/route.tsx index ffbe0d2..de83096 100644 --- a/app/api/auth/login/route.tsx +++ b/app/api/auth/login/route.tsx @@ -16,6 +16,8 @@ export async function POST(request: Request) { body: JSON.stringify({ email, password }), }); + console.log("[LOGIN] resp", resp); + if (!resp.ok) { const errJson = await safeJson(resp); return NextResponse.json( diff --git a/app/api/auth/reset-password/[id]/route.ts b/app/api/auth/reset-password/[id]/route.ts index 8f49468..75c63bd 100644 --- a/app/api/auth/reset-password/[id]/route.ts +++ b/app/api/auth/reset-password/[id]/route.ts @@ -1,15 +1,17 @@ -import { NextResponse } from "next/server"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { NextResponse, type NextRequest } from "next/server"; import { cookies } from "next/headers"; const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:8583"; const COOKIE_NAME = "auth_token"; export async function PUT( - request: Request, - { params }: { params: { id: string } } + request: NextRequest, + context: { params: Promise<{ id: string }> } ) { try { - const { id } = params; + const { id } = await context.params; if (!id) { return NextResponse.json( @@ -39,15 +41,14 @@ export async function PUT( } ); - // Attempt to parse JSON; fall back to status-only response - let data: unknown = null; + let data; try { data = await resp.json(); } catch { data = { success: resp.ok }; } - return NextResponse.json(data ?? { success: resp.ok }, { + return NextResponse.json(data, { status: resp.status, }); } catch (error: unknown) { diff --git a/app/api/dashboard/admin/groups/route.ts b/app/api/dashboard/admin/groups/route.ts new file mode 100644 index 0000000..a7c6daa --- /dev/null +++ b/app/api/dashboard/admin/groups/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildFilterParam } from "../utils"; + +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + + const queryParams = new URLSearchParams(); + queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("page", String(pagination.page ?? 1)); + + if (sort?.field && sort?.order) { + queryParams.set("sort", `${sort.field}:${sort.order}`); + } + + const filterParam = buildFilterParam(filters); + if (filterParam) { + queryParams.set("filter", filterParam); + } + + const backendUrl = `${BE_BASE_URL}/api/v1/groups${ + queryParams.size ? `?${queryParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch groups" })); + + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch groups", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error("Proxy POST /api/dashboard/admin/groups error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/permissions/route.ts b/app/api/dashboard/admin/permissions/route.ts new file mode 100644 index 0000000..8fb217d --- /dev/null +++ b/app/api/dashboard/admin/permissions/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildFilterParam } from "../utils"; + +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + + const queryParams = new URLSearchParams(); + queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("page", String(pagination.page ?? 1)); + + if (sort?.field && sort?.order) { + queryParams.set("sort", `${sort.field}:${sort.order}`); + } + + const filterParam = buildFilterParam(filters); + if (filterParam) { + queryParams.set("filter", filterParam); + } + + const backendUrl = `${BE_BASE_URL}/api/v1/permissions${ + queryParams.size ? `?${queryParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch permissions" })); + + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch permissions", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error("Proxy POST /api/dashboard/admin/permissions error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/sessions/route.ts b/app/api/dashboard/admin/sessions/route.ts new file mode 100644 index 0000000..17049c6 --- /dev/null +++ b/app/api/dashboard/admin/sessions/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildFilterParam } from "../utils"; + +const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000"; +const COOKIE_NAME = "auth_token"; + +export async function POST(request: NextRequest) { + try { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + + if (!token) { + return NextResponse.json( + { message: "Missing Authorization header" }, + { status: 401 } + ); + } + + const body = await request.json(); + const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body; + + const queryParams = new URLSearchParams(); + queryParams.set("limit", String(pagination.limit ?? 10)); + queryParams.set("page", String(pagination.page ?? 1)); + + if (sort?.field && sort?.order) { + queryParams.set("sort", `${sort.field}:${sort.order}`); + } + + const filterParam = buildFilterParam(filters); + if (filterParam) { + queryParams.set("filter", filterParam); + } + + const backendUrl = `${BE_BASE_URL}/api/v1/sessions${ + queryParams.size ? `?${queryParams.toString()}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch sessions" })); + + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch sessions", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error("Proxy POST /api/dashboard/admin/sessions error:", err); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/dashboard/admin/users/[id]/route.ts b/app/api/dashboard/admin/users/[id]/route.ts index 50f15fc..9a9c5df 100644 --- a/app/api/dashboard/admin/users/[id]/route.ts +++ b/app/api/dashboard/admin/users/[id]/route.ts @@ -63,10 +63,10 @@ function transformUserUpdateData(updates: Record): { export async function PUT( request: Request, - { params }: { params: { id: string } } + context: { params: Promise<{ id: string }> } ) { try { - const { id } = await params; + const { id } = await context.params; const body = await request.json(); // Transform the request body to match backend format @@ -108,10 +108,10 @@ export async function PUT( export async function DELETE( _request: Request, - { params }: { params: { id: string } } + context: { params: Promise<{ id: string }> } ) { try { - const { id } = await params; + const { id } = await context.params; const { cookies } = await import("next/headers"); const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; diff --git a/app/api/dashboard/admin/utils.ts b/app/api/dashboard/admin/utils.ts new file mode 100644 index 0000000..ca70038 --- /dev/null +++ b/app/api/dashboard/admin/utils.ts @@ -0,0 +1,34 @@ +export type FilterValue = + | string + | { + operator?: string; + value: string; + }; + +export const buildFilterParam = (filters: Record) => { + const filterExpressions: string[] = []; + + for (const [key, filterValue] of Object.entries(filters)) { + if (!filterValue) continue; + + let operator = "=="; + let value: string; + + if (typeof filterValue === "string") { + value = filterValue; + } else { + operator = filterValue.operator || "=="; + value = filterValue.value; + } + + if (!value) continue; + + const encodedValue = encodeURIComponent(value); + const needsEqualsPrefix = /^[A-Za-z]/.test(operator); + const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator; + + filterExpressions.push(`${key}${operatorSegment}/${encodedValue}`); + } + + return filterExpressions.length > 0 ? filterExpressions.join(",") : undefined; +}; diff --git a/app/api/dashboard/transactions/[id]/route.ts b/app/api/dashboard/transactions/[id]/route.ts index bca9543..2702f90 100644 --- a/app/api/dashboard/transactions/[id]/route.ts +++ b/app/api/dashboard/transactions/[id]/route.ts @@ -5,10 +5,10 @@ const COOKIE_NAME = "auth_token"; export async function PUT( request: NextRequest, - { params }: { params: { id: string } } + context: { params: Promise<{ id: string }> } ) { try { - const { id } = await params; + const { id } = await context.params; const { cookies } = await import("next/headers"); const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; diff --git a/app/api/dashboard/transactions/route.ts b/app/api/dashboard/transactions/route.ts index 2a2348a..c297e66 100644 --- a/app/api/dashboard/transactions/route.ts +++ b/app/api/dashboard/transactions/route.ts @@ -32,10 +32,32 @@ export async function POST(request: NextRequest) { queryParts.push(`sort=${sort.field}:${sort.order}`); } + // Track date ranges separately so we can emit BETWEEN/>/< syntax + const dateRanges: Record = {}; + // Process filters - convert FilterValue objects to operator/value format for (const [key, filterValue] of Object.entries(filters)) { if (!filterValue) continue; + // Handle date range helpers (e.g. Created_start / Created_end) + if (/_start$|_end$/.test(key)) { + const baseField = key.replace(/_(start|end)$/, ""); + if (!dateRanges[baseField]) { + dateRanges[baseField] = {}; + } + + const targetKey = key.endsWith("_start") ? "start" : "end"; + const stringValue = + typeof filterValue === "string" + ? filterValue + : (filterValue as { value?: string }).value; + + if (stringValue) { + dateRanges[baseField][targetKey] = stringValue; + } + continue; + } + let op: string; let value: string; @@ -57,6 +79,24 @@ export async function POST(request: NextRequest) { queryParts.push(`${key}=${op}/${encodedValue}`); } + // Emit date range filters using backend format + for (const [field, { start, end }] of Object.entries(dateRanges)) { + if (start && end) { + queryParts.push( + `${field}=BETWEEN/${encodeURIComponent(start)}/${encodeURIComponent( + end + )}` + ); + continue; + } + + if (start) { + queryParts.push(`${field}=>/${encodeURIComponent(start)}`); + } else if (end) { + queryParts.push(`${field}= = { + ...filters, + Type: { + operator: "==", + value: "withdrawal", + }, + }; + + const queryParts: string[] = []; + queryParts.push(`limit=${pagination.limit}`); + queryParts.push(`page=${pagination.page}`); + + if (sort) { + queryParts.push(`sort=${sort.field}:${sort.order}`); + } + + for (const [key, filterValue] of Object.entries(mergedFilters)) { + if (!filterValue) continue; + + let operator: string; + let value: string; + + if (typeof filterValue === "string") { + operator = "=="; + value = filterValue; + } else { + operator = filterValue.operator || "=="; + value = filterValue.value; + } + + if (!value) continue; + + const encodedValue = encodeURIComponent(value); + const needsEqualsPrefix = /^[A-Za-z]/.test(operator); + const operatorSegment = needsEqualsPrefix ? `=${operator}` : operator; + + queryParts.push(`${key}${operatorSegment}/${encodedValue}`); + } + + const queryString = queryParts.join("&"); + const backendUrl = `${BE_BASE_URL}/api/v1/transactions${ + queryString ? `?${queryString}` : "" + }`; + + const response = await fetch(backendUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + cache: "no-store", + }); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ message: "Failed to fetch withdrawals" })); + return NextResponse.json( + { + success: false, + message: errorData?.message || "Failed to fetch withdrawals", + }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: response.status }); + } catch (err: unknown) { + console.error( + "Proxy POST /api/dashboard/transactions/withdrawals error:", + err + ); + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ); + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index e69de29..89ac520 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; + +/** + * Placeholder Settings API route. + * Keeps the module valid while the real implementation + * is being built, and makes the intent obvious to clients. + */ +export async function GET() { + return NextResponse.json( + { message: "Settings endpoint not implemented" }, + { status: 501 } + ); +} diff --git a/app/components/TestTokenExpiration.tsx b/app/components/TestTokenExpiration.tsx index f4e67c9..1fdd530 100644 --- a/app/components/TestTokenExpiration.tsx +++ b/app/components/TestTokenExpiration.tsx @@ -4,7 +4,7 @@ import React from "react"; import { useDispatch } from "react-redux"; import { Button, Box, Typography, Stack } from "@mui/material"; import { AppDispatch } from "@/app/redux/types"; -import { autoLogout, refreshAuthStatus } from "@/app/redux/auth/authSlice"; +import { autoLogout, validateAuth } from "@/app/redux/auth/authSlice"; export default function TestTokenExpiration() { const dispatch = useDispatch(); @@ -14,7 +14,7 @@ export default function TestTokenExpiration() { }; const handleRefreshAuth = () => { - dispatch(refreshAuthStatus()); + dispatch(validateAuth()); }; return ( diff --git a/app/dashboard/admin/groups/page.tsx b/app/dashboard/admin/groups/page.tsx new file mode 100644 index 0000000..f2e668d --- /dev/null +++ b/app/dashboard/admin/groups/page.tsx @@ -0,0 +1,13 @@ +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; + +export default function GroupsPage() { + return ( + + ); +} diff --git a/app/dashboard/admin/permissions/page.tsx b/app/dashboard/admin/permissions/page.tsx new file mode 100644 index 0000000..cde9367 --- /dev/null +++ b/app/dashboard/admin/permissions/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; + +export default function PermissionsPage() { + return ( + + ); +} diff --git a/app/dashboard/admin/sessions/page.tsx b/app/dashboard/admin/sessions/page.tsx new file mode 100644 index 0000000..8d750b6 --- /dev/null +++ b/app/dashboard/admin/sessions/page.tsx @@ -0,0 +1,13 @@ +import AdminResourceList from "@/app/features/AdminList/AdminResourceList"; + +export default function SessionsPage() { + return ( + + ); +} diff --git a/app/dashboard/audits/page.scss b/app/dashboard/audits/page.scss index e8f142e..4951157 100644 --- a/app/dashboard/audits/page.scss +++ b/app/dashboard/audits/page.scss @@ -30,7 +30,7 @@ overflow: hidden; .scroll-wrapper { - width: 100dvw; + width: 85dvw; overflow-x: auto; overflow-y: hidden; diff --git a/app/dashboard/audits/page.tsx b/app/dashboard/audits/page.tsx index 10a804f..5217065 100644 --- a/app/dashboard/audits/page.tsx +++ b/app/dashboard/audits/page.tsx @@ -9,6 +9,8 @@ import { } 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 }; @@ -178,6 +180,8 @@ export default function AuditPage() { pageSize: DEFAULT_PAGE_SIZE, }); const [sortModel, setSortModel] = useState([]); + const [entitySearch, setEntitySearch] = useState(""); + const [entitySearchInput, setEntitySearchInput] = useState(""); useEffect(() => { const controller = new AbortController(); @@ -191,11 +195,16 @@ export default function AuditPage() { ? `${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; @@ -229,7 +238,7 @@ export default function AuditPage() { fetchAudits(); return () => controller.abort(); - }, [paginationModel, sortModel]); + }, [paginationModel, sortModel, entitySearch]); const handlePaginationChange = (model: GridPaginationModel) => { setPaginationModel(model); @@ -240,6 +249,26 @@ export default function AuditPage() { 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 @@ -250,6 +279,16 @@ export default function AuditPage() { return (
+ + handleEntitySearchChange(e.target.value)} + sx={{ width: 300, backgroundColor: "#f0f0f0" }} + /> +

{pageTitle}

{error && (
diff --git a/app/dashboard/transactions/all/page.tsx b/app/dashboard/transactions/all/page.tsx index 1e1745a..637b155 100644 --- a/app/dashboard/transactions/all/page.tsx +++ b/app/dashboard/transactions/all/page.tsx @@ -18,11 +18,17 @@ export default function AllTransactionPage() { const pagination = useSelector(selectPagination); const sort = useSelector(selectSort); - const [tableRows, setTableRows] = useState([]); + const [tableData, setTableData] = useState<{ + transactions: TransactionRow[]; + total: number; + }>({ transactions: [], total: 0 }); const extraColumns: string[] = []; // static for now // Memoize rows to avoid new reference each render - const memoizedRows = useMemo(() => tableRows, [tableRows]); + const memoizedRows = useMemo( + () => tableData.transactions, + [tableData.transactions] + ); // Fetch data when filters, pagination, or sort changes useEffect(() => { @@ -37,12 +43,12 @@ export default function AllTransactionPage() { if (!response.ok) { dispatch(setAdvancedSearchError("Failed to fetch transactions")); - setTableRows([]); + setTableData({ transactions: [], total: 0 }); return; } - const backendData = await response.json(); - const transactions = backendData.transactions || []; + const data = await response.json(); + const transactions = data.transactions || []; const rows = transactions.map((tx: BackendTransaction) => ({ id: tx.id || 0, @@ -59,19 +65,25 @@ export default function AllTransactionPage() { modified: tx.modified, })); - setTableRows(rows); + setTableData({ transactions: rows, total: data?.total }); } catch (error) { dispatch( setAdvancedSearchError( error instanceof Error ? error.message : "Unknown error" ) ); - setTableRows([]); + setTableData({ transactions: [], total: 0 }); } }; fetchData(); }, [dispatch, filters, pagination, sort]); - return ; + return ( + + ); } diff --git a/app/dashboard/transactions/deposits/page.tsx b/app/dashboard/transactions/deposits/page.tsx index eb59da3..97d269f 100644 --- a/app/dashboard/transactions/deposits/page.tsx +++ b/app/dashboard/transactions/deposits/page.tsx @@ -21,6 +21,7 @@ export default function DepositTransactionPage() { const pagination = useSelector(selectPagination); const sort = useSelector(selectSort); const [tableRows, setTableRows] = useState([]); + const [rowCount, setRowCount] = useState(0); const memoizedRows = useMemo(() => tableRows, [tableRows]); @@ -75,6 +76,7 @@ export default function DepositTransactionPage() { })); setTableRows(rows); + setRowCount(100); dispatch(setStatus("succeeded")); } catch (error) { dispatch( @@ -89,5 +91,7 @@ export default function DepositTransactionPage() { fetchDeposits(); }, [dispatch, depositFilters, pagination, sort]); - return ; + return ( + + ); } diff --git a/app/dashboard/transactions/withdrawals/page.tsx b/app/dashboard/transactions/withdrawals/page.tsx index e61e9bd..b676a9e 100644 --- a/app/dashboard/transactions/withdrawals/page.tsx +++ b/app/dashboard/transactions/withdrawals/page.tsx @@ -1,23 +1,100 @@ +"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 WithdrawalTransactionPage({ - 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 = "withdrawal"; - const data = await getTransactions({ transactionType, query }); +export default function WithdrawalTransactionPage() { + const dispatch = useDispatch(); + const filters = useSelector(selectFilters); + const pagination = useSelector(selectPagination); + const sort = useSelector(selectSort); + const [tableRows, setTableRows] = useState([]); + const [rowCount, setRowCount] = useState(0); - return ; + const memoizedRows = useMemo(() => tableRows, [tableRows]); + + const withdrawalFilters = useMemo(() => { + return { + ...filters, + Type: { + operator: "==", + value: "withdrawal", + }, + }; + }, [filters]); + + useEffect(() => { + const fetchWithdrawals = async () => { + dispatch(setStatus("loading")); + dispatch(setAdvancedSearchError(null)); + try { + const response = await fetch( + "/api/dashboard/transactions/withdrawals", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filters: withdrawalFilters, + pagination, + sort, + }), + } + ); + + if (!response.ok) { + dispatch(setAdvancedSearchError("Failed to fetch withdrawals")); + setTableRows([]); + return; + } + + const backendData = await response.json(); + const transactions: BackendTransaction[] = + backendData.transactions || []; + + const rows: TransactionRow[] = transactions.map(tx => ({ + id: tx.id, + userId: tx.customer, + transactionId: String(tx.external_id ?? tx.id), + type: tx.type, + currency: tx.currency, + amount: tx.amount, + status: tx.status, + dateTime: tx.created || tx.modified, + merchantId: tx.merchant_id, + pspId: tx.psp_id, + methodId: tx.method_id, + modified: tx.modified, + })); + + setTableRows(rows); + setRowCount(100); + dispatch(setStatus("succeeded")); + } catch (error) { + dispatch( + setAdvancedSearchError( + error instanceof Error ? error.message : "Unknown error" + ) + ); + setTableRows([]); + } + }; + + fetchWithdrawals(); + }, [dispatch, withdrawalFilters, pagination, sort]); + + return ( + + ); } diff --git a/app/features/AdminList/AdminResourceList.tsx b/app/features/AdminList/AdminResourceList.tsx new file mode 100644 index 0000000..4695d26 --- /dev/null +++ b/app/features/AdminList/AdminResourceList.tsx @@ -0,0 +1,290 @@ +"use client"; + +import Spinner from "@/app/components/Spinner/Spinner"; +import { DataRowBase } from "@/app/features/DataTable/types"; +import { + setError as setAdvancedSearchError, + setStatus, +} from "@/app/redux/advanedSearch/advancedSearchSlice"; +import { + selectError, + selectFilters, + selectPagination, + selectSort, + selectStatus, +} from "@/app/redux/advanedSearch/selectors"; +import { AppDispatch } from "@/app/redux/store"; +import { + Alert, + Box, + Chip, + Divider, + List, + ListItem, + Typography, +} from "@mui/material"; +import { useEffect, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +type ResourceRow = DataRowBase & Record; + +type FilterValue = + | string + | { + operator?: string; + value: string; + }; + +interface AdminResourceListProps { + title: string; + endpoint: string; + responseCollectionKeys?: string[]; + primaryLabelKeys: string[]; + chipKeys?: string[]; + excludeKeys?: string[]; + filterOverrides?: Record; +} + +const DEFAULT_COLLECTION_KEYS = ["data", "items"]; + +const ensureRowId = ( + row: Record, + fallbackId: number +): ResourceRow => { + const currentId = row.id; + + if (typeof currentId === "number") { + return row as ResourceRow; + } + + const numericId = Number(currentId); + + if (!Number.isNaN(numericId) && numericId !== 0) { + return { ...row, id: numericId } as ResourceRow; + } + + return { ...row, id: fallbackId } as ResourceRow; +}; + +const resolveCollection = ( + payload: Record, + preferredKeys: string[] = [] +) => { + for (const key of [...preferredKeys, ...DEFAULT_COLLECTION_KEYS]) { + const maybeCollection = payload?.[key]; + if (Array.isArray(maybeCollection)) { + return maybeCollection as Record[]; + } + } + + if (Array.isArray(payload)) { + return payload as Record[]; + } + + return []; +}; + +const AdminResourceList = ({ + title, + endpoint, + responseCollectionKeys = [], + primaryLabelKeys, + chipKeys = [], + excludeKeys = [], +}: AdminResourceListProps) => { + const dispatch = useDispatch(); + const filters = useSelector(selectFilters); + const pagination = useSelector(selectPagination); + const sort = useSelector(selectSort); + const status = useSelector(selectStatus); + const errorMessage = useSelector(selectError); + + const [rows, setRows] = useState([]); + + const normalizedTitle = title.toLowerCase(); + + const excludedKeys = useMemo(() => { + const baseExcluded = new Set(["id", ...primaryLabelKeys, ...chipKeys]); + excludeKeys.forEach(key => baseExcluded.add(key)); + return Array.from(baseExcluded); + }, [primaryLabelKeys, chipKeys, excludeKeys]); + + const getPrimaryLabel = (row: ResourceRow) => { + for (const key of primaryLabelKeys) { + if (row[key]) { + return String(row[key]); + } + } + return `${title} #${row.id}`; + }; + + const getMetaChips = (row: ResourceRow) => + chipKeys + .filter(key => row[key]) + .map(key => ({ + key, + value: String(row[key]), + })); + + const getSecondaryDetails = (row: ResourceRow) => + Object.entries(row).filter(([key]) => !excludedKeys.includes(key)); + + const resolvedCollectionKeys = useMemo( + () => [...responseCollectionKeys], + [responseCollectionKeys] + ); + + useEffect(() => { + const fetchResources = async () => { + dispatch(setStatus("loading")); + dispatch(setAdvancedSearchError(null)); + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + filters, + pagination, + sort, + }), + }); + + if (!response.ok) { + dispatch( + setAdvancedSearchError(`Failed to fetch ${normalizedTitle}`) + ); + setRows([]); + return; + } + + const backendData = await response.json(); + const collection = resolveCollection( + backendData, + resolvedCollectionKeys + ); + + const nextRows = collection.map((item, index) => + ensureRowId(item, index + 1) + ); + + setRows(nextRows); + dispatch(setStatus("succeeded")); + } catch (error) { + dispatch( + setAdvancedSearchError( + error instanceof Error ? error.message : "Unknown error" + ) + ); + setRows([]); + } + }; + + fetchResources(); + }, [ + dispatch, + endpoint, + filters, + pagination, + sort, + resolvedCollectionKeys, + normalizedTitle, + ]); + + return ( + + + {title} + + + {status === "loading" && ( + + + + {`Loading ${normalizedTitle}...`} + + + )} + + {status === "failed" && ( + + {errorMessage || `Failed to load ${normalizedTitle}`} + + )} + + {!rows.length && status === "succeeded" && ( + + {`No ${normalizedTitle} found.`} + + )} + + {rows.length > 0 && ( + + {rows.map(row => { + const chips = getMetaChips(row); + const secondary = getSecondaryDetails(row); + + return ( + + + + + + {getPrimaryLabel(row)} + + + + ID: {row.id} + + + + {chips.length > 0 && ( + + {chips.map(chip => ( + + ))} + + )} + + {secondary.length > 0 && ( + + {secondary + .map(([key, value]) => `${key}: ${String(value)}`) + .join(" โ€ข ")} + + )} + + + + + ); + })} + + )} + + ); +}; + +export default AdminResourceList; diff --git a/app/features/AdvancedSearch/AdvancedSearch.tsx b/app/features/AdvancedSearch/AdvancedSearch.tsx index 694225b..97088f1 100644 --- a/app/features/AdvancedSearch/AdvancedSearch.tsx +++ b/app/features/AdvancedSearch/AdvancedSearch.tsx @@ -28,6 +28,7 @@ import { } from "@/app/redux/advanedSearch/advancedSearchSlice"; import { selectFilters } from "@/app/redux/advanedSearch/selectors"; import { normalizeValue, defaultOperatorForField } from "./utils/utils"; +import { selectConditionOperators } from "@/app/redux/metadata/selectors"; // ----------------------------------------------------- // COMPONENT @@ -42,7 +43,9 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) { // Local form state for UI (synced with Redux) const [formValues, setFormValues] = useState>({}); const [operators, setOperators] = useState>({}); + const conditionOperators = useSelector(selectConditionOperators); + console.log("[conditionOperators]", conditionOperators); // ----------------------------------------------------- // SYNC REDUX FILTERS TO LOCAL STATE ON LOAD // ----------------------------------------------------- @@ -255,12 +258,14 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) { updateOperator(field, e.target.value) } > - Greater or equal - Less or equal - Equal - Not equal - Greater - Less + {Object.entries(conditionOperators ?? {}).map( + ([key, value]) => ( + + {key.replace(/_/g, " ")}{" "} + {/* Optional: make it readable */} + + ) + )} diff --git a/app/features/AdvancedSearch/utils/utils.ts b/app/features/AdvancedSearch/utils/utils.ts index 69edc5b..2ea578d 100644 --- a/app/features/AdvancedSearch/utils/utils.ts +++ b/app/features/AdvancedSearch/utils/utils.ts @@ -1,17 +1,4 @@ -// ----------------------------------------------------- -// UTILITIES -// ----------------------------------------------------- - -export const extractOperator = (val?: string | null): string | null => { - if (!val) return null; - - const match = val.match(/^(==|!=|>=|<=|LIKE|>|<)/); - return match ? match[0] : null; -}; - -export const formatWithOperator = (operator: string, value: string) => - `${operator}/${value}`; - +// eslint-disable-next-line @typescript-eslint/no-explicit-any export const normalizeValue = (input: any): string => { if (input == null) return ""; if (typeof input === "string" || typeof input === "number") @@ -27,22 +14,6 @@ export const normalizeValue = (input: any): string => { return ""; }; -export const encodeFilter = (fullValue: string): string => { - // Split ONLY on the first slash - const index = fullValue.indexOf("/"); - if (index === -1) return fullValue; - - const operator = fullValue.slice(0, index); - const rawValue = fullValue.slice(index + 1); - - return `${operator}/${encodeURIComponent(rawValue)}`; -}; - -export const decodeFilter = (encoded: string): string => { - const [operator, encodedValue] = encoded.split("/"); - return `${operator}/${decodeURIComponent(encodedValue)}`; -}; - // Default operator based on field and type export const defaultOperatorForField = ( field: string, diff --git a/app/features/DataTable/DataTable.tsx b/app/features/DataTable/DataTable.tsx index 0f755f9..1816a8b 100644 --- a/app/features/DataTable/DataTable.tsx +++ b/app/features/DataTable/DataTable.tsx @@ -1,29 +1,38 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; -import { DataGrid } from "@mui/x-data-grid"; +import React, { useState, useCallback, useMemo } from "react"; +import { DataGrid, GridPaginationModel } from "@mui/x-data-grid"; import { Box, Paper, Alert } from "@mui/material"; import DataTableHeader from "./DataTableHeader"; import StatusChangeDialog from "./StatusChangeDialog"; import Spinner from "@/app/components/Spinner/Spinner"; -import { selectStatus, selectError } from "@/app/redux/advanedSearch/selectors"; -import { selectEnhancedColumns } from "./re-selectors"; -import { useSelector } from "react-redux"; +import { + selectStatus, + selectError, + selectPagination, + selectPaginationModel, +} from "@/app/redux/advanedSearch/selectors"; +import { makeSelectEnhancedColumns } from "./re-selectors"; +import { useDispatch, useSelector } from "react-redux"; import { DataRowBase } from "./types"; +import { setPagination } from "@/app/redux/advanedSearch/advancedSearchSlice"; +import { AppDispatch } from "@/app/redux/store"; interface DataTableProps { rows: TRow[]; extraColumns?: string[]; enableStatusActions?: boolean; + totalRows?: number; } const DataTable = ({ - rows, + rows: localRows, extraColumns, enableStatusActions = false, + totalRows: totalRows, }: DataTableProps) => { + const dispatch = useDispatch(); const [showExtraColumns, setShowExtraColumns] = useState(false); - const [localRows, setLocalRows] = useState(rows); const [modalOpen, setModalOpen] = useState(false); const [selectedRowId, setSelectedRowId] = useState(null); const [pendingStatus, setPendingStatus] = useState(""); @@ -35,9 +44,21 @@ const DataTable = ({ const status = useSelector(selectStatus); const errorMessage = useSelector(selectError); - useEffect(() => { - setLocalRows(rows); - }, [rows]); + const pagination = useSelector(selectPagination); + const paginationModel = useSelector(selectPaginationModel); + + const handlePaginationModelChange = useCallback( + (model: GridPaginationModel) => { + console.log("model", model); + const nextPage = model.page + 1; + const nextLimit = model.pageSize; + + if (nextPage !== pagination.page || nextLimit !== pagination.limit) { + dispatch(setPagination({ page: nextPage, limit: nextLimit })); + } + }, + [dispatch, pagination.page, pagination.limit] + ); const handleStatusChange = useCallback((rowId: number, newStatus: string) => { setSelectedRowId(rowId); @@ -78,11 +99,6 @@ const DataTable = ({ ); } - setLocalRows(prev => - prev.map(row => - row.id === selectedRowId ? { ...row, status: pendingStatus } : row - ) - ); setModalOpen(false); setReason(""); setPendingStatus(""); @@ -97,18 +113,17 @@ const DataTable = ({ } }; - // Columns with custom renderers + const selectEnhancedColumns = useMemo(makeSelectEnhancedColumns, []); + const enhancedColumns = useSelector(state => - selectEnhancedColumns( - state, + selectEnhancedColumns(state, { enableStatusActions, extraColumns, showExtraColumns, localRows, - handleStatusChange - ) + handleStatusChange, + }) ); - return ( <> {status === "loading" && } @@ -125,11 +140,15 @@ const DataTable = ({ onOpenExport={() => {}} /> - + void; +type SelectorProps = { + enableStatusActions: boolean; + extraColumns?: string[] | null; + showExtraColumns?: boolean; + localRows: DataRowBase[]; + handleStatusChange: (rowId: number, newStatus: string) => void; +}; -const selectEnableStatusActions = ( - _state: RootState, - enableStatusActions: boolean -) => enableStatusActions; +// ------------------------- +// Basic Selectors (props-driven) +// ------------------------- -const selectExtraColumns = ( - _state: RootState, - _enableStatusActions: boolean, - extraColumns?: string[] | null -) => extraColumns ?? null; +const propsEnableStatusActions = (_: RootState, props: SelectorProps) => + props.enableStatusActions; -const selectShowExtraColumns = ( - _state: RootState, - _enableStatusActions: boolean, - _extraColumns?: string[] | null, - showExtraColumns = false -) => showExtraColumns; +const propsExtraColumns = (_: RootState, props: SelectorProps) => + props.extraColumns ?? null; -const selectLocalRows = ( - _state: RootState, - _enableStatusActions: boolean, - _extraColumns?: string[] | null, - _showExtraColumns?: boolean, - localRows?: DataRowBase[] -) => localRows ?? []; +const propsShowExtraColumns = (_: RootState, props: SelectorProps) => + props.showExtraColumns ?? false; -const noopStatusChangeHandler: StatusChangeHandler = () => {}; +const propsLocalRows = (_: RootState, props: SelectorProps) => + props.localRows ?? []; -const selectStatusChangeHandler = ( - _state: RootState, - _enableStatusActions: boolean, - _extraColumns?: string[] | null, - _showExtraColumns?: boolean, - _localRows?: DataRowBase[], - handleStatusChange?: StatusChangeHandler -) => handleStatusChange ?? noopStatusChangeHandler; +const propsStatusChangeHandler = (_: RootState, props: SelectorProps) => + props.handleStatusChange; -export const selectBaseColumns = createSelector( - [selectEnableStatusActions], - enableStatusActions => { - if (!enableStatusActions) { +// ------------------------- +// Helper: Format field name to header name +// ------------------------- + +/** + * Converts a field name to a readable header name + * e.g., "userId" -> "User ID", "transactionId" -> "Transaction ID" + */ +const formatFieldNameToHeader = (fieldName: string): string => { + // Handle camelCase: insert space before capital letters and capitalize first letter + return fieldName + .replace(/([A-Z])/g, " $1") // Add space before capital letters + .replace(/^./, str => str.toUpperCase()) // Capitalize first letter + .trim(); +}; + +// ------------------------- +// Dynamic Columns from Row Data +// ------------------------- + +const makeSelectDynamicColumns = () => + createSelector([propsLocalRows], (localRows): GridColDef[] => { + // If no rows, fall back to static columns + if (!localRows || localRows.length === 0) { return TABLE_COLUMNS; } - return [ - ...TABLE_COLUMNS, - { - field: "actions", - headerName: "Actions", - width: 160, - sortable: false, - filterable: false, - } as GridColDef, - ]; - } -); + // Get all unique field names from the row data + const fieldSet = new Set(); + localRows.forEach(row => { + Object.keys(row).forEach(key => { + if (key !== "options") { + // Exclude internal fields + fieldSet.add(key); + } + }); + }); -export const selectVisibleColumns = createSelector( - [selectBaseColumns, selectExtraColumns, selectShowExtraColumns], - (baseColumns, extraColumns, showExtraColumns) => { - if (!extraColumns || extraColumns.length === 0) { - return baseColumns; + // Build columns from actual row data fields + const dynamicColumns: GridColDef[] = Array.from(fieldSet).map(field => { + // Format field name to readable header + const headerName = formatFieldNameToHeader(field); + + // Set default widths based on field type + let width = 150; + if (field.includes("id") || field.includes("Id")) { + width = 180; + } else if (field === "amount" || field === "currency") { + width = 120; + } else if (field === "status") { + width = 120; + } else if ( + field.includes("date") || + field.includes("Date") || + field === "dateTime" || + field === "created" || + field === "modified" + ) { + width = 180; + } + + return { + field, + headerName, + width, + sortable: true, + filterable: true, + } as GridColDef; + }); + + return dynamicColumns; + }); + +// ------------------------- +// Base Columns +// ------------------------- + +const makeSelectBaseColumns = () => + createSelector( + [makeSelectDynamicColumns(), propsEnableStatusActions], + (dynamicColumns, enableStatusActions) => { + const baseColumns = dynamicColumns; + + if (!enableStatusActions) return baseColumns; + + return [ + ...baseColumns, + { + field: "actions", + headerName: "Actions", + width: 160, + sortable: false, + filterable: false, + } as GridColDef, + ]; } + ); - return showExtraColumns - ? baseColumns - : baseColumns.filter(col => !extraColumns.includes(col.field)); - } -); +// ------------------------- +// Visible Columns +// ------------------------- -export const selectResolvedTransactionStatuses = createSelector( - [selectTransactionStatuses], - statuses => (statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK) -); +const makeSelectVisibleColumns = () => + createSelector( + [makeSelectBaseColumns(), propsExtraColumns, propsShowExtraColumns], + (baseColumns, extraColumns, showExtraColumns) => { + // Columns are already built from row data, so they're all valid + if (!extraColumns || extraColumns.length === 0) return baseColumns; -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 ( + const visibleColumns = showExtraColumns + ? baseColumns + : baseColumns.filter(col => !extraColumns.includes(col.field)); + + console.log("visibleColumns", visibleColumns); + return visibleColumns; + } + ); +// ------------------------- +// Resolved Statuses (STATE-based) +// ------------------------- + +const makeSelectResolvedStatuses = () => + createSelector([selectTransactionStatuses], statuses => + statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK + ); + +// ------------------------- +// Enhanced Columns +// ------------------------- + +export const makeSelectEnhancedColumns = () => + createSelector( + [ + makeSelectVisibleColumns(), + propsLocalRows, + propsStatusChangeHandler, + makeSelectResolvedStatuses(), + ], + ( + visibleColumns, + localRows, + handleStatusChange, + resolvedStatusOptions + ): GridColDef[] => { + console.log("visibleColumns", visibleColumns); + return visibleColumns.map(col => { + // -------------------------------- + // 1. STATUS COLUMN RENDERER + // -------------------------------- + if (col.field === "status") { + return { + ...col, + renderCell: (params: GridRenderCellParams) => { + const value = params.value?.toLowerCase(); + let bgColor = "#e0e0e0"; + let textColor = "#000"; + + switch (value) { + case "completed": + bgColor = "#d0f0c0"; + textColor = "#1b5e20"; + break; + case "pending": + bgColor = "#fff4cc"; + textColor = "#9e7700"; + break; + case "inprogress": + bgColor = "#cce5ff"; + textColor = "#004085"; + break; + case "error": + bgColor = "#ffcdd2"; + textColor = "#c62828"; + break; + } + + return ( + + {params.value} + + ); + }, + }; + } + + // -------------------------------- + // 2. USER ID COLUMN + // -------------------------------- + if (col.field === "userId") { + return { + ...col, + headerAlign: "center", + align: "center", + renderCell: (params: GridRenderCellParams) => ( - {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 }, + px: 1, }} onClick={e => e.stopPropagation()} > - {uniqueOptions.map(option => ( - - {option} - - ))} - - ); - }, - }; - } + + {params.value} + + e.stopPropagation()} + > + + + + ), + }; + } - return col; - }) as GridColDef[]; - } -); + // -------------------------------- + // 3. ACTIONS COLUMN + // -------------------------------- + if (col.field === "actions") { + return { + ...col, + renderCell: (params: GridRenderCellParams) => { + const currentRow = localRows.find(row => row.id === params.id); + + const options = + currentRow?.options?.map(option => option.value) ?? + resolvedStatusOptions; + + const uniqueOptions = Array.from(new Set(options)); + + return ( + + 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; + }); + } + ); diff --git a/app/features/DataTable/types.ts b/app/features/DataTable/types.ts index 5cc2efe..b332c06 100644 --- a/app/features/DataTable/types.ts +++ b/app/features/DataTable/types.ts @@ -17,3 +17,21 @@ export interface DataRowBase { status?: string; options?: { value: string; label: string }[]; } + +export interface ITransactions { + id: string; + psp_id: string; + method_id: string; + merchant_id: string; + external_id?: string; // optional: may not always be present + customer?: string; // keep as string unless you provide structure + type?: string; + currency?: string; + amount: number | string; // sometimes APIs return strings for money + status?: string; + notes?: string; + creator?: string; + created: string; // ISO datetime string from API + modifier?: string; + modified?: string; // ISO datetime string or undefined +} diff --git a/app/features/Pages/Settings/SettingsAccountSecurity.tsx b/app/features/Pages/Settings/SettingsAccountSecurity.tsx index 668cf78..170981f 100644 --- a/app/features/Pages/Settings/SettingsAccountSecurity.tsx +++ b/app/features/Pages/Settings/SettingsAccountSecurity.tsx @@ -31,6 +31,8 @@ const SettingsAccountSecurity: React.FC = () => { currentPassword: passwordData.currentPassword, }) ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { // Error handling is now done by the epic console.error("Password change error:", err); diff --git a/app/features/Pages/Settings/SettingsPageClient.tsx b/app/features/Pages/Settings/SettingsPageClient.tsx index b3438b6..fe31548 100644 --- a/app/features/Pages/Settings/SettingsPageClient.tsx +++ b/app/features/Pages/Settings/SettingsPageClient.tsx @@ -20,11 +20,9 @@ const SettingsPageClient: React.FC = () => { "personal" | "account") - ) => setActiveSection(section)} + onChange={(section: "personal" | "account") => + setActiveSection(section) + } /> diff --git a/app/features/Pages/Settings/SettingsPersonalInfo.tsx b/app/features/Pages/Settings/SettingsPersonalInfo.tsx index 9b21b09..19b2773 100644 --- a/app/features/Pages/Settings/SettingsPersonalInfo.tsx +++ b/app/features/Pages/Settings/SettingsPersonalInfo.tsx @@ -15,6 +15,7 @@ import { updateUserDetails } from "@/app/redux/user/userSlice"; const SettingsPersonalInfo: React.FC = () => { const user = useSelector((state: RootState) => state.auth.user); + console.log("[SettingsPersonalInfo] user", user); const dispatch = useDispatch(); const [formData, setFormData] = useState({ @@ -28,8 +29,8 @@ const SettingsPersonalInfo: React.FC = () => { useEffect(() => { if (user) { setFormData({ - first_name: user.firstName ?? "", - last_name: user.lastName ?? "", + first_name: user.first_name ?? "", + last_name: user.last_name ?? "", username: user.username ?? "", email: user.email ?? "", }); @@ -66,17 +67,20 @@ const SettingsPersonalInfo: React.FC = () => { = ({ open, onClose }) => { const loading = status === "loading"; + const COUNTRY_CODES = useSelector(selectPhoneNumberCountries); + const handleChange = ( e: React.ChangeEvent ) => { @@ -115,7 +119,6 @@ const AddUser: React.FC = ({ open, onClose }) => { if (result && resultAction.payload.success) { toast.success(resultAction.payload.message); - // router.refresh(); onClose(); } } catch (err) { @@ -255,9 +258,12 @@ const AddUser: React.FC = ({ open, onClose }) => { onChange={handleCountryCodeChange} className="country-code-select" > - {COUNTRY_CODES.map(country => ( - ))} diff --git a/app/features/UserRoles/DeleteUser/DeleteUser.tsx b/app/features/UserRoles/DeleteUser/DeleteUser.tsx index adc585b..3fa929f 100644 --- a/app/features/UserRoles/DeleteUser/DeleteUser.tsx +++ b/app/features/UserRoles/DeleteUser/DeleteUser.tsx @@ -47,6 +47,7 @@ const DeleteUser: React.FC = ({ open, onClose, user }) => { (resultAction.payload as string) || "Failed to delete user" ); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { toast.error(error.message || "An unexpected error occurred"); } diff --git a/app/features/dashboard/header/Header.tsx b/app/features/dashboard/header/Header.tsx index d1d4d61..542e4d4 100644 --- a/app/features/dashboard/header/Header.tsx +++ b/app/features/dashboard/header/Header.tsx @@ -5,8 +5,6 @@ import AccountMenu from "./accountMenu/AccountMenu"; import "./Header.scss"; const Header = () => { - const handleChange = () => {}; - return ( { >
- +
diff --git a/app/features/dashboard/header/dropDown/DropDown.tsx b/app/features/dashboard/header/dropDown/DropDown.tsx index f81b600..f7997cd 100644 --- a/app/features/dashboard/header/dropDown/DropDown.tsx +++ b/app/features/dashboard/header/dropDown/DropDown.tsx @@ -1,57 +1,137 @@ import React from "react"; import { - FormControl, - InputLabel, - Select, + Button, + Menu, MenuItem, - SelectChangeEvent, + ListItemText, + ListItemIcon, + Divider, } from "@mui/material"; -import PageLinks from "../../../../components/PageLinks/PageLinks"; import { useSelector } from "react-redux"; +import { useRouter } from "next/navigation"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { selectNavigationSidebar } from "@/app/redux/metadata/selectors"; +import { SidebarLink } from "@/app/redux/metadata/metadataSlice"; +import { resolveIcon } from "@/app/utils/iconMap"; import "./DropDown.scss"; interface Props { - onChange?: (event: SelectChangeEvent) => void; + onChange?: (path: string) => void; } export default function SidebarDropdown({ onChange }: Props) { - const [value, setValue] = React.useState(""); - const sidebar = useSelector(selectNavigationSidebar)?.links; - const handleChange = (event: SelectChangeEvent) => { - setValue(event.target.value); - onChange?.(event); + const [anchorEl, setAnchorEl] = React.useState(null); + const [openMenus, setOpenMenus] = React.useState>({}); + const sidebar = useSelector(selectNavigationSidebar); + const router = useRouter(); + const open = Boolean(anchorEl); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + setOpenMenus({}); + }; + + const toggleMenu = (title: string) => { + setOpenMenus(prev => ({ ...prev, [title]: !prev[title] })); + }; + + const handleNavigation = (path: string) => { + router.push(path); + onChange?.(path); + handleClose(); + }; + + const renderMenuItem = ( + link: SidebarLink, + level: number = 0 + ): React.ReactNode => { + const Icon = link.icon ? resolveIcon(link.icon as string) : undefined; + const hasChildren = link.children && link.children.length > 0; + const isOpen = openMenus[link.title]; + const indent = level * 24; + + if (hasChildren) { + return ( + + toggleMenu(link.title)} + sx={{ + pl: `${8 + indent}px`, + }} + > + {Icon && ( + + + + )} + + {isOpen ? ( + + ) : ( + + )} + + {isOpen && + link.children?.map(child => renderMenuItem(child, level + 1))} + + ); + } + + return ( + handleNavigation(link.path)} + sx={{ + pl: `${8 + indent}px`, + }} + > + {Icon && ( + + + + )} + + + ); }; return ( - - Navigate To - - + +
); } diff --git a/app/features/dashboard/layout/mainContent.tsx b/app/features/dashboard/layout/mainContent.tsx index 1f55fd3..2b6cd32 100644 --- a/app/features/dashboard/layout/mainContent.tsx +++ b/app/features/dashboard/layout/mainContent.tsx @@ -10,7 +10,7 @@ export const MainContent = ({ children }: MainContentProps) => { const isSidebarOpen = useSelector((state: RootState) => state.ui.sidebarOpen); const style: React.CSSProperties = { - marginLeft: isSidebarOpen ? "240px" : "30px", + marginLeft: isSidebarOpen ? "230px" : "30px", padding: "24px", minHeight: "100vh", width: isSidebarOpen ? "calc(100% - 240px)" : "calc(100% - 30px)", diff --git a/app/redux/InitializeAuth.tsx b/app/redux/InitializeAuth.tsx index 7db3d15..f61fba9 100644 --- a/app/redux/InitializeAuth.tsx +++ b/app/redux/InitializeAuth.tsx @@ -2,14 +2,14 @@ import { useEffect } from "react"; import { useDispatch } from "react-redux"; -import { initializeAuth } from "./auth/authSlice"; +import { validateAuth } from "./auth/authSlice"; import { AppDispatch } from "./types"; export function InitializeAuth() { const dispatch = useDispatch(); useEffect(() => { - dispatch(initializeAuth()); + dispatch(validateAuth()); }, [dispatch]); return null; diff --git a/app/redux/ReduxProvider.tsx b/app/redux/ReduxProvider.tsx index 369d925..e393c1a 100644 --- a/app/redux/ReduxProvider.tsx +++ b/app/redux/ReduxProvider.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef } from "react"; import { Provider } from "react-redux"; import { store } from "./store"; -import { checkAuthStatus } from "./auth/authSlice"; +import { validateAuth } from "./auth/authSlice"; export default function ReduxProvider({ children, @@ -16,17 +16,17 @@ export default function ReduxProvider({ useEffect(() => { // Check authentication status when the ReduxProvider component mounts on the client. // This ensures your Redux auth state is synced with the server-side token. - store.dispatch(checkAuthStatus()); + store.dispatch(validateAuth()); // Do an additional check after 2 seconds to ensure we have the latest token info initialCheckRef.current = setTimeout(() => { - store.dispatch(checkAuthStatus()); + store.dispatch(validateAuth()); }, 2000); // Set up periodic token validation every 5 minutes intervalRef.current = setInterval( () => { - store.dispatch(checkAuthStatus()); + store.dispatch(validateAuth()); }, 5 * 60 * 1000 ); // 5 minutes diff --git a/app/redux/advanedSearch/selectors.ts b/app/redux/advanedSearch/selectors.ts index 70f2288..eecb2e6 100644 --- a/app/redux/advanedSearch/selectors.ts +++ b/app/redux/advanedSearch/selectors.ts @@ -1,3 +1,4 @@ +import { createSelector } from "@reduxjs/toolkit"; import { RootState } from "../store"; import { AdvancedSearchFilters, @@ -11,6 +12,14 @@ export const selectFilters = (state: RootState): AdvancedSearchFilters => export const selectPagination = (state: RootState) => state.advancedSearch.pagination; +export const selectPaginationModel = createSelector( + [selectPagination], + pagination => ({ + page: Math.max(0, (pagination.page ?? 1) - 1), + pageSize: pagination.limit ?? 10, + }) +); + export const selectSort = (state: RootState) => state.advancedSearch.sort; export const selectFilterValue = ( diff --git a/app/redux/metadata/constants.ts b/app/redux/metadata/constants.ts new file mode 100644 index 0000000..e7712d9 --- /dev/null +++ b/app/redux/metadata/constants.ts @@ -0,0 +1,240 @@ +// Country codes for phone prefixes +export const COUNTRY_CODES = [ + { code: "+233", flag: "๐Ÿ‡ฌ๐Ÿ‡ญ", country: "Ghana" }, + { code: "+672", flag: "๐Ÿ‡ณ๐Ÿ‡ซ", country: "Norfolk Island" }, + { code: "+226", flag: "๐Ÿ‡ง๐Ÿ‡ซ", country: "Burkina Faso" }, + { code: "+591", flag: "๐Ÿ‡ง๐Ÿ‡ด", country: "Bolivia" }, + { code: "+33", flag: "๐Ÿ‡ซ๐Ÿ‡ท", country: "France" }, + { code: "+1-876", flag: "๐Ÿ‡ฏ๐Ÿ‡ฒ", country: "Jamaica" }, + { code: "+249", flag: "๐Ÿ‡ธ๐Ÿ‡ฉ", country: "Sudan" }, + { + code: "+243", + flag: "๐Ÿ‡จ๐Ÿ‡ฉ", + country: "Congo, Democratic Republic Of (Was Zaire)", + }, + { code: "+679", flag: "๐Ÿ‡ซ๐Ÿ‡ฏ", country: "Fiji" }, + { code: "+44", flag: "๐Ÿ‡ฎ๐Ÿ‡ฒ", country: "Isle Of Man" }, + { code: "+382", flag: "๐Ÿ‡ฒ๐Ÿ‡ช", country: "Montenegro" }, + { code: "+353", flag: "๐Ÿ‡ฎ๐Ÿ‡ช", country: "Ireland" }, + { code: "+237", flag: "๐Ÿ‡จ๐Ÿ‡ฒ", country: "Cameroon" }, + { code: "+592", flag: "๐Ÿ‡ฌ๐Ÿ‡พ", country: "Guyana" }, + { code: "+234", flag: "๐Ÿ‡ณ๐Ÿ‡ฌ", country: "Nigeria" }, + { code: "+1-868", flag: "๐Ÿ‡น๐Ÿ‡น", country: "Trinidad And Tobago" }, + { code: "+45", flag: "๐Ÿ‡ฉ๐Ÿ‡ฐ", country: "Denmark" }, + { code: "+852", flag: "๐Ÿ‡ญ๐Ÿ‡ฐ", country: "Hong Kong" }, + { code: "+223", flag: "๐Ÿ‡ฒ๐Ÿ‡ฑ", country: "Mali" }, + { code: "+239", flag: "๐Ÿ‡ธ๐Ÿ‡น", country: "Sao Tome And Principe" }, + { code: "+690", flag: "๐Ÿ‡น๐Ÿ‡ฐ", country: "Tokelau" }, + { code: "+590", flag: "๐Ÿ‡ฌ๐Ÿ‡ต", country: "Guadeloupe" }, + { code: "+66", flag: "๐Ÿ‡น๐Ÿ‡ญ", country: "Thailand" }, + { code: "+504", flag: "๐Ÿ‡ญ๐Ÿ‡ณ", country: "Honduras" }, + { code: "+27", flag: "๐Ÿ‡ฟ๐Ÿ‡ฆ", country: "South Africa" }, + { code: "+358", flag: "๐Ÿ‡ซ๐Ÿ‡ฎ", country: "Finland" }, + { code: "+1-264", flag: "๐Ÿ‡ฆ๐Ÿ‡ฎ", country: "Anguilla" }, + { code: "+262", flag: "๐Ÿ‡ท๐Ÿ‡ช", country: "Reunion" }, + { code: "+992", flag: "๐Ÿ‡น๐Ÿ‡ฏ", country: "Tajikistan" }, + { code: "+971", flag: "๐Ÿ‡ฆ๐Ÿ‡ช", country: "United Arab Emirates" }, + { code: "+212", flag: "๐Ÿ‡ช๐Ÿ‡ญ", country: "Western Sahara" }, + { code: "+692", flag: "๐Ÿ‡ฒ๐Ÿ‡ญ", country: "Marshall Islands" }, + { code: "+674", flag: "๐Ÿ‡ณ๐Ÿ‡ท", country: "Nauru" }, + { code: "+229", flag: "๐Ÿ‡ง๐Ÿ‡ฏ", country: "Benin" }, + { code: "+55", flag: "๐Ÿ‡ง๐Ÿ‡ท", country: "Brazil" }, + { code: "+299", flag: "๐Ÿ‡ฌ๐Ÿ‡ฑ", country: "Greenland" }, + { code: "+61", flag: "๐Ÿ‡ญ๐Ÿ‡ฒ", country: "Heard and Mc Donald Islands" }, + { code: "+98", flag: "๐Ÿ‡ฎ๐Ÿ‡ท", country: "Iran (Islamic Republic Of)" }, + { code: "+231", flag: "๐Ÿ‡ฑ๐Ÿ‡ท", country: "Liberia" }, + { code: "+370", flag: "๐Ÿ‡ฑ๐Ÿ‡น", country: "Lithuania" }, + { code: "+377", flag: "๐Ÿ‡ฒ๐Ÿ‡จ", country: "Monaco" }, + { code: "+222", flag: "๐Ÿ‡ฒ๐Ÿ‡ท", country: "Mauritania" }, + { code: "+57", flag: "๐Ÿ‡จ๐Ÿ‡ด", country: "Colombia" }, + { code: "+216", flag: "๐Ÿ‡น๐Ÿ‡ณ", country: "Tunisia" }, + { code: "+1-345", flag: "๐Ÿ‡ฐ๐Ÿ‡พ", country: "Cayman Islands" }, + { code: "+62", flag: "๐Ÿ‡ฎ๐Ÿ‡ฉ", country: "Indonesia" }, + { code: "+378", flag: "๐Ÿ‡ธ๐Ÿ‡ฒ", country: "San Marino" }, + { code: "+1", flag: "๐Ÿ‡บ๐Ÿ‡ธ", country: "United States" }, + { code: "+383", flag: "๐Ÿ‡ฝ๐Ÿ‡ฐ", country: "Kosovo" }, + { code: "+376", flag: "๐Ÿ‡ฆ๐Ÿ‡ฉ", country: "Andorra" }, + { code: "+1-246", flag: "๐Ÿ‡ง๐Ÿ‡ง", country: "Barbados" }, + { code: "+963", flag: "๐Ÿ‡ธ๐Ÿ‡พ", country: "Syrian Arab Republic" }, + { code: "+359", flag: "๐Ÿ‡ง๐Ÿ‡ฌ", country: "Bulgaria" }, + { code: "+213", flag: "๐Ÿ‡ฉ๐Ÿ‡ฟ", country: "Algeria" }, + { code: "+593", flag: "๐Ÿ‡ช๐Ÿ‡จ", country: "Ecuador" }, + { code: "+240", flag: "๐Ÿ‡ฌ๐Ÿ‡ถ", country: "Equatorial Guinea" }, + { code: "+44", flag: "๐Ÿ‡ฏ๐Ÿ‡ช", country: "Jersey" }, + { code: "+254", flag: "๐Ÿ‡ฐ๐Ÿ‡ช", country: "Kenya" }, + { code: "+64", flag: "๐Ÿ‡ณ๐Ÿ‡ฟ", country: "New Zealand" }, + { code: "+250", flag: "๐Ÿ‡ท๐Ÿ‡ผ", country: "Rwanda" }, + { code: "+291", flag: "๐Ÿ‡ช๐Ÿ‡ท", country: "Eritrea" }, + { code: "+47", flag: "๐Ÿ‡ณ๐Ÿ‡ด", country: "Norway" }, + { code: "+51", flag: "๐Ÿ‡ต๐Ÿ‡ช", country: "Peru" }, + { code: "+290", flag: "๐Ÿ‡ธ๐Ÿ‡ญ", country: "Saint Helena" }, + { code: "+508", flag: "๐Ÿ‡ต๐Ÿ‡ฒ", country: "Saint Pierre And Miquelon" }, + { code: "+260", flag: "๐Ÿ‡ฟ๐Ÿ‡ฒ", country: "Zambia" }, + { code: "+354", flag: "๐Ÿ‡ฎ๐Ÿ‡ธ", country: "Iceland" }, + { code: "+39", flag: "๐Ÿ‡ฎ๐Ÿ‡น", country: "Italy" }, + { code: "+977", flag: "๐Ÿ‡ณ๐Ÿ‡ต", country: "Nepal" }, + { code: "+386", flag: "๐Ÿ‡ธ๐Ÿ‡ฎ", country: "Slovenia" }, + { code: "+218", flag: "๐Ÿ‡ฑ๐Ÿ‡พ", country: "Libyan Arab Jamahiriya" }, + { code: "+505", flag: "๐Ÿ‡ณ๐Ÿ‡ฎ", country: "Nicaragua" }, + { code: "+248", flag: "๐Ÿ‡ธ๐Ÿ‡จ", country: "Seychelles" }, + { code: "+594", flag: "๐Ÿ‡ฌ๐Ÿ‡ซ", country: "French Guiana" }, + { code: "+972", flag: "๐Ÿ‡ฎ๐Ÿ‡ฑ", country: "Israel" }, + { code: "+1-670", flag: "๐Ÿ‡ฒ๐Ÿ‡ต", country: "Northern Mariana Islands" }, + { code: "+1-64", flag: "๐Ÿ‡ต๐Ÿ‡ณ", country: "Pitcairn" }, + { code: "+351", flag: "๐Ÿ‡ต๐Ÿ‡น", country: "Portugal" }, + { code: "+503", flag: "๐Ÿ‡ธ๐Ÿ‡ป", country: "El Salvador" }, + { code: "+44", flag: "๐Ÿ‡ฌ๐Ÿ‡ง", country: "United Kingdom" }, + { code: "+689", flag: "๐Ÿ‡ต๐Ÿ‡ซ", country: "French Polynesia" }, + { code: "+1-721", flag: "๐Ÿ‡ธ๐Ÿ‡ฝ", country: "Sint Maarten" }, + { code: "+380", flag: "๐Ÿ‡บ๐Ÿ‡ฆ", country: "Ukraine" }, + { code: "+599", flag: "๐Ÿ‡ง๐Ÿ‡ถ", country: "Bonaire, Saint Eustatius and Saba" }, + { code: "+500", flag: "๐Ÿ‡ซ๐Ÿ‡ฐ", country: "Falkland Islands (Malvinas)" }, + { code: "+995", flag: "๐Ÿ‡ฌ๐Ÿ‡ช", country: "Georgia" }, + { code: "+1-671", flag: "๐Ÿ‡ฌ๐Ÿ‡บ", country: "Guam" }, + { code: "+82", flag: "๐Ÿ‡ฐ๐Ÿ‡ท", country: "Korea, Republic Of" }, + { code: "+507", flag: "๐Ÿ‡ต๐Ÿ‡ฆ", country: "Panama" }, + { code: "+1", flag: "๐Ÿ‡บ๐Ÿ‡ธ", country: "United States Minor Outlying Islands" }, + { code: "+964", flag: "๐Ÿ‡ฎ๐Ÿ‡ถ", country: "Iraq" }, + { code: "+965", flag: "๐Ÿ‡ฐ๐Ÿ‡ผ", country: "Kuwait" }, + { code: "+39", flag: "๐Ÿ‡ป๐Ÿ‡ฆ", country: "Vatican City State (Holy See)" }, + { code: "+385", flag: "๐Ÿ‡ญ๐Ÿ‡ท", country: "Croatia (Local Name: Hrvatska)" }, + { code: "+92", flag: "๐Ÿ‡ต๐Ÿ‡ฐ", country: "Pakistan" }, + { code: "+967", flag: "๐Ÿ‡พ๐Ÿ‡ช", country: "Yemen" }, + { code: "+267", flag: "๐Ÿ‡ง๐Ÿ‡ผ", country: "Botswana" }, + { code: "+970", flag: "๐Ÿ‡ต๐Ÿ‡ธ", country: "Palestinian Territory, Occupied" }, + { code: "+90", flag: "๐Ÿ‡น๐Ÿ‡ท", country: "Turkey" }, + { code: "+1-473", flag: "๐Ÿ‡ฌ๐Ÿ‡ฉ", country: "Grenada" }, + { code: "+356", flag: "๐Ÿ‡ฒ๐Ÿ‡น", country: "Malta" }, + { + code: "+995", + flag: "๐Ÿ‡ฌ๐Ÿ‡ช", + country: "South Georgia And The South Sandwich Islands", + }, + { code: "+236", flag: "๐Ÿ‡จ๐Ÿ‡ซ", country: "Central African Republic" }, + { code: "+371", flag: "๐Ÿ‡ฑ๐Ÿ‡ป", country: "Latvia" }, + { + code: "+850", + flag: "๐Ÿ‡ฐ๐Ÿ‡ต", + country: "Korea, Democratic People's Republic Of", + }, + { code: "+1-649", flag: "๐Ÿ‡น๐Ÿ‡จ", country: "Turks And Caicos Islands" }, + { code: "+599", flag: "๐Ÿ‡จ๐Ÿ‡ผ", country: "Curacao" }, + { code: "+245", flag: "๐Ÿ‡ฌ๐Ÿ‡ผ", country: "Guinea-Bissau" }, + { code: "+94", flag: "๐Ÿ‡ฑ๐Ÿ‡ฐ", country: "Sri Lanka" }, + { code: "+596", flag: "๐Ÿ‡ฒ๐Ÿ‡ถ", country: "Martinique" }, + { code: "+262", flag: "๐Ÿ‡พ๐Ÿ‡น", country: "Mayotte" }, + { code: "+688", flag: "๐Ÿ‡น๐Ÿ‡ป", country: "Tuvalu" }, + { code: "+49", flag: "๐Ÿ‡ฉ๐Ÿ‡ช", country: "Germany" }, + { code: "+65", flag: "๐Ÿ‡ธ๐Ÿ‡ฌ", country: "Singapore" }, + { code: "+381", flag: "๐Ÿ‡ท๐Ÿ‡ธ", country: "Serbia" }, + { code: "+975", flag: "๐Ÿ‡ง๐Ÿ‡น", country: "Bhutan" }, + { code: "+266", flag: "๐Ÿ‡ฑ๐Ÿ‡ธ", country: "Lesotho" }, + { code: "+421", flag: "๐Ÿ‡ธ๐Ÿ‡ฐ", country: "Slovakia" }, + { code: "+1-784", flag: "๐Ÿ‡ป๐Ÿ‡จ", country: "Saint Vincent And The Grenadines" }, + { code: "+673", flag: "๐Ÿ‡ง๐Ÿ‡ณ", country: "Brunei Darussalam" }, + { code: "+509", flag: "๐Ÿ‡ญ๐Ÿ‡น", country: "Haiti" }, + { + code: "+389", + flag: "๐Ÿ‡ฒ๐Ÿ‡ฐ", + country: "Macedonia, The Former Yugoslav Republic Of", + }, + { code: "+886", flag: "๐Ÿ‡น๐Ÿ‡ผ", country: "Taiwan" }, + { code: "+670", flag: "๐Ÿ‡น๐Ÿ‡ฑ", country: "Cocos (Keeling) Islands" }, + { code: "+352", flag: "๐Ÿ‡ฑ๐Ÿ‡บ", country: "Luxembourg" }, + { code: "+880", flag: "๐Ÿ‡ง๐Ÿ‡ฉ", country: "Bangladesh" }, + { code: "+676", flag: "๐Ÿ‡น๐Ÿ‡ด", country: "Tonga" }, + { code: "+681", flag: "๐Ÿ‡ผ๐Ÿ‡ซ", country: "Wallis And Futuna Islands" }, + { code: "+257", flag: "๐Ÿ‡ง๐Ÿ‡ฎ", country: "Burundi" }, + { code: "+502", flag: "๐Ÿ‡ฌ๐Ÿ‡น", country: "Guatemala" }, + { code: "+855", flag: "๐Ÿ‡ฐ๐Ÿ‡ญ", country: "Cambodia" }, + { code: "+235", flag: "๐Ÿ‡น๐Ÿ‡ฉ", country: "Chad" }, + { code: "+216", flag: "๐Ÿ‡น๐Ÿ‡ณ", country: "Tunisia" }, + { code: "+1-242", flag: "๐Ÿ‡ง๐Ÿ‡ธ", country: "Bahamas" }, + { code: "+350", flag: "๐Ÿ‡ฌ๐Ÿ‡ฎ", country: "Gibraltar" }, + { code: "+52", flag: "๐Ÿ‡ฒ๐Ÿ‡ฝ", country: "Mexico" }, + { code: "+856", flag: "๐Ÿ‡ฑ๐Ÿ‡ฆ", country: "Lao People's Democratic Republic" }, + { code: "+680", flag: "๐Ÿ‡ต๐Ÿ‡ผ", country: "Palau" }, + { code: "+249", flag: "๐Ÿ‡ธ๐Ÿ‡ฉ", country: "South Sudan" }, + { code: "+1-340", flag: "๐Ÿ‡ป๐Ÿ‡ฎ", country: "Virgin Islands (U.S.)" }, + { code: "+355", flag: "๐Ÿ‡ฆ๐Ÿ‡ฑ", country: "Albania" }, + { code: "+246", flag: "๐Ÿ‡ฎ๐Ÿ‡ด", country: "British Indian Ocean Territory" }, + { code: "+235", flag: "๐Ÿ‡น๐Ÿ‡ฉ", country: "Chad" }, + { code: "+263", flag: "๐Ÿ‡ฟ๐Ÿ‡ผ", country: "Zimbabwe" }, + { code: "+357", flag: "๐Ÿ‡จ๐Ÿ‡พ", country: "Cyprus" }, + { code: "+350", flag: "๐Ÿ‡ฌ๐Ÿ‡ฎ", country: "Gibraltar" }, + { code: "+256", flag: "๐Ÿ‡บ๐Ÿ‡ฌ", country: "Uganda" }, + { code: "+685", flag: "๐Ÿ‡ผ๐Ÿ‡ธ", country: "Samoa" }, + { code: "+1", flag: "๐Ÿ‡บ๐Ÿ‡ธ", country: "Canada" }, + { code: "+506", flag: "๐Ÿ‡จ๐Ÿ‡ท", country: "Costa Rica" }, + { code: "+34", flag: "๐Ÿ‡ช๐Ÿ‡ธ", country: "Spain" }, + { code: "+684", flag: "๐Ÿ‡ฆ๐Ÿ‡ธ", country: "American Samoa" }, + { code: "+1-268", flag: "๐Ÿ‡ฆ๐Ÿ‡ฌ", country: "Antigua and Barbuda" }, + { code: "+86", flag: "๐Ÿ‡จ๐Ÿ‡ณ", country: "China" }, + { code: "+48", flag: "๐Ÿ‡ต๐Ÿ‡ฑ", country: "Poland" }, + { code: "+974", flag: "๐Ÿ‡ถ๐Ÿ‡ฆ", country: "Qatar" }, + { code: "+36", flag: "๐Ÿ‡ญ๐Ÿ‡บ", country: "Hungary" }, + { code: "+996", flag: "๐Ÿ‡ฐ๐Ÿ‡ฌ", country: "Kyrgyzstan" }, + { code: "+258", flag: "๐Ÿ‡ฒ๐Ÿ‡ฟ", country: "Mozambique" }, + { code: "+675", flag: "๐Ÿ‡ต๐Ÿ‡ฌ", country: "Papua New Guinea" }, + { code: "+41", flag: "๐Ÿ‡จ๐Ÿ‡ญ", country: "Switzerland" }, + { code: "+269", flag: "๐Ÿ‡ฐ๐Ÿ‡ฒ", country: "Comoros" }, + { code: "+230", flag: "๐Ÿ‡ฒ๐Ÿ‡บ", country: "Mauritius" }, + { code: "+60", flag: "๐Ÿ‡ฒ๐Ÿ‡พ", country: "Malaysia" }, + { code: "+228", flag: "๐Ÿ‡น๐Ÿ‡ฌ", country: "Togo" }, + { code: "+994", flag: "๐Ÿ‡ฆ๐Ÿ‡ฟ", country: "Azerbaijan" }, + { code: "+501", flag: "๐Ÿ‡ง๐Ÿ‡ฟ", country: "Belize" }, + { code: "+682", flag: "๐Ÿ‡จ๐Ÿ‡ฐ", country: "Cook Islands" }, + { code: "+1-767", flag: "๐Ÿ‡ฉ๐Ÿ‡ฒ", country: "Dominica" }, + { code: "+372", flag: "๐Ÿ‡ช๐Ÿ‡ช", country: "Estonia" }, + { code: "+220", flag: "๐Ÿ‡ฌ๐Ÿ‡ฒ", country: "Gambia" }, + { code: "+423", flag: "๐Ÿ‡ฑ๐Ÿ‡ฎ", country: "Liechtenstein" }, + { code: "+683", flag: "๐Ÿ‡ณ๐Ÿ‡บ", country: "Niue" }, + { code: "+244", flag: "๐Ÿ‡ฆ๐Ÿ‡ด", country: "Angola" }, + { code: "+241", flag: "๐Ÿ‡ฌ๐Ÿ‡ฆ", country: "Gabon" }, + { code: "+40", flag: "๐Ÿ‡ท๐Ÿ‡ด", country: "Romania" }, + { code: "+966", flag: "๐Ÿ‡ธ๐Ÿ‡ฆ", country: "Saudi Arabia" }, + { code: "+221", flag: "๐Ÿ‡ธ๐Ÿ‡ณ", country: "Senegal" }, + { code: "+232", flag: "๐Ÿ‡ธ๐Ÿ‡ฑ", country: "Sierra Leone" }, + { code: "+262", flag: "๐Ÿ‡น๐Ÿ‡ซ", country: "French Southern Territories" }, + { code: "+670", flag: "๐Ÿ‡น๐Ÿ‡ฑ", country: "Timor-Leste" }, + { code: "+1-284", flag: "๐Ÿ‡ป๐Ÿ‡ฌ", country: "Virgin Islands (British)" }, + { code: "+297", flag: "๐Ÿ‡ฆ๐Ÿ‡ผ", country: "Aruba" }, + { code: "+56", flag: "๐Ÿ‡จ๐Ÿ‡ฑ", country: "Chile" }, + { code: "+53", flag: "๐Ÿ‡จ๐Ÿ‡บ", country: "Cuba" }, + { code: "+595", flag: "๐Ÿ‡ต๐Ÿ‡พ", country: "Paraguay" }, + { code: "+43", flag: "๐Ÿ‡ฆ๐Ÿ‡น", country: "Austria" }, + { code: "+590", flag: "๐Ÿ‡ง๐Ÿ‡ฑ", country: "Saint Barthรฉlemy" }, + { code: "+238", flag: "๐Ÿ‡จ๐Ÿ‡ป", country: "Cape Verde" }, + { code: "+853", flag: "๐Ÿ‡ฒ๐Ÿ‡ด", country: "Macau" }, + { code: "+1-664", flag: "๐Ÿ‡ฒ๐Ÿ‡ธ", country: "Montserrat" }, + { code: "+265", flag: "๐Ÿ‡ฒ๐Ÿ‡ผ", country: "Malawi" }, + { code: "+678", flag: "๐Ÿ‡ป๐Ÿ‡บ", country: "Vanuatu" }, + { code: "+251", flag: "๐Ÿ‡ช๐Ÿ‡น", country: "Ethiopia" }, + { code: "+298", flag: "๐Ÿ‡ซ๐Ÿ‡ด", country: "Faroe Islands" }, + { code: "+224", flag: "๐Ÿ‡ฌ๐Ÿ‡ณ", country: "Guinea" }, + { code: "+30", flag: "๐Ÿ‡ฌ๐Ÿ‡ท", country: "Greece" }, + { code: "+370", flag: "๐Ÿ‡ฑ๐Ÿ‡น", country: "Aaland Islands" }, + { code: "+84", flag: "๐Ÿ‡ป๐Ÿ‡ณ", country: "Viet Nam" }, + { code: "+960", flag: "๐Ÿ‡ฒ๐Ÿ‡ป", country: "Maldives" }, + { code: "+264", flag: "๐Ÿ‡ณ๐Ÿ‡ฆ", country: "Namibia" }, + { code: "+31", flag: "๐Ÿ‡ณ๐Ÿ‡ฑ", country: "Netherlands" }, + { code: "+1-340", flag: "๐Ÿ‡ป๐Ÿ‡ฎ", country: "Virgin Islands (U.S.)" }, + { code: "+374", flag: "๐Ÿ‡ฆ๐Ÿ‡ฒ", country: "Armenia" }, + { code: "+255", flag: "๐Ÿ‡น๐Ÿ‡ฟ", country: "Tanzania, United Republic Of" }, + { code: "+373", flag: "๐Ÿ‡ฒ๐Ÿ‡ฉ", country: "Moldova, Republic Of" }, + { code: "+681", flag: "๐Ÿ‡ผ๐Ÿ‡ซ", country: "Wallis And Futuna Islands" }, + { code: "+46", flag: "๐Ÿ‡ธ๐Ÿ‡ช", country: "Sweden" }, + { code: "+973", flag: "๐Ÿ‡ง๐Ÿ‡ญ", country: "Bahrain" }, + { code: "+32", flag: "๐Ÿ‡ง๐Ÿ‡ช", country: "Belgium" }, + { code: "+61", flag: "๐Ÿ‡ฆ๐Ÿ‡ถ", country: "Christmas Island" }, + { code: "+20", flag: "๐Ÿ‡ช๐Ÿ‡ฌ", country: "Egypt" }, + { code: "+420", flag: "๐Ÿ‡จ๐Ÿ‡ฟ", country: "Czech Republic" }, + { code: "+61", flag: "๐Ÿ‡ฆ๐Ÿ‡บ", country: "Australia" }, + { code: "+1-441", flag: "๐Ÿ‡ง๐Ÿ‡ธ", country: "Bermuda" }, + { code: "+228", flag: "๐Ÿ‡ฌ๐Ÿ‡ฒ", country: "Guernsey" }, +]; + +export type CountryCodeEntry = (typeof COUNTRY_CODES)[number]; + +// Phone number validation regex +export const PHONE_REGEX = /^[\+]?[1-9][\d]{0,15}$/; diff --git a/app/redux/metadata/selectors.ts b/app/redux/metadata/selectors.ts index b70e490..9a67148 100644 --- a/app/redux/metadata/selectors.ts +++ b/app/redux/metadata/selectors.ts @@ -1,5 +1,7 @@ +import { createSelector } from "@reduxjs/toolkit"; import { RootState } from "../store"; import { FieldGroupMap, SidebarLink } from "./metadataSlice"; +import { COUNTRY_CODES, CountryCodeEntry } from "./constants"; export const selectMetadataState = (state: RootState) => state.metadata; @@ -33,3 +35,64 @@ export const selectTransactionStatuses = (state: RootState): string[] => export const selectNavigationSidebar = (state: RootState): SidebarLink[] => state.metadata.data?.sidebar?.links ?? []; + +export const selectConditionOperators = ( + state: RootState +): Record | undefined => + state.metadata.data?.field_names?.conditions; + +export const selectTransactionFieldNames = ( + state: RootState +): Record | undefined => + state.metadata.data?.field_names?.transactions; + +// Re-Selectcrors +const normalizeCountryName = (value: string): string => + value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]/gi, "") + .toLowerCase(); + +const COUNTRY_CODE_LOOKUP = COUNTRY_CODES.reduce< + Record +>((acc, entry) => { + acc[normalizeCountryName(entry.country)] = entry; + return acc; +}, {}); + +const findCountryMetadata = ( + countryName: string +): CountryCodeEntry | undefined => { + const normalized = normalizeCountryName(countryName); + if (!normalized) { + return undefined; + } + + if (COUNTRY_CODE_LOOKUP[normalized]) { + return COUNTRY_CODE_LOOKUP[normalized]; + } + + return COUNTRY_CODES.find(entry => { + const normalizedCountry = normalizeCountryName(entry.country); + return ( + normalizedCountry && + (normalizedCountry.includes(normalized) || + normalized.includes(normalizedCountry)) + ); + }); +}; + +export const selectPhoneNumberCountries = createSelector( + [selectCountries], + countries => + countries.map(country => { + const metadata = findCountryMetadata(country); + + return { + code: metadata?.code ?? "", + flag: metadata?.flag ?? "", + name: country, + }; + }) +); diff --git a/app/redux/user/epic.ts b/app/redux/user/epic.ts index 4fda5a8..dfa8d0e 100644 --- a/app/redux/user/epic.ts +++ b/app/redux/user/epic.ts @@ -5,6 +5,7 @@ import toast from "react-hot-toast"; import { filter, switchMap } from "rxjs/operators"; import { of } from "rxjs"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export const changePasswordEpic: Epic = action$ => action$.pipe( // Listen for any action related to changePassword (pending, fulfilled, rejected) diff --git a/app/services/audits.ts b/app/services/audits.ts index 9723e26..9dbb954 100644 --- a/app/services/audits.ts +++ b/app/services/audits.ts @@ -3,6 +3,7 @@ interface GetAuditsParams { page?: number; sort?: string; filter?: string; + entity?: string; signal?: AbortSignal; } @@ -11,6 +12,7 @@ export async function getAudits({ page, sort, filter, + entity, signal, }: GetAuditsParams = {}) { const params = new URLSearchParams(); @@ -19,6 +21,7 @@ export async function getAudits({ if (page) params.set("page", String(page)); if (sort) params.set("sort", sort); if (filter) params.set("filter", filter); + if (entity) params.set("Entity", entity); const queryString = params.toString(); const response = await fetch( diff --git a/middleware.ts b/middleware.ts index 59b2265..545cd9e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,6 +5,46 @@ import { jwtVerify } from "jose"; const COOKIE_NAME = "auth_token"; const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!); +// Define route-to-role mappings +// Routes can be protected by specific roles/groups or left open to all authenticated users +// Users can have multiple groups, and access is granted if ANY of their groups match +const ROUTE_ROLES: Record = { + // Admin routes - only accessible by Super Admin or admin groups + "/dashboard/admin": ["Super Admin", "Admin"], + "/admin": ["Super Admin", "Admin"], + + // Add more route guards here as needed + // Example: "/dashboard/settings": ["Super Admin", "admin", "manager"], + // Example: "/dashboard/transactions": ["Super Admin", "admin", "operator", "viewer"], +}; + +/** + * Check if a user's groups have access to a specific route + * Returns true if ANY of the user's groups match ANY of the required roles + */ +function hasRouteAccess( + userGroups: string[] | undefined, + pathname: string +): boolean { + // If no role is required for this route, allow access + const requiredRoles = Object.entries(ROUTE_ROLES).find(([route]) => + pathname.startsWith(route) + )?.[1]; + + // If no role requirement found, allow access (route is open to all authenticated users) + if (!requiredRoles) { + return true; + } + + // If user has no groups, deny access + if (!userGroups || userGroups.length === 0) { + return false; + } + + // Check if ANY of the user's groups match ANY of the required roles + return userGroups.some(group => requiredRoles.includes(group)); +} + function isExpired(exp?: number) { return exp ? exp * 1000 <= Date.now() : false; } @@ -17,9 +57,11 @@ async function validateToken(token: string) { algorithms: ["HS256"], }); + console.log("[middleware] payload", payload); return payload as { exp?: number; MustChangePassword?: boolean; + Groups?: string[]; [key: string]: unknown; }; } catch (err) { @@ -67,6 +109,19 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(loginUrl); } + // 5๏ธโƒฃ Role-based route guard (checking Groups array) + const userGroups = (payload.Groups as string[] | undefined) || []; + if (!hasRouteAccess(userGroups, currentPath)) { + // Redirect to dashboard home or unauthorized page + const unauthorizedUrl = new URL("/dashboard", request.url); + unauthorizedUrl.searchParams.set("reason", "unauthorized"); + unauthorizedUrl.searchParams.set( + "message", + "You don't have permission to access this page" + ); + return NextResponse.redirect(unauthorizedUrl); + } + // โœ… All good return NextResponse.next(); }