import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Box, Button, Group, Modal, Progress, Stack, Text } from '@mantine/core'; import { useForm, FormProvider } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import 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 { /** 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; // --------------------------------------------------------------------------- // 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 [, setCurrentBatch] = useState(0); const [, 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 && ( )} ); }