Hooked up transaction with filtering
This commit is contained in:
parent
c686965b37
commit
5638d02793
@ -5,7 +5,6 @@ import { useEffect, useRef } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { AppDispatch } from "@/app/redux/types";
|
||||
import { validateAuth } from "./redux/auth/authSlice";
|
||||
import { fetchMetadata } from "./redux/metadata/metadataSlice";
|
||||
export function AuthBootstrap() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
@ -71,7 +71,6 @@ export async function PUT(
|
||||
|
||||
// Transform the request body to match backend format
|
||||
const transformedBody = transformUserUpdateData(body);
|
||||
console.log("[PUT /api/v1/users/{id}] - transformed body", transformedBody);
|
||||
|
||||
// Get the auth token from cookies
|
||||
const { cookies } = await import("next/headers");
|
||||
|
||||
@ -1,264 +0,0 @@
|
||||
import { GridColDef } from "@mui/x-data-grid";
|
||||
|
||||
export const allTransactionDummyData = [
|
||||
{
|
||||
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 allTransactionsColumns: 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 allTransactionsExtraColumns: 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 allTransactionsSearchLabels = [
|
||||
{ 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" },
|
||||
];
|
||||
@ -1,84 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
allTransactionDummyData,
|
||||
allTransactionsColumns,
|
||||
allTransactionsSearchLabels,
|
||||
extraColumns,
|
||||
} from "./mockData";
|
||||
|
||||
// import { formatToDateTimeString } from "@/app/utils/formatDate";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
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");
|
||||
|
||||
const dateTimeStart = searchParams.get("dateTime_start");
|
||||
const dateTimeEnd = searchParams.get("dateTime_end");
|
||||
|
||||
let filteredTransactions = [...allTransactionDummyData];
|
||||
|
||||
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())) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid date range",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
filteredTransactions = filteredTransactions.filter(tx => {
|
||||
const txDate = new Date(tx.dateTime);
|
||||
|
||||
if (isNaN(txDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return txDate >= start && txDate <= end;
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tableRows: filteredTransactions,
|
||||
tableSearchLabels: allTransactionsSearchLabels,
|
||||
tableColumns: allTransactionsColumns,
|
||||
extraColumns: extraColumns,
|
||||
});
|
||||
}
|
||||
99
app/api/dashboard/transactions/route.ts
Normal file
99
app/api/dashboard/transactions/route.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BE_BASE_URL = process.env.BE_BASE_URL || "http://localhost:5000";
|
||||
const COOKIE_NAME = "auth_token";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { cookies } = await import("next/headers");
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ message: "Missing Authorization header" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { filters = {}, pagination = { page: 1, limit: 10 }, sort } = body;
|
||||
|
||||
// Build query string for backend
|
||||
const queryParts: string[] = [];
|
||||
|
||||
// Add pagination (standard key=value format)
|
||||
queryParts.push(`limit=${pagination.limit}`);
|
||||
queryParts.push(`page=${pagination.page}`);
|
||||
|
||||
// Add sorting if provided (still key=value)
|
||||
if (sort) {
|
||||
queryParts.push(`sort=${sort.field}:${sort.order}`);
|
||||
}
|
||||
|
||||
// Process filters - convert FilterValue objects to operator/value format
|
||||
for (const [key, filterValue] of Object.entries(filters)) {
|
||||
if (!filterValue) continue;
|
||||
|
||||
let op: string;
|
||||
let value: string;
|
||||
|
||||
if (typeof filterValue === "string") {
|
||||
// Simple string filter - default to ==
|
||||
op = "==";
|
||||
value = filterValue;
|
||||
} else {
|
||||
// FilterValue object with operator and value
|
||||
const filterVal = filterValue as { operator?: string; value: string };
|
||||
op = filterVal.operator || "==";
|
||||
value = filterVal.value;
|
||||
}
|
||||
|
||||
if (!value) continue;
|
||||
|
||||
// Encode value to prevent breaking URL
|
||||
const encodedValue = encodeURIComponent(value);
|
||||
queryParts.push(`${key}=${op}/${encodedValue}`);
|
||||
}
|
||||
|
||||
const queryString = queryParts.join("&");
|
||||
const backendUrl = `${BE_BASE_URL}/api/v1/transactions${queryString ? `?${queryString}` : ""}`;
|
||||
|
||||
console.log("[DEBUG] [TRANSACTIONS] Backend URL:", backendUrl);
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ message: "Failed to fetch transactions" }));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: errorData?.message || "Failed to fetch transactions",
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("[DEBUG] [TRANSACTIONS] Response data:", data);
|
||||
|
||||
return NextResponse.json(data, { status: response.status });
|
||||
} catch (err: unknown) {
|
||||
console.error("Proxy GET /api/v1/transactions error:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json(
|
||||
{ message: "Internal server error", error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -28,8 +28,6 @@ export async function GET() {
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log("metadata", data);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@ -1,23 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import DataTable from "@/app/features/DataTable/DataTable";
|
||||
import { getTransactions } from "@/app/services/transactions";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
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";
|
||||
|
||||
export default async function AllTransactionPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
// Await searchParams before processing
|
||||
const params = await searchParams;
|
||||
// Create a safe query string by filtering only string values
|
||||
const safeParams: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === "string") {
|
||||
safeParams[key] = value;
|
||||
}
|
||||
}
|
||||
const query = new URLSearchParams(safeParams).toString();
|
||||
const transactionType = "all";
|
||||
const data = await getTransactions({ transactionType, query });
|
||||
import {
|
||||
TABLE_COLUMNS,
|
||||
TABLE_SEARCH_LABELS,
|
||||
} from "@/app/features/DataTable/constants";
|
||||
|
||||
return <DataTable data={data} />;
|
||||
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 default function AllTransactionPage() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const filters = useSelector(selectFilters);
|
||||
const pagination = useSelector(selectPagination);
|
||||
const sort = useSelector(selectSort);
|
||||
|
||||
const [tableRows, setTableRows] = useState<TransactionRow[]>([]);
|
||||
const extraColumns: string[] = []; // static for now
|
||||
|
||||
// Memoize rows to avoid new reference each render
|
||||
const memoizedRows = useMemo(() => tableRows, [tableRows]);
|
||||
|
||||
// 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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filters, pagination, sort }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
dispatch(setAdvancedSearchError("Failed to fetch transactions"));
|
||||
setTableRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const backendData = await response.json();
|
||||
const transactions = backendData.transactions || [];
|
||||
|
||||
const rows = transactions.map((tx: any) => ({
|
||||
id: tx.id,
|
||||
userId: tx.customer,
|
||||
transactionId: tx.external_id || tx.id,
|
||||
type: tx.type,
|
||||
currency: tx.currency,
|
||||
amount: tx.amount,
|
||||
status: tx.status,
|
||||
dateTime: tx.created || tx.modified,
|
||||
merchantId: tx.merchant_id,
|
||||
pspId: tx.psp_id,
|
||||
methodId: tx.method_id,
|
||||
modified: tx.modified,
|
||||
}));
|
||||
|
||||
setTableRows(rows);
|
||||
dispatch(setStatus("succeeded"));
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
setAdvancedSearchError(
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
)
|
||||
);
|
||||
setTableRows([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [dispatch, filters, pagination, sort]);
|
||||
|
||||
return <DataTable rows={memoizedRows} extraColumns={extraColumns} />;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
@ -16,57 +17,158 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ISearchLabel } from "../DataTable/types";
|
||||
import { AppDispatch } from "@/app/redux/store";
|
||||
import {
|
||||
updateFilter,
|
||||
clearFilters,
|
||||
FilterValue,
|
||||
} from "@/app/redux/advanedSearch/advancedSearchSlice";
|
||||
import { selectFilters } from "@/app/redux/advanedSearch/selectors";
|
||||
import { normalizeValue, defaultOperatorForField } from "./utils/utils";
|
||||
|
||||
// -----------------------------------------------------
|
||||
// COMPONENT
|
||||
// -----------------------------------------------------
|
||||
|
||||
export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const filters = useSelector(selectFilters);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Local form state for UI (synced with Redux)
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||
const [operators, setOperators] = useState<Record<string, string>>({});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// SYNC REDUX FILTERS TO LOCAL STATE ON LOAD
|
||||
// -----------------------------------------------------
|
||||
useEffect(() => {
|
||||
const initialParams = Object.fromEntries(searchParams.entries());
|
||||
setFormValues(initialParams);
|
||||
}, [searchParams]);
|
||||
const values: Record<string, string> = {};
|
||||
const ops: Record<string, string> = {};
|
||||
|
||||
const updateURL = useMemo(
|
||||
labels.forEach(({ field, type }) => {
|
||||
const filter = filters[field];
|
||||
|
||||
if (filter) {
|
||||
if (typeof filter === "string") {
|
||||
// Simple string filter
|
||||
values[field] = filter;
|
||||
ops[field] = defaultOperatorForField(field, type);
|
||||
} else {
|
||||
// FilterValue object with operator and value
|
||||
values[field] = filter.value;
|
||||
ops[field] = filter.operator;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle date ranges
|
||||
const startKey = `${field}_start`;
|
||||
const endKey = `${field}_end`;
|
||||
const startFilter = filters[startKey];
|
||||
const endFilter = filters[endKey];
|
||||
|
||||
if (startFilter && typeof startFilter === "string") {
|
||||
values[startKey] = startFilter;
|
||||
}
|
||||
if (endFilter && typeof endFilter === "string") {
|
||||
values[endKey] = endFilter;
|
||||
}
|
||||
});
|
||||
|
||||
setFormValues(values);
|
||||
setOperators(ops);
|
||||
}, [filters, labels]);
|
||||
|
||||
// -----------------------------------------------------
|
||||
// DEBOUNCED FILTER UPDATE
|
||||
// -----------------------------------------------------
|
||||
const debouncedUpdateFilter = useMemo(
|
||||
() =>
|
||||
debounce((newValues: Record<string, string>) => {
|
||||
const updatedParams = new URLSearchParams();
|
||||
Object.entries(newValues).forEach(([key, value]) => {
|
||||
if (value) updatedParams.set(key, value);
|
||||
});
|
||||
router.push(`?${updatedParams.toString()}`);
|
||||
}, 500),
|
||||
[router]
|
||||
debounce(
|
||||
(field: string, value: string | undefined, operator?: string) => {
|
||||
if (!value || value === "") {
|
||||
dispatch(updateFilter({ field, value: undefined }));
|
||||
return;
|
||||
}
|
||||
|
||||
const safeValue = normalizeValue(value);
|
||||
if (!safeValue) {
|
||||
dispatch(updateFilter({ field, value: undefined }));
|
||||
return;
|
||||
}
|
||||
|
||||
// For text/select fields, use FilterValue with operator
|
||||
const filterValue: FilterValue = {
|
||||
operator: operator ?? defaultOperatorForField(field, "text"),
|
||||
value: safeValue,
|
||||
};
|
||||
|
||||
dispatch(updateFilter({ field, value: filterValue }));
|
||||
},
|
||||
300
|
||||
),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleFieldChange = (field: string, value: string) => {
|
||||
const updatedValues = { ...formValues, [field]: value };
|
||||
console.log(updatedValues);
|
||||
setFormValues(updatedValues);
|
||||
updateURL(updatedValues);
|
||||
// -----------------------------------------------------
|
||||
// handlers
|
||||
// -----------------------------------------------------
|
||||
|
||||
const updateField = (field: string, value: string) => {
|
||||
setFormValues(prev => ({ ...prev, [field]: value }));
|
||||
const operator = operators[field] ?? defaultOperatorForField(field, "text");
|
||||
debouncedUpdateFilter(field, value, operator);
|
||||
};
|
||||
|
||||
const updateOperator = (field: string, op: string) => {
|
||||
setOperators(prev => ({ ...prev, [field]: op }));
|
||||
|
||||
// If value exists, update filter immediately with new operator
|
||||
const currentValue = formValues[field];
|
||||
if (currentValue) {
|
||||
const safeValue = normalizeValue(currentValue);
|
||||
if (safeValue) {
|
||||
dispatch(
|
||||
updateFilter({
|
||||
field,
|
||||
value: { operator: op, value: safeValue },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateDateRange = (
|
||||
field: string,
|
||||
start: string | undefined,
|
||||
end: string | undefined
|
||||
) => {
|
||||
if (start) {
|
||||
dispatch(updateFilter({ field: `${field}_start`, value: start }));
|
||||
} else {
|
||||
dispatch(updateFilter({ field: `${field}_start`, value: undefined }));
|
||||
}
|
||||
|
||||
if (end) {
|
||||
dispatch(updateFilter({ field: `${field}_end`, value: end }));
|
||||
} else {
|
||||
dispatch(updateFilter({ field: `${field}_end`, value: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormValues({});
|
||||
router.push("?");
|
||||
setOperators({});
|
||||
dispatch(clearFilters());
|
||||
};
|
||||
|
||||
const toggleDrawer =
|
||||
(open: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
|
||||
if (
|
||||
event.type === "keydown" &&
|
||||
((event as React.KeyboardEvent).key === "Tab" ||
|
||||
(event as React.KeyboardEvent).key === "Shift")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(open);
|
||||
};
|
||||
|
||||
// -----------------------------------------------------
|
||||
// render
|
||||
// -----------------------------------------------------
|
||||
return (
|
||||
<Box sx={{ width: "185px" }}>
|
||||
<Button
|
||||
@ -79,134 +181,211 @@ export default function AdvancedSearch({ labels }: { labels: ISearchLabel[] }) {
|
||||
boxShadow: "inset 0 0 0 1px #ddd",
|
||||
fontWeight: 400,
|
||||
fontSize: "16px",
|
||||
justifyContent: "flex-start",
|
||||
"& .MuiButton-startIcon": {
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: "#e0e0e0",
|
||||
},
|
||||
"&:hover": { backgroundColor: "#e0e0e0" },
|
||||
}}
|
||||
startIcon={<SearchIcon />}
|
||||
onClick={toggleDrawer(true)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Advanced Search
|
||||
</Button>
|
||||
|
||||
<Drawer anchor="right" open={open} onClose={toggleDrawer(false)}>
|
||||
<Box sx={{ width: 400 }} role="presentation">
|
||||
<Drawer anchor="right" open={open} onClose={() => setOpen(false)}>
|
||||
<Box sx={{ width: 400 }} p={2}>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Box p={2}>
|
||||
<Box sx={{ display: "flex", gap: "60px" }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Search
|
||||
</Typography>
|
||||
<Box display="flex" justifyContent="flex-end" gap={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SearchIcon />}
|
||||
onClick={() => console.log("Params:", formValues)}
|
||||
>
|
||||
Apply Filter
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={resetForm}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}
|
||||
>
|
||||
<Typography variant="h6">Search</Typography>
|
||||
|
||||
<Box display="flex" gap={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SearchIcon />}
|
||||
onClick={() => {
|
||||
// Apply all current form values to Redux
|
||||
labels.forEach(({ field, type }) => {
|
||||
const val = formValues[field];
|
||||
if (!val) return;
|
||||
|
||||
if (type === "select" || type === "text") {
|
||||
const operator =
|
||||
operators[field] ??
|
||||
defaultOperatorForField(field, type);
|
||||
const safeValue = normalizeValue(val);
|
||||
if (safeValue) {
|
||||
dispatch(
|
||||
updateFilter({
|
||||
field,
|
||||
value: { operator, value: safeValue },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={resetForm}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{labels?.map(({ label, field, type, options }) => (
|
||||
<Box key={field}>
|
||||
<Typography variant="body2" fontWeight={600} mb={0.5}>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{type === "text" && (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formValues[field] || ""}
|
||||
onChange={e => handleFieldChange(field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === "select" && (
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={formValues[field] || ""}
|
||||
onChange={e =>
|
||||
handleFieldChange(field, e.target.value)
|
||||
}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>{label}</em>
|
||||
</MenuItem>
|
||||
{options?.map(option => (
|
||||
<MenuItem value={option} key={option}>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{type === "date" && (
|
||||
<Stack spacing={2}>
|
||||
<DatePicker
|
||||
label="Start Date"
|
||||
value={
|
||||
formValues[`${field}_start`]
|
||||
? new Date(formValues[`${field}_start`])
|
||||
: null
|
||||
}
|
||||
onChange={newValue => {
|
||||
if (!newValue)
|
||||
return handleFieldChange(`${field}_start`, "");
|
||||
const start = new Date(newValue);
|
||||
start.setHours(0, 0, 0, 0); // force start of day
|
||||
handleFieldChange(
|
||||
`${field}_start`,
|
||||
start.toISOString()
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: { fullWidth: true, size: "small" },
|
||||
}}
|
||||
/>
|
||||
<DatePicker
|
||||
label="End Date"
|
||||
value={
|
||||
formValues[`${field}_end`]
|
||||
? new Date(formValues[`${field}_end`])
|
||||
: null
|
||||
}
|
||||
onChange={newValue => {
|
||||
if (!newValue)
|
||||
return handleFieldChange(`${field}_end`, "");
|
||||
const end = new Date(newValue);
|
||||
end.setHours(23, 59, 59, 999); // force end of day
|
||||
handleFieldChange(
|
||||
`${field}_end`,
|
||||
end.toISOString()
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: { fullWidth: true, size: "small" },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{labels.map(({ label, field, type, options }) => (
|
||||
<Box key={field}>
|
||||
<Typography variant="body2" fontWeight={600} mb={0.5}>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
{/* TEXT FIELDS */}
|
||||
{type === "text" && (
|
||||
<>
|
||||
{/* AMOUNT WITH OPERATOR */}
|
||||
{field === "Amount" ? (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 130 }}>
|
||||
<Select
|
||||
value={operators[field] ?? ">="}
|
||||
onChange={e =>
|
||||
updateOperator(field, e.target.value)
|
||||
}
|
||||
>
|
||||
<MenuItem value=">=">Greater or equal</MenuItem>
|
||||
<MenuItem value="<=">Less or equal</MenuItem>
|
||||
<MenuItem value="=">Equal</MenuItem>
|
||||
<MenuItem value="!=">Not equal</MenuItem>
|
||||
<MenuItem value=">">Greater</MenuItem>
|
||||
<MenuItem value="<">Less</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formValues[field] ?? ""}
|
||||
onChange={e => updateField(field, e.target.value)}
|
||||
placeholder="Enter amount"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={formValues[field] ?? ""}
|
||||
onChange={e => updateField(field, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SELECTS */}
|
||||
{type === "select" && (
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
value={formValues[field] ?? ""}
|
||||
onChange={e => updateField(field, e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>{label}</em>
|
||||
</MenuItem>
|
||||
{options?.map(opt => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{/* DATE RANGE */}
|
||||
{type === "date" && (
|
||||
<Stack spacing={2}>
|
||||
<DatePicker
|
||||
label="Start Date"
|
||||
value={
|
||||
formValues[`${field}_start`]
|
||||
? new Date(formValues[`${field}_start`])
|
||||
: null
|
||||
}
|
||||
onChange={value => {
|
||||
if (!value) {
|
||||
updateDateRange(
|
||||
field,
|
||||
undefined,
|
||||
formValues[`${field}_end`]
|
||||
);
|
||||
setFormValues(prev => ({
|
||||
...prev,
|
||||
[`${field}_start`]: "",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const v = new Date(value);
|
||||
v.setHours(0, 0, 0, 0);
|
||||
const isoString = v.toISOString();
|
||||
setFormValues(prev => ({
|
||||
...prev,
|
||||
[`${field}_start`]: isoString,
|
||||
}));
|
||||
updateDateRange(
|
||||
field,
|
||||
isoString,
|
||||
formValues[`${field}_end`]
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: { fullWidth: true, size: "small" },
|
||||
}}
|
||||
/>
|
||||
|
||||
<DatePicker
|
||||
label="End Date"
|
||||
value={
|
||||
formValues[`${field}_end`]
|
||||
? new Date(formValues[`${field}_end`])
|
||||
: null
|
||||
}
|
||||
onChange={value => {
|
||||
if (!value) {
|
||||
updateDateRange(
|
||||
field,
|
||||
formValues[`${field}_start`],
|
||||
undefined
|
||||
);
|
||||
setFormValues(prev => ({
|
||||
...prev,
|
||||
[`${field}_end`]: "",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const v = new Date(value);
|
||||
v.setHours(23, 59, 59, 999);
|
||||
const isoString = v.toISOString();
|
||||
setFormValues(prev => ({
|
||||
...prev,
|
||||
[`${field}_end`]: isoString,
|
||||
}));
|
||||
updateDateRange(
|
||||
field,
|
||||
formValues[`${field}_start`],
|
||||
isoString
|
||||
);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: { fullWidth: true, size: "small" },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</LocalizationProvider>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
54
app/features/AdvancedSearch/utils/utils.ts
Normal file
54
app/features/AdvancedSearch/utils/utils.ts
Normal file
@ -0,0 +1,54 @@
|
||||
// -----------------------------------------------------
|
||||
// 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}`;
|
||||
|
||||
export const normalizeValue = (input: any): string => {
|
||||
if (input == null) return "";
|
||||
if (typeof input === "string" || typeof input === "number")
|
||||
return String(input);
|
||||
|
||||
if (input?.value) return String(input.value);
|
||||
if (input?.id) return String(input.id);
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.map(normalizeValue).join(",");
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
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,
|
||||
type: string
|
||||
): string => {
|
||||
if (field === "Amount") return ">="; // numeric field
|
||||
if (type === "text") return "LIKE"; // string/text search
|
||||
return "=="; // everything else (select, etc.)
|
||||
};
|
||||
@ -1,86 +1,58 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Stack,
|
||||
Paper,
|
||||
TextField,
|
||||
Box,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import FileUploadIcon from "@mui/icons-material/FileUpload";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
|
||||
import AdvancedSearch from "../AdvancedSearch/AdvancedSearch";
|
||||
import SearchFilters from "@/app/components/searchFilter/SearchFilters";
|
||||
import { exportData } from "@/app/utils/exportData";
|
||||
import StatusChangeDialog from "./StatusChangeDialog";
|
||||
import { IDataTable } from "./types";
|
||||
|
||||
interface IDataTableProps<TRow, TColumn> {
|
||||
data: IDataTable<TRow, TColumn>;
|
||||
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 DataTableHeader from "./DataTableHeader";
|
||||
import { TABLE_COLUMNS } from "./constants";
|
||||
import Spinner from "@/app/components/Spinner/Spinner";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectStatus, selectError } from "@/app/redux/auth/selectors";
|
||||
|
||||
interface IDataTableProps<TRow extends { id: number }> {
|
||||
rows: TRow[];
|
||||
extraColumns?: string[];
|
||||
}
|
||||
|
||||
export type TWithId = { id: number };
|
||||
|
||||
const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
data,
|
||||
}: IDataTableProps<TRow, TColumn>) => {
|
||||
const { tableRows, tableColumns, tableSearchLabels, extraColumns } = data;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [rows, setRows] = useState<TRow[]>(tableRows);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [fileType, setFileType] = useState<"csv" | "xls" | "xlsx">("csv");
|
||||
const [onlyCurrentTable, setOnlyCurrentTable] = useState(false);
|
||||
const DataTable = <TRow extends { id: number }>({
|
||||
rows,
|
||||
extraColumns,
|
||||
}: IDataTableProps<TRow>) => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedRowId, setSelectedRowId] = useState<number | null>(null);
|
||||
const [newStatus, setNewStatus] = useState<string>("");
|
||||
const [reason, setReason] = useState<string>("");
|
||||
const [showExtraColumns, setShowExtraColumns] = useState(false);
|
||||
|
||||
const filters = Object.fromEntries(searchParams.entries());
|
||||
const status = useSelector(selectStatus);
|
||||
const errorMessage = useSelector(selectError);
|
||||
|
||||
const handleClickField = (field: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(field, value);
|
||||
router.push(`?${params.toString()}`);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleStatusChange = (id: number, newStatus: string) => {
|
||||
// Open status modal
|
||||
const handleStatusChange = (id: number, status: string) => {
|
||||
setSelectedRowId(id);
|
||||
setNewStatus(newStatus);
|
||||
setNewStatus(status);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleStatusSave = () => {
|
||||
console.log(
|
||||
`Status changed for row with ID ${selectedRowId}. New status: ${newStatus}. Reason: ${reason}`
|
||||
);
|
||||
|
||||
setRows(
|
||||
rows.map(row =>
|
||||
row.id === selectedRowId ? { ...row, status: newStatus } : row
|
||||
)
|
||||
);
|
||||
setModalOpen(false);
|
||||
setReason("");
|
||||
// rows update should happen in parent component
|
||||
};
|
||||
|
||||
const getColumnsWithDropdown = (columns: TColumn[]): GridColDef[] => {
|
||||
return columns.map(col => {
|
||||
// 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]);
|
||||
|
||||
// Columns with custom renderers
|
||||
const enhancedColumns = useMemo<GridColDef[]>(() => {
|
||||
return visibleColumns.map(col => {
|
||||
if (col.field === "status") {
|
||||
return {
|
||||
...col,
|
||||
@ -88,7 +60,6 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
const value = params.value?.toLowerCase();
|
||||
let bgColor = "#e0e0e0";
|
||||
let textColor = "#000";
|
||||
|
||||
switch (value) {
|
||||
case "completed":
|
||||
bgColor = "#d0f0c0";
|
||||
@ -107,7 +78,6 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
textColor = "#c62828";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@ -144,7 +114,7 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
width: "100%",
|
||||
px: 1,
|
||||
}}
|
||||
onClick={e => e.stopPropagation()} // keep row click from firing when clicking inside
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
@ -155,7 +125,6 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
>
|
||||
{params.value}
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
href={`/users/${params.value}`}
|
||||
target="_blank"
|
||||
@ -171,179 +140,63 @@ const DataTable = <TRow extends TWithId, TColumn extends GridColDef>({
|
||||
};
|
||||
}
|
||||
|
||||
if (col.field === "actions") {
|
||||
return {
|
||||
...col,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const row = tableRows.find(r => r.id === params.id) as {
|
||||
id: number;
|
||||
status?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
};
|
||||
|
||||
const options = row?.options;
|
||||
if (!options) return params.value;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={params.value ?? row.status}
|
||||
onChange={e =>
|
||||
handleStatusChange(params.id as number, e.target.value)
|
||||
}
|
||||
size="small"
|
||||
sx={{
|
||||
width: "100%",
|
||||
"& .MuiOutlinedInput-notchedOutline": { border: "none" },
|
||||
"& .MuiSelect-select": { py: 0.5 },
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return col;
|
||||
});
|
||||
};
|
||||
|
||||
let filteredColumns = tableColumns;
|
||||
if (extraColumns && extraColumns.length > 0) {
|
||||
filteredColumns = showExtraColumns
|
||||
? tableColumns
|
||||
: tableColumns.filter(col => !extraColumns.includes(col.field));
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ width: "calc(100vw - 300px)", overflowX: "hidden" }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
p={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<TextField
|
||||
label="Search"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
|
||||
sx={{ width: 300 }}
|
||||
<>
|
||||
{status === "loading" && <Spinner size="small" color="#fff" />}
|
||||
{status === "failed" && (
|
||||
<Alert severity="error">
|
||||
{errorMessage || "Failed to load transactions."}
|
||||
</Alert>
|
||||
)}
|
||||
<Paper sx={{ width: "100%", overflowX: "hidden" }}>
|
||||
<DataTableHeader
|
||||
extraColumns={extraColumns}
|
||||
showExtraColumns={showExtraColumns}
|
||||
onToggleExtraColumns={() => setShowExtraColumns(prev => !prev)}
|
||||
onOpenExport={() => {}}
|
||||
/>
|
||||
<AdvancedSearch labels={tableSearchLabels} />
|
||||
<SearchFilters filters={filters} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileUploadIcon />}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
{extraColumns && extraColumns.length > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setShowExtraColumns(prev => !prev)}
|
||||
>
|
||||
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ width: "calc(100vw - 300px)", overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: 1200 }}>
|
||||
<DataGrid
|
||||
rows={tableRows}
|
||||
columns={getColumnsWithDropdown(filteredColumns)}
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 50 } },
|
||||
}}
|
||||
pageSizeOptions={[50, 100]}
|
||||
sx={{
|
||||
border: 0,
|
||||
cursor: "pointer",
|
||||
"& .MuiDataGrid-cell": {
|
||||
py: 1,
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
"& .MuiDataGrid-columnHeader": {
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}}
|
||||
onCellClick={params => {
|
||||
if (params.field !== "actions") {
|
||||
handleClickField(params.field, params.value as string);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ width: "100%", overflowX: "auto" }}>
|
||||
<Box sx={{ minWidth: 1200 }}>
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={enhancedColumns}
|
||||
pageSizeOptions={[10, 25, 50, 100]}
|
||||
sx={{
|
||||
border: 0,
|
||||
cursor: "pointer",
|
||||
"& .MuiDataGrid-cell": {
|
||||
py: 1,
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
"& .MuiDataGrid-columnHeader": {
|
||||
textAlign: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<StatusChangeDialog
|
||||
open={modalOpen}
|
||||
newStatus={newStatus}
|
||||
reason={reason}
|
||||
setReason={setReason}
|
||||
handleClose={() => setModalOpen(false)}
|
||||
handleSave={handleStatusSave}
|
||||
/>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)}>
|
||||
<DialogTitle>Export Transactions</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<Select
|
||||
value={fileType}
|
||||
onChange={e =>
|
||||
setFileType(e.target.value as "csv" | "xls" | "xlsx")
|
||||
}
|
||||
>
|
||||
<MenuItem value="csv">CSV</MenuItem>
|
||||
<MenuItem value="xls">XLS</MenuItem>
|
||||
<MenuItem value="xlsx">XLSX</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={onlyCurrentTable}
|
||||
onChange={e => setOnlyCurrentTable(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Only export current table"
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
exportData(
|
||||
tableRows,
|
||||
tableColumns,
|
||||
fileType,
|
||||
onlyCurrentTable,
|
||||
setOpen
|
||||
)
|
||||
}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
<StatusChangeDialog
|
||||
open={modalOpen}
|
||||
newStatus={newStatus}
|
||||
reason={reason}
|
||||
setReason={setReason}
|
||||
handleClose={() => setModalOpen(false)}
|
||||
handleSave={handleStatusSave}
|
||||
/>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTable;
|
||||
// Memoize to avoid unnecessary re-renders
|
||||
export default React.memo(DataTable);
|
||||
|
||||
51
app/features/DataTable/DataTableHeader.tsx
Normal file
51
app/features/DataTable/DataTableHeader.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { Button, TextField, Stack } from "@mui/material";
|
||||
import FileUploadIcon from "@mui/icons-material/FileUpload";
|
||||
import AdvancedSearch from "../AdvancedSearch/AdvancedSearch";
|
||||
import { TABLE_SEARCH_LABELS } from "./constants";
|
||||
|
||||
type DataTableHeaderProps = {
|
||||
extraColumns?: string[];
|
||||
showExtraColumns: boolean;
|
||||
onToggleExtraColumns: () => void;
|
||||
onOpenExport: () => void;
|
||||
};
|
||||
|
||||
export default function DataTableHeader({
|
||||
extraColumns,
|
||||
showExtraColumns,
|
||||
onToggleExtraColumns,
|
||||
onOpenExport,
|
||||
}: DataTableHeaderProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
p={2}
|
||||
flexWrap="wrap"
|
||||
gap={2}
|
||||
>
|
||||
<TextField
|
||||
label="Search = To Be Implemented"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled
|
||||
onChange={e => console.log(`setSearchQuery(${e.target.value})`)}
|
||||
sx={{ width: 300, backgroundColor: "#f0f0f0" }}
|
||||
/>
|
||||
<AdvancedSearch labels={TABLE_SEARCH_LABELS} />
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FileUploadIcon />}
|
||||
onClick={onOpenExport}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
{extraColumns && extraColumns.length > 0 && (
|
||||
<Button variant="outlined" onClick={onToggleExtraColumns}>
|
||||
{showExtraColumns ? "Hide Extra Columns" : "Show Extra Columns"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
37
app/features/DataTable/constants.ts
Normal file
37
app/features/DataTable/constants.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { GridColDef } from "@mui/x-data-grid";
|
||||
import { ISearchLabel } from "./types";
|
||||
|
||||
export const TABLE_COLUMNS: GridColDef[] = [
|
||||
{ field: "userId", headerName: "User ID", width: 130 },
|
||||
{ field: "transactionId", headerName: "Transaction ID", width: 180 },
|
||||
{ field: "type", headerName: "Type", width: 120 },
|
||||
{ field: "currency", headerName: "Currency", width: 100 },
|
||||
{ field: "amount", headerName: "Amount", width: 120 },
|
||||
{ field: "status", headerName: "Status", width: 120 },
|
||||
{ field: "dateTime", headerName: "Date / Time", width: 180 },
|
||||
];
|
||||
|
||||
export const TABLE_SEARCH_LABELS: ISearchLabel[] = [
|
||||
{ label: "User", field: "Customer", type: "text" },
|
||||
{ label: "Transaction ID", field: "ExternalID", type: "text" },
|
||||
{
|
||||
label: "Type",
|
||||
field: "Type",
|
||||
type: "select",
|
||||
options: ["deposit", "withdrawal"],
|
||||
},
|
||||
{
|
||||
label: "Currency",
|
||||
field: "Currency",
|
||||
type: "select",
|
||||
options: ["USD", "EUR", "GBP", "TRY"],
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
field: "Status",
|
||||
type: "select",
|
||||
options: ["pending", "completed", "failed"],
|
||||
},
|
||||
{ label: "Amount", field: "Amount", type: "text" },
|
||||
{ label: "Date / Time", field: "created", type: "date" },
|
||||
];
|
||||
@ -161,7 +161,7 @@ export default function UserRoleCard({ user }: Props) {
|
||||
<Modal
|
||||
open={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
title="Reset Password"
|
||||
title={`Reset Password - ${user.first_name}`}
|
||||
>
|
||||
{newPassword && (
|
||||
<div className="reset-password__content">
|
||||
|
||||
@ -24,8 +24,6 @@ export default function SidebarDropdown({ onChange }: Props) {
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
console.log("sidebar", sidebar);
|
||||
|
||||
return (
|
||||
<FormControl fullWidth variant="outlined" sx={{ minWidth: 200 }}>
|
||||
<InputLabel id="sidebar-dropdown-label">Navigate To</InputLabel>
|
||||
|
||||
@ -1,53 +1,106 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface AdvancedSearchState {
|
||||
keyword: string;
|
||||
transactionID: string;
|
||||
transactionReferenceId: string;
|
||||
user: string;
|
||||
currency: string;
|
||||
state: string;
|
||||
statusDescription: string;
|
||||
transactionType: string;
|
||||
paymentMethod: string;
|
||||
psps: string;
|
||||
initialPsps: string;
|
||||
merchants: string;
|
||||
startDate: null | string;
|
||||
endDate: null | string;
|
||||
lastUpdatedFrom: null | string;
|
||||
lastUpdatedTo: null | string;
|
||||
minAmount: string;
|
||||
maxAmount: string;
|
||||
channel: string;
|
||||
// Filter value can be a simple string or an object with operator and value
|
||||
export interface FilterValue {
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type FilterField = string | FilterValue;
|
||||
|
||||
export interface AdvancedSearchFilters {
|
||||
[field: string]: FilterField | undefined;
|
||||
}
|
||||
|
||||
export type FetchStatus = "idle" | "loading" | "succeeded" | "failed";
|
||||
|
||||
export interface AdvancedSearchState {
|
||||
filters: AdvancedSearchFilters;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
sort?: {
|
||||
field: string;
|
||||
order: "asc" | "desc";
|
||||
};
|
||||
status: FetchStatus;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: AdvancedSearchState = {
|
||||
keyword: "",
|
||||
transactionID: "",
|
||||
transactionReferenceId: "",
|
||||
user: "",
|
||||
currency: "",
|
||||
state: "",
|
||||
statusDescription: "",
|
||||
transactionType: "",
|
||||
paymentMethod: "",
|
||||
psps: "",
|
||||
initialPsps: "",
|
||||
merchants: "",
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
lastUpdatedFrom: null,
|
||||
lastUpdatedTo: null,
|
||||
minAmount: "",
|
||||
maxAmount: "",
|
||||
channel: "",
|
||||
filters: {},
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
status: "idle",
|
||||
error: null,
|
||||
};
|
||||
|
||||
const advancedSearchSlice = createSlice({
|
||||
name: "advancedSearch",
|
||||
initialState,
|
||||
reducers: {},
|
||||
reducers: {
|
||||
setFilters: (state, action: PayloadAction<AdvancedSearchFilters>) => {
|
||||
state.filters = action.payload;
|
||||
// Reset to page 1 when filters change
|
||||
state.pagination.page = 1;
|
||||
},
|
||||
updateFilter: (
|
||||
state,
|
||||
action: PayloadAction<{ field: string; value: FilterField | undefined }>
|
||||
) => {
|
||||
const { field, value } = action.payload;
|
||||
if (value === undefined || value === "") {
|
||||
delete state.filters[field];
|
||||
} else {
|
||||
state.filters[field] = value;
|
||||
}
|
||||
// Reset to page 1 when a filter changes
|
||||
state.pagination.page = 1;
|
||||
},
|
||||
clearFilters: state => {
|
||||
state.filters = {};
|
||||
state.pagination.page = 1;
|
||||
},
|
||||
setPagination: (
|
||||
state,
|
||||
action: PayloadAction<{ page: number; limit: number }>
|
||||
) => {
|
||||
state.pagination = action.payload;
|
||||
},
|
||||
setSort: (
|
||||
state,
|
||||
action: PayloadAction<
|
||||
{ field: string; order: "asc" | "desc" } | undefined
|
||||
>
|
||||
) => {
|
||||
state.sort = action.payload;
|
||||
},
|
||||
setStatus: (state, action: PayloadAction<FetchStatus>) => {
|
||||
state.status = action.payload;
|
||||
if (action.payload === "loading") {
|
||||
state.error = null;
|
||||
}
|
||||
},
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
state.error = action.payload;
|
||||
if (action.payload) {
|
||||
state.status = "failed";
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setFilters,
|
||||
updateFilter,
|
||||
clearFilters,
|
||||
setPagination,
|
||||
setSort,
|
||||
setStatus,
|
||||
setError,
|
||||
} = advancedSearchSlice.actions;
|
||||
|
||||
export default advancedSearchSlice.reducer;
|
||||
|
||||
24
app/redux/advanedSearch/selectors.ts
Normal file
24
app/redux/advanedSearch/selectors.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { RootState } from "../store";
|
||||
import {
|
||||
AdvancedSearchFilters,
|
||||
FilterField,
|
||||
FetchStatus,
|
||||
} from "./advancedSearchSlice";
|
||||
|
||||
export const selectFilters = (state: RootState): AdvancedSearchFilters =>
|
||||
state.advancedSearch.filters;
|
||||
|
||||
export const selectPagination = (state: RootState) =>
|
||||
state.advancedSearch.pagination;
|
||||
|
||||
export const selectSort = (state: RootState) => state.advancedSearch.sort;
|
||||
|
||||
export const selectFilterValue = (
|
||||
state: RootState,
|
||||
field: string
|
||||
): FilterField | undefined => state.advancedSearch.filters[field];
|
||||
|
||||
export const selectStatus = (state: RootState): FetchStatus =>
|
||||
state.advancedSearch.status;
|
||||
|
||||
export const selectError = (state: RootState) => state.advancedSearch.error;
|
||||
@ -7,6 +7,25 @@ export type AppDispatch = typeof store.dispatch;
|
||||
export type ThunkSuccess<T extends object = object> = { message: string } & T;
|
||||
export type ThunkError = string;
|
||||
|
||||
//User related types
|
||||
export type TGroupName = "Super Admin" | "Admin" | "Reader";
|
||||
|
||||
export type TJobTitle =
|
||||
| "C-Level"
|
||||
| "Admin"
|
||||
| "Operations"
|
||||
| "Support"
|
||||
| "KYC"
|
||||
| "Payments"
|
||||
| "Risk"
|
||||
| "Finance"
|
||||
| "Trading"
|
||||
| "Compliance"
|
||||
| "DevOps"
|
||||
| "Software Engineer";
|
||||
|
||||
export type TMerchantName = "Data Spin" | "Win Bot";
|
||||
|
||||
export interface IUserResponse {
|
||||
success: boolean;
|
||||
token: string;
|
||||
@ -17,6 +36,8 @@ export interface IUserResponse {
|
||||
lastName: string | null;
|
||||
merchantId?: number | null;
|
||||
username?: string | null;
|
||||
groups?: TGroupName[];
|
||||
merchants?: TMerchantName[];
|
||||
phone?: string | null;
|
||||
jobTitle?: string | null;
|
||||
enabled?: boolean;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user