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
@@ -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>
);
}