import { useMemo, useCallback, useRef, useState } from 'react'; import { AgGridReactProps } from 'ag-grid-react'; import { ColDef, GridReadyEvent, GridApi, IServerSideDatasource, IServerSideGetRowsParams, SelectionChangedEvent, MenuItemDef, DefaultMenuItem, StatusBar, } from 'ag-grid-community'; import { Box, Group, TextInput, Indicator, CloseButton } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { Search, Filter, Settings } from 'lucide-react'; import { DataGrid, StatusBadge, PageActions } from '../../../../components'; import type { PageActionProps } from '../../../../components'; import { useEnterpriseModuleConfigContext, useEnterpriseModuleDataServiceContext, useEnterpriseModuleSelectionContext, useEnterpriseModuleTranslationContext, useEnterpriseModuleNavigationContext, } from '../../hooks/use-module.context'; import type { BaseEntity } from '@repo/core-api/data-services'; import { notifications } from '@mantine/notifications'; import { ModuleActionType, ModuleAction, ActionModalState, ActionModalConfig, BulkActionModalState, BulkActionResult, } from '../../entities/entity'; import { RowActionMenu } from './components/row-actions'; import { BulkActionMenu } from './components/bulk-actions'; import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal'; import { BulkActionConfirmationModal } from '../bulk-action-confirmation'; import { TableFilterDrawer, TableFilterConfig } from './components/table-filter-drawer'; import { TableSettingDrawer } from './components/table-setting-drawer'; import { EntityId } from '../../../../../../core-api/src/data-services/types'; export * from 'ag-grid-community'; export * from 'ag-grid-react'; export type { TableFilterConfig }; // --------------------------------------------------------------------------- // Types & Interfaces // --------------------------------------------------------------------------- export type PaginationMode = 'pagination' | 'infinite-scroll'; export interface EnterpriseDataTableProps extends Omit, 'rowData'> { columnDefs: ColDef[]; gridHeight?: number | string; /** * Controls the data navigation strategy: * - `'pagination'` – Manual page navigation with Previous/Next buttons (default). * - `'infinite-scroll'` – Rows are lazily loaded as the user scrolls, leveraging * AG Grid's server-side infinite row model. * * Both modes use the same server-side datasource under the hood. * @default 'pagination' */ paginationMode?: PaginationMode; /** * Custom message displayed in the loading overlay while data is being fetched. * @default 'Loading data...' */ loadingMessage?: string; /** * Custom message displayed when the datasource returns zero rows. * @default 'No records found' */ noRowsMessage?: string; showStatusbar?: boolean; customPrefixColumn?: (col: ColDef[]) => ColDef[]; // Action related props statusKey?: string; customRowActions?: (data: E, defaultActions: any[]) => any[]; onClickView?: (data: E) => void; onClickEdit?: (data: E) => void; onClickDuplicate?: (data: E) => void; onClickDelete?: (data: E) => void; onClickActivate?: (data: E) => void; onClickDeactivate?: (data: E) => void; onClickConfirm?: (data: E) => void; onClickCancel?: (data: E) => void; onClickRollback?: (data: E) => void; onClickHold?: (data: E) => void; deleteModalConfig?: ActionModalConfig; activateModalConfig?: ActionModalConfig; deactivateModalConfig?: ActionModalConfig; confirmModalConfig?: ActionModalConfig; cancelModalConfig?: ActionModalConfig; rollbackModalConfig?: ActionModalConfig; holdModalConfig?: ActionModalConfig; // Bulk action props /** Custom function to modify/extend the default bulk action buttons shown in the table header. */ customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[]; /** Maximum number of IDs per batch request during bulk operations. @default 20 */ batchSize?: number; // Bulk action custom flow callbacks (mirrors single-action onClick* pattern) onBulkClickDelete?: (data: E[]) => void; onBulkClickActivate?: (data: E[]) => void; onBulkClickDeactivate?: (data: E[]) => void; onBulkClickConfirm?: (data: E[]) => void; onBulkClickCancel?: (data: E[]) => void; onBulkClickRollback?: (data: E[]) => void; onBulkClickHold?: (data: E[]) => void; /** * The query parameter key used for search. * @default 'q' */ searchKey?: string; /** * Configuration for the Filter Drawer */ filterConfig?: TableFilterConfig; } // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- export function EnterpriseDataTable(props: EnterpriseDataTableProps) { // --------------------------------------------------------------------------- // Component Props Destructuring // --------------------------------------------------------------------------- const { columnDefs, gridHeight = 600, paginationMode = 'pagination', loadingMessage = 'Loading data...', noRowsMessage = 'No records found', showStatusbar, customPrefixColumn, // new action props statusKey = 'status', customRowActions, onClickView, onClickEdit, onClickDuplicate, onClickDelete, onClickActivate, onClickDeactivate, onClickConfirm, onClickCancel, onClickRollback, onClickHold, deleteModalConfig, activateModalConfig, deactivateModalConfig, confirmModalConfig, cancelModalConfig, rollbackModalConfig, holdModalConfig, searchKey = 'q', filterConfig, // Bulk action props customBulkActions, batchSize, onBulkClickDelete, onBulkClickActivate, onBulkClickDeactivate, onBulkClickConfirm, onBulkClickCancel, onBulkClickRollback, onBulkClickHold, ...restAgGridProps } = props; // --------------------------------------------------------------------------- // Context & Hooks // --------------------------------------------------------------------------- const { t } = useEnterpriseModuleTranslationContext(); const { dataServices } = useEnterpriseModuleDataServiceContext(); const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = useEnterpriseModuleSelectionContext(); const navigation = useEnterpriseModuleNavigationContext(); const { config } = useEnterpriseModuleConfigContext(); const { moduleType } = config; const isTransaction = moduleType === 'TRANSACTION'; // --------------------------------------------------------------------------- // Local UI State // --------------------------------------------------------------------------- const [openedFilter, { open: openFilter, close: closeFilter }] = useDisclosure(false); const [openedSetting, { open: openSetting, close: closeSetting }] = useDisclosure(false); const moduleTitle = config.tabTitle || t(`${config.translationNamespace}:title`); // Reference to the AG Grid API for programmatic interaction const gridApiRef = useRef | null>(null); // --------------------------------------------------------------------------- // Search & Filter State // --------------------------------------------------------------------------- const searchRef = useRef((filterData?.[searchKey] as string) || ''); const filterRef = useRef>((() => { if (!filterData) return {}; const copy = { ...filterData }; delete copy[searchKey]; return copy; })()); const [searchValue, setSearchValue] = useState(searchRef.current); const handleSearchChange = useCallback((e: React.ChangeEvent) => { setSearchValue(e.currentTarget.value); }, []); const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { searchRef.current = searchValue; gridApiRef.current?.refreshServerSide({ purge: true }); } }, [searchValue]); const handleSearchClear = useCallback(() => { setSearchValue(''); if (searchRef.current !== '') { searchRef.current = ''; gridApiRef.current?.refreshServerSide({ purge: true }); } }, []); const activeFilterCount = useMemo(() => { if (!filterData) return 0; const filterKeys = filterConfig?.defaultValues ? Object.keys(filterConfig.defaultValues) : Object.keys(filterData).filter( (key) => key !== searchKey && !['page', 'limit', 'order_by', 'order_type'].includes(key) ); return filterKeys.filter((key) => { const val = filterData[key]; if (val === undefined || val === null || val === '') return false; if (Array.isArray(val) && val.length === 0) return false; return true; }).length; }, [filterData, searchKey, filterConfig]); const handleFilterApply = useCallback((data: any) => { filterRef.current = data || {}; gridApiRef.current?.refreshServerSide({ purge: true }); }, []); // --------------------------------------------------------------------------- // Action Handlers & Modal State // --------------------------------------------------------------------------- const CLOSED_MODAL: ActionModalState = useMemo(() => ({ opened: false, action: null, data: null }), []); const [actionModalState, setActionModalState] = useState>(CLOSED_MODAL); const modalConfigMap = useMemo( () => ({ [ModuleAction.DELETE]: deleteModalConfig, [ModuleAction.ACTIVATE]: activateModalConfig, [ModuleAction.DEACTIVATE]: deactivateModalConfig, [ModuleAction.CONFIRM]: confirmModalConfig, [ModuleAction.CANCEL]: cancelModalConfig, [ModuleAction.ROLLBACK]: rollbackModalConfig, [ModuleAction.HOLD]: holdModalConfig, }), [ deleteModalConfig, activateModalConfig, deactivateModalConfig, confirmModalConfig, cancelModalConfig, rollbackModalConfig, holdModalConfig, ], ); const openActionModal = useCallback( (action: ModuleActionType, data: E) => { const config = modalConfigMap[action as keyof typeof modalConfigMap]; setActionModalState({ opened: true, action, data, config }); }, [modalConfigMap], ); const closeActionModal = useCallback(() => { setActionModalState(CLOSED_MODAL); }, [CLOSED_MODAL]); const executeAction = useCallback( async (action: ModuleActionType, data: E, meta?: Record) => { const id = data.id; if (!id) return; const currentConfig = modalConfigMap[action as keyof typeof modalConfigMap]; try { switch (action) { case ModuleAction.DELETE: await dataServices.delete(id, meta); break; case ModuleAction.ACTIVATE: await dataServices.activate(id, meta); break; case ModuleAction.DEACTIVATE: await dataServices.deactivate(id, meta); break; case ModuleAction.CONFIRM: await dataServices.confirmData(id, meta); break; case ModuleAction.CANCEL: await dataServices.cancelData(id, meta); break; case ModuleAction.ROLLBACK: await dataServices.rollbackData(id, meta); break; case ModuleAction.HOLD: await dataServices.holdData(id, meta); break; default: console.warn(`[executeAction] Unhandled action: ${action}`); return; } const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`; const defaultSuccessMessage = t('common:notifications.actionSuccess', { action: t(actionKey) }); const customSuccessMessage = typeof currentConfig?.successMessage === 'function' ? currentConfig.successMessage(data) : currentConfig?.successMessage; notifications.show({ title: t('common:notifications.successTitle'), message: customSuccessMessage || defaultSuccessMessage, color: 'teal', }); closeActionModal(); if (gridApiRef.current) { gridApiRef.current.refreshServerSide({ purge: false }); } } catch (error: any) { const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`; const defaultErrorMessage = t('common:notifications.actionFailed', { action: t(actionKey), message: error?.message || 'Unknown error', }); const customErrorMessage = typeof currentConfig?.errorMessage === 'function' ? currentConfig.errorMessage(error) : currentConfig?.errorMessage; notifications.show({ title: t('common:notifications.errorTitle'), message: customErrorMessage || defaultErrorMessage, color: 'red', }); throw error; } }, [dataServices, closeActionModal, modalConfigMap, t], ); // --------------------------------------------------------------------------- // Bulk Action Modal State & Handlers // --------------------------------------------------------------------------- const CLOSED_BULK_MODAL: BulkActionModalState = useMemo(() => ({ opened: false, action: null, data: [] }), []); const [bulkModalState, setBulkModalState] = useState>(CLOSED_BULK_MODAL); const openBulkActionModal = useCallback( (action: ModuleActionType, data: E[]) => { const config = modalConfigMap[action as keyof typeof modalConfigMap]; setBulkModalState({ opened: true, action, data, config }); }, [modalConfigMap], ); const closeBulkActionModal = useCallback(() => { setBulkModalState(CLOSED_BULK_MODAL); // Refresh grid data after bulk action completes if (gridApiRef.current) { gridApiRef.current.refreshServerSide({ purge: false }); } // Clear selected rows setSelectedRows([]); if (gridApiRef.current) { gridApiRef.current.deselectAll(); } }, [CLOSED_BULK_MODAL, setSelectedRows]); const handleBulkActionClick = useCallback( (action: ModuleActionType, data: E[]) => { switch (action) { case ModuleAction.DELETE: if (onBulkClickDelete) onBulkClickDelete(data); else openBulkActionModal(action, data); break; case ModuleAction.ACTIVATE: if (onBulkClickActivate) onBulkClickActivate(data); else openBulkActionModal(action, data); break; case ModuleAction.DEACTIVATE: if (onBulkClickDeactivate) onBulkClickDeactivate(data); else openBulkActionModal(action, data); break; case ModuleAction.CONFIRM: if (onBulkClickConfirm) onBulkClickConfirm(data); else openBulkActionModal(action, data); break; case ModuleAction.CANCEL: if (onBulkClickCancel) onBulkClickCancel(data); else openBulkActionModal(action, data); break; case ModuleAction.ROLLBACK: if (onBulkClickRollback) onBulkClickRollback(data); else openBulkActionModal(action, data); break; case ModuleAction.HOLD: if (onBulkClickHold) onBulkClickHold(data); else openBulkActionModal(action, data); break; default: openBulkActionModal(action, data); break; } }, [ openBulkActionModal, onBulkClickDelete, onBulkClickActivate, onBulkClickDeactivate, onBulkClickConfirm, onBulkClickCancel, onBulkClickRollback, onBulkClickHold, ], ); /** * Executes a single batch chunk of the bulk action. * Called by BulkActionConfirmationModal per chunk. */ const executeBulkAction = useCallback( async (action: ModuleActionType, ids: EntityId[], meta?: Record): Promise => { try { switch (action) { case ModuleAction.DELETE: await dataServices.batchDelete(ids, meta); break; case ModuleAction.ACTIVATE: await dataServices.batchActivate(ids, meta); break; case ModuleAction.DEACTIVATE: await dataServices.batchDeactivate(ids, meta); break; case ModuleAction.CONFIRM: await dataServices.batchConfirmData(ids, meta); break; case ModuleAction.CANCEL: await dataServices.batchCancelData(ids, meta); break; case ModuleAction.ROLLBACK: await dataServices.batchRollbackData(ids, meta); break; case ModuleAction.HOLD: await dataServices.batchHoldData(ids, meta); break; default: console.warn(`[executeBulkAction] Unhandled action: ${action}`); return { total_items: ids.length, total_success: 0, total_failed: ids.length }; } return { total_items: ids.length, total_success: ids.length, total_failed: 0, }; } catch (error: any) { return { total_items: ids.length, total_success: 0, total_failed: ids.length, messages: [error?.message || 'Unknown error'], }; } }, [dataServices], ); // --------------------------------------------------------------------------- // Action Handlers // --------------------------------------------------------------------------- const handleActionClick = useCallback( (action: ModuleActionType | 'VIEW', data: E) => { switch (action) { case 'VIEW': if (onClickView) onClickView(data); else navigation.navigateToDetail(data.id as string); break; case ModuleAction.EDIT: if (onClickEdit) onClickEdit(data); else navigation.navigateToEdit(data.id as string); break; case ModuleAction.DUPLICATE: if (onClickDuplicate) onClickDuplicate(data); else navigation.navigateToDuplicate(data.id as string); break; case ModuleAction.DELETE: if (onClickDelete) onClickDelete(data); else openActionModal(ModuleAction.DELETE, data); break; case ModuleAction.ACTIVATE: if (onClickActivate) onClickActivate(data); else openActionModal(ModuleAction.ACTIVATE, data); break; case ModuleAction.DEACTIVATE: if (onClickDeactivate) onClickDeactivate(data); else openActionModal(ModuleAction.DEACTIVATE, data); break; case ModuleAction.CONFIRM: if (onClickConfirm) onClickConfirm(data); else openActionModal(ModuleAction.CONFIRM, data); break; case ModuleAction.CANCEL: if (onClickCancel) onClickCancel(data); else openActionModal(ModuleAction.CANCEL, data); break; case ModuleAction.ROLLBACK: if (onClickRollback) onClickRollback(data); else openActionModal(ModuleAction.ROLLBACK, data); break; case ModuleAction.HOLD: if (onClickHold) onClickHold(data); else openActionModal(ModuleAction.HOLD, data); break; default: console.warn(`[ActionHandler] Unhandled action key: ${action}`); break; } }, [ navigation, onClickView, onClickEdit, onClickDuplicate, onClickDelete, onClickActivate, onClickDeactivate, onClickConfirm, onClickCancel, onClickRollback, onClickHold, openActionModal, ], ); // --------------------------------------------------------------------------- // Derived State & Configuration // --------------------------------------------------------------------------- // Determine the number of rows per page based on metadata, defaulting to 10 const perPage = useMemo(() => { return metaData?.limit ?? 10; }, [metaData]); // Boolean flag to check if the current mode is pagination const isPaginated = paginationMode === 'pagination'; // Ensure column definitions are referentially stable const finalColumnDefs = useMemo[]>(() => { // Dedicated Checkbox Column (Pinned to the far left) const selectionColumn: ColDef = { colId: 'selection_column', maxWidth: 40, pinned: 'left', sortable: false, filter: false, suppressHeaderMenuButton: true, checkboxSelection: true, // Manually enable checkbox selection specifically for this column suppressMovable: true, }; // Define the Master-Detail collapse/expand column const masterDetailColumn: ColDef = { colId: 'master_detail_column', pinned: 'left', // Pin to the right so it remains visible during horizontal scrolling maxWidth: 50, sortable: false, // Disable sorting filter: false, // Disable filtering suppressHeaderMenuButton: true, // Suppress menu to keep the header clean suppressMovable: true, cellRenderer: 'agGroupCellRenderer', }; // Define the Action column (for Edit, Delete, View, etc.) const actionColumn: ColDef = { colId: 'action_column', pinned: 'left', // Pin to the right so it remains visible during horizontal scrolling, width: 180, minWidth: 100, sortable: false, // Disable sorting filter: false, // Disable filtering suppressHeaderMenuButton: true, // Suppress menu to keep the header clean suppressSizeToFit: true, // Prevent this column from stretching if you call api.sizeColumnsToFit() suppressMovable: true, headerName: t('common:fields.action'), cellRenderer: (params: any) => { if (!params.data) return null; return ( ); }, }; // Define the default Status column const statusColumn: ColDef = { colId: 'status', maxWidth: 130, suppressHeaderMenuButton: true, // Suppress menu to keep the header clean field: 'status' as any, headerName: t('common:fields.status'), cellRenderer: ({ value }: any) => , }; const prefixColumn: ColDef[] = [ selectionColumn, props.masterDetail ? masterDetailColumn : (null as any), actionColumn, statusColumn, ].filter(Boolean); return [...(customPrefixColumn ? customPrefixColumn(prefixColumn) : prefixColumn), ...columnDefs]; }, [ columnDefs, props.masterDetail, isTransaction, customPrefixColumn, t, statusKey, customRowActions, handleActionClick, ]); // Default configuration applied to all columns in the grid const defaultColDef = useMemo(() => ({ flex: 1, minWidth: 40, sortable: true, resizable: true }), []); // --------------------------------------------------------------------------- // Data Source (Server-Side Row Model) // --------------------------------------------------------------------------- // Configures the server-side datasource to handle data fetching, pagination, and sorting const datasource: IServerSideDatasource = useMemo( () => ({ getRows: async (params: IServerSideGetRowsParams) => { try { const request = params.request; const limit = perPage const page = Math.floor((request.startRow ?? 0) / limit) + 1; // Extract sorting information from the request const sortModel = request.sortModel[0]; const orderBy = sortModel?.colId; const orderType = sortModel?.sort?.toUpperCase(); // Prepare the request parameters for the API call const requestParams: Record = { page, limit, order_by: orderBy, order_type: orderType, ...filterRef.current }; if (searchRef.current) { requestParams[searchKey] = searchRef.current; } const response = await dataServices.getMany({ params: requestParams }); if (!response.data?.data) throw new Error('Invalid response'); const rowData = response.data.data; const meta = response.data.meta; const rowCount = meta?.total || 0; // Update the global metadata state setMetaData(meta); setFilterData({ ...filterRef.current, [searchKey]: searchRef.current }); // Pass the retrieved data back to AG Grid params.success({ rowData, rowCount }); } catch (error: any) { // Display an error notification if the request fails notifications.show({ title: t('common:notifications.errorTitle'), message: error?.message, color: 'red' }); params.fail(); } }, }), [dataServices, perPage, setMetaData, setFilterData, searchKey, t], ); // --------------------------------------------------------------------------- // Event Handlers // --------------------------------------------------------------------------- // Triggered when the grid is initialized and ready const onGridReady = useCallback( (params: GridReadyEvent) => { gridApiRef.current = params.api; setSelectedRows([]); if (params.api) { // Attach the server-side datasource to the grid API params.api.setGridOption('serverSideDatasource', datasource); } }, [datasource, setSelectedRows], ); // Triggered whenever the row selection in the grid changes const handleSelectionChanged = useCallback( (event: SelectionChangedEvent) => { const selectedData = event.api.getSelectedRows(); // Forward full rows to the context for backward compatibility with consumers setSelectedRows(selectedData); }, [setSelectedRows], ); // Configures the context menu items available when right-clicking a cell const getContextMenuItems = useCallback((): | (DefaultMenuItem | MenuItemDef)[] | Promise<(DefaultMenuItem | MenuItemDef)[]> => { return ['copy', 'copyWithHeaders']; }, []); const statusBar = useMemo(() => { if (!showStatusbar) return undefined; return { statusPanels: [{ statusPanel: 'agSelectedRowCountComponent', align: 'left' }] }; }, [showStatusbar]); const domLayout = useMemo(() => { const meta = { total: metaData?.total ?? 0, limit: metaData?.limit ?? 0 }; if (!isPaginated) { return meta?.total < meta?.limit ? 'autoHeight' : 'normal'; } return 'autoHeight'; }, [metaData, isPaginated]); // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return ( {/* TABLE HEADER (Search, Filter, Bulk Actions) */} } rightSection={ searchValue ? ( e.preventDefault()} onClick={handleSearchClear} aria-label={t('common:actions.clear')} /> ) : null } value={searchValue} onChange={handleSearchChange} onKeyDown={handleSearchKeyDown} /> ), variant: 'default', showLabel: false, tooltipLabel: t('common:actions.filter'), onClick: openFilter, }, { key: 'setting', icon: , variant: 'default', showLabel: false, tooltipLabel: t('common:actions.setting'), onClick: openSetting, }, ]} /> {/* BULK ACTION TOOLBAR — shown when rows are selected */} void} statusKey={statusKey} customBulkActions={customBulkActions} /> {/* GRID CONTAINER */} rowModelType="serverSide" getRowId={(v) => v.data.id as string} cacheBlockSize={perPage} pagination={isPaginated} paginationPageSize={isPaginated ? perPage : undefined} paginationPageSizeSelector={isPaginated ? [10, 15, 20, 50] : undefined} columnDefs={finalColumnDefs} defaultColDef={defaultColDef} animateRows={true} rowSelection={{ mode: 'multiRow', checkboxes: false, copySelectedRows: false, headerCheckbox: false }} enableCellTextSelection={true} onSelectionChanged={handleSelectionChanged} onGridReady={onGridReady} getContextMenuItems={getContextMenuItems} statusBar={statusBar} domLayout={domLayout} overlayLoadingTemplate={`${loadingMessage}`} overlayNoRowsTemplate={`${noRowsMessage}`} {...restAgGridProps} /> {/* Single-Row Action Confirmation Modal */} modalState={actionModalState} onClose={closeActionModal} onExecute={executeAction} t={t} /> {/* Bulk Action Confirmation Modal */} modalState={bulkModalState} onClose={closeBulkActionModal} onExecute={executeBulkAction} t={t} batchSize={batchSize} /> ); }