- 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.
223 lines
8.3 KiB
TypeScript
223 lines
8.3 KiB
TypeScript
import React, { useCallback, useMemo, useState } from 'react';
|
|
import { Button, Group, Modal, 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, ActionModalState, ModuleActionType } from '../../entities/entity';
|
|
import { ModuleAction } from '../../entities/entity';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ActionConfirmationModalProps<E extends BaseEntity = BaseEntity> {
|
|
/** Current modal state (opened, action, data, config) */
|
|
modalState: ActionModalState<E>;
|
|
/** Close the modal */
|
|
onClose: () => void;
|
|
/** Execute the action with optional meta from form */
|
|
onExecute: (action: ModuleActionType, data: E, meta?: Record<string, unknown>) => Promise<void>;
|
|
/** Translation function scoped to [moduleNamespace, 'common'] */
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Default Translation Keys per Action
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const ACTION_TRANSLATION_MAP: Record<string, { titleKey: string; confirmKey: string; descriptionKey: string }> =
|
|
{
|
|
[ModuleAction.DELETE]: {
|
|
titleKey: 'common:confirmDialog.delete.title',
|
|
confirmKey: 'common:actions.delete',
|
|
descriptionKey: 'common:confirmDialog.delete.description',
|
|
},
|
|
[ModuleAction.ACTIVATE]: {
|
|
titleKey: 'common:confirmDialog.activate.title',
|
|
confirmKey: 'common:actions.activate',
|
|
descriptionKey: 'common:confirmDialog.activate.description',
|
|
},
|
|
[ModuleAction.DEACTIVATE]: {
|
|
titleKey: 'common:confirmDialog.deactivate.title',
|
|
confirmKey: 'common:actions.deactivate',
|
|
descriptionKey: 'common:confirmDialog.deactivate.description',
|
|
},
|
|
[ModuleAction.CONFIRM]: {
|
|
titleKey: 'common:confirmDialog.confirm.title',
|
|
confirmKey: 'common:actions.confirm',
|
|
descriptionKey: 'common:confirmDialog.confirm.description',
|
|
},
|
|
[ModuleAction.CANCEL]: {
|
|
titleKey: 'common:confirmDialog.cancel.title',
|
|
confirmKey: 'common:actions.cancel',
|
|
descriptionKey: 'common:confirmDialog.cancel.description',
|
|
},
|
|
[ModuleAction.ROLLBACK]: {
|
|
titleKey: 'common:confirmDialog.rollback.title',
|
|
confirmKey: 'common:actions.rollback',
|
|
descriptionKey: 'common:confirmDialog.rollback.description',
|
|
},
|
|
[ModuleAction.HOLD]: {
|
|
titleKey: 'common:confirmDialog.hold.title',
|
|
confirmKey: 'common:actions.hold',
|
|
descriptionKey: 'common:confirmDialog.hold.description',
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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>,
|
|
>({
|
|
renderBody,
|
|
form,
|
|
}: {
|
|
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
|
|
form?: ReturnType<typeof useForm<TMeta>>;
|
|
}) {
|
|
return <>{renderBody(form)}</>;
|
|
}) as <TMeta extends Record<string, unknown>>(props: {
|
|
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
|
|
form?: ReturnType<typeof useForm<TMeta>>;
|
|
}) => React.ReactElement;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ActionConfirmationModal
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Controlled confirmation modal for lifecycle actions (delete, activate, etc.).
|
|
*
|
|
* Supports three modes:
|
|
* 1. **Simple** — no custom body, just title + default description + confirm/cancel.
|
|
* 2. **Static body** — custom body without form (informational content).
|
|
* 3. **Form body** — custom body with RHF FormProvider for validation + meta payload.
|
|
*
|
|
* @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 is wrapped in React.memo to isolate re-renders from modal state changes.
|
|
*/
|
|
export function ActionConfirmationModal<E extends BaseEntity = BaseEntity>(props: ActionConfirmationModalProps<E>) {
|
|
const { modalState, onClose, onExecute, t } = props;
|
|
const { opened, action, data, config } = modalState;
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Determine if this modal has a form (schema + defaultValues provided)
|
|
const hasForm = Boolean(config?.schema && config?.defaultValues);
|
|
|
|
// Create form instance only when form mode is active.
|
|
// `useForm` is always called (Rules of Hooks) but resolver/defaultValues
|
|
// are conditionally set so it becomes a no-op when hasForm is false.
|
|
const form = useForm<Record<string, unknown>>({
|
|
mode: 'onSubmit',
|
|
resolver: config?.schema ? zodResolver(config.schema as any) : undefined,
|
|
defaultValues: config?.defaultValues ?? {},
|
|
});
|
|
|
|
// Resolve translation keys for the current action
|
|
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]);
|
|
|
|
// Determine button color based on action type
|
|
const confirmColor = useMemo(() => {
|
|
if (action === ModuleAction.DELETE) return 'red';
|
|
// if (action === ModuleAction.DEACTIVATE || action === ModuleAction.CANCEL) return 'orange';
|
|
return 'brand';
|
|
}, [action]);
|
|
|
|
// Handle confirm button click
|
|
const handleConfirmClick = useCallback(async () => {
|
|
if (!action || !data) return;
|
|
|
|
if (hasForm) {
|
|
// Trigger RHF validation, then execute if valid
|
|
const isValid = await form.trigger();
|
|
if (!isValid) return;
|
|
|
|
const formValues = form.getValues();
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onExecute(action, data, formValues);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
} else {
|
|
// No form — execute directly
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onExecute(action, data, undefined);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
}
|
|
}, [action, data, hasForm, form, onExecute]);
|
|
|
|
// Handle modal close (prevent close while submitting)
|
|
const handleClose = useCallback(() => {
|
|
if (isSubmitting) return;
|
|
form.reset();
|
|
onClose();
|
|
}, [isSubmitting, form, onClose]);
|
|
|
|
return (
|
|
<Modal
|
|
opened={opened}
|
|
onClose={handleClose}
|
|
title={translations.title}
|
|
size={config?.size ?? 'md'}
|
|
centered
|
|
closeOnClickOutside={!isSubmitting}
|
|
closeOnEscape={!isSubmitting}
|
|
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 */}
|
|
{config?.renderBody ? (
|
|
hasForm ? (
|
|
<FormProvider {...form}>
|
|
<ModalFormBody renderBody={config.renderBody} form={form} />
|
|
</FormProvider>
|
|
) : (
|
|
<ModalFormBody renderBody={config.renderBody} />
|
|
)
|
|
) : (
|
|
translations.description && (
|
|
<Text size="md" c="dimmed">
|
|
{translations.description}
|
|
</Text>
|
|
)
|
|
)}
|
|
|
|
{/* Footer actions */}
|
|
<Group justify="flex-end" mt="sm">
|
|
<Button size="xs" variant="default" onClick={handleClose} disabled={isSubmitting}>
|
|
{translations.cancelLabel}
|
|
</Button>
|
|
<Button size="xs" color={confirmColor} onClick={handleConfirmClick} loading={isSubmitting}>
|
|
{translations.confirmLabel}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
);
|
|
}
|