968 lines
33 KiB
TypeScript
968 lines
33 KiB
TypeScript
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';
|
||
import { DateUtils } from '@repo/utils';
|
||
|
||
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>[];
|
||
customPostfixColumn?: (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,
|
||
customPostfixColumn,
|
||
|
||
// new action props
|
||
statusKey = 'status',
|
||
customRowActions,
|
||
onClickView,
|
||
onClickEdit,
|
||
onClickDuplicate,
|
||
onClickDelete,
|
||
onClickActivate,
|
||
onClickDeactivate,
|
||
onClickConfirm,
|
||
onClickCancel,
|
||
onClickRollback,
|
||
onClickHold,
|
||
deleteModalConfig,
|
||
activateModalConfig,
|
||
deactivateModalConfig,
|
||
confirmModalConfig,
|
||
cancelModalConfig,
|
||
rollbackModalConfig,
|
||
holdModalConfig,
|
||
searchKey = 'search',
|
||
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 message = error?.response?.data?.message;
|
||
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
||
action: t(actionKey),
|
||
message: 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) {
|
||
const message = error?.response?.data?.message;
|
||
return {
|
||
total_items: ids.length,
|
||
total_success: 0,
|
||
total_failed: ids.length,
|
||
messages: [message ?? 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);
|
||
|
||
const postfixColumn: ColDef<E>[] = [
|
||
{
|
||
colId: 'creator_name',
|
||
field: 'creator_name' as any,
|
||
headerName: t('common:fields.createdBy'),
|
||
cellRenderer: ({ value }: any) => value ?? '-',
|
||
},
|
||
|
||
{
|
||
colId: 'created_at',
|
||
field: 'created_at',
|
||
headerName: t('common:fields.createdAt'),
|
||
cellRenderer: ({ value }: any) => {
|
||
return value ? new DateUtils(Number(value)).format('DD-MM-YYYY, HH:mm') : '-';
|
||
},
|
||
},
|
||
{
|
||
colId: 'editor_name',
|
||
field: 'editor_name' as any,
|
||
headerName: t('common:fields.updatedBy'),
|
||
cellRenderer: ({ value }: any) => value ?? '-',
|
||
},
|
||
|
||
{
|
||
colId: 'updated_at',
|
||
field: 'updated_at',
|
||
headerName: t('common:fields.updatedAt'),
|
||
cellRenderer: ({ value }: any) => {
|
||
return value ? new DateUtils(Number(value)).format('DD-MM-YYYY, HH:mm') : '-';
|
||
},
|
||
},
|
||
];
|
||
|
||
const finalPrefixColumn = customPrefixColumn ? customPrefixColumn(prefixColumn) : prefixColumn;
|
||
const finalPostfixColumn = customPostfixColumn ? customPostfixColumn(postfixColumn) : postfixColumn;
|
||
|
||
return [...finalPrefixColumn, ...columnDefs, ...finalPostfixColumn];
|
||
}, [
|
||
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
|
||
const message = error?.response?.data?.message;
|
||
notifications.show({
|
||
title: t('common:notifications.errorTitle'),
|
||
message: 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);
|
||
|
||
// Synchronously jump to the restored page immediately after attaching the datasource.
|
||
// This ensures the grid doesn't reset our page back to 1.
|
||
// NOTE: This relies on `serverSideInitialRowCount` being provided so the grid knows
|
||
// there are enough pages to jump to!
|
||
if (isPaginated && metaData?.page && metaData.page > 1) {
|
||
params.api.paginationGoToPage(metaData.page - 1);
|
||
}
|
||
}
|
||
},
|
||
[datasource, setSelectedRows, isPaginated, metaData],
|
||
);
|
||
|
||
// 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} color="yellow">
|
||
<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}
|
||
serverSideInitialRowCount={metaData?.total ?? 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>
|
||
);
|
||
}
|