Files
trackgo-fe/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx
T

903 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<E extends BaseEntity> extends Omit<AgGridReactProps<E>, 'rowData'> {
columnDefs: ColDef<E>[];
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<E>[]) => ColDef<E>[];
// 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<any>;
}
// ---------------------------------------------------------------------------
// Main Component
// ---------------------------------------------------------------------------
export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataTableProps<E>) {
// ---------------------------------------------------------------------------
// 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<E>();
const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = useEnterpriseModuleSelectionContext<E>();
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<GridApi<E> | null>(null);
// ---------------------------------------------------------------------------
// Search & Filter State
// ---------------------------------------------------------------------------
const searchRef = useRef<string>((filterData?.[searchKey] as string) || '');
const filterRef = useRef<Record<string, any>>((() => {
if (!filterData) return {};
const copy = { ...filterData };
delete copy[searchKey];
return copy;
})());
const [searchValue, setSearchValue] = useState(searchRef.current);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.currentTarget.value);
}, []);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
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<E> = useMemo(() => ({ opened: false, action: null, data: null }), []);
const [actionModalState, setActionModalState] = useState<ActionModalState<E>>(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<string, unknown>) => {
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<E> = useMemo(() => ({ opened: false, action: null, data: [] }), []);
const [bulkModalState, setBulkModalState] = useState<BulkActionModalState<E>>(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<string, unknown>): Promise<BulkActionResult> => {
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<ColDef<E>[]>(() => {
// Dedicated Checkbox Column (Pinned to the far left)
const selectionColumn: ColDef<any> = {
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<E> = {
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<any> = {
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 (
<RowActionMenu
data={params.data}
onActionClick={handleActionClick}
statusKey={statusKey}
customActions={customRowActions}
/>
);
},
};
// Define the default Status column
const statusColumn: ColDef<E> = {
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) => <StatusBadge status={value} size="md" variant="outline" />,
};
const prefixColumn: ColDef<E>[] = [
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<ColDef>(() => ({ 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<string, any> = {
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<E>) => {
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<E>) => {
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<StatusBar | undefined>(() => {
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 (
<Box>
{/* TABLE HEADER (Search, Filter, Bulk Actions) */}
<Group justify="space-between" align="center" mb="sm">
<Group gap="sm">
<TextInput
size="sm"
w={{ base: '100%', sm: 350 }}
placeholder={t('common:searchPlaceholder')}
leftSection={<Search size={16} />}
rightSection={
searchValue ? (
<CloseButton
size="sm"
onMouseDown={(e) => e.preventDefault()}
onClick={handleSearchClear}
aria-label={t('common:actions.clear')}
/>
) : null
}
value={searchValue}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
<PageActions
actions={[
{ type: 'divider', key: 'search-divider' },
{
key: 'filter',
icon: (
<Indicator disabled={activeFilterCount === 0} size={8} offset={2}>
<Filter size={16} />
</Indicator>
),
variant: 'default',
showLabel: false,
tooltipLabel: t('common:actions.filter'),
onClick: openFilter,
},
{
key: 'setting',
icon: <Settings size={16} />,
variant: 'default',
showLabel: false,
tooltipLabel: t('common:actions.setting'),
onClick: openSetting,
},
]}
/>
</Group>
{/* BULK ACTION TOOLBAR — shown when rows are selected */}
<BulkActionMenu
selectedRows={selectedRows as E[]}
onActionClick={handleBulkActionClick as (action: ModuleActionType, data: any[]) => void}
statusKey={statusKey}
customBulkActions={customBulkActions}
/>
</Group>
{/* GRID CONTAINER */}
<Box
className="erp-data-grid-container"
style={{
width: '100%',
height: domLayout === 'autoHeight' || restAgGridProps.domLayout === 'autoHeight' ? '100%' : gridHeight,
}}
>
<DataGrid<E>
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={`<span style="padding:10px">${loadingMessage}</span>`}
overlayNoRowsTemplate={`<span style="padding:10px;color:var(--ag-foreground-color,#868e96)">${noRowsMessage}</span>`}
{...restAgGridProps}
/>
</Box>
{/* Single-Row Action Confirmation Modal */}
<ActionConfirmationModal<E>
modalState={actionModalState}
onClose={closeActionModal}
onExecute={executeAction}
t={t}
/>
{/* Bulk Action Confirmation Modal */}
<BulkActionConfirmationModal<E>
modalState={bulkModalState}
onClose={closeBulkActionModal}
onExecute={executeBulkAction}
t={t}
batchSize={batchSize}
/>
<TableFilterDrawer
opened={openedFilter}
onClose={closeFilter}
title={t('common:filterTitle', { module: moduleTitle })}
config={filterConfig}
currentFilterData={filterData}
onFilter={handleFilterApply}
/>
<TableSettingDrawer
opened={openedSetting}
onClose={closeSetting}
title={t('common:tableSettingTitle', { module: moduleTitle })}
/>
</Box>
);
}