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:
+1
-1
@@ -50,7 +50,7 @@ export const ACTION_TRANSLATION_MAP: Record<string, { titleKey: string; confirmK
|
||||
},
|
||||
[ModuleAction.CANCEL]: {
|
||||
titleKey: 'common:confirmDialog.cancel.title',
|
||||
confirmKey: 'common:actions.cancel_action',
|
||||
confirmKey: 'common:actions.cancel',
|
||||
descriptionKey: 'common:confirmDialog.cancel.description',
|
||||
},
|
||||
[ModuleAction.ROLLBACK]: {
|
||||
|
||||
+457
@@ -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>
|
||||
);
|
||||
}
|
||||
+104
@@ -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;
|
||||
+140
-34
@@ -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: <XCircle size={16} />,
|
||||
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: <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) {
|
||||
defaultActions.push({
|
||||
key: ModuleAction.ACTIVATE,
|
||||
label: t('common:actions.activate'),
|
||||
icon: <CheckCircle size={16} />,
|
||||
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: <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({
|
||||
key: ModuleAction.DELETE,
|
||||
label: t('common:actions.delete'),
|
||||
icon: <Trash2 size={16} />,
|
||||
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: <Trash2 size={16} />,
|
||||
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 (
|
||||
<Group justify="flex-end" mb="sm">
|
||||
<PageActions actions={actions} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<E extends BaseEntity> extends Omit<AgG
|
||||
cancelModalConfig?: ActionModalConfig;
|
||||
rollbackModalConfig?: 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,
|
||||
holdModalConfig,
|
||||
|
||||
// Bulk action props
|
||||
customBulkActions,
|
||||
batchSize,
|
||||
onBulkClickDelete,
|
||||
onBulkClickActivate,
|
||||
onBulkClickDeactivate,
|
||||
onBulkClickConfirm,
|
||||
onBulkClickCancel,
|
||||
onBulkClickRollback,
|
||||
onBulkClickHold,
|
||||
|
||||
...restAgGridProps
|
||||
} = props;
|
||||
|
||||
@@ -137,7 +174,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
||||
// ---------------------------------------------------------------------------
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
|
||||
const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
|
||||
const { selectedRows, setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
|
||||
const navigation = useEnterpriseModuleNavigationContext();
|
||||
|
||||
const { config } = useEnterpriseModuleConfigContext();
|
||||
@@ -262,6 +299,138 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
||||
[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(
|
||||
(action: ModuleActionType | 'VIEW', data: E) => {
|
||||
switch (action) {
|
||||
@@ -521,6 +690,14 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<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 */}
|
||||
<Box
|
||||
className="erp-data-grid-container"
|
||||
@@ -552,13 +729,22 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action Confirmation Modal */}
|
||||
{/* Single-Row Action Confirmation Modal */}
|
||||
<ActionConfirmationModal<E>
|
||||
modalState={actionModalState}
|
||||
onClose={closeActionModal}
|
||||
onExecute={executeAction}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Bulk Action Confirmation Modal */}
|
||||
<BulkActionConfirmationModal<E>
|
||||
modalState={bulkModalState}
|
||||
onClose={closeBulkActionModal}
|
||||
onExecute={executeBulkAction}
|
||||
t={t}
|
||||
batchSize={batchSize}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -302,6 +302,40 @@ export interface ActionModalState<E extends BaseEntity = BaseEntity> {
|
||||
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 {
|
||||
editMode?: 'FULL' | 'PARTIAL';
|
||||
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 './components/module-page-header';
|
||||
export * from './components/action-confirmation-modal';
|
||||
export * from './components/bulk-action-confirmation';
|
||||
export * from './components/data-table';
|
||||
|
||||
Reference in New Issue
Block a user