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.
This commit is contained in:
Firman Ramdhani
2026-07-23 11:47:23 +07:00
parent 0243b6aa96
commit 4e13423409
11 changed files with 977 additions and 69 deletions
@@ -279,10 +279,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Delete multiple entities by IDs. */ /** Delete multiple entities by IDs. Optionally sends form data as `meta` in the request body. */
batchDelete(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchDelete(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDelete, { return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -296,10 +296,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Activate multiple entities. */ /** Activate multiple entities. Optionally sends form data as `meta` in the request body. */
batchActivate(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchActivate(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchActivate, { return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -311,10 +311,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Deactivate multiple entities. */ /** Deactivate multiple entities. Optionally sends form data as `meta` in the request body. */
batchDeactivate(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchDeactivate(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDeactivate, { return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -328,10 +328,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Confirm processing of multiple data records. */ /** Confirm processing of multiple data records. Optionally sends form data as `meta` in the request body. */
batchConfirmData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchConfirmData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmData, { return this.execute<void>(DESCRIPTORS.batchConfirmData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -343,10 +343,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Cancel processing of multiple data records. */ /** Cancel processing of multiple data records. Optionally sends form data as `meta` in the request body. */
batchCancelData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchCancelData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelData, { return this.execute<void>(DESCRIPTORS.batchCancelData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -360,10 +360,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Rollback multiple transactions. */ /** Rollback multiple transactions. Optionally sends form data as `meta` in the request body. */
batchRollbackData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchRollbackData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackData, { return this.execute<void>(DESCRIPTORS.batchRollbackData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -375,10 +375,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Hold multiple transactions. */ /** Hold multiple transactions. Optionally sends form data as `meta` in the request body. */
batchHoldData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchHoldData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldData, { return this.execute<void>(DESCRIPTORS.batchHoldData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
} }
@@ -33,22 +33,22 @@ export const DEFAULT_METHODS: RequestMethodMap = {
createMethod: 'POST', createMethod: 'POST',
editMethod: 'PUT', editMethod: 'PUT',
deleteMethod: 'DELETE', deleteMethod: 'DELETE',
batchDeleteMethod: 'POST', batchDeleteMethod: 'PUT',
activateMethod: 'PATCH', activateMethod: 'PATCH',
batchActivateMethod: 'POST', batchActivateMethod: 'PUT',
deactivateMethod: 'PATCH', deactivateMethod: 'PATCH',
batchDeactivateMethod: 'POST', batchDeactivateMethod: 'PUT',
confirmDataMethod: 'PATCH', confirmDataMethod: 'PATCH',
batchConfirmDataMethod: 'POST', batchConfirmDataMethod: 'PUT',
cancelDataMethod: 'PATCH', cancelDataMethod: 'PATCH',
batchCancelDataMethod: 'POST', batchCancelDataMethod: 'PUT',
rollbackDataMethod: 'PATCH', rollbackDataMethod: 'PATCH',
batchRollbackDataMethod: 'POST', batchRollbackDataMethod: 'PUT',
holdDataMethod: 'PATCH', holdDataMethod: 'PATCH',
batchHoldDataMethod: 'POST', batchHoldDataMethod: 'PUT',
}; };
// ─── Operation Descriptors ────────────────────────────────────── // ─── Operation Descriptors ──────────────────────────────────────
@@ -60,7 +60,9 @@
"filter": "Filter", "filter": "Filter",
"setting": "Setting", "setting": "Setting",
"detail": "Detail", "detail": "Detail",
"view": "View" "view": "View",
"close": "Close",
"progress": "Progress"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -98,6 +100,14 @@
"continueEditing": "Continue Editing", "continueEditing": "Continue Editing",
"discardDraft": "Start Fresh" "discardDraft": "Start Fresh"
}, },
"bulkAction": {
"totalData": "Total Data",
"totalSuccess": "Success",
"totalFailed": "Failed",
"messages": "Messages",
"selectedData": "{{count}} data selected",
"batchProgress": "Processing batch {{current}} of {{total}}"
},
"notifications": { "notifications": {
"successTitle": "Success", "successTitle": "Success",
"errorTitle": "Error", "errorTitle": "Error",
@@ -60,7 +60,9 @@
"filter": "Filter", "filter": "Filter",
"setting": "Pengaturan", "setting": "Pengaturan",
"detail": "Detail", "detail": "Detail",
"view": "Lihat" "view": "Lihat",
"close": "Tutup",
"progress": "Progres"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -98,6 +100,14 @@
"continueEditing": "Lanjutkan", "continueEditing": "Lanjutkan",
"discardDraft": "Mulai Baru" "discardDraft": "Mulai Baru"
}, },
"bulkAction": {
"totalData": "Total Data",
"totalSuccess": "Berhasil",
"totalFailed": "Gagal",
"messages": "Pesan",
"selectedData": "{{count}} data terpilih",
"batchProgress": "Memproses batch {{current}} dari {{total}}"
},
"notifications": { "notifications": {
"successTitle": "Berhasil", "successTitle": "Berhasil",
"errorTitle": "Galat", "errorTitle": "Galat",
@@ -50,7 +50,7 @@ export const ACTION_TRANSLATION_MAP: Record<string, { titleKey: string; confirmK
}, },
[ModuleAction.CANCEL]: { [ModuleAction.CANCEL]: {
titleKey: 'common:confirmDialog.cancel.title', titleKey: 'common:confirmDialog.cancel.title',
confirmKey: 'common:actions.cancel_action', confirmKey: 'common:actions.cancel',
descriptionKey: 'common:confirmDialog.cancel.description', descriptionKey: 'common:confirmDialog.cancel.description',
}, },
[ModuleAction.ROLLBACK]: { [ModuleAction.ROLLBACK]: {
@@ -0,0 +1,457 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Box, Button, Group, List, Modal, Paper, Progress, Stack, Text, ThemeIcon } 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 {
ActionModalConfig,
BulkActionModalState,
BulkActionResult,
ModuleActionType,
} from '../../entities/entity';
import { ModuleAction } from '../../entities/entity';
import { ACTION_TRANSLATION_MAP } from '../action-confirmation-modal';
import { EntityId } from '../../../../../../core-api/src/data-services/types';
import SummaryPanel, { AggregatedResult } from './summary-pannel';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BulkActionConfirmationModalProps<E extends BaseEntity = BaseEntity> {
/** Current modal state (opened, action, data[], config) */
modalState: BulkActionModalState<E>;
/** 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<string, unknown>) => Promise<BulkActionResult>;
/** Translation function scoped to [moduleNamespace, 'common'] */
t: (key: string, options?: Record<string, unknown>) => 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<T>(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<string, unknown> = Record<string, unknown>,
>(props: {
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) {
return <>{props.renderBody(props.form)}</>;
}) as <TMeta extends Record<string, unknown>>(props: {
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) => React.ReactElement;
// ---------------------------------------------------------------------------
// Summary Panel (shown after processing completes)
// ---------------------------------------------------------------------------
// const SummaryPanel = React.memo(function SummaryPanel({
// result,
// t,
// }: {
// result: AggregatedResult;
// t: (key: string) => string;
// }) {
// return (
// <Paper p="md" radius="md" withBorder>
// <Stack gap="sm">
// {/* Stats row */}
// <Group gap="lg">
// <Group gap="xs">
// <ThemeIcon variant="light" color="blue" size="sm" radius="xl">
// <Info size={14} />
// </ThemeIcon>
// <Text size="sm" fw={500}>
// {t('common:bulkAction.totalItems')}:
// </Text>
// <Text size="sm" c="dimmed">
// {result.totalItems}
// </Text>
// </Group>
// <Group gap="xs">
// <ThemeIcon variant="light" color="green" size="sm" radius="xl">
// <Check size={14} />
// </ThemeIcon>
// <Text size="sm" fw={500}>
// {t('common:bulkAction.totalSuccess')}:
// </Text>
// <Text size="sm" c="dimmed">
// {result.totalSuccess}
// </Text>
// </Group>
// <Group gap="xs">
// <ThemeIcon variant="light" color="red" size="sm" radius="xl">
// <X size={14} />
// </ThemeIcon>
// <Text size="sm" fw={500}>
// {t('common:bulkAction.totalFailed')}:
// </Text>
// <Text size="sm" c="dimmed">
// {result.totalFailed}
// </Text>
// </Group>
// </Group>
// {/* Messages list */}
// {result.messages.length > 0 && (
// <>
// <Text size="sm" fw={600} mt="xs">
// {t('common:bulkAction.messages')}:
// </Text>
// <List size="sm" spacing="xs" withPadding>
// {result.messages.map((msg, idx) => (
// <List.Item key={idx}>
// <Text size="xs" c="dimmed">
// {msg}
// </Text>
// </List.Item>
// ))}
// </List>
// </>
// )}
// </Stack>
// </Paper>
// );
// });
// ---------------------------------------------------------------------------
// 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<E extends BaseEntity = BaseEntity>(
props: BulkActionConfirmationModalProps<E>,
) {
const { modalState, onClose, onExecute, t, batchSize = 20 } = props;
const { opened, action, data, config } = modalState;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const [phase, setPhase] = useState<ProcessingPhase>('idle');
const [progress, setProgress] = useState(0);
const [currentBatch, setCurrentBatch] = useState(0);
const [totalBatches, setTotalBatches] = useState(0);
const [aggregatedResult, setAggregatedResult] = useState<AggregatedResult | null>(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<Record<string, unknown>>({
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<string, unknown>) => {
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 (
<Modal
opened={opened}
onClose={handleClose}
title={translations.title}
size={config?.size ?? 'lg'}
centered
closeOnClickOutside={!isProcessing}
closeOnEscape={!isProcessing}
keepMounted={false}
padding="lg"
styles={{
title: { fontWeight: 600, fontSize: 'var(--mantine-font-size-xl)' },
header: { paddingBottom: 'var(--mantine-spacing-md)' },
}}
>
<Stack gap="xl">
{/* Body: custom or default description */}
{phase === 'idle' && (
<>
{config?.renderBody ? (
hasForm ? (
<FormProvider {...form}>
<ModalFormBody renderBody={config.renderBody} form={form} />
</FormProvider>
) : (
<ModalFormBody renderBody={config.renderBody} />
)
) : (
<Stack gap="sm">
{translations.description && (
<Text size="md" c="dimmed">
{translations.description}
</Text>
)}
<Text size="sm" fw={500}>
{t('common:bulkAction.selectedData', { count: selectedCount })}
</Text>
</Stack>
)}
</>
)}
{/* Progress section (visible during processing and after completion) */}
{(isProcessing || isCompleted) && (
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={600} c={progressColor}>
{t('common:actions.progress')}
</Text>
<Text size="sm" fw={600} c={progressColor}>
{progress}%
</Text>
</Group>
<Progress
value={progress}
color={progressColor}
size="md"
radius="xl"
animated={isProcessing}
striped={isProcessing}
/>
</Box>
)}
{/* Summary panel (visible after completion) */}
{isCompleted && aggregatedResult && <SummaryPanel result={aggregatedResult} t={t} />}
{/* Footer actions */}
<Group justify="flex-end" mt="sm">
<Button
size="xs"
variant={!isCompleted ? 'default' : undefined}
onClick={handleClose}
disabled={isProcessing}
>
{isCompleted ? t('common:actions.close') : translations.cancelLabel}
</Button>
{!isCompleted && (
<Button
size="xs"
color={confirmColor}
onClick={handleConfirmClick}
loading={isProcessing}
disabled={isProcessing || selectedCount === 0}
>
{translations.confirmLabel}
</Button>
)}
</Group>
</Stack>
</Modal>
);
}
@@ -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 (
<Paper p="sm" radius="md" withBorder>
<Stack gap="sm">
{/* Stats row - Grid for compact and even distribution */}
<SimpleGrid cols={3} spacing="xs">
{/* Total Items Card */}
<Paper py="sm" px="md" radius="sm" withBorder bg="blue.0">
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<ThemeIcon variant="transparent" color="blue.7" size="sm">
<Info size={16} strokeWidth={2.5} />
</ThemeIcon>
<Text size="xs" fw={600} c="blue.9">
{t('common:bulkAction.totalData')}
</Text>
</Group>
<Text size="sm" fw={700} c="blue.9">
{result.totalItems}
</Text>
</Group>
</Paper>
{/* Total Success Card */}
<Paper py="sm" px="md" radius="sm" withBorder bg="green.0">
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<ThemeIcon variant="transparent" color="green.7" size="sm">
<Check size={16} strokeWidth={2.5} />
</ThemeIcon>
<Text size="xs" fw={600} c="green.9">
{t('common:bulkAction.totalSuccess')}
</Text>
</Group>
<Text size="sm" fw={700} c="green.9">
{result.totalSuccess}
</Text>
</Group>
</Paper>
{/* Total Failed Card */}
<Paper py="sm" px="md" radius="sm" withBorder bg="red.0">
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<ThemeIcon variant="transparent" color="red.7" size="sm">
<X size={16} strokeWidth={2.5} />
</ThemeIcon>
<Text size="xs" fw={600} c="red.9">
{t('common:bulkAction.totalFailed')}
</Text>
</Group>
<Text size="sm" fw={700} c="red.9">
{result.totalFailed}
</Text>
</Group>
</Paper>
</SimpleGrid>
{/* Messages list area (Log style) */}
{messages.length > 0 && (
<Stack gap={4} mt={4}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
{t('common:bulkAction.messages')}
</Text>
{/* Mengunci tinggi maksimal dan menambahkan scroll jika pesan banyak */}
<ScrollArea h={messages.length > 3 ? 120 : undefined} type="auto" offsetScrollbars>
<Stack gap={6}>
{messages.map((msg, idx) => (
<Paper key={idx} p={8} px="sm" radius="sm" bg="white" withBorder>
<Text size="xs" c="gray.7" lh={1.4}>
{msg}
</Text>
</Paper>
))}
</Stack>
</ScrollArea>
</Stack>
)}
</Stack>
</Paper>
);
});
export default SummaryPanel;
@@ -1,61 +1,167 @@
import { useMemo } from 'react'; import { useMemo, useCallback } from 'react';
import { ModuleAction, ModuleActionType } from '../../../entities/entity'; import { ModuleAction, ModuleActionType } from '../../../entities/entity';
import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context'; import {
import { Trash2, CheckCircle, XCircle } from 'lucide-react'; useEnterpriseModuleTranslationContext,
import { PageActionProps } from '../../../../../components'; 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[]; selectedRows: any[];
onActionClick: (action: ModuleActionType, data: any[]) => void; onActionClick: (action: ModuleActionType, data: any[]) => void;
statusKey?: string; statusKey?: string;
customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[]; 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, selectedRows,
onActionClick, onActionClick,
statusKey = 'status', statusKey = 'status',
customBulkActions, customBulkActions,
}: UseBulkActionsProps) { }: BulkActionMenuProps) {
const { t } = useEnterpriseModuleTranslationContext(); 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 []; 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 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[] = []; const defaultActions: PageActionProps[] = [];
if (hasActive) { // --- Master Data Lifecycle Actions ---
defaultActions.push({ if (isMasterData) {
key: ModuleAction.DEACTIVATE, if (ALLOW_DEACTIVATE && hasActive) {
label: t('common:actions.deactivate'), defaultActions.push({
icon: <XCircle size={16} />, key: ModuleAction.DEACTIVATE,
variant: 'default', label: t('common:actions.deactivate'),
onClick: () => onActionClick(ModuleAction.DEACTIVATE, selectedRows), icon: <XCircle size={16} />,
}); variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.DEACTIVATE),
});
}
if (ALLOW_ACTIVATE && hasInactive) {
defaultActions.push({
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
icon: <CheckCircle size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.ACTIVATE),
});
}
} }
if (hasInactive) { // --- Transaction Lifecycle Actions ---
defaultActions.push({ if (isTransaction) {
key: ModuleAction.ACTIVATE, if (ALLOW_HOLD) {
label: t('common:actions.activate'), defaultActions.push({
icon: <CheckCircle size={16} />, key: ModuleAction.HOLD,
variant: 'default', label: t('common:actions.hold'),
onClick: () => onActionClick(ModuleAction.ACTIVATE, selectedRows), icon: <PauseCircle size={16} />,
}); variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.HOLD),
});
}
if (ALLOW_ROLLBACK) {
defaultActions.push({
key: ModuleAction.ROLLBACK,
label: t('common:actions.rollback'),
icon: <RotateCcw size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.ROLLBACK),
});
}
if (ALLOW_CANCEL) {
defaultActions.push({
key: ModuleAction.CANCEL,
label: t('common:actions.cancel'),
icon: <X size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.CANCEL),
});
}
if (ALLOW_CONFIRM) {
defaultActions.push({
key: ModuleAction.CONFIRM,
label: t('common:actions.confirm'),
icon: <Check size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.CONFIRM),
});
}
} }
defaultActions.push({ // --- Delete (always last, universal) ---
key: ModuleAction.DELETE, if (ALLOW_DELETE) {
label: t('common:actions.delete'), defaultActions.push({
icon: <Trash2 size={16} />, key: ModuleAction.DELETE,
variant: 'outline', label: t('common:actions.delete'),
intent: 'destructive', icon: <Trash2 size={16} />,
onClick: () => onActionClick(ModuleAction.DELETE, selectedRows), variant: 'outline',
}); intent: 'destructive',
showLabel: false,
onClick: handleClick(ModuleAction.DELETE),
});
}
return customBulkActions ? customBulkActions(selectedRows, defaultActions) : defaultActions; 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 (
<Group justify="flex-end" mb="sm">
<PageActions actions={actions} />
</Group>
);
} }
@@ -14,6 +14,7 @@ import {
import { Box } from '@mantine/core'; import { Box } from '@mantine/core';
import { DataGrid, StatusBadge } from '../../../../components'; import { DataGrid, StatusBadge } from '../../../../components';
import type { PageActionProps } from '../../../../components';
import { import {
useEnterpriseModuleConfigContext, useEnterpriseModuleConfigContext,
@@ -22,11 +23,21 @@ import {
useEnterpriseModuleTranslationContext, useEnterpriseModuleTranslationContext,
useEnterpriseModuleNavigationContext, useEnterpriseModuleNavigationContext,
} from '../../hooks/use-module.context'; } 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 { 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 { RowActionMenu } from './components/row-actions';
import { BulkActionMenu } from './components/bulk-actions';
import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal'; 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-community';
export * from 'ag-grid-react'; export * from 'ag-grid-react';
@@ -89,6 +100,21 @@ export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgG
cancelModalConfig?: ActionModalConfig; cancelModalConfig?: ActionModalConfig;
rollbackModalConfig?: ActionModalConfig; rollbackModalConfig?: ActionModalConfig;
holdModalConfig?: ActionModalConfig; holdModalConfig?: ActionModalConfig;
// Bulk action props
/** Custom function to modify/extend the default bulk action buttons shown in the table header. */
customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[];
/** Maximum number of IDs per batch request during bulk operations. @default 20 */
batchSize?: number;
// Bulk action custom flow callbacks (mirrors single-action onClick* pattern)
onBulkClickDelete?: (data: E[]) => void;
onBulkClickActivate?: (data: E[]) => void;
onBulkClickDeactivate?: (data: E[]) => void;
onBulkClickConfirm?: (data: E[]) => void;
onBulkClickCancel?: (data: E[]) => void;
onBulkClickRollback?: (data: E[]) => void;
onBulkClickHold?: (data: E[]) => void;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -129,6 +155,17 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
rollbackModalConfig, rollbackModalConfig,
holdModalConfig, holdModalConfig,
// Bulk action props
customBulkActions,
batchSize,
onBulkClickDelete,
onBulkClickActivate,
onBulkClickDeactivate,
onBulkClickConfirm,
onBulkClickCancel,
onBulkClickRollback,
onBulkClickHold,
...restAgGridProps ...restAgGridProps
} = props; } = props;
@@ -137,7 +174,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>(); const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>(); const { selectedRows, setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
const navigation = useEnterpriseModuleNavigationContext(); const navigation = useEnterpriseModuleNavigationContext();
const { config } = useEnterpriseModuleConfigContext(); const { config } = useEnterpriseModuleConfigContext();
@@ -262,6 +299,138 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
[dataServices, closeActionModal, modalConfigMap, t], [dataServices, closeActionModal, modalConfigMap, t],
); );
// ---------------------------------------------------------------------------
// Bulk Action Modal State & Handlers
// ---------------------------------------------------------------------------
const CLOSED_BULK_MODAL: BulkActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: [] }), []);
const [bulkModalState, setBulkModalState] = useState<BulkActionModalState<E>>(CLOSED_BULK_MODAL);
const openBulkActionModal = useCallback(
(action: ModuleActionType, data: E[]) => {
const config = modalConfigMap[action as keyof typeof modalConfigMap];
setBulkModalState({ opened: true, action, data, config });
},
[modalConfigMap],
);
const closeBulkActionModal = useCallback(() => {
setBulkModalState(CLOSED_BULK_MODAL);
// Refresh grid data after bulk action completes
if (gridApiRef.current) {
gridApiRef.current.refreshServerSide({ purge: false });
}
// Clear selected rows
setSelectedRows([]);
if (gridApiRef.current) {
gridApiRef.current.deselectAll();
}
}, [CLOSED_BULK_MODAL, setSelectedRows]);
const handleBulkActionClick = useCallback(
(action: ModuleActionType, data: E[]) => {
switch (action) {
case ModuleAction.DELETE:
if (onBulkClickDelete) onBulkClickDelete(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.ACTIVATE:
if (onBulkClickActivate) onBulkClickActivate(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.DEACTIVATE:
if (onBulkClickDeactivate) onBulkClickDeactivate(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.CONFIRM:
if (onBulkClickConfirm) onBulkClickConfirm(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.CANCEL:
if (onBulkClickCancel) onBulkClickCancel(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.ROLLBACK:
if (onBulkClickRollback) onBulkClickRollback(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.HOLD:
if (onBulkClickHold) onBulkClickHold(data);
else openBulkActionModal(action, data);
break;
default:
openBulkActionModal(action, data);
break;
}
},
[
openBulkActionModal,
onBulkClickDelete,
onBulkClickActivate,
onBulkClickDeactivate,
onBulkClickConfirm,
onBulkClickCancel,
onBulkClickRollback,
onBulkClickHold,
],
);
/**
* Executes a single batch chunk of the bulk action.
* Called by BulkActionConfirmationModal per chunk.
*/
const executeBulkAction = useCallback(
async (action: ModuleActionType, ids: EntityId[], meta?: Record<string, unknown>): Promise<BulkActionResult> => {
try {
switch (action) {
case ModuleAction.DELETE:
await dataServices.batchDelete(ids, meta);
break;
case ModuleAction.ACTIVATE:
await dataServices.batchActivate(ids, meta);
break;
case ModuleAction.DEACTIVATE:
await dataServices.batchDeactivate(ids, meta);
break;
case ModuleAction.CONFIRM:
await dataServices.batchConfirmData(ids, meta);
break;
case ModuleAction.CANCEL:
await dataServices.batchCancelData(ids, meta);
break;
case ModuleAction.ROLLBACK:
await dataServices.batchRollbackData(ids, meta);
break;
case ModuleAction.HOLD:
await dataServices.batchHoldData(ids, meta);
break;
default:
console.warn(`[executeBulkAction] Unhandled action: ${action}`);
return { total_items: ids.length, total_success: 0, total_failed: ids.length };
}
return {
total_items: ids.length,
total_success: ids.length,
total_failed: 0,
};
} catch (error: any) {
return {
total_items: ids.length,
total_success: 0,
total_failed: ids.length,
messages: [error?.message || 'Unknown error'],
};
}
},
[dataServices],
);
// ---------------------------------------------------------------------------
// Action Handlers
// ---------------------------------------------------------------------------
const handleActionClick = useCallback( const handleActionClick = useCallback(
(action: ModuleActionType | 'VIEW', data: E) => { (action: ModuleActionType | 'VIEW', data: E) => {
switch (action) { switch (action) {
@@ -521,6 +690,14 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
return ( return (
<Box> <Box>
{/* BULK ACTION TOOLBAR — shown when rows are selected */}
<BulkActionMenu
selectedRows={selectedRows as E[]}
onActionClick={handleBulkActionClick as (action: ModuleActionType, data: any[]) => void}
statusKey={statusKey}
customBulkActions={customBulkActions}
/>
{/* GRID CONTAINER */} {/* GRID CONTAINER */}
<Box <Box
className="erp-data-grid-container" className="erp-data-grid-container"
@@ -552,13 +729,22 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
/> />
</Box> </Box>
{/* Action Confirmation Modal */} {/* Single-Row Action Confirmation Modal */}
<ActionConfirmationModal<E> <ActionConfirmationModal<E>
modalState={actionModalState} modalState={actionModalState}
onClose={closeActionModal} onClose={closeActionModal}
onExecute={executeAction} onExecute={executeAction}
t={t} t={t}
/> />
{/* Bulk Action Confirmation Modal */}
<BulkActionConfirmationModal<E>
modalState={bulkModalState}
onClose={closeBulkActionModal}
onExecute={executeBulkAction}
t={t}
batchSize={batchSize}
/>
</Box> </Box>
); );
} }
@@ -302,6 +302,40 @@ export interface ActionModalState<E extends BaseEntity = BaseEntity> {
config?: ActionModalConfig<any>; config?: ActionModalConfig<any>;
} }
// ---------------------------------------------------------------------------
// 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<E extends BaseEntity = BaseEntity> {
opened: boolean;
action: ModuleActionType | null;
/** The selected rows to process in bulk. */
data: E[];
config?: ActionModalConfig<any>;
}
/**
* 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<E extends BaseEntity = BaseEntity> extends BasePageConfig { export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
editMode?: 'FULL' | 'PARTIAL'; editMode?: 'FULL' | 'PARTIAL';
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions']; customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
@@ -9,4 +9,5 @@ export * from './providers/index-page.provider';
export * from './providers/detail-page.provider'; export * from './providers/detail-page.provider';
export * from './components/module-page-header'; export * from './components/module-page-header';
export * from './components/action-confirmation-modal'; export * from './components/action-confirmation-modal';
export * from './components/bulk-action-confirmation';
export * from './components/data-table'; export * from './components/data-table';