From 4f230fa208b14565eb453d97ccfb06b6c8a2d05a Mon Sep 17 00:00:00 2001 From: Mitchell Magro Date: Wed, 19 Nov 2025 16:30:16 +0100 Subject: [PATCH] Refactored more on data tables --- app/api/dashboard/transactions/route.ts | 40 ++ app/dashboard/audits/page.tsx | 41 +- app/dashboard/transactions/all/page.tsx | 29 +- app/dashboard/transactions/deposits/page.tsx | 6 +- .../AdvancedSearch/AdvancedSearch.tsx | 17 +- app/features/AdvancedSearch/utils/utils.ts | 30 -- app/features/DataTable/DataTable.tsx | 61 ++- app/features/DataTable/constants.ts | 2 +- app/features/DataTable/re-selectors/index.tsx | 395 +++++++++--------- app/redux/advanedSearch/selectors.ts | 9 + app/redux/metadata/selectors.ts | 5 + app/services/audits.ts | 3 + 12 files changed, 379 insertions(+), 259 deletions(-) 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}= & { 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..dca5190 100644 --- a/app/dashboard/transactions/all/page.tsx +++ b/app/dashboard/transactions/all/page.tsx @@ -18,11 +18,18 @@ 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 [totalRows, setRowCount] = useState(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 +44,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 +66,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/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..412ed5a 100644 --- a/app/features/AdvancedSearch/utils/utils.ts +++ b/app/features/AdvancedSearch/utils/utils.ts @@ -1,17 +1,3 @@ -// ----------------------------------------------------- -// 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") @@ -27,22 +13,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..d39fcae 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, useEffect, 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" && } @@ -130,6 +145,10 @@ const DataTable = ({ 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) { - return TABLE_COLUMNS; - } +// ------------------------- +// Base Columns +// ------------------------- + +const makeSelectBaseColumns = () => + createSelector([propsEnableStatusActions], enableStatusActions => { + if (!enableStatusActions) return TABLE_COLUMNS; return [ ...TABLE_COLUMNS, @@ -71,168 +60,192 @@ export const selectBaseColumns = createSelector( filterable: false, } as GridColDef, ]; - } -); + }); -export const selectVisibleColumns = createSelector( - [selectBaseColumns, selectExtraColumns, selectShowExtraColumns], - (baseColumns, extraColumns, showExtraColumns) => { - if (!extraColumns || extraColumns.length === 0) { - return baseColumns; +// ------------------------- +// Visible Columns +// ------------------------- + +const makeSelectVisibleColumns = () => + createSelector( + [makeSelectBaseColumns(), propsExtraColumns, propsShowExtraColumns], + (baseColumns, extraColumns, showExtraColumns) => { + if (!extraColumns || extraColumns.length === 0) return baseColumns; + + return showExtraColumns + ? baseColumns + : baseColumns.filter(col => !extraColumns.includes(col.field)); } + ); - return showExtraColumns - ? baseColumns - : baseColumns.filter(col => !extraColumns.includes(col.field)); - } -); +// ------------------------- +// Resolved Statuses (STATE-based) +// ------------------------- -export const selectResolvedTransactionStatuses = createSelector( - [selectTransactionStatuses], - statuses => (statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK) -); +const makeSelectResolvedStatuses = () => + createSelector([selectTransactionStatuses], statuses => + statuses.length > 0 ? statuses : TRANSACTION_STATUS_FALLBACK + ); -export const selectEnhancedColumns = createSelector( - [ - selectVisibleColumns, - selectLocalRows, - selectStatusChangeHandler, - selectResolvedTransactionStatuses, - ], - ( - visibleColumns, - localRows, - handleStatusChange, - resolvedStatusOptions - ): GridColDef[] => { - return visibleColumns.map(col => { - if (col.field === "status") { - return { - ...col, - renderCell: (params: GridRenderCellParams) => { - const value = params.value?.toLowerCase(); - let bgColor = "#e0e0e0"; - let textColor = "#000"; - switch (value) { - case "completed": - bgColor = "#d0f0c0"; - textColor = "#1b5e20"; - break; - case "pending": - bgColor = "#fff4cc"; - textColor = "#9e7700"; - break; - case "inprogress": - bgColor = "#cce5ff"; - textColor = "#004085"; - break; - case "error": - bgColor = "#ffcdd2"; - textColor = "#c62828"; - break; - } - return ( +// ------------------------- +// Enhanced Columns +// ------------------------- + +export const makeSelectEnhancedColumns = () => + createSelector( + [ + makeSelectVisibleColumns(), + propsLocalRows, + propsStatusChangeHandler, + makeSelectResolvedStatuses(), + ], + ( + visibleColumns, + localRows, + handleStatusChange, + resolvedStatusOptions + ): GridColDef[] => { + 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/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/selectors.ts b/app/redux/metadata/selectors.ts index b70e490..6d84b41 100644 --- a/app/redux/metadata/selectors.ts +++ b/app/redux/metadata/selectors.ts @@ -33,3 +33,8 @@ 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; 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(