From 0243b6aa96e507f51b25c42d58030ee4cf995a43 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:35:39 +0700 Subject: [PATCH 1/8] feat: enhance internationalization with new translation keys, improve action handling in data tables, and add responsive view support for row actions --- .../core-i18n/src/languages/en/common.json | 4 +- .../core-i18n/src/languages/id/common.json | 4 +- .../components/actions-tools/page-actions.tsx | 36 +- .../components/actions-tools/row-actions.tsx | 248 +++++++------ .../ui/src/components/actions-tools/types.ts | 2 + .../components/status-badge/status-badge.tsx | 3 +- .../data-table/components/row-actions.tsx | 166 ++++++--- .../components/data-table/index.tsx | 337 +++++++++++++++++- .../components/module-page-header/index.tsx | 1 - .../providers/detail-page.provider.tsx | 3 +- 10 files changed, 623 insertions(+), 181 deletions(-) diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 98e6a83..6bd07fa 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -58,7 +58,9 @@ "back": "Back", "reload": "Reload", "filter": "Filter", - "setting": "Setting" + "setting": "Setting", + "detail": "Detail", + "view": "View" }, "confirmDialog": { "delete": { diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index a390063..28cabce 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -58,7 +58,9 @@ "back": "Kembali", "reload": "Muat Ulang", "filter": "Filter", - "setting": "Pengaturan" + "setting": "Pengaturan", + "detail": "Detail", + "view": "Lihat" }, "confirmDialog": { "delete": { diff --git a/packages/ui/src/components/actions-tools/page-actions.tsx b/packages/ui/src/components/actions-tools/page-actions.tsx index 26b38a8..8226b0d 100644 --- a/packages/ui/src/components/actions-tools/page-actions.tsx +++ b/packages/ui/src/components/actions-tools/page-actions.tsx @@ -40,10 +40,12 @@ export const PageActions = memo(function PageActions({ actions = [], customButto } const isPremiumGlow = action.intent === 'primary' && action.variant === 'filled'; + const showLabel = action.showLabel !== false; + const tooltipContent = action.tooltipLabel || (!showLabel ? action.label : undefined); // 1. Button with Dropdown (Menu.Target) if (action.children && action.children.length > 0) { - const ButtonWithDropdown = ( + const ButtonWithDropdown = showLabel ? ( + ) : ( + + {action.icon} + ); return ( - {/* Shortcuts on the main button remain hidden in the Tooltip */} - {action.tooltipLabel ? ( - + {tooltipContent ? ( + {ButtonWithDropdown} ) : ( @@ -92,7 +103,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto } // 2. Regular Button (Standalone) - const StandaloneButton = ( + const StandaloneButton = showLabel ? ( + ) : ( + action.onClick?.(action.key || '')} + size="lg" + style={isPremiumGlow ? defaultButtonStyle(true).style : undefined} + > + {action.icon} + ); - return action.tooltipLabel ? ( - + return tooltipContent ? ( + {StandaloneButton} ) : ( diff --git a/packages/ui/src/components/actions-tools/row-actions.tsx b/packages/ui/src/components/actions-tools/row-actions.tsx index 35e85c4..62a3c3f 100644 --- a/packages/ui/src/components/actions-tools/row-actions.tsx +++ b/packages/ui/src/components/actions-tools/row-actions.tsx @@ -8,6 +8,7 @@ export interface RowActionsProps { /** Array of configured row-level actions. */ actions: RowActionProps[]; showLabels?: boolean; + responsiveView?: boolean; } /** @@ -16,34 +17,54 @@ export interface RowActionsProps { * * @performance Wrapped in React.memo to guarantee zero overhead inside large lists/grids. */ -export const RowActions = memo(function RowActions({ actions = [], showLabels = false }: RowActionsProps) { +export const RowActions = memo(function RowActions({ + actions = [], + showLabels = false, + responsiveView = true, +}: RowActionsProps) { /** - * Helper function to render a standalone icon button. - * Wraps the icon in a Tooltip if the configuration provides one. + * Helper function to render a standalone item. */ - const renderIcon = (action: RowActionProps, fallbackKey: string) => { + const renderItem = (action: RowActionProps, fallbackKey: string) => { const actionKey = action.key || fallbackKey; + const isButton = showLabels; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + action.onClick?.(action.key || ''); + }; + + if (isButton) { + return ( + + ); + } const iconBtn = ( - + {action.icon} + ); return action.tooltip ? ( - + {iconBtn} ) : ( @@ -51,104 +72,119 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels = ); }; + const renderFlatActions = () => { + return actions.map((action, index) => { + if (action.type === 'divider') { + return ; + } + + if (action.children && action.children.length > 0) { + return ( + + {renderItem(action, `action-${index}`)} + + {action.children.map((child, childIndex) => { + if (child.type === 'divider') { + return ; + } + return ( + { + e.stopPropagation(); + child.onClick?.(child.key || ''); + }} + > + {child.label} + + ); + })} + + + ); + } + + return renderItem(action, `action-${index}`); + }); + }; + return ( {/* --- DESKTOP VIEW (hidden on mobile devices) --- */} - - {actions.map((action, index) => { - if (action.type === 'divider') { - return ; - } - - // Render Dropdown Menu for actions with children - if (action.children && action.children.length > 0) { - return ( - - {renderIcon(action, `action-${index}`)} - - {action.children.map((child, childIndex) => { - if (child.type === 'divider') { - return ; - } - return ( - child.onClick?.(child.key || '')} - > - {child.label} - - ); - })} - - - ); - } - - return renderIcon(action, `action-${index}`); - })} + + {renderFlatActions()} {/* --- MOBILE VIEW (hidden on desktop devices) --- */} - - - - - - - - - {actions.map((action, index) => { - if (action.type === 'divider') { - return ; - } - if (action.children && action.children.length > 0) { + {responsiveView && ( + + + + + + + + + {actions.map((action, index) => { + if (action.type === 'divider') { + return ; + } + + if (action.children && action.children.length > 0) { + return ( + + {action.label} + {action.children.map((child, childIndex) => { + if (child.type === 'divider') { + return ; + } + return ( + { + e.stopPropagation(); + child.onClick?.(child.key || ''); + }} + style={{ paddingLeft: '1.5rem' }} + mt="sm" + mb="sm" + > + {child.label} + + ); + })} + + ); + } + return ( - - {action.label} - {action.children.map((child, childIndex) => { - if (child.type === 'divider') { - return ; - } - return ( - child.onClick?.(child.key || '')} - style={{ paddingLeft: '1.5rem' }} // Indent nested items - mt="sm" - mb="sm" - > - {child.label} - - ); - })} - + { + e.stopPropagation(); + action.onClick?.(action.key || ''); + }} + mt="sm" + mb="sm" + > + {action.label} + ); - } - - return ( - action.onClick?.(action.key || '')} - mt="sm" - mb="sm" - > - {action.label} - - ); - })} - - - + })} + + + + )} ); }); diff --git a/packages/ui/src/components/actions-tools/types.ts b/packages/ui/src/components/actions-tools/types.ts index f465efc..1aa78e5 100644 --- a/packages/ui/src/components/actions-tools/types.ts +++ b/packages/ui/src/components/actions-tools/types.ts @@ -44,6 +44,8 @@ export interface PageActionProps extends BaseAction { /** Human-readable keyboard tooltip label (e.g., '⇧⌘N'). Shown in tooltip. */ tooltipLabel?: string; + /** Whether to show the text label for the main action button. Defaults to true. If false, renders as ActionIcon. */ + showLabel?: boolean; } /** diff --git a/packages/ui/src/components/status-badge/status-badge.tsx b/packages/ui/src/components/status-badge/status-badge.tsx index 3b3c2cc..bfc314f 100644 --- a/packages/ui/src/components/status-badge/status-badge.tsx +++ b/packages/ui/src/components/status-badge/status-badge.tsx @@ -207,7 +207,7 @@ export const DEFAULT_STATUS_MAP: Record = { [STATUS_DATA.PRESENT]: { color: '#E2B43E', leftSection: getIcon(Users) }, }; -export function StatusBadge({ status, label, getCustomConfig, color, leftSection, ...rest }: StatusBadgeProps) { +export function StatusBadge({ status, label, getCustomConfig, color, ...rest }: StatusBadgeProps) { if (!status) return null; const normalizedStatus = status?.toLowerCase() || ''; @@ -222,7 +222,6 @@ export function StatusBadge({ status, label, getCustomConfig, color, leftSection style={{ textTransform: 'capitalize' }} variant={customConfig.variant ? customConfig.variant : 'light'} color={color || customConfig.color || defaultConfig.color} - leftSection={leftSection || customConfig.leftSection || defaultConfig.leftSection} {...rest} > {label || (status ? status.replace(/-/g, ' ') : 'Unknown')} diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx index b45ae8f..0731048 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx @@ -1,77 +1,151 @@ import { useMemo } from 'react'; -import { Menu, ActionIcon } from '@mantine/core'; -import { MoreVertical, Eye, Trash2, CheckCircle, XCircle } from 'lucide-react'; +import { Eye, Trash2, CheckCircle, XCircle, Edit2, Copy, PauseCircle, RotateCcw, X, Check } from 'lucide-react'; import { ModuleAction, ModuleActionType } from '../../../entities/entity'; -import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context'; +import { + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, +} from '../../../hooks/use-module.context'; +import { RowActionProps, RowActions } from '../../../../../components'; export interface RowActionMenuProps { data: any; - rowIndex: number; - onActionClick: (action: ModuleActionType, data: any) => void; + onActionClick: (action: ModuleActionType | 'VIEW', data: any) => void; statusKey?: string; - customActions?: (data: any, defaultActions: any[]) => any[]; + customActions?: (data: any, defaultActions: RowActionProps[]) => RowActionProps[]; } export function RowActionMenu({ data, onActionClick, statusKey = 'status', customActions }: RowActionMenuProps) { const { t } = useEnterpriseModuleTranslationContext(); + const { privileges, config } = useEnterpriseModuleConfigContext(); + const { moduleType } = config; + const status = data?.[statusKey]?.toLowerCase(); - const defaultActions = useMemo(() => { - const actions: any[] = []; + const defaultActions = useMemo(() => { + const actions: RowActionProps[] = []; + + const { + ALLOW_EDIT, + ALLOW_DELETE, + ALLOW_CREATE, + ALLOW_ACTIVATE, + ALLOW_DEACTIVATE, + ALLOW_CONFIRM, + ALLOW_CANCEL, + ALLOW_ROLLBACK, + ALLOW_HOLD, + } = privileges; + + const isTransaction = moduleType === 'TRANSACTION'; + const isMasterData = moduleType === 'MASTER_DATA'; + + const isDataActive = status === 'active'; + const isDataInActive = status === 'inactive' || status === 'draft'; // View Details actions.push({ key: 'VIEW', label: t('common:actions.detail'), + tooltip: t('common:actions.detail'), icon: , onClick: () => onActionClick('VIEW' as any, data), }); - // Active/Inactive toggle - if (status === 'active') { + if (ALLOW_EDIT) { actions.push({ - key: ModuleAction.DEACTIVATE, - label: t('common:actions.deactivate'), - icon: , - onClick: () => onActionClick(ModuleAction.DEACTIVATE, data), - }); - } else if (status === 'inactive') { - actions.push({ - key: ModuleAction.ACTIVATE, - label: t('common:actions.activate'), - icon: , - onClick: () => onActionClick(ModuleAction.ACTIVATE, data), + key: ModuleAction.EDIT, + label: t('common:actions.edit'), + tooltip: t('common:actions.edit'), + icon: , + onClick: () => onActionClick(ModuleAction.EDIT, data), }); } - // Delete - actions.push({ - key: ModuleAction.DELETE, - label: t('common:actions.delete'), - icon: , - color: 'red', - onClick: () => onActionClick(ModuleAction.DELETE, data), - }); + if (ALLOW_CREATE) { + actions.push({ + key: ModuleAction.DUPLICATE, + label: t('common:actions.duplicate'), + tooltip: t('common:actions.duplicate'), + icon: , + onClick: () => onActionClick(ModuleAction.DUPLICATE, data), + }); + } + + if (isMasterData) { + if (ALLOW_DEACTIVATE && isDataActive) { + actions.push({ + key: ModuleAction.DEACTIVATE, + label: t('common:actions.deactivate'), + tooltip: t('common:actions.deactivate'), + icon: , + onClick: () => onActionClick(ModuleAction.DEACTIVATE, data), + }); + } + if (ALLOW_ACTIVATE && isDataInActive) { + actions.push({ + key: ModuleAction.ACTIVATE, + label: t('common:actions.activate'), + tooltip: t('common:actions.activate'), + icon: , + onClick: () => onActionClick(ModuleAction.ACTIVATE, data), + }); + } + } + + if (isTransaction) { + if (ALLOW_HOLD) { + actions.push({ + key: ModuleAction.HOLD, + label: t('common:actions.hold'), + tooltip: t('common:actions.hold'), + icon: , + onClick: () => onActionClick(ModuleAction.HOLD, data), + }); + } + if (ALLOW_ROLLBACK) { + actions.push({ + key: ModuleAction.ROLLBACK, + label: t('common:actions.rollback'), + tooltip: t('common:actions.rollback'), + icon: , + onClick: () => onActionClick(ModuleAction.ROLLBACK, data), + }); + } + if (ALLOW_CANCEL) { + actions.push({ + key: ModuleAction.CANCEL, + label: t('common:actions.cancel'), + tooltip: t('common:actions.cancel'), + icon: , + onClick: () => onActionClick(ModuleAction.CANCEL, data), + }); + } + if (ALLOW_CONFIRM) { + actions.push({ + key: ModuleAction.CONFIRM, + label: t('common:actions.confirm'), + tooltip: t('common:actions.confirm'), + icon: , + onClick: () => onActionClick(ModuleAction.CONFIRM, data), + }); + } + } + + if (ALLOW_DELETE) { + actions.push({ + key: ModuleAction.DELETE, + label: t('common:actions.delete'), + tooltip: t('common:actions.delete'), + icon: , + intent: 'destructive', + onClick: () => onActionClick(ModuleAction.DELETE, data), + }); + } return actions; - }, [status, t, onActionClick, data]); + }, [status, t, onActionClick, data, privileges, moduleType]); const finalActions = customActions ? customActions(data, defaultActions) : defaultActions; - return ( - - - - - - - - {finalActions.map((action, idx) => ( - - {action.label} - - ))} - - - ); + return ; } 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 index 51bccbf..d4a5cdd 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback, useRef } from 'react'; +import { useMemo, useCallback, useRef, useState } from 'react'; import { AgGridReactProps } from 'ag-grid-react'; import { ColDef, @@ -16,12 +16,17 @@ import { Box } from '@mantine/core'; import { DataGrid, StatusBadge } from '../../../../components'; import { + useEnterpriseModuleConfigContext, useEnterpriseModuleDataServiceContext, useEnterpriseModuleSelectionContext, useEnterpriseModuleTranslationContext, + useEnterpriseModuleNavigationContext, } from '../../hooks/use-module.context'; import { BaseEntity } from '@repo/core-api/data-services'; import { notifications } from '@mantine/notifications'; +import { ModuleActionType, ModuleAction, ActionModalState, ActionModalConfig } from '../../entities/entity'; +import { RowActionMenu } from './components/row-actions'; +import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal'; export * from 'ag-grid-community'; export * from 'ag-grid-react'; @@ -60,6 +65,30 @@ export interface EnterpriseDataTableProps extends Omit[]) => 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; } // --------------------------------------------------------------------------- @@ -77,6 +106,29 @@ export function EnterpriseDataTable(props: EnterpriseDataT 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, + ...restAgGridProps } = props; @@ -86,6 +138,11 @@ export function EnterpriseDataTable(props: EnterpriseDataT const { t } = useEnterpriseModuleTranslationContext(); const { dataServices } = useEnterpriseModuleDataServiceContext(); const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext(); + const navigation = useEnterpriseModuleNavigationContext(); + + const { config } = useEnterpriseModuleConfigContext(); + const { moduleType } = config; + const isTransaction = moduleType === 'TRANSACTION'; // --------------------------------------------------------------------------- // Local UI State @@ -94,12 +151,186 @@ export function EnterpriseDataTable(props: EnterpriseDataT // Reference to the AG Grid API for programmatic interaction const gridApiRef = useRef | null>(null); + // --------------------------------------------------------------------------- + // 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], + ); + + 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(() => { - console.log({ metaData }); return metaData?.limit ?? 10; }, [metaData]); @@ -108,23 +339,91 @@ export function EnterpriseDataTable(props: EnterpriseDataT // Ensure column definitions are referentially stable const finalColumnDefs = useMemo[]>(() => { - const masterDetailColumn: ColDef = { maxWidth: 50, sortable: false, cellRenderer: 'agGroupCellRenderer' }; - const statusColumn: ColDef = { - maxWidth: 130, - field: 'status' as any, - headerName: t('common:fields.status'), - cellRenderer: ({ value }: any) => , + // 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, }; - const prefixColumn: ColDef[] = [props.masterDetail ? masterDetailColumn : (null as any), statusColumn].filter( - Boolean, - ); + // 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, - return [...prefixColumn, ...columnDefs]; - }, [columnDefs, props.masterDetail]); + 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: 100, sortable: true, resizable: true }), []); + const defaultColDef = useMemo(() => ({ flex: 1, minWidth: 40, sortable: true, resizable: true }), []); // --------------------------------------------------------------------------- // Data Source (Server-Side Row Model) @@ -240,7 +539,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT columnDefs={finalColumnDefs} defaultColDef={defaultColDef} animateRows={true} - rowSelection={{ mode: 'multiRow', checkboxes: true, copySelectedRows: false, headerCheckbox: false }} + rowSelection={{ mode: 'multiRow', checkboxes: false, copySelectedRows: false, headerCheckbox: false }} enableCellTextSelection={true} onSelectionChanged={handleSelectionChanged} onGridReady={onGridReady} @@ -252,6 +551,14 @@ export function EnterpriseDataTable(props: EnterpriseDataT {...restAgGridProps} /> + + {/* Action Confirmation Modal */} + + modalState={actionModalState} + onClose={closeActionModal} + onExecute={executeAction} + t={t} + /> ); } diff --git a/packages/ui/src/foundations/enterprise-module/components/module-page-header/index.tsx b/packages/ui/src/foundations/enterprise-module/components/module-page-header/index.tsx index 80d55f0..ad16a1c 100644 --- a/packages/ui/src/foundations/enterprise-module/components/module-page-header/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/module-page-header/index.tsx @@ -238,7 +238,6 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { // Expanded / Full mode // ------------------------------------------------------------------------- - // Helper variable agar kode lebih bersih const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0; return ( diff --git a/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx index e217240..86e91be 100644 --- a/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx +++ b/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx @@ -274,7 +274,7 @@ export function EnterpriseDetailPageProvider( // --------------------------------------------------------------------------- const handleActionClick = useCallback( async (key: string) => { - // Guard utama: Untuk aksi selain CREATE, pastikan dataId dan detailData sudah ada + // Main guard: For actions other than CREATE, make sure dataId and detailData exist. const hasValidData = Boolean(dataId && detailData); const currentData = detailData as E; @@ -356,7 +356,6 @@ export function EnterpriseDetailPageProvider( } }, [ - // Semua dependensi wajib dimasukkan agar terhindar dari bug Stale Closure navigation, privileges, dataId, From 4e1342340947c78f6f145d92e524d2d18a870152 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:47:23 +0700 Subject: [PATCH 2/8] feat: implement bulk action confirmation modal and related functionality - Updated request methods for batch operations in constants.ts to use PUT instead of POST. - Added new translation keys for bulk actions in English and Indonesian language files. - Created BulkActionConfirmationModal component to handle bulk lifecycle actions with progress tracking and summary display. - Introduced SummaryPanel component to show aggregated results of bulk actions. - Enhanced BulkActionMenu to support custom bulk actions and improved action handling based on selected rows. - Integrated bulk action handling in EnterpriseDataTable, allowing for batch processing of selected entities. - Added new types for bulk action modal state and results in entity.ts. - Exported BulkActionConfirmationModal from the module index for accessibility. --- .../base-remote.data-services.ts | 42 +- .../core-api/src/data-services/constants.ts | 14 +- .../core-i18n/src/languages/en/common.json | 12 +- .../core-i18n/src/languages/id/common.json | 12 +- .../action-confirmation-modal/index.tsx | 2 +- .../bulk-action-confirmation/index.tsx | 457 ++++++++++++++++++ .../summary-pannel.tsx | 104 ++++ .../data-table/components/bulk-actions.tsx | 174 +++++-- .../components/data-table/index.tsx | 194 +++++++- .../enterprise-module/entities/entity.ts | 34 ++ .../foundations/enterprise-module/index.ts | 1 + 11 files changed, 977 insertions(+), 69 deletions(-) create mode 100644 packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx create mode 100644 packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/summary-pannel.tsx diff --git a/packages/core-api/src/data-services/base-remote.data-services.ts b/packages/core-api/src/data-services/base-remote.data-services.ts index 5eb61aa..b84f2bc 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.ts @@ -279,10 +279,10 @@ export abstract class BaseRemoteDataServices> { + /** Delete multiple entities by IDs. Optionally sends form data as `meta` in the request body. */ + batchDelete(ids: EntityId[], meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchDelete, { - config: { ...config, data: { ids } }, + config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, }); } @@ -296,10 +296,10 @@ export abstract class BaseRemoteDataServices> { + /** Activate multiple entities. Optionally sends form data as `meta` in the request body. */ + batchActivate(ids: EntityId[], meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchActivate, { - config: { ...config, data: { ids } }, + config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, }); } @@ -311,10 +311,10 @@ export abstract class BaseRemoteDataServices> { + /** Deactivate multiple entities. Optionally sends form data as `meta` in the request body. */ + batchDeactivate(ids: EntityId[], meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchDeactivate, { - config: { ...config, data: { ids } }, + config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, }); } @@ -328,10 +328,10 @@ export abstract class BaseRemoteDataServices> { + /** Confirm processing of multiple data records. Optionally sends form data as `meta` in the request body. */ + batchConfirmData(ids: EntityId[], meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchConfirmData, { - config: { ...config, data: { ids } }, + config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, }); } @@ -343,10 +343,10 @@ export abstract class BaseRemoteDataServices> { + /** Cancel processing of multiple data records. Optionally sends form data as `meta` in the request body. */ + batchCancelData(ids: EntityId[], meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchCancelData, { - config: { ...config, data: { ids } }, + config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, }); } @@ -360,10 +360,10 @@ export abstract class BaseRemoteDataServices> { + /** Rollback multiple transactions. Optionally sends form data as `meta` in the request body. */ + batchRollbackData(ids: EntityId[], meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchRollbackData, { - config: { ...config, data: { ids } }, + config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, }); } @@ -375,10 +375,10 @@ export abstract class BaseRemoteDataServices> { + /** Hold multiple transactions. Optionally sends form data as `meta` in the request body. */ + batchHoldData(ids: EntityId[], meta?: Record, config?: AxiosRequestConfig): Promise> { return this.execute(DESCRIPTORS.batchHoldData, { - config: { ...config, data: { ids } }, + config: { ...config, data: { ids, ...(meta ? { meta } : {}) } }, }); } } diff --git a/packages/core-api/src/data-services/constants.ts b/packages/core-api/src/data-services/constants.ts index 2f711a6..0d8b0e2 100644 --- a/packages/core-api/src/data-services/constants.ts +++ b/packages/core-api/src/data-services/constants.ts @@ -33,22 +33,22 @@ export const DEFAULT_METHODS: RequestMethodMap = { createMethod: 'POST', editMethod: 'PUT', deleteMethod: 'DELETE', - batchDeleteMethod: 'POST', + batchDeleteMethod: 'PUT', activateMethod: 'PATCH', - batchActivateMethod: 'POST', + batchActivateMethod: 'PUT', deactivateMethod: 'PATCH', - batchDeactivateMethod: 'POST', + batchDeactivateMethod: 'PUT', confirmDataMethod: 'PATCH', - batchConfirmDataMethod: 'POST', + batchConfirmDataMethod: 'PUT', cancelDataMethod: 'PATCH', - batchCancelDataMethod: 'POST', + batchCancelDataMethod: 'PUT', rollbackDataMethod: 'PATCH', - batchRollbackDataMethod: 'POST', + batchRollbackDataMethod: 'PUT', holdDataMethod: 'PATCH', - batchHoldDataMethod: 'POST', + batchHoldDataMethod: 'PUT', }; // ─── Operation Descriptors ────────────────────────────────────── diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 6bd07fa..7997037 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -60,7 +60,9 @@ "filter": "Filter", "setting": "Setting", "detail": "Detail", - "view": "View" + "view": "View", + "close": "Close", + "progress": "Progress" }, "confirmDialog": { "delete": { @@ -98,6 +100,14 @@ "continueEditing": "Continue Editing", "discardDraft": "Start Fresh" }, + "bulkAction": { + "totalData": "Total Data", + "totalSuccess": "Success", + "totalFailed": "Failed", + "messages": "Messages", + "selectedData": "{{count}} data selected", + "batchProgress": "Processing batch {{current}} of {{total}}" + }, "notifications": { "successTitle": "Success", "errorTitle": "Error", diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 28cabce..4d14de9 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -60,7 +60,9 @@ "filter": "Filter", "setting": "Pengaturan", "detail": "Detail", - "view": "Lihat" + "view": "Lihat", + "close": "Tutup", + "progress": "Progres" }, "confirmDialog": { "delete": { @@ -98,6 +100,14 @@ "continueEditing": "Lanjutkan", "discardDraft": "Mulai Baru" }, + "bulkAction": { + "totalData": "Total Data", + "totalSuccess": "Berhasil", + "totalFailed": "Gagal", + "messages": "Pesan", + "selectedData": "{{count}} data terpilih", + "batchProgress": "Memproses batch {{current}} dari {{total}}" + }, "notifications": { "successTitle": "Berhasil", "errorTitle": "Galat", diff --git a/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal/index.tsx b/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal/index.tsx index 46913e0..ad450a9 100644 --- a/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal/index.tsx @@ -50,7 +50,7 @@ export const ACTION_TRANSLATION_MAP: Record { + /** Current modal state (opened, action, data[], config) */ + modalState: BulkActionModalState; + /** Close the modal */ + onClose: () => void; + /** + * Execute the bulk action for a single batch of IDs. + * + * The component handles chunking internally — this callback is called + * once per chunk. The consumer is responsible for calling the correct + * batch method on their data services (e.g., `batchDelete`, `batchActivate`). + * + * @param action - The action type being performed + * @param ids - IDs for this particular chunk + * @param meta - Optional form data from the confirmation form + */ + onExecute: (action: ModuleActionType, ids: EntityId[], meta?: Record) => Promise; + /** Translation function scoped to [moduleNamespace, 'common'] */ + t: (key: string, options?: Record) => string; + /** + * Maximum number of IDs per batch request. + * @default 20 + */ + batchSize?: number; +} + +// --------------------------------------------------------------------------- +// Processing Phase Type +// --------------------------------------------------------------------------- + +type ProcessingPhase = 'idle' | 'processing' | 'completed'; + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +/** + * Splits an array into smaller chunks of a given size. + * Pure function — no side effects. + */ +function chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + +// --------------------------------------------------------------------------- +// Inner Form Body (memoized to prevent re-renders of the entire modal) +// --------------------------------------------------------------------------- + +const ModalFormBody = React.memo(function ModalFormBody< + TMeta extends Record = Record, +>(props: { + renderBody: NonNullable['renderBody']>; + form?: ReturnType>; +}) { + return <>{props.renderBody(props.form)}; +}) as >(props: { + renderBody: NonNullable['renderBody']>; + form?: ReturnType>; +}) => React.ReactElement; + +// --------------------------------------------------------------------------- +// Summary Panel (shown after processing completes) +// --------------------------------------------------------------------------- + +// const SummaryPanel = React.memo(function SummaryPanel({ +// result, +// t, +// }: { +// result: AggregatedResult; +// t: (key: string) => string; +// }) { +// return ( +// +// +// {/* Stats row */} +// +// +// +// +// +// +// {t('common:bulkAction.totalItems')}: +// +// +// {result.totalItems} +// +// + +// +// +// +// +// +// {t('common:bulkAction.totalSuccess')}: +// +// +// {result.totalSuccess} +// +// + +// +// +// +// +// +// {t('common:bulkAction.totalFailed')}: +// +// +// {result.totalFailed} +// +// +// + +// {/* Messages list */} +// {result.messages.length > 0 && ( +// <> +// +// {t('common:bulkAction.messages')}: +// +// +// {result.messages.map((msg, idx) => ( +// +// +// {msg} +// +// +// ))} +// +// +// )} +// +// +// ); +// }); + +// --------------------------------------------------------------------------- +// BulkActionConfirmationModal +// --------------------------------------------------------------------------- + +/** + * Controlled confirmation modal for bulk lifecycle actions (batch delete, activate, etc.). + * + * Processes selected rows in configurable chunks, providing real-time progress + * feedback and an aggregated success/failure summary upon completion. + * + * Supports three body modes (identical to `ActionConfirmationModal`): + * 1. **Simple** — no custom body, just title + selected count + confirm/cancel. + * 2. **Static body** — custom body without form (informational content). + * 3. **Form body** — custom body with RHF FormProvider for validation + meta payload. + * + * Processing lifecycle: + * 1. **Idle** — User reviews selected items and optionally fills a form. + * 2. **Processing** — Batches are sent sequentially with progress bar updates. + * 3. **Completed** — Summary panel shows total/success/failed counts + messages. + * + * @performance + * - Modal is NOT mounted when `opened === false` (uses `keepMounted={false}`). + * - Form is created with `mode: 'onSubmit'` to avoid re-renders on every keystroke. + * - Inner body and summary panel are wrapped in React.memo to isolate re-renders. + * - Batch processing uses sequential iteration (not Promise.all) to avoid server overload. + */ +export function BulkActionConfirmationModal( + props: BulkActionConfirmationModalProps, +) { + const { modalState, onClose, onExecute, t, batchSize = 20 } = props; + const { opened, action, data, config } = modalState; + + // --------------------------------------------------------------------------- + // State + // --------------------------------------------------------------------------- + + const [phase, setPhase] = useState('idle'); + const [progress, setProgress] = useState(0); + const [currentBatch, setCurrentBatch] = useState(0); + const [totalBatches, setTotalBatches] = useState(0); + const [aggregatedResult, setAggregatedResult] = useState(null); + + // Ref to track cancellation requests during processing + const abortRef = useRef(false); + + // --------------------------------------------------------------------------- + // Form Setup (identical to single-action modal) + // --------------------------------------------------------------------------- + + const hasForm = Boolean(config?.schema && config?.defaultValues); + + const form = useForm>({ + mode: 'onSubmit', + resolver: config?.schema ? zodResolver(config.schema as any) : undefined, + defaultValues: config?.defaultValues ?? {}, + }); + + // --------------------------------------------------------------------------- + // Translation Resolution + // --------------------------------------------------------------------------- + + const translations = useMemo(() => { + const actionKey = action ?? ''; + const map = ACTION_TRANSLATION_MAP[actionKey]; + + return { + title: config?.title ?? (map ? t(map.titleKey) : (action ?? '')), + confirmLabel: config?.confirmLabel ?? (map ? t(map.confirmKey) : t('common:actions.confirm')), + cancelLabel: config?.cancelLabel ?? t('common:actions.cancel'), + description: map ? t(map.descriptionKey) : '', + }; + }, [action, config?.title, config?.confirmLabel, config?.cancelLabel, t]); + + // --------------------------------------------------------------------------- + // Confirm Button Color (identical to single-action modal) + // --------------------------------------------------------------------------- + + const confirmColor = useMemo(() => { + if (action === ModuleAction.DELETE) return 'red'; + return 'brand'; + }, [action]); + + // --------------------------------------------------------------------------- + // Progress Bar Color + // --------------------------------------------------------------------------- + + const progressColor = useMemo(() => { + if (phase !== 'completed' || !aggregatedResult) return 'brand'; + if (aggregatedResult.totalFailed === 0) return 'green'; + if (aggregatedResult.totalSuccess === 0) return 'red'; + return 'orange'; // partial success + }, [phase, aggregatedResult]); + + // --------------------------------------------------------------------------- + // Batch Processing Engine + // --------------------------------------------------------------------------- + + const processBatches = useCallback( + async (meta?: Record) => { + if (!action || !data || data.length === 0) return; + + // Extract IDs from selected entities + const ids: EntityId[] = data.map((item) => item.id!).filter(Boolean); + if (ids.length === 0) return; + + // Split into chunks + const chunks = chunkArray(ids, batchSize); + const batchCount = chunks.length; + + // Reset state for processing + abortRef.current = false; + setPhase('processing'); + setProgress(0); + setCurrentBatch(0); + setTotalBatches(batchCount); + + const accumulated: AggregatedResult = { + totalItems: ids.length, + totalSuccess: 0, + totalFailed: 0, + messages: [], + }; + + // Process chunks sequentially to avoid server overload + for (let i = 0; i < chunks.length; i++) { + if (abortRef.current) break; + + setCurrentBatch(i + 1); + + try { + const result = await onExecute(action, chunks[i], meta); + + accumulated.totalSuccess += result.total_success; + accumulated.totalFailed += result.total_failed; + if (result.messages?.length) { + accumulated.messages.push(...result.messages); + } + } catch (error: any) { + // Count entire chunk as failed on network/unexpected errors + accumulated.totalFailed += chunks[i].length; + const errorMessage: string = error?.message ?? JSON.stringify(error); + accumulated.messages.push(errorMessage); + } + + // Update progress after each batch + const progressPercent = ((i + 1) / batchCount) * 100; + setProgress(Number(progressPercent.toFixed(2))); + } + + setAggregatedResult(accumulated); + setPhase('completed'); + }, + [action, data, batchSize, onExecute], + ); + + // --------------------------------------------------------------------------- + // Confirm Handler + // --------------------------------------------------------------------------- + + const handleConfirmClick = useCallback(async () => { + if (!action || !data || data.length === 0) return; + + if (hasForm) { + // Trigger RHF validation, then process if valid + const isValid = await form.trigger(); + if (!isValid) return; + + const formValues = form.getValues(); + await processBatches(formValues); + } else { + // No form — process directly + await processBatches(undefined); + } + }, [action, data, hasForm, form, processBatches]); + + // --------------------------------------------------------------------------- + // Close Handler + // --------------------------------------------------------------------------- + + const handleClose = useCallback(() => { + // Prevent close while actively processing + if (phase === 'processing') return; + + // Reset all state + abortRef.current = true; + setPhase('idle'); + setProgress(0); + setCurrentBatch(0); + setTotalBatches(0); + setAggregatedResult(null); + form.reset(); + onClose(); + }, [phase, form, onClose]); + + // --------------------------------------------------------------------------- + // Derived UI State + // --------------------------------------------------------------------------- + + const isProcessing = phase === 'processing'; + const isCompleted = phase === 'completed'; + const selectedCount = data?.length ?? 0; + + return ( + + + {/* Body: custom or default description */} + {phase === 'idle' && ( + <> + {config?.renderBody ? ( + hasForm ? ( + + + + ) : ( + + ) + ) : ( + + {translations.description && ( + + {translations.description} + + )} + + {t('common:bulkAction.selectedData', { count: selectedCount })} + + + )} + + )} + + {/* Progress section (visible during processing and after completion) */} + {(isProcessing || isCompleted) && ( + + + + {t('common:actions.progress')} + + + {progress}% + + + + + )} + + {/* Summary panel (visible after completion) */} + {isCompleted && aggregatedResult && } + + {/* Footer actions */} + + + {!isCompleted && ( + + )} + + + + ); +} diff --git a/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/summary-pannel.tsx b/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/summary-pannel.tsx new file mode 100644 index 0000000..09f8f56 --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/summary-pannel.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Paper, Stack, Group, ThemeIcon, Text, SimpleGrid, ScrollArea } from '@mantine/core'; +import { Info, Check, X } from 'lucide-react'; // Atau tabler-icons, sesuaikan dengan library Anda + +export interface AggregatedResult { + totalItems: number; + totalSuccess: number; + totalFailed: number; + messages: string[]; +} + +const SummaryPanel = React.memo(function SummaryPanel({ + result, + t, +}: { + result: AggregatedResult; + t: (key: string) => string; +}) { + const messages = result.messages ?? []; + + return ( + + + {/* Stats row - Grid for compact and even distribution */} + + {/* Total Items Card */} + + + + + + + + {t('common:bulkAction.totalData')} + + + + {result.totalItems} + + + + + {/* Total Success Card */} + + + + + + + + {t('common:bulkAction.totalSuccess')} + + + + {result.totalSuccess} + + + + + {/* Total Failed Card */} + + + + + + + + {t('common:bulkAction.totalFailed')} + + + + {result.totalFailed} + + + + + + {/* Messages list area (Log style) */} + {messages.length > 0 && ( + + + {t('common:bulkAction.messages')} + + + {/* Mengunci tinggi maksimal dan menambahkan scroll jika pesan banyak */} + 3 ? 120 : undefined} type="auto" offsetScrollbars> + + {messages.map((msg, idx) => ( + + + {msg} + + + ))} + + + + )} + + + ); +}); + +export default SummaryPanel; diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx index 3148b04..19fdb90 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx @@ -1,61 +1,167 @@ -import { useMemo } from 'react'; +import { useMemo, useCallback } from 'react'; import { ModuleAction, ModuleActionType } from '../../../entities/entity'; -import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context'; -import { Trash2, CheckCircle, XCircle } from 'lucide-react'; -import { PageActionProps } from '../../../../../components'; +import { + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, +} from '../../../hooks/use-module.context'; +import { Trash2, CheckCircle, XCircle, PauseCircle, RotateCcw, X, Check } from 'lucide-react'; +import { PageActions, PageActionProps } from '../../../../../components'; +import { Group } from '@mantine/core'; -export interface UseBulkActionsProps { +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BulkActionMenuProps { selectedRows: any[]; onActionClick: (action: ModuleActionType, data: any[]) => void; statusKey?: string; customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[]; } -export function useBulkActions({ +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Renders bulk action icon buttons for selected rows in the data table header. + * + * Actions are derived from the module type (MASTER_DATA vs TRANSACTION), + * privilege checks, and the status of selected rows — mirroring the + * row-level action logic in `RowActionMenu`. + * + * All actions have `showLabel: false` so they render as icon-only + * `ActionIcon` buttons via the `PageActions` component. + * + * @performance + * - Uses component pattern (not hook) to isolate re-renders from the parent DataTable. + * - The parent only re-renders the BulkActionMenu — not the entire grid — when selection changes. + */ +export function BulkActionMenu({ selectedRows, onActionClick, statusKey = 'status', customBulkActions, -}: UseBulkActionsProps) { +}: BulkActionMenuProps) { const { t } = useEnterpriseModuleTranslationContext(); + const { privileges, config } = useEnterpriseModuleConfigContext(); + const { moduleType } = config; - return useMemo(() => { + // Stabilise the callback reference so the memo only depends on `onActionClick` + const handleClick = useCallback( + (action: ModuleActionType) => () => onActionClick(action, selectedRows), + [onActionClick, selectedRows], + ); + + const actions = useMemo(() => { if (!selectedRows || selectedRows.length === 0) return []; + const { ALLOW_DELETE, ALLOW_ACTIVATE, ALLOW_DEACTIVATE, ALLOW_CONFIRM, ALLOW_CANCEL, ALLOW_ROLLBACK, ALLOW_HOLD } = + privileges; + + const isTransaction = moduleType === 'TRANSACTION'; + const isMasterData = moduleType === 'MASTER_DATA'; + const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active'); - const hasInactive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'inactive'); + const hasInactive = selectedRows.some( + (row) => row[statusKey]?.toLowerCase() === 'inactive' || row[statusKey]?.toLowerCase() === 'draft', + ); const defaultActions: PageActionProps[] = []; - if (hasActive) { - defaultActions.push({ - key: ModuleAction.DEACTIVATE, - label: t('common:actions.deactivate'), - icon: , - variant: 'default', - onClick: () => onActionClick(ModuleAction.DEACTIVATE, selectedRows), - }); + // --- Master Data Lifecycle Actions --- + if (isMasterData) { + if (ALLOW_DEACTIVATE && hasActive) { + defaultActions.push({ + key: ModuleAction.DEACTIVATE, + label: t('common:actions.deactivate'), + icon: , + variant: 'default', + showLabel: false, + onClick: handleClick(ModuleAction.DEACTIVATE), + }); + } + + if (ALLOW_ACTIVATE && hasInactive) { + defaultActions.push({ + key: ModuleAction.ACTIVATE, + label: t('common:actions.activate'), + icon: , + variant: 'default', + showLabel: false, + onClick: handleClick(ModuleAction.ACTIVATE), + }); + } } - if (hasInactive) { - defaultActions.push({ - key: ModuleAction.ACTIVATE, - label: t('common:actions.activate'), - icon: , - variant: 'default', - onClick: () => onActionClick(ModuleAction.ACTIVATE, selectedRows), - }); + // --- Transaction Lifecycle Actions --- + if (isTransaction) { + if (ALLOW_HOLD) { + defaultActions.push({ + key: ModuleAction.HOLD, + label: t('common:actions.hold'), + icon: , + variant: 'default', + showLabel: false, + onClick: handleClick(ModuleAction.HOLD), + }); + } + + if (ALLOW_ROLLBACK) { + defaultActions.push({ + key: ModuleAction.ROLLBACK, + label: t('common:actions.rollback'), + icon: , + variant: 'default', + showLabel: false, + onClick: handleClick(ModuleAction.ROLLBACK), + }); + } + + if (ALLOW_CANCEL) { + defaultActions.push({ + key: ModuleAction.CANCEL, + label: t('common:actions.cancel'), + icon: , + variant: 'default', + showLabel: false, + onClick: handleClick(ModuleAction.CANCEL), + }); + } + + if (ALLOW_CONFIRM) { + defaultActions.push({ + key: ModuleAction.CONFIRM, + label: t('common:actions.confirm'), + icon: , + variant: 'default', + showLabel: false, + onClick: handleClick(ModuleAction.CONFIRM), + }); + } } - defaultActions.push({ - key: ModuleAction.DELETE, - label: t('common:actions.delete'), - icon: , - variant: 'outline', - intent: 'destructive', - onClick: () => onActionClick(ModuleAction.DELETE, selectedRows), - }); + // --- Delete (always last, universal) --- + if (ALLOW_DELETE) { + defaultActions.push({ + key: ModuleAction.DELETE, + label: t('common:actions.delete'), + icon: , + variant: 'outline', + intent: 'destructive', + showLabel: false, + onClick: handleClick(ModuleAction.DELETE), + }); + } return customBulkActions ? customBulkActions(selectedRows, defaultActions) : defaultActions; - }, [selectedRows, statusKey, t, onActionClick, customBulkActions]); + }, [selectedRows, statusKey, t, handleClick, privileges, moduleType, customBulkActions]); + + if (actions.length === 0) return null; + + return ( + + + + ); } 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 index d4a5cdd..4464a8a 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -14,6 +14,7 @@ import { import { Box } from '@mantine/core'; import { DataGrid, StatusBadge } from '../../../../components'; +import type { PageActionProps } from '../../../../components'; import { useEnterpriseModuleConfigContext, @@ -22,11 +23,21 @@ import { useEnterpriseModuleTranslationContext, useEnterpriseModuleNavigationContext, } from '../../hooks/use-module.context'; -import { BaseEntity } from '@repo/core-api/data-services'; +import type { BaseEntity } from '@repo/core-api/data-services'; import { notifications } from '@mantine/notifications'; -import { ModuleActionType, ModuleAction, ActionModalState, ActionModalConfig } from '../../entities/entity'; +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 { EntityId } from '../../../../../../core-api/src/data-services/types'; export * from 'ag-grid-community'; export * from 'ag-grid-react'; @@ -89,6 +100,21 @@ export interface EnterpriseDataTableProps extends Omit 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; } // --------------------------------------------------------------------------- @@ -129,6 +155,17 @@ export function EnterpriseDataTable(props: EnterpriseDataT rollbackModalConfig, holdModalConfig, + // Bulk action props + customBulkActions, + batchSize, + onBulkClickDelete, + onBulkClickActivate, + onBulkClickDeactivate, + onBulkClickConfirm, + onBulkClickCancel, + onBulkClickRollback, + onBulkClickHold, + ...restAgGridProps } = props; @@ -137,7 +174,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT // --------------------------------------------------------------------------- const { t } = useEnterpriseModuleTranslationContext(); const { dataServices } = useEnterpriseModuleDataServiceContext(); - const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext(); + const { selectedRows, setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext(); const navigation = useEnterpriseModuleNavigationContext(); const { config } = useEnterpriseModuleConfigContext(); @@ -262,6 +299,138 @@ export function EnterpriseDataTable(props: EnterpriseDataT [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) { @@ -521,6 +690,14 @@ export function EnterpriseDataTable(props: EnterpriseDataT // --------------------------------------------------------------------------- return ( + {/* BULK ACTION TOOLBAR — shown when rows are selected */} + void} + statusKey={statusKey} + customBulkActions={customBulkActions} + /> + {/* GRID CONTAINER */} (props: EnterpriseDataT /> - {/* Action Confirmation Modal */} + {/* 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} + /> ); } diff --git a/packages/ui/src/foundations/enterprise-module/entities/entity.ts b/packages/ui/src/foundations/enterprise-module/entities/entity.ts index 35f658b..6157f47 100644 --- a/packages/ui/src/foundations/enterprise-module/entities/entity.ts +++ b/packages/ui/src/foundations/enterprise-module/entities/entity.ts @@ -302,6 +302,40 @@ export interface ActionModalState { config?: ActionModalConfig; } +// --------------------------------------------------------------------------- +// Bulk Action Confirmation Modal Configuration +// --------------------------------------------------------------------------- + +/** + * Internal state for the bulk action confirmation modal. + * + * Unlike `ActionModalState` which holds a single entity, this state holds + * an array of selected entities for batch processing. + * + * @template E The base database entity. + * @internal + */ +export interface BulkActionModalState { + opened: boolean; + action: ModuleActionType | null; + /** The selected rows to process in bulk. */ + data: E[]; + config?: ActionModalConfig; +} + +/** + * Aggregated result from a bulk batch operation. + * + * Each batch call returns one of these, and the component aggregates + * them across all chunks to display a final summary. + */ +export interface BulkActionResult { + total_items: number; + total_success: number; + total_failed: number; + messages?: string[]; +} + export interface EnterpriseDetailPageConfig extends BasePageConfig { editMode?: 'FULL' | 'PARTIAL'; customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions']; diff --git a/packages/ui/src/foundations/enterprise-module/index.ts b/packages/ui/src/foundations/enterprise-module/index.ts index 24bd8b3..80d95e9 100644 --- a/packages/ui/src/foundations/enterprise-module/index.ts +++ b/packages/ui/src/foundations/enterprise-module/index.ts @@ -9,4 +9,5 @@ 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/bulk-action-confirmation'; export * from './components/data-table'; From ddf11741e9ef933e5646e21144ec740591fc1d23 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:30:39 +0700 Subject: [PATCH 3/8] feat: enhance data-table with search, filter/setting drawers, and updated UI controls --- .../core-i18n/src/languages/en/common.json | 3 + .../core-i18n/src/languages/id/common.json | 5 +- .../components/actions-tools/page-actions.tsx | 11 +-- .../data-table/components/bulk-actions.tsx | 7 +- .../components/table-filter-drawer.tsx | 24 +++++++ .../components/table-setting-drawer.tsx | 24 +++++++ .../components/data-table/index.tsx | 69 ++++++++++++++++--- 7 files changed, 123 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx create mode 100644 packages/ui/src/foundations/enterprise-module/components/data-table/components/table-setting-drawer.tsx diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 7997037..413cd6d 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -40,8 +40,11 @@ "collapseAll": "Collapse all menu", "searchMenu": "Search menu", "searchData": "Search data", + "searchPlaceholder": "Type to search & press Enter...", "collapse": "Collapse", "expandSidebar": "Expand Sidebar", + "filterTitle": "Filter {{module}}", + "tableSettingTitle": "Table Setting {{module}}", "actions": { "create": "Create New", "edit": "Edit", diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 4d14de9..609f5fa 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -40,8 +40,11 @@ "collapseAll": "Tutup semua menu", "searchMenu": "Cari menu", "searchData": "Cari data", + "searchPlaceholder": "Ketik pencarian & tekan Enter...", "collapse": "Tutup", - "expandSidebar": "Perluas Sidebar", + "expandSidebar": "Perluas Bilah Sisi", + "filterTitle": "Filter {{module}}", + "tableSettingTitle": "Pengaturan Tabel {{module}}", "actions": { "create": "Buat Baru", "edit": "Ubah", diff --git a/packages/ui/src/components/actions-tools/page-actions.tsx b/packages/ui/src/components/actions-tools/page-actions.tsx index 8226b0d..bb763b6 100644 --- a/packages/ui/src/components/actions-tools/page-actions.tsx +++ b/packages/ui/src/components/actions-tools/page-actions.tsx @@ -1,5 +1,5 @@ import { memo, Fragment } from 'react'; -import { Group, Button, Menu, Divider, ActionIcon, Box, ButtonProps, Tooltip } from '@mantine/core'; +import { Group, Button, Menu, Divider, ActionIcon, Box, ButtonProps, Tooltip, MantineSpacing } from '@mantine/core'; import { ChevronDown, MoreVertical } from 'lucide-react'; import { PageActionProps } from './types'; import { getIntentColor } from './utils'; @@ -8,13 +8,16 @@ export interface PageActionsProps { /** Array of configured page-level actions. */ actions?: PageActionProps[]; customButtonProps?: (action: PageActionProps) => ButtonProps; + gapActionDesktop?:MantineSpacing; + gapActionMobile?:MantineSpacing } /** * A responsive and flexible presentational component for page-level actions. * Automatically adapts layout based on screen size. */ -export const PageActions = memo(function PageActions({ actions = [], customButtonProps }: PageActionsProps) { +export const PageActions = memo(function PageActions(props: PageActionsProps) { + const { actions = [], customButtonProps, gapActionDesktop='xs', gapActionMobile='sm' }=props; if (!actions || actions?.length === 0) { return null; } @@ -33,7 +36,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto return ( {/* --- DESKTOP VIEW --- */} - + {actions.map((action, index) => { if (action.type === 'divider') { return ; @@ -139,7 +142,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto {/* --- MOBILE VIEW --- */} - + diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx index 19fdb90..4d61a12 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/bulk-actions.tsx @@ -6,7 +6,6 @@ import { } from '../../../hooks/use-module.context'; import { Trash2, CheckCircle, XCircle, PauseCircle, RotateCcw, X, Check } from 'lucide-react'; import { PageActions, PageActionProps } from '../../../../../components'; -import { Group } from '@mantine/core'; // --------------------------------------------------------------------------- // Types @@ -159,9 +158,5 @@ export function BulkActionMenu({ if (actions.length === 0) return null; - return ( - - - - ); + return ; } diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx new file mode 100644 index 0000000..0bce4d0 --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx @@ -0,0 +1,24 @@ +import { Drawer, Text } from '@mantine/core'; + +export interface TableFilterDrawerProps { + opened: boolean; + onClose: () => void; + title: string; +} + +export function TableFilterDrawer({ opened, onClose, title }: TableFilterDrawerProps) { + return ( + {title}} + position="right" + size="md" + padding="md" + > + + Filter configuration will go here. + + + ); +} diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-setting-drawer.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-setting-drawer.tsx new file mode 100644 index 0000000..4181987 --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-setting-drawer.tsx @@ -0,0 +1,24 @@ +import { Drawer, Text } from '@mantine/core'; + +export interface TableSettingDrawerProps { + opened: boolean; + onClose: () => void; + title: string; +} + +export function TableSettingDrawer({ opened, onClose, title }: TableSettingDrawerProps) { + return ( + {title}} + position="right" + size="md" + padding="md" + > + + Table setting configuration will go here. + + + ); +} 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 index 4464a8a..2ac170a 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -11,9 +11,11 @@ import { DefaultMenuItem, StatusBar, } from 'ag-grid-community'; -import { Box } from '@mantine/core'; +import { Box, Group, TextInput } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { Search, Filter, Settings } from 'lucide-react'; -import { DataGrid, StatusBadge } from '../../../../components'; +import { DataGrid, StatusBadge, PageActions } from '../../../../components'; import type { PageActionProps } from '../../../../components'; import { @@ -37,6 +39,8 @@ 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 } 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'; @@ -184,6 +188,9 @@ export function EnterpriseDataTable(props: EnterpriseDataT // --------------------------------------------------------------------------- // 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); @@ -690,13 +697,46 @@ export function EnterpriseDataTable(props: EnterpriseDataT // --------------------------------------------------------------------------- return ( - {/* BULK ACTION TOOLBAR — shown when rows are selected */} - void} - statusKey={statusKey} - customBulkActions={customBulkActions} - /> + {/* TABLE HEADER (Search, Filter, Bulk Actions) */} + + + } + /> + , + 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 */} (props: EnterpriseDataT t={t} batchSize={batchSize} /> + + + ); } From fa119deec217b1ffa3605d2f83f3c67c3806fa1c Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:24:08 +0700 Subject: [PATCH 4/8] feat: add configurable data table filtering and update API endpoints for the example module --- .../components/filter-content.tsx | 37 +++++ .../pages/full-page.page.index.tsx | 29 +++- .../core-i18n/src/languages/en/common.json | 4 +- .../core-i18n/src/languages/id/common.json | 4 +- .../bulk-action-confirmation/index.tsx | 7 +- .../components/table-filter-drawer.tsx | 145 +++++++++++++++++- .../components/data-table/index.tsx | 17 +- 7 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/components/filter-content.tsx diff --git a/apps/web/src/apps/modules/example/full-page/presentation/components/filter-content.tsx b/apps/web/src/apps/modules/example/full-page/presentation/components/filter-content.tsx new file mode 100644 index 0000000..ba4d61f --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/components/filter-content.tsx @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; +import { SimpleGrid } from '@repo/ui/components'; +import { FieldTextInput } from '@repo/ui/form'; +import { UseFormReturn, useWatch } from 'react-hook-form'; + +export const FilterFormContent = ({ form, t }: { form: UseFormReturn; t: any }) => { + const codeValue = useWatch({ + control: form.control, + name: 'code', + }); + + useEffect(() => { + if (!codeValue) { + form.setValue('name', ''); + form.clearErrors('name'); + } + }, [codeValue, form]); + + return ( + + + + + + ); +}; diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx index f142aa1..94f4467 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx @@ -4,10 +4,27 @@ import { EnterpriseDataTable, } from '@repo/ui/foundations'; import { ColDef } from '@repo/ui/components'; +import { z } from 'zod'; import { Trans } from '@repo/core-i18n'; import { Text } from '@repo/ui/components'; import { LayoutDashboard } from 'lucide-react'; import { useMemo } from 'react'; +import { FilterFormContent } from '../components/filter-content'; + +const filterSchema = z + .object({ + code: z.string().optional(), + name: z.string().optional(), + }) + .superRefine((data, ctx) => { + if (data.code && !data.name) { + ctx.addIssue({ + path: ['name'], + code: z.ZodIssueCode.custom, + message: 'Name is required when code is provided', + }); + } + }); export default function FullPagePageIndex() { const { t } = useEnterpriseModuleTranslationContext(); @@ -19,6 +36,16 @@ export default function FullPagePageIndex() { ]; }, [t]); + const filterConfig = useMemo(() => { + return { + schema: filterSchema, + renderBody: (form: any) => { + if (!form) return null; + return ; + }, + }; + }, [t]); + return ( - + ); } diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index 413cd6d..2f7fa3f 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -65,7 +65,9 @@ "detail": "Detail", "view": "View", "close": "Close", - "progress": "Progress" + "progress": "Progress", + "reset": "Reset", + "undo": "Undo" }, "confirmDialog": { "delete": { diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 609f5fa..c22cb9a 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -65,7 +65,9 @@ "detail": "Detail", "view": "Lihat", "close": "Tutup", - "progress": "Progres" + "progress": "Progres", + "reset": "Atur Ulang", + "undo": "Kembalikan" }, "confirmDialog": { "delete": { diff --git a/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx b/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx index f787130..218ea71 100644 --- a/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx @@ -1,8 +1,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { Box, Button, Group, List, Modal, Paper, Progress, Stack, Text, ThemeIcon } from '@mantine/core'; +import { Box, Button, Group, Modal, Progress, Stack, Text } from '@mantine/core'; import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Check, X, Info } from 'lucide-react'; import type { BaseEntity } from '@repo/core-api/data-services'; import type { @@ -197,8 +196,8 @@ export function BulkActionConfirmationModal( const [phase, setPhase] = useState('idle'); const [progress, setProgress] = useState(0); - const [currentBatch, setCurrentBatch] = useState(0); - const [totalBatches, setTotalBatches] = useState(0); + const [, setCurrentBatch] = useState(0); + const [, setTotalBatches] = useState(0); const [aggregatedResult, setAggregatedResult] = useState(null); // Ref to track cancellation requests during processing diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx index 0bce4d0..1550ea0 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx @@ -1,24 +1,155 @@ -import { Drawer, Text } from '@mantine/core'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Button, Drawer, DrawerProps, Group, Stack, Text, ScrollArea } from '@mantine/core'; +import { useForm, FormProvider, UseFormReturn } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { ZodType } from 'zod'; +import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context'; + +/** + * Configuration for the filter drawer content and behavior. + * @template TMeta - The schema type for the react-hook-form + */ +export interface TableFilterConfig = Record> { + /** Renders the body of the filter drawer */ + renderBody?: (form?: UseFormReturn) => React.ReactNode; + /** Zod schema for form validation */ + schema?: ZodType; + /** Default values for the form fields */ + defaultValues?: TMeta; + /** Additional props to pass to the underlying Mantine Drawer */ + drawerProps?: Omit; +} export interface TableFilterDrawerProps { opened: boolean; onClose: () => void; title: string; + config?: TableFilterConfig; + currentFilterData: any; + onFilter: (data: any) => void; } -export function TableFilterDrawer({ opened, onClose, title }: TableFilterDrawerProps) { +const DrawerFormBody = React.memo(function DrawerFormBody< + TMeta extends Record = Record, +>({ + renderBody, + form, +}: { + renderBody: NonNullable['renderBody']>; + form?: ReturnType>; +}) { + return <>{renderBody(form)}; +}) as >(props: { + renderBody: NonNullable['renderBody']>; + form?: ReturnType>; +}) => React.ReactElement; + +export function TableFilterDrawer({ + opened, + onClose, + title, + config, + onFilter, + currentFilterData, +}: TableFilterDrawerProps) { + const { t } = useEnterpriseModuleTranslationContext(); + + // We have a form if a renderBody is provided + const hasForm = Boolean(config?.renderBody); + + const form = useForm>({ + mode: 'onSubmit', + resolver: config?.schema ? zodResolver(config.schema as any) : undefined, + defaultValues: config?.defaultValues ?? {}, + }); + + const [previousValues, setPreviousValues] = useState | null>(null); + const [hasReset, setHasReset] = useState(false); + + useEffect(() => { + if (opened && hasForm) { + form.reset({ + ...config?.defaultValues, + ...(currentFilterData || {}), + }); + setHasReset(false); + setPreviousValues(null); + } + }, [opened]); // Only reset when opened + + const handleApply = useCallback(async () => { + if (hasForm) { + const isValid = await form.trigger(); + if (!isValid) return; + const values = form.getValues(); + onFilter(values); + } else { + onFilter({}); + } + onClose(); + }, [hasForm, form, onFilter, onClose]); + + const handleReset = useCallback(() => { + if (hasForm) { + setPreviousValues(form.getValues()); + form.reset(config?.defaultValues ?? {}); + setHasReset(true); + } + }, [hasForm, form, config]); + + const handleUndoReset = useCallback(() => { + if (previousValues) { + form.reset(previousValues); + setHasReset(false); + setPreviousValues(null); + } + }, [form, previousValues]); + return ( {title}} + title={ + + {title} + + } position="right" + keepMounted={false} size="md" - padding="md" + {...config?.drawerProps} > - - Filter configuration will go here. - + + + {config?.renderBody ? ( + hasForm ? ( + + + + ) : ( + + ) + ) : ( + + Filter configuration will go here. + + )} + + + + {hasReset && ( + + )} + + + + ); } 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 index 2ac170a..62a047e 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -39,13 +39,15 @@ 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 } from './components/table-filter-drawer'; +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 // --------------------------------------------------------------------------- @@ -119,6 +121,11 @@ export interface EnterpriseDataTableProps extends Omit void; onBulkClickRollback?: (data: E[]) => void; onBulkClickHold?: (data: E[]) => void; + + /** + * Configuration for the Filter Drawer + */ + filterConfig?: TableFilterConfig; } // --------------------------------------------------------------------------- @@ -158,6 +165,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT cancelModalConfig, rollbackModalConfig, holdModalConfig, + filterConfig, // Bulk action props customBulkActions, @@ -178,7 +186,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT // --------------------------------------------------------------------------- const { t } = useEnterpriseModuleTranslationContext(); const { dataServices } = useEnterpriseModuleDataServiceContext(); - const { selectedRows, setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext(); + const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = useEnterpriseModuleSelectionContext(); const navigation = useEnterpriseModuleNavigationContext(); const { config } = useEnterpriseModuleConfigContext(); @@ -789,6 +797,11 @@ export function EnterpriseDataTable(props: EnterpriseDataT opened={openedFilter} onClose={closeFilter} title={t('common:filterTitle', { module: moduleTitle })} + config={filterConfig} + currentFilterData={filterData} + onFilter={(data) => { + console.log('Filter applied:', data); + }} /> Date: Thu, 23 Jul 2026 15:12:49 +0700 Subject: [PATCH 5/8] feat: implement server-side search, filter state management, and clearable inputs for data tables with associated API transformer updates. --- .../components/table-filter-drawer.tsx | 9 +- .../components/data-table/index.tsx | 104 ++++++++++++++++-- .../components/module-page-header/index.tsx | 3 +- 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx index 1550ea0..059be51 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/table-filter-drawer.tsx @@ -92,7 +92,14 @@ export function TableFilterDrawer({ const handleReset = useCallback(() => { if (hasForm) { setPreviousValues(form.getValues()); - form.reset(config?.defaultValues ?? {}); + + // Ensure all fields are explicitly cleared + const cleared = Object.keys(form.getValues()).reduce((acc, key) => { + acc[key] = ''; + return acc; + }, {} as Record); + + form.reset({ ...cleared, ...(config?.defaultValues || {}) }); setHasReset(true); } }, [hasForm, form, config]); 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 index 62a047e..e647e1c 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -11,7 +11,7 @@ import { DefaultMenuItem, StatusBar, } from 'ag-grid-community'; -import { Box, Group, TextInput } from '@mantine/core'; +import { Box, Group, TextInput, Indicator, CloseButton } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { Search, Filter, Settings } from 'lucide-react'; @@ -122,6 +122,12 @@ export interface EnterpriseDataTableProps extends Omit void; onBulkClickHold?: (data: E[]) => void; + /** + * The query parameter key used for search. + * @default 'q' + */ + searchKey?: string; + /** * Configuration for the Filter Drawer */ @@ -165,6 +171,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT cancelModalConfig, rollbackModalConfig, holdModalConfig, + searchKey = 'q', filterConfig, // Bulk action props @@ -203,6 +210,58 @@ export function EnterpriseDataTable(props: EnterpriseDataT // 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(''); + 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 // --------------------------------------------------------------------------- @@ -619,8 +678,8 @@ export function EnterpriseDataTable(props: EnterpriseDataT 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; + const limit = perPage + const page = Math.floor((request.startRow ?? 0) / limit) + 1; // Extract sorting information from the request const sortModel = request.sortModel[0]; @@ -628,7 +687,18 @@ export function EnterpriseDataTable(props: EnterpriseDataT 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 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'); @@ -639,6 +709,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT // 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 }); @@ -649,7 +720,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT } }, }), - [dataServices, perPage, setMetaData, t], + [dataServices, perPage, setMetaData, setFilterData, searchKey, t], ); // --------------------------------------------------------------------------- @@ -713,13 +784,30 @@ export function EnterpriseDataTable(props: EnterpriseDataT w={{ base: '100%', sm: 350 }} placeholder={t('common:searchPlaceholder')} leftSection={} + rightSection={ + searchValue ? ( + e.preventDefault()} + onClick={handleSearchClear} + aria-label={t('common:actions.clear')} + /> + ) : null + } + value={searchValue} + onChange={handleSearchChange} + onKeyDown={handleSearchKeyDown} /> , + icon: ( + + + + ), variant: 'default', showLabel: false, tooltipLabel: t('common:actions.filter'), @@ -799,9 +887,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT title={t('common:filterTitle', { module: moduleTitle })} config={filterConfig} currentFilterData={filterData} - onFilter={(data) => { - console.log('Filter applied:', data); - }} + onFilter={handleFilterApply} /> + {item.label} ) : ( From 9a8d94cd98a3fca4b41bd3778c385cdc566d8ac3 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:33:55 +0700 Subject: [PATCH 6/8] feat: update full-page module endpoint and transformer logic, optimize data-table search clearing, and hardcode auth token for development --- .../enterprise-module/components/data-table/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index e647e1c..e9e869e 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -236,8 +236,10 @@ export function EnterpriseDataTable(props: EnterpriseDataT const handleSearchClear = useCallback(() => { setSearchValue(''); - searchRef.current = ''; - gridApiRef.current?.refreshServerSide({ purge: true }); + if (searchRef.current !== '') { + searchRef.current = ''; + gridApiRef.current?.refreshServerSide({ purge: true }); + } }, []); const activeFilterCount = useMemo(() => { From 79b291cf74b95121d32becd534289ff952661039 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:58:08 +0700 Subject: [PATCH 7/8] refactor: improve code readability by formatting and restructuring useRef and useCallback hooks in EnterpriseDataTable component --- .../components/data-table/index.tsx | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) 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 index e9e869e..cb12d5e 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -193,7 +193,8 @@ export function EnterpriseDataTable(props: EnterpriseDataT // --------------------------------------------------------------------------- const { t } = useEnterpriseModuleTranslationContext(); const { dataServices } = useEnterpriseModuleDataServiceContext(); - const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = useEnterpriseModuleSelectionContext(); + const { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } = + useEnterpriseModuleSelectionContext(); const navigation = useEnterpriseModuleNavigationContext(); const { config } = useEnterpriseModuleConfigContext(); @@ -214,12 +215,14 @@ export function EnterpriseDataTable(props: EnterpriseDataT // 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 filterRef = useRef>( + (() => { + if (!filterData) return {}; + const copy = { ...filterData }; + delete copy[searchKey]; + return copy; + })(), + ); const [searchValue, setSearchValue] = useState(searchRef.current); @@ -227,12 +230,15 @@ export function EnterpriseDataTable(props: EnterpriseDataT setSearchValue(e.currentTarget.value); }, []); - const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - searchRef.current = searchValue; - gridApiRef.current?.refreshServerSide({ purge: true }); - } - }, [searchValue]); + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + searchRef.current = searchValue; + gridApiRef.current?.refreshServerSide({ purge: true }); + } + }, + [searchValue], + ); const handleSearchClear = useCallback(() => { setSearchValue(''); @@ -248,7 +254,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT const filterKeys = filterConfig?.defaultValues ? Object.keys(filterConfig.defaultValues) : Object.keys(filterData).filter( - (key) => key !== searchKey && !['page', 'limit', 'order_by', 'order_type'].includes(key) + (key) => key !== searchKey && !['page', 'limit', 'order_by', 'order_type'].includes(key), ); return filterKeys.filter((key) => { @@ -680,7 +686,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT try { const request = params.request; - const limit = perPage + const limit = perPage; const page = Math.floor((request.startRow ?? 0) / limit) + 1; // Extract sorting information from the request @@ -689,12 +695,12 @@ export function EnterpriseDataTable(props: EnterpriseDataT const orderType = sortModel?.sort?.toUpperCase(); // Prepare the request parameters for the API call - const requestParams: Record = { - page, - limit, - order_by: orderBy, + const requestParams: Record = { + page, + limit, + order_by: orderBy, order_type: orderType, - ...filterRef.current + ...filterRef.current, }; if (searchRef.current) { @@ -737,9 +743,17 @@ export function EnterpriseDataTable(props: EnterpriseDataT 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], + [datasource, setSelectedRows, isPaginated, metaData], ); // Triggered whenever the row selection in the grid changes @@ -851,6 +865,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT pagination={isPaginated} paginationPageSize={isPaginated ? perPage : undefined} paginationPageSizeSelector={isPaginated ? [10, 15, 20, 50] : undefined} + serverSideInitialRowCount={metaData?.total ?? undefined} columnDefs={finalColumnDefs} defaultColDef={defaultColDef} animateRows={true} From 8f2eecd8ae5470421fa04d58223c5cab8a2f07c4 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:59:11 +0700 Subject: [PATCH 8/8] refactor: remove unused SummaryPanel component from BulkActionConfirmationModal --- .../bulk-action-confirmation/index.tsx | 75 ------------------- 1 file changed, 75 deletions(-) diff --git a/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx b/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx index 218ea71..33aba85 100644 --- a/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/bulk-action-confirmation/index.tsx @@ -83,81 +83,6 @@ const ModalFormBody = React.memo(function ModalFormBody< form?: ReturnType>; }) => React.ReactElement; -// --------------------------------------------------------------------------- -// Summary Panel (shown after processing completes) -// --------------------------------------------------------------------------- - -// const SummaryPanel = React.memo(function SummaryPanel({ -// result, -// t, -// }: { -// result: AggregatedResult; -// t: (key: string) => string; -// }) { -// return ( -// -// -// {/* Stats row */} -// -// -// -// -// -// -// {t('common:bulkAction.totalItems')}: -// -// -// {result.totalItems} -// -// - -// -// -// -// -// -// {t('common:bulkAction.totalSuccess')}: -// -// -// {result.totalSuccess} -// -// - -// -// -// -// -// -// {t('common:bulkAction.totalFailed')}: -// -// -// {result.totalFailed} -// -// -// - -// {/* Messages list */} -// {result.messages.length > 0 && ( -// <> -// -// {t('common:bulkAction.messages')}: -// -// -// {result.messages.map((msg, idx) => ( -// -// -// {msg} -// -// -// ))} -// -// -// )} -// -// -// ); -// }); - // --------------------------------------------------------------------------- // BulkActionConfirmationModal // ---------------------------------------------------------------------------