feat: enhance enterprise module with new actions and UI improvements

- Added support for rollback and hold actions in the enterprise module.
- Updated tooltip labels for action buttons to improve user experience.
- Refactored PageActions component to use tooltipLabel instead of shortcutLabel.
- Introduced height prop for system pages (Coming Soon, Forbidden, Maintenance, Not Found) for better layout control.
- Improved ModulePageHeader to accept custom button properties and enhanced title handling.
- Cleaned up default privileges by removing unused actions (ALLOW_DUPLICATE, ALLOW_SAVE).
- Implemented detail page provider with comprehensive action handling for CRUD operations.
- Created a new full-page detail component for better data presentation.
This commit is contained in:
Firman Ramdhani
2026-07-15 15:28:58 +07:00
parent 383e2627e0
commit 7ce4ced7d6
24 changed files with 741 additions and 208 deletions
@@ -1,17 +1,488 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
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 } from '../entities/entity';
import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType } from '../entities/entity';
import { CorePageContainer, PageActionProps } from '../../../components';
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
import {
useEnterpriseModuleConfigContext,
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleNavigationContext,
useEnterpriseModuleTranslationContext,
} from '../hooks/use-module.context';
import { shortcutsData } from '../../../constants';
export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) {
const {
children,
showPageHeader,
useDefaultPadding,
editMode,
editMode = 'FULL',
pageHeaderProps,
px,
py,
customPageActions,
onClickCreate,
onClickDuplicate,
onClickEdit,
onClickDelete,
onClickActivate,
onClickDeactivate,
onClickConfirm,
onClickCancel,
onClickRollback,
onClickHold,
showHighlightData = true,
showHighlightDataOnBreadcrumbs = true,
highlightDataKey = 'code',
} = props;
return <DetailPageContext.Provider value={{}}></DetailPageContext.Provider>;
const { t } = useEnterpriseModuleTranslationContext();
const navigation = useEnterpriseModuleNavigationContext();
const { config, privileges, IS_MACOS } = useEnterpriseModuleConfigContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const [isActiveEditMode, setIsActiveEditMode] = useState<boolean>(false);
const params = useParams();
const dataId = params.dataId;
const { moduleKey, moduleType } = config;
const [detailData, setDetailData] = useState<E | any>({ id: 1, code: 'ABC-001' });
const [isLoading, setIsLoading] = useState(false);
const loadData = useCallback(async () => {
if (!dataId) return;
setIsLoading(true);
try {
const response = await dataServices.getOne(dataId);
if (response && response.data) {
setDetailData(response.data as E);
}
} catch (error) {
console.error('Failed to load detail data', error);
} finally {
setIsLoading(false);
}
}, [dataId, dataServices]);
useEffect(() => {
loadData();
}, [loadData]);
// ---------------------------------------------------------------------------
// 1. Stub Handlers (Sudah diperbaiki typonya & lengkap)
// ---------------------------------------------------------------------------
async function handleDelete(data: E): Promise<void> {
// implementation later
console.log({ data });
}
async function handleActivate(data: E): Promise<void> {
// implementation later
console.log({ data });
}
async function handleDeactivate(data: E): Promise<void> {
// implementation later
console.log({ data });
}
async function handleConfirm(data: E): Promise<void> {
// implementation later
console.log({ data });
}
async function handleCancel(data: E): Promise<void> {
// implementation later
console.log({ data });
}
async function handleRollback(data: E): Promise<void> {
// implementation later
console.log({ data });
}
async function handleHold(data: E): Promise<void> {
// implementation later
console.log({ data });
}
// ---------------------------------------------------------------------------
// 2. Action Dispatcher (Optimized with Switch Case & Complete Deps)
// ---------------------------------------------------------------------------
const handleActionClick = useCallback(
async (key: string) => {
// Guard utama: Untuk aksi selain CREATE, pastikan dataId dan detailData sudah ada
const hasValidData = Boolean(dataId && detailData);
const currentData = detailData as E;
switch (key) {
// --- CREATE ---
case ModuleAction.CREATE:
if (!privileges.ALLOW_CREATE) return;
if (onClickCreate) onClickCreate();
else navigation.navigateToCreate();
break;
// --- EDIT ---
case ModuleAction.EDIT:
if (!privileges.ALLOW_EDIT || !hasValidData) return;
if (onClickEdit) onClickEdit(currentData);
else if (editMode === 'FULL') navigation.navigateToEdit(dataId!);
else setIsActiveEditMode(true);
break;
// --- DUPLICATE ---
case ModuleAction.DUPLICATE:
if (!privileges.ALLOW_CREATE || !hasValidData) return;
if (onClickDuplicate) onClickDuplicate(currentData);
else navigation.navigateToDuplicate(dataId!);
break;
// --- DELETE ---
case ModuleAction.DELETE:
if (!privileges.ALLOW_DELETE || !hasValidData) return;
if (onClickDelete) onClickDelete(currentData);
else await handleDelete(currentData);
break;
// --- ACTIVATE ---
case ModuleAction.ACTIVATE:
if (!privileges.ALLOW_ACTIVATE || !hasValidData) return;
if (onClickActivate) onClickActivate(currentData);
else await handleActivate(currentData);
break;
// --- DEACTIVATE ---
case ModuleAction.DEACTIVATE:
if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return;
if (onClickDeactivate) onClickDeactivate(currentData);
else await handleDeactivate(currentData);
break;
// --- CONFIRM (Tambahan Baru) ---
case ModuleAction.CONFIRM:
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
if (onClickConfirm) onClickConfirm(currentData);
else await handleConfirm(currentData);
break;
// --- CANCEL (Tambahan Baru) ---
case ModuleAction.CANCEL:
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
if (onClickCancel) onClickCancel(currentData);
else await handleCancel(currentData);
break;
// --- ROLLBACK (Tambahan Baru) ---
case ModuleAction.ROLLBACK:
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
if (onClickRollback) onClickRollback(currentData);
else await handleRollback(currentData);
break;
// --- HOLD (Tambahan Baru) ---
case ModuleAction.HOLD:
if (!privileges.ALLOW_HOLD || !hasValidData) return;
if (onClickHold) onClickHold(currentData);
else await handleHold(currentData);
break;
default:
console.warn(`[ActionHandler] Unhandled action key: ${key}`);
break;
}
},
[
// Semua dependensi wajib dimasukkan agar terhindar dari bug Stale Closure
navigation,
privileges,
dataId,
detailData,
editMode,
setIsActiveEditMode,
onClickCreate,
onClickEdit,
onClickDuplicate,
onClickDelete,
onClickActivate,
onClickDeactivate,
onClickConfirm,
onClickCancel,
onClickRollback,
onClickHold,
],
);
/** Platform-aware shortcut label for the Create action. */
const CREATE_SHORTCUT_LABEL = useMemo(() => {
const shortcutData = shortcutsData.find((s) => s.key === 'collapse_sidebar');
return IS_MACOS ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
}, [IS_MACOS]);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const isShortcut = (e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'n';
if (!isShortcut) return;
// Prevent browser default (e.g., Chrome's "new incognito window").
e.preventDefault();
e.stopPropagation();
handleActionClick(ModuleAction.CREATE);
}
window.addEventListener('keydown', onKeyDown, { capture: true });
return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
}, [handleActionClick]);
const pageActions = useMemo(() => {
const {
ALLOW_CREATE,
ALLOW_EDIT,
ALLOW_DELETE,
ALLOW_ACTIVATE,
ALLOW_DEACTIVATE,
ALLOW_CONFIRM,
ALLOW_CANCEL,
ALLOW_ROLLBACK,
ALLOW_HOLD,
} = privileges;
const isTransaction = moduleType === 'TRANSACTION';
const isMasterData = moduleType === 'MASTER_DATA';
// 1. Declare action with Privilege & Module Type conditions directly
const rawActions = [
ALLOW_DELETE && {
key: ModuleAction.DELETE,
label: t('common:actions.delete'),
icon: <Trash2 size={16} />,
intent: 'destructive',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
ALLOW_CREATE && {
key: ModuleAction.DUPLICATE,
label: t('common:actions.duplicate'),
icon: <Copy size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
ALLOW_EDIT && {
key: ModuleAction.EDIT,
label: t('common:actions.edit'),
icon: <Edit2 size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
{ key: 'DIVIDER_1', type: 'divider' }, // Will be cleared if empty
// Transaction Group
isTransaction &&
ALLOW_HOLD && {
key: ModuleAction.HOLD,
label: t('common:actions.hold'),
icon: <PauseCircle size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
isTransaction &&
ALLOW_ROLLBACK && {
key: ModuleAction.ROLLBACK,
label: t('common:actions.rollback'),
icon: <RotateCcw size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
isTransaction &&
ALLOW_CANCEL && {
key: ModuleAction.CANCEL,
label: t('common:actions.cancel'),
icon: <X size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
isTransaction &&
ALLOW_CONFIRM && {
key: ModuleAction.CONFIRM,
label: t('common:actions.confirm'),
icon: <Check size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
// Master Data Group
isMasterData &&
ALLOW_DEACTIVATE && {
key: ModuleAction.DEACTIVATE,
label: t('common:actions.deactivate'),
icon: <XCircle size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
isMasterData &&
ALLOW_ACTIVATE && {
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
tooltipLabel: t('common:actions.activate'),
icon: <CheckCircle size={16} />,
intent: 'default',
variant: 'subtle',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
{ key: 'DIVIDER_2', type: 'divider' },
ALLOW_CREATE && {
key: ModuleAction.CREATE,
label: t('common:actions.create'),
tooltipLabel: `${t('common:actions.create')} (${CREATE_SHORTCUT_LABEL})`,
icon: <Plus size={16} />,
intent: 'primary',
variant: 'filled',
onClick: (key: ModuleActionType) => handleActionClick(key),
},
].filter(Boolean) as PageActionProps[]; // Remove all false/null/undefined
// 2. Smart Divider Cleaning Algorithm
const cleanedActions: PageActionProps[] = [];
for (let i = 0; i < rawActions.length; i++) {
const current = rawActions[i];
if (current.type === 'divider') {
// Ignore if this divider is at the front (beginning of the array)
if (cleanedActions.length === 0) continue;
// Ignore if the previous item is also a divider (prevents nesting: || )
if (cleanedActions[cleanedActions.length - 1].type === 'divider') continue;
// Ignore if after this divider there are no action buttons at all (prevent at the end: | )
const hasActionAfter = rawActions.slice(i + 1).some((a) => a.type !== 'divider');
if (!hasActionAfter) continue;
}
cleanedActions.push(current);
}
// 3. Inject custom actions
return customPageActions && detailData ? customPageActions(detailData, cleanedActions) : cleanedActions;
}, [t, customPageActions, handleActionClick, privileges, moduleType, detailData]);
const contextValue = useMemo(
() => ({
detailData,
isLoading,
reload: loadData,
isPartialEdit: editMode === 'PARTIAL',
isActiveEditMode,
setIsActiveEditMode,
}),
[detailData, isLoading, loadData, editMode, isActiveEditMode, setIsActiveEditMode],
);
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
const staticTitle = pageProvide?.title;
if (!showHighlight) {
return { flatTitle: staticTitle, title: staticTitle };
} else {
const highlightData = detailData[key];
const flatTitle = `${staticTitle} | ${highlightData}`;
return {
flatTitle,
title: (
<span>
{staticTitle}
{highlightData && (
<span
style={{
fontWeight: 400,
marginLeft: '8px',
color: 'var(--mantine-color-dimmed)',
}}
>
| {highlightData}
</span>
)}
</span>
),
};
}
}
function makeBreadcrumbs(
showHighlight: boolean,
key: string,
pageProvide: ModulePageHeaderProps | any,
detailData: any,
) {
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
if (!showHighlight || staticBreadcrumbs.length === 0) {
return pageProvide.breadcrumbs;
} else {
const highlightData = detailData[key];
const breadcrumbs = [
...staticBreadcrumbs,
{
type: 'link',
label: `${highlightData}`,
href: `${config.webUrl}/detail/${dataId}`,
},
];
return breadcrumbs;
}
}
const pageHeaderPropsValue = useMemo(() => {
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
document.title = title.flatTitle;
const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData);
return { ...pageHeaderProps, title: title.title, breadcrumbs: breadcrumbs };
}, [pageHeaderProps, dataId, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]);
return (
<DetailPageContext.Provider value={contextValue}>
<CorePageContainer
px={px}
py={py}
headerSlot={
<ModulePageHeader
customButtonProps={(action) => {
return {
size: 'xs',
p: action.key === ModuleAction.CREATE ? undefined : 5,
style: { fontSize: 12 },
};
}}
actions={pageActions}
{...pageHeaderPropsValue}
moduleKey={moduleKey}
titleProps={{
fz: { base: 16, sm: 18 },
}}
miniTitleProps={{
fz: { base: 'md', sm: 'lg' },
}}
/>
}
>
{children}
</CorePageContainer>
</DetailPageContext.Provider>
);
}
@@ -1,4 +1,3 @@
import { BaseEntity } from '@repo/core-api/data-services';
import { EnterpriseIndexPageConfig, ModuleAction } from '../entities/entity';
import { IndexPageContext } from '../hooks/use-index-page.context';
import { CorePageContainer, PageActionProps } from '../../../components';
@@ -70,7 +69,7 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
icon: <Plus size={16} />,
intent: 'primary',
variant: 'filled',
shortcutLabel: CREATE_SHORTCUT_LABEL,
tooltipLabel: `${t('common:actions.create')} (${CREATE_SHORTCUT_LABEL})`,
onClick: (key) => handleActionClick(key),
});
}
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect, ReactNode } from 'react';
import { useMemo, useState, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from '@repo/core-i18n';
import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
@@ -13,6 +13,7 @@ import {
EnterpriseTranslationContext,
} from '../hooks/use-module.context';
import { defaultPrivileges } from '../constant/default-privilege';
import { Forbidden } from '../../../components';
export interface EnterpriseModuleProviderProps<E extends BaseEntity> {
children: ReactNode;
@@ -47,12 +48,12 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
useEffect(() => {
const title = config.tabTitle || t('title');
if (title) {
document.title = title;
}
}, [config.tabTitle, t]);
// useEffect(() => {
// const title = config.tabTitle || t('title');
// if (title) {
// document.title = title;
// }
// }, [config.tabTitle, t]);
// ---------------------------------------------------------------------------
// 2. Data Service Slice (Stable refs)
@@ -143,13 +144,7 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
const { ALLOW_VIEW } = configSlice.privileges;
useEffect(() => {
if (!ALLOW_VIEW) navigate('/403', { replace: true });
}, [ALLOW_VIEW, navigate]);
if (!ALLOW_VIEW) {
return null;
}
if (!ALLOW_VIEW) return <Forbidden homeUrl="/app" height={500} />;
return (
<EnterpriseConfigContext.Provider value={configSlice}>