diff --git a/packages/core-api/src/data-services/index.ts b/packages/core-api/src/data-services/index.ts index ff7ca20..d58722b 100644 --- a/packages/core-api/src/data-services/index.ts +++ b/packages/core-api/src/data-services/index.ts @@ -19,4 +19,4 @@ export type { DataServicesConfig, } from './types'; -export type { IDataTransformer } from './base-data.transformer'; +export type { IDataTransformer, StandardPaginationMeta } from './base-data.transformer'; diff --git a/packages/ui/src/components/ag-grid/ag-grid-provider.tsx b/packages/ui/src/components/ag-grid/ag-grid-provider.tsx index 85bf64c..c59ca70 100644 --- a/packages/ui/src/components/ag-grid/ag-grid-provider.tsx +++ b/packages/ui/src/components/ag-grid/ag-grid-provider.tsx @@ -1,8 +1,10 @@ import React, { createContext, useContext, useEffect, useRef } from 'react'; import { useMantineColorScheme } from '@mantine/core'; -import type { Theme } from 'ag-grid-community'; +import { AllCommunityModule, type Theme } from 'ag-grid-community'; import { initAgGrid, type AgGridInitOptions } from './ag-grid-setup'; import { agGridMantineTheme } from './ag-grid-theme'; +import { AgGridProvider as BaseAgGridProvider } from 'ag-grid-react'; +import { AllEnterpriseModule } from 'ag-grid-enterprise'; /* ─── Context ──────────────────────────────────────────────────────── */ @@ -68,10 +70,12 @@ export function AgGridProvider({ licenseKey, bypassLicense, children }: AgGridPr const agThemeMode = colorScheme === 'dark' ? 'dark' : 'light'; return ( - -
- {children} -
-
+ + +
+ {children} +
+
+
); } diff --git a/packages/ui/src/components/ag-grid/ag-grid-theme.ts b/packages/ui/src/components/ag-grid/ag-grid-theme.ts index 7fe386c..a873e68 100644 --- a/packages/ui/src/components/ag-grid/ag-grid-theme.ts +++ b/packages/ui/src/components/ag-grid/ag-grid-theme.ts @@ -56,7 +56,7 @@ export const agGridMantineTheme = themeQuartz selectedRowBackgroundColor: 'var(--mantine-primary-color-light)', /* ── Radius ─────────────────────────────────────────────────── */ - borderRadius: 'var(--mantine-radius-xs)', + borderRadius: 'var(--mantine-radius-sm)', wrapperBorderRadius: 'var(--mantine-radius-sm)', /* ── Spacing ────────────────────────────────────────────────── */ @@ -88,8 +88,7 @@ export const agGridMantineTheme = themeQuartz /* ── Row Styling ──────────────────────────────────────────── */ oddRowBackgroundColor: 'var(--mantine-color-dark-6)', rowHoverColor: 'color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent)', - selectedRowBackgroundColor: - 'color-mix(in srgb, var(--mantine-primary-color-filled) 18%, transparent)', + selectedRowBackgroundColor: 'color-mix(in srgb, var(--mantine-primary-color-filled) 18%, transparent)', }, 'dark', ); diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx new file mode 100644 index 0000000..a50b27f --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -0,0 +1,234 @@ +import { useMemo, useCallback, useRef } from 'react'; +import { AgGridReactProps } from 'ag-grid-react'; +import { + ColDef, + GridReadyEvent, + GridApi, + IServerSideDatasource, + IServerSideGetRowsParams, + SelectionChangedEvent, + GetContextMenuItemsParams, + MenuItemDef, + DefaultMenuItem, + StatusBar, +} from 'ag-grid-community'; +import { Box } from '@mantine/core'; + +import { DataGrid } from '../../../../components'; + +import { + useEnterpriseModuleDataServiceContext, + useEnterpriseModuleSelectionContext, + useEnterpriseModuleTranslationContext, +} from '../../hooks/use-module.context'; +import { BaseEntity } from '@repo/core-api/data-services'; +import { notifications } from '@mantine/notifications'; + +export * from 'ag-grid-community'; +export * from 'ag-grid-react'; + +// --------------------------------------------------------------------------- +// 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; +} + +// --------------------------------------------------------------------------- +// Main Component +// --------------------------------------------------------------------------- + +export function EnterpriseDataTable(props: EnterpriseDataTableProps) { + // --------------------------------------------------------------------------- + // Component Props Destructuring + // --------------------------------------------------------------------------- + const { + columnDefs, + gridHeight = 600, + paginationMode = 'pagination', + loadingMessage = 'Loading data...', + noRowsMessage = 'No records found', + showStatusbar, + ...restAgGridProps + } = props; + + // --------------------------------------------------------------------------- + // Context & Hooks + // --------------------------------------------------------------------------- + const { t } = useEnterpriseModuleTranslationContext(); + const { dataServices } = useEnterpriseModuleDataServiceContext(); + const { setSelectedRows, setFilterData, metaData, setMetaData } = useEnterpriseModuleSelectionContext(); + + // --------------------------------------------------------------------------- + // Local UI State + // --------------------------------------------------------------------------- + + // Reference to the AG Grid API for programmatic interaction + const gridApiRef = useRef | null>(null); + + // --------------------------------------------------------------------------- + // 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[]>(() => { + return columnDefs; + }, [columnDefs]); + + // Default configuration applied to all columns in the grid + const defaultColDef = useMemo(() => ({ flex: 1, minWidth: 100, 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; + + // Calculate the current page based on the start row and per-page limit + const page = Math.floor((request.startRow ?? 0) / perPage) + 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 = { page, limit: perPage, order_by: orderBy, order_type: orderType }; + 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); + + // 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, 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( + ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + params: GetContextMenuItemsParams, + ): (DefaultMenuItem | MenuItemDef)[] | Promise<(DefaultMenuItem | MenuItemDef)[]> => { + return ['copy', 'copyWithHeaders']; + }, + [], + ); + + const statusBar = useMemo(() => { + if (!showStatusbar) return undefined; + return { statusPanels: [{ statusPanel: 'agSelectedRowCountComponent', align: 'left' }] }; + }, [showStatusbar]); + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return ( + + {/* GRID CONTAINER */} + + + rowModelType="serverSide" + getRowId={(v) => v.data.id as string} + cacheBlockSize={perPage} + pagination={isPaginated} + paginationPageSize={isPaginated ? perPage : undefined} + paginationPageSizeSelector={isPaginated ? [10, 20, 50] : undefined} + columnDefs={finalColumnDefs} + defaultColDef={defaultColDef} + animateRows={true} + rowSelection={{ mode: 'multiRow', checkboxes: true, copySelectedRows: false, headerCheckbox: false }} + enableCellTextSelection={true} + onSelectionChanged={handleSelectionChanged} + onGridReady={onGridReady} + getContextMenuItems={getContextMenuItems} + statusBar={statusBar} + overlayLoadingTemplate={`${loadingMessage}`} + overlayNoRowsTemplate={`${noRowsMessage}`} + {...restAgGridProps} + /> + + + ); +} diff --git a/packages/ui/src/foundations/enterprise-module/entities/entity.ts b/packages/ui/src/foundations/enterprise-module/entities/entity.ts index 4c6ee6d..35f658b 100644 --- a/packages/ui/src/foundations/enterprise-module/entities/entity.ts +++ b/packages/ui/src/foundations/enterprise-module/entities/entity.ts @@ -1,7 +1,7 @@ import { ReactNode } from 'react'; import type { UseFormReturn } from 'react-hook-form'; import type { ZodType } from 'zod'; -import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services'; +import type { BaseEntity, BaseRemoteDataServices, StandardPaginationMeta } from '@repo/core-api/data-services'; import type { ModulePageHeaderProps } from '../components/module-page-header'; import { PageActionsProps } from '../../../components'; @@ -132,7 +132,7 @@ export interface DataServiceSlice< export interface SelectionSlice< E extends BaseEntity = BaseEntity, TFilter = Record, - TMeta = Record, + TMeta = StandardPaginationMeta, > { selectedRows: E[]; setSelectedRows: (rows: E[]) => void; @@ -148,7 +148,7 @@ export interface SelectionSlice< export interface EnterpriseModuleState< E extends BaseEntity = BaseEntity, TFilter = Record, - TMeta = Record, + TMeta = StandardPaginationMeta, > { metaData: TMeta | null; setMetaData: (data: TMeta | null) => void; diff --git a/packages/ui/src/foundations/enterprise-module/index.ts b/packages/ui/src/foundations/enterprise-module/index.ts index fb8ba94..24bd8b3 100644 --- a/packages/ui/src/foundations/enterprise-module/index.ts +++ b/packages/ui/src/foundations/enterprise-module/index.ts @@ -9,3 +9,4 @@ export * from './providers/index-page.provider'; export * from './providers/detail-page.provider'; export * from './components/module-page-header'; export * from './components/action-confirmation-modal'; +export * from './components/data-table';