Files
trackgo-fe/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal/index.tsx
T
Firman Ramdhani 4e13423409 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.
2026-07-23 11:47:23 +07:00

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