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:
Firman Ramdhani
2026-07-15 17:27:29 +07:00
parent 7ce4ced7d6
commit 43f9d3d491
21 changed files with 966 additions and 93 deletions
@@ -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>
);
}