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] 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';