382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
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<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;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 [, setCurrentBatch] = useState(0);
|
|
const [, 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>
|
|
);
|
|
}
|