feat: add StatusBadge component and integrate action confirmation modal
- Introduced StatusBadge component for displaying various status indicators with customizable colors and icons. - Updated ModulePageHeader to conditionally render badges based on props. - Implemented ActionConfirmationModal for handling lifecycle actions (delete, activate, etc.) with support for forms and custom body rendering. - Enhanced EnterpriseDetailPageProvider to manage action confirmation modal state and execution of actions. - Added new modal configurations for various actions in EnterpriseDetailPageConfig. - Updated theme provider to include ModalsProvider and Notifications for better user feedback.
This commit is contained in:
+222
@@ -0,0 +1,222 @@
|
||||
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_action',
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,9 @@ export interface ModulePageHeaderProps {
|
||||
miniTitleProps?: Omit<TitleProps, 'children'>;
|
||||
description?: React.ReactNode;
|
||||
icon?: LucideIcon;
|
||||
|
||||
badges?: React.ReactNode;
|
||||
showBadges?: boolean;
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
|
||||
showPageHeader?: boolean;
|
||||
@@ -148,6 +150,8 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
||||
description,
|
||||
icon: Icon,
|
||||
badges,
|
||||
showBadges = true,
|
||||
|
||||
breadcrumbs,
|
||||
actions,
|
||||
customButtonProps,
|
||||
@@ -209,7 +213,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
|
||||
{showBadges && badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
|
||||
</Flex>
|
||||
|
||||
{/* Right: actions + inline toggle (separated by divider) */}
|
||||
@@ -304,7 +308,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{badges && <Box>{badges}</Box>}
|
||||
{showBadges && badges && <Box>{badges}</Box>}
|
||||
</Flex>
|
||||
|
||||
{description && (
|
||||
@@ -312,7 +316,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
||||
fz={{ base: 'xs', sm: 'sm' }}
|
||||
c="dimmed"
|
||||
fw={400}
|
||||
lineClamp={2}
|
||||
lineClamp={1}
|
||||
mt={4}
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { ZodType } from 'zod';
|
||||
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||
import type { ModulePageHeaderProps } from '../components/module-page-header';
|
||||
import { PageActionsProps } from '../../../components';
|
||||
@@ -219,9 +221,84 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext
|
||||
presetDuplicate?: (data: E) => Promise<Partial<E>>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action Confirmation Modal Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configuration for the confirmation modal shown before executing a lifecycle action.
|
||||
*
|
||||
* Supports three modes:
|
||||
* 1. **Simple confirmation** — no `renderBody`, just a confirm/cancel dialog.
|
||||
* 2. **Static body** — `renderBody` returns static JSX (text, checkboxes, etc.).
|
||||
* 3. **Form body** — `renderBody` receives an RHF `UseFormReturn` instance.
|
||||
* The implementer renders `Field*` components bound to the form.
|
||||
* Form data is validated via `schema` before submission and sent as `meta`.
|
||||
*
|
||||
* @template TMeta The shape of the form data (defaults to Record<string, unknown>).
|
||||
*/
|
||||
export interface ActionModalConfig<TMeta extends Record<string, unknown> = Record<string, unknown>> {
|
||||
/** Modal title override. Defaults to action-specific translation (e.g., "Delete Item?") */
|
||||
title?: string;
|
||||
/**
|
||||
* Custom modal body renderer.
|
||||
*
|
||||
* When provided WITHOUT `schema`/`defaultValues`, receives `undefined` — render static content.
|
||||
* When provided WITH `schema`/`defaultValues`, receives a fully-typed `UseFormReturn<TMeta>`
|
||||
* instance. Use `Field*` components from `@repo/ui/form` bound to this form.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Static body (no form)
|
||||
* renderBody: () => <Text>Are you sure you want to delete this item?</Text>
|
||||
*
|
||||
* // Form body
|
||||
* renderBody: (form) => (
|
||||
* <FieldTextarea name="reason" control={form.control} label="Reason" />
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
renderBody?: (form?: UseFormReturn<TMeta>) => ReactNode;
|
||||
/** Zod schema for validating the form body. When omitted, no validation is applied */
|
||||
schema?: ZodType<TMeta>;
|
||||
/** Default values for the form. Required when schema is provided */
|
||||
defaultValues?: TMeta;
|
||||
/** Confirm button label override. Defaults to action translation */
|
||||
confirmLabel?: string;
|
||||
/** Cancel button label override. Defaults to t('common:actions.cancel') */
|
||||
cancelLabel?: string;
|
||||
/** Size of the modal. @default 'md' */
|
||||
size?: string | number;
|
||||
/** Custom success message or a function to generate it after action completes successfully */
|
||||
successMessage?: string | ((data?: any) => string);
|
||||
/** Custom error message or a function to extract it from the network error */
|
||||
errorMessage?: string | ((error: any) => string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal state for the action confirmation modal managed by the detail page provider.
|
||||
* @internal
|
||||
*/
|
||||
export interface ActionModalState<E extends BaseEntity = BaseEntity> {
|
||||
opened: boolean;
|
||||
action: ModuleActionType | null;
|
||||
data: E | null;
|
||||
config?: ActionModalConfig<any>;
|
||||
}
|
||||
|
||||
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
|
||||
editMode?: 'FULL' | 'PARTIAL';
|
||||
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
||||
onDetailLoaded?: (data: E) => void;
|
||||
|
||||
showHighlightData?: boolean;
|
||||
showHighlightDataOnBreadcrumbs?: boolean;
|
||||
highlightDataKey?: string;
|
||||
|
||||
/** Key to reference status data in the entity, used to render the status badge automatically. @default 'status' */
|
||||
statusKey?: string;
|
||||
/** Custom callback to provide dynamic status badge properties */
|
||||
getCustomStatusConfig?: (status: string) => Partial<import('@mantine/core').BadgeProps>;
|
||||
|
||||
onClickCreate?: () => void;
|
||||
onClickDuplicate?: (data: E) => void;
|
||||
@@ -238,9 +315,14 @@ export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> e
|
||||
onClickRollback?: (data: E) => void;
|
||||
onClickHold?: (data: E) => void;
|
||||
|
||||
showHighlightData?: boolean;
|
||||
showHighlightDataOnBreadcrumbs?: boolean;
|
||||
highlightDataKey?: string;
|
||||
// Action Modal Configurations
|
||||
deleteModalConfig?: ActionModalConfig;
|
||||
activateModalConfig?: ActionModalConfig;
|
||||
deactivateModalConfig?: ActionModalConfig;
|
||||
confirmModalConfig?: ActionModalConfig;
|
||||
cancelModalConfig?: ActionModalConfig;
|
||||
rollbackModalConfig?: ActionModalConfig;
|
||||
holdModalConfig?: ActionModalConfig;
|
||||
}
|
||||
|
||||
export interface PrivilegeEntity {
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './providers/module.provider';
|
||||
export * from './providers/index-page.provider';
|
||||
export * from './providers/detail-page.provider';
|
||||
export * from './components/module-page-header';
|
||||
export * from './components/action-confirmation-modal';
|
||||
|
||||
@@ -4,9 +4,11 @@ import { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { Check, CheckCircle, Copy, Edit2, PauseCircle, Plus, RotateCcw, Trash2, X, XCircle } from 'lucide-react';
|
||||
|
||||
import { DetailPageContext } from '../hooks/use-detail-page.context';
|
||||
import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType } from '../entities/entity';
|
||||
import { CorePageContainer, PageActionProps } from '../../../components';
|
||||
import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType, ActionModalState } from '../entities/entity';
|
||||
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
|
||||
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
|
||||
import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../components/action-confirmation-modal';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
useEnterpriseModuleConfigContext,
|
||||
useEnterpriseModuleDataServiceContext,
|
||||
@@ -22,6 +24,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
pageHeaderProps,
|
||||
px,
|
||||
py,
|
||||
|
||||
showHighlightData = true,
|
||||
showHighlightDataOnBreadcrumbs = true,
|
||||
highlightDataKey = 'code',
|
||||
statusKey = 'status',
|
||||
getCustomStatusConfig,
|
||||
onDetailLoaded,
|
||||
|
||||
customPageActions,
|
||||
onClickCreate,
|
||||
onClickDuplicate,
|
||||
@@ -34,9 +44,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
onClickRollback,
|
||||
onClickHold,
|
||||
|
||||
showHighlightData = true,
|
||||
showHighlightDataOnBreadcrumbs = true,
|
||||
highlightDataKey = 'code',
|
||||
// Modal configurations per action
|
||||
deleteModalConfig,
|
||||
activateModalConfig,
|
||||
deactivateModalConfig,
|
||||
confirmModalConfig,
|
||||
cancelModalConfig,
|
||||
rollbackModalConfig,
|
||||
holdModalConfig,
|
||||
} = props;
|
||||
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
@@ -51,7 +66,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
|
||||
const { moduleKey, moduleType } = config;
|
||||
|
||||
const [detailData, setDetailData] = useState<E | any>({ id: 1, code: 'ABC-001' });
|
||||
const [detailData, setDetailData] = useState<E | any>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -60,9 +75,16 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
try {
|
||||
const response = await dataServices.getOne(dataId);
|
||||
if (response && response.data) {
|
||||
setDetailData(response.data as E);
|
||||
const data = response.data;
|
||||
setDetailData(data as E);
|
||||
if (onDetailLoaded) onDetailLoaded(data as E);
|
||||
}
|
||||
} catch (error) {
|
||||
// FIXME => remove this example later;
|
||||
|
||||
const data = { id: 1, code: 'ABCD-0001', status: 'open' } as any;
|
||||
setDetailData(data as E);
|
||||
if (onDetailLoaded) onDetailLoaded(data as E);
|
||||
console.error('Failed to load detail data', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -74,41 +96,178 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
}, [loadData]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Stub Handlers (Sudah diperbaiki typonya & lengkap)
|
||||
// 1. Action Confirmation Modal State & Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handleDelete(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
|
||||
/** Default (closed) modal state — stable reference to avoid re-creating on every render. */
|
||||
const CLOSED_MODAL: ActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: null }), []);
|
||||
|
||||
const [actionModalState, setActionModalState] = useState<ActionModalState<E>>(CLOSED_MODAL);
|
||||
|
||||
/** Map action type → modal config provided by the implementer. */
|
||||
const modalConfigMap = useMemo(
|
||||
() => ({
|
||||
[ModuleAction.DELETE]: deleteModalConfig,
|
||||
[ModuleAction.ACTIVATE]: activateModalConfig,
|
||||
[ModuleAction.DEACTIVATE]: deactivateModalConfig,
|
||||
[ModuleAction.CONFIRM]: confirmModalConfig,
|
||||
[ModuleAction.CANCEL]: cancelModalConfig,
|
||||
[ModuleAction.ROLLBACK]: rollbackModalConfig,
|
||||
[ModuleAction.HOLD]: holdModalConfig,
|
||||
}),
|
||||
[
|
||||
deleteModalConfig,
|
||||
activateModalConfig,
|
||||
deactivateModalConfig,
|
||||
confirmModalConfig,
|
||||
cancelModalConfig,
|
||||
rollbackModalConfig,
|
||||
holdModalConfig,
|
||||
],
|
||||
);
|
||||
|
||||
/** Open the confirmation modal for the given action. */
|
||||
const openActionModal = useCallback(
|
||||
(action: ModuleActionType, data: E) => {
|
||||
const config = modalConfigMap[action as keyof typeof modalConfigMap];
|
||||
setActionModalState({ opened: true, action, data, config });
|
||||
},
|
||||
[modalConfigMap],
|
||||
);
|
||||
|
||||
/** Close the confirmation modal and reset state. */
|
||||
const closeActionModal = useCallback(() => {
|
||||
setActionModalState(CLOSED_MODAL);
|
||||
}, [CLOSED_MODAL]);
|
||||
|
||||
/**
|
||||
* Execute a lifecycle action against the data service.
|
||||
*
|
||||
* Called by ActionConfirmationModal after the user confirms.
|
||||
* Dispatches to the correct `dataServices.*` method based on action type.
|
||||
* Passes optional `meta` (form data from the modal body) wrapped in the request payload.
|
||||
*
|
||||
* Post-action behavior:
|
||||
* - DELETE → navigates back to index (record no longer exists)
|
||||
* - All others → reloads detail data (record still exists, status changed)
|
||||
*/
|
||||
const executeAction = useCallback(
|
||||
async (action: ModuleActionType, data: E, meta?: Record<string, unknown>) => {
|
||||
const id = data.id;
|
||||
if (!id) return;
|
||||
|
||||
const currentConfig = modalConfigMap[action as keyof typeof modalConfigMap];
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case ModuleAction.DELETE:
|
||||
await dataServices.delete(id, meta);
|
||||
break;
|
||||
|
||||
case ModuleAction.ACTIVATE:
|
||||
await dataServices.activate(id, meta);
|
||||
break;
|
||||
|
||||
case ModuleAction.DEACTIVATE:
|
||||
await dataServices.deactivate(id, meta);
|
||||
break;
|
||||
|
||||
case ModuleAction.CONFIRM:
|
||||
await dataServices.confirmData(id, meta);
|
||||
break;
|
||||
|
||||
case ModuleAction.CANCEL:
|
||||
await dataServices.cancelData(id, meta);
|
||||
break;
|
||||
|
||||
case ModuleAction.ROLLBACK:
|
||||
await dataServices.rollbackData(id, meta);
|
||||
break;
|
||||
|
||||
case ModuleAction.HOLD:
|
||||
await dataServices.holdData(id, meta);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[executeAction] Unhandled action: ${action}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
||||
const defaultSuccessMessage = t('common:notifications.actionSuccess', { action: t(actionKey) });
|
||||
|
||||
const customSuccessMessage =
|
||||
typeof currentConfig?.successMessage === 'function'
|
||||
? currentConfig.successMessage(data)
|
||||
: currentConfig?.successMessage;
|
||||
|
||||
notifications.show({
|
||||
title: t('common:notifications.successTitle', { defaultValue: 'Success' }),
|
||||
message: customSuccessMessage || defaultSuccessMessage,
|
||||
color: 'teal',
|
||||
});
|
||||
|
||||
if (action === ModuleAction.DELETE) {
|
||||
closeActionModal();
|
||||
navigation.navigateToIndex();
|
||||
} else {
|
||||
closeActionModal();
|
||||
await loadData();
|
||||
}
|
||||
} catch (error: any) {
|
||||
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
||||
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
||||
action: t(actionKey),
|
||||
message: error?.message || 'Unknown error',
|
||||
});
|
||||
|
||||
const customErrorMessage =
|
||||
typeof currentConfig?.errorMessage === 'function'
|
||||
? currentConfig.errorMessage(error)
|
||||
: currentConfig?.errorMessage;
|
||||
|
||||
notifications.show({
|
||||
title: t('common:notifications.errorTitle', { defaultValue: 'Error' }),
|
||||
message: customErrorMessage || defaultErrorMessage,
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
// Rethrow the error so the modal knows the submission failed
|
||||
// and re-enables the buttons for the user to try again or cancel
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[dataServices, closeActionModal, navigation, loadData, modalConfigMap, t],
|
||||
);
|
||||
|
||||
// --- Handler functions that open the confirmation modal ---
|
||||
|
||||
function handleDelete(data: E): void {
|
||||
openActionModal(ModuleAction.DELETE, data);
|
||||
}
|
||||
|
||||
async function handleActivate(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleActivate(data: E): void {
|
||||
openActionModal(ModuleAction.ACTIVATE, data);
|
||||
}
|
||||
|
||||
async function handleDeactivate(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleDeactivate(data: E): void {
|
||||
openActionModal(ModuleAction.DEACTIVATE, data);
|
||||
}
|
||||
|
||||
async function handleConfirm(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleConfirm(data: E): void {
|
||||
openActionModal(ModuleAction.CONFIRM, data);
|
||||
}
|
||||
|
||||
async function handleCancel(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleCancel(data: E): void {
|
||||
openActionModal(ModuleAction.CANCEL, data);
|
||||
}
|
||||
|
||||
async function handleRollback(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleRollback(data: E): void {
|
||||
openActionModal(ModuleAction.ROLLBACK, data);
|
||||
}
|
||||
|
||||
async function handleHold(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleHold(data: E): void {
|
||||
openActionModal(ModuleAction.HOLD, data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -164,28 +323,28 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
else await handleDeactivate(currentData);
|
||||
break;
|
||||
|
||||
// --- CONFIRM (Tambahan Baru) ---
|
||||
// --- CONFIRM ---
|
||||
case ModuleAction.CONFIRM:
|
||||
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
|
||||
if (onClickConfirm) onClickConfirm(currentData);
|
||||
else await handleConfirm(currentData);
|
||||
break;
|
||||
|
||||
// --- CANCEL (Tambahan Baru) ---
|
||||
// --- CANCEL ---
|
||||
case ModuleAction.CANCEL:
|
||||
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
|
||||
if (onClickCancel) onClickCancel(currentData);
|
||||
else await handleCancel(currentData);
|
||||
break;
|
||||
|
||||
// --- ROLLBACK (Tambahan Baru) ---
|
||||
// --- ROLLBACK ---
|
||||
case ModuleAction.ROLLBACK:
|
||||
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
|
||||
if (onClickRollback) onClickRollback(currentData);
|
||||
else await handleRollback(currentData);
|
||||
break;
|
||||
|
||||
// --- HOLD (Tambahan Baru) ---
|
||||
// --- HOLD ---
|
||||
case ModuleAction.HOLD:
|
||||
if (!privileges.ALLOW_HOLD || !hasValidData) return;
|
||||
if (onClickHold) onClickHold(currentData);
|
||||
@@ -397,10 +556,10 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
|
||||
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||
const staticTitle = pageProvide?.title;
|
||||
if (!showHighlight) {
|
||||
if (!showHighlight || !detailData) {
|
||||
return { flatTitle: staticTitle, title: staticTitle };
|
||||
} else {
|
||||
const highlightData = detailData[key];
|
||||
const highlightData = detailData && detailData[key];
|
||||
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||
|
||||
return {
|
||||
@@ -413,7 +572,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
marginLeft: '8px',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
// color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
| {highlightData}
|
||||
@@ -432,10 +591,10 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
detailData: any,
|
||||
) {
|
||||
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||
if (!showHighlight || staticBreadcrumbs.length === 0) {
|
||||
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
||||
return pageProvide.breadcrumbs;
|
||||
} else {
|
||||
const highlightData = detailData[key];
|
||||
const highlightData = detailData && detailData[key];
|
||||
const breadcrumbs = [
|
||||
...staticBreadcrumbs,
|
||||
{
|
||||
@@ -478,11 +637,27 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
miniTitleProps={{
|
||||
fz: { base: 'md', sm: 'lg' },
|
||||
}}
|
||||
badges={
|
||||
detailData ? (
|
||||
<StatusBadge
|
||||
status={detailData[statusKey as keyof typeof detailData] as string}
|
||||
getCustomConfig={getCustomStatusConfig}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</CorePageContainer>
|
||||
|
||||
{/* Action Confirmation Modal */}
|
||||
<ActionConfirmationModal<E>
|
||||
modalState={actionModalState}
|
||||
onClose={closeActionModal}
|
||||
onExecute={executeAction}
|
||||
t={t}
|
||||
/>
|
||||
</DetailPageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user