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
@@ -60,13 +60,8 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target>
{/* Shortcuts on the main button remain hidden in the Tooltip */}
{action.shortcutLabel ? (
<Tooltip
position="bottom"
label={`${action.label} (${action.shortcutLabel})`}
withArrow
openDelay={500}
>
{action.tooltipLabel ? (
<Tooltip position="bottom" label={`${action.tooltipLabel}`} withArrow openDelay={500}>
{ButtonWithDropdown}
</Tooltip>
) : (
@@ -110,14 +105,8 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
</Button>
);
return action.shortcutLabel ? (
<Tooltip
position="bottom"
key={action.key}
label={`${action.label} (${action.shortcutLabel})`}
withArrow
openDelay={500}
>
return action.tooltipLabel ? (
<Tooltip position="bottom" key={action.key} label={`${action.tooltipLabel}`} withArrow openDelay={500}>
{StandaloneButton}
</Tooltip>
) : (
@@ -39,8 +39,9 @@ export interface PageActionProps extends BaseAction {
variant?: 'filled' | 'light' | 'outline' | 'default' | 'subtle' | 'transparent';
/** Nested actions rendered as a Dropdown Menu below the main button. */
children?: PageActionProps[];
/** Human-readable keyboard shortcut label (e.g., '⇧⌘N'). Shown in tooltip. */
shortcutLabel?: string;
/** Human-readable keyboard tooltip label (e.g., '⇧⌘N'). Shown in tooltip. */
tooltipLabel?: string;
}
/**
@@ -1,12 +1,13 @@
interface ComingSoonProps {
showActions?: boolean;
height?: StyleProp<React.CSSProperties['height']>;
}
import { Title, Text, Button, Container, Stack, Center } from '@mantine/core';
import { Title, Text, Button, Container, Stack, Center, StyleProp } from '@mantine/core';
export function ComingSoon({ showActions = false }: ComingSoonProps) {
export function ComingSoon({ showActions = false, height = '100vh' }: ComingSoonProps) {
return (
<Container size="sm" h="100vh" pos="relative">
<Container size="sm" h={height} pos="relative">
<Center h="100%">
<Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -22,7 +23,7 @@ export function ComingSoon({ showActions = false }: ComingSoonProps) {
</Text>
{showActions && (
<Button component="a" href="/" color="brand" size="md" mt="xl">
<Button component="a" href="/" color="brand" size="sm" mt="xl">
Back to Home
</Button>
)}
@@ -4,9 +4,10 @@ interface ForbiddenProps {
onClickGoBack?(): void;
onClickBackToHome?(): void;
homeUrl?: string;
height?: StyleProp<React.CSSProperties['height']>;
}
import { Title, Text, Button, Container, Stack, Group, Center } from '@mantine/core';
import { Title, Text, Button, Container, Stack, Group, Center, StyleProp } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
export function Forbidden({
@@ -15,6 +16,7 @@ export function Forbidden({
onClickGoBack,
onClickBackToHome,
homeUrl,
height = '100vh',
}: ForbiddenProps) {
const navigate = useNavigate();
@@ -28,7 +30,7 @@ export function Forbidden({
else if (homeUrl) navigate(homeUrl, { replace: true });
}
return (
<Container size="sm" h="100vh" pos="relative">
<Container size="sm" h={height} pos="relative">
<Center h="100%">
<Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -47,13 +49,13 @@ export function Forbidden({
{(showActionsBack || showActionsHome) && (
<Group mt="xl" justify="center">
{showActionsBack && (
<Button variant="default" size="md" onClick={onBack}>
<Button variant="default" size="sm" onClick={onBack}>
Go Back
</Button>
)}
{showActionsHome && (
<Button color="brand" size="md" onClick={onBackHome}>
<Button color="brand" size="sm" onClick={onBackHome}>
Back to Home
</Button>
)}
@@ -1,12 +1,13 @@
interface MaintenanceProps {
showActions?: boolean;
height?: StyleProp<React.CSSProperties['height']>;
}
import { Title, Text, Button, Container, Stack, Center } from '@mantine/core';
import { Title, Text, Button, Container, Stack, Center, StyleProp } from '@mantine/core';
export function Maintenance({ showActions = false }: MaintenanceProps) {
export function Maintenance({ showActions = false, height = '100vh' }: MaintenanceProps) {
return (
<Container size="sm" h="100vh" pos="relative">
<Container size="sm" h={height} pos="relative">
<Center h="100%">
<Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -23,7 +24,7 @@ export function Maintenance({ showActions = false }: MaintenanceProps) {
</Text>
{showActions && (
<Button component="a" href="/" color="brand" size="md" mt="xl">
<Button component="a" href="/" color="brand" size="sm" mt="xl">
Back to Home
</Button>
)}
@@ -4,9 +4,10 @@ interface NotFoundProps {
onClickGoBack?(): void;
onClickBackToHome?(): void;
homeUrl?: string;
height?: StyleProp<React.CSSProperties['height']>;
}
import { Title, Text, Button, Container, Stack, Group, Center } from '@mantine/core';
import { Title, Text, Button, Container, Stack, Group, Center, StyleProp } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
export function NotFound({
@@ -15,6 +16,7 @@ export function NotFound({
onClickGoBack,
onClickBackToHome,
homeUrl,
height = '100vh',
}: NotFoundProps) {
const navigate = useNavigate();
@@ -29,7 +31,7 @@ export function NotFound({
}
return (
<Container size="sm" h="100vh" pos="relative">
<Container size="sm" h={height} pos="relative">
<Center h="100%">
<Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -47,13 +49,13 @@ export function NotFound({
{(showActionsBack || showActionsHome) && (
<Group mt="xl" justify="center">
{showActionsBack && (
<Button variant="default" size="md" onClick={onBack}>
<Button variant="default" size="sm" onClick={onBack}>
Go Back
</Button>
)}
{showActionsHome && (
<Button color="brand" size="md" onClick={onBackHome}>
<Button color="brand" size="sm" onClick={onBackHome}>
Back to Home
</Button>
)}
@@ -1,5 +1,17 @@
import React, { useCallback, useMemo } from 'react';
import { Title, Breadcrumbs, Anchor, Box, Text, ThemeIcon, Flex, Divider, ActionIcon, Tooltip } from '@mantine/core';
import React, { ReactNode, useCallback, useMemo } from 'react';
import {
Title,
TitleProps,
Breadcrumbs,
Anchor,
Box,
Text,
ThemeIcon,
Flex,
Divider,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon
import { PageActions, PageActionsProps } from '../../../components';
@@ -15,15 +27,20 @@ export interface BreadcrumbItem {
}
export interface ModulePageHeaderProps {
title?: string;
title?: ReactNode;
titleProps?: Omit<TitleProps, 'children'>;
miniTitleProps?: Omit<TitleProps, 'children'>;
description?: React.ReactNode;
icon?: LucideIcon;
badges?: React.ReactNode;
breadcrumbs?: BreadcrumbItem[];
actions?: PageActionsProps['actions'];
showPageHeader?: boolean;
disableMinimize?: boolean;
moduleKey: string;
actions?: PageActionsProps['actions'];
customButtonProps?: PageActionsProps['customButtonProps'];
}
// ---------------------------------------------------------------------------
@@ -46,7 +63,7 @@ function BreadcrumbBar({ breadcrumbs }: BreadcrumbBarProps) {
return (
<Breadcrumbs
style={{ flexWrap: 'wrap' }}
visibleFrom="sm"
// visibleFrom="xs"
separator={
<ChevronRight
size={12}
@@ -126,11 +143,14 @@ function HeaderToggle({ isMinimized, onToggle }: HeaderToggleProps) {
export function ModulePageHeader(_props: ModulePageHeaderProps) {
const {
title,
titleProps,
miniTitleProps,
description,
icon: Icon,
badges,
breadcrumbs,
actions,
customButtonProps,
showPageHeader = true,
disableMinimize = false,
moduleKey,
@@ -148,7 +168,13 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
const showToggle = !disableMinimize;
const handleToggle = useCallback(() => setIsMinimized((v) => !v), [setIsMinimized]);
const compactButtonProps = useMemo(() => () => ({ size: 'xs' as const }), []);
const compactButtonProps = useMemo(
() => (action: any) => {
if (customButtonProps) return customButtonProps(action);
return { size: 'xs' as const };
},
[customButtonProps],
);
// -------------------------------------------------------------------------
// Compact / Toolbar mode
@@ -167,19 +193,21 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
{/* Left cluster: title + badges */}
<Flex gap="xs" align="center" style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
<Title
fw={600}
fz={{ base: 'lg', sm: 'xl' }}
truncate="end"
lineClamp={1}
{...(miniTitleProps ?? {})}
style={{
color: 'var(--mantine-color-text)',
letterSpacing: '-0.2px',
lineHeight: 1.3,
...TRANSITION_STYLE,
...miniTitleProps?.style,
}}
>
{title}
</Text>
</Title>
)}
{badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
</Flex>
@@ -262,12 +290,15 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
<Title
order={2}
fw={600}
lineClamp={1}
fz={{ base: 20, sm: 24 }}
lh={{ base: 1.3, sm: 1.2 }}
{...(titleProps ?? {})}
style={{
color: 'var(--mantine-color-text)',
letterSpacing: '-0.3px',
...TRANSITION_STYLE,
...titleProps?.style,
}}
>
{title}
@@ -302,7 +333,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
ml={{ base: 'lg', sm: 0 }}
style={{ flexShrink: 0, alignSelf: 'center' }}
>
{actions && <PageActions actions={actions} />}
{actions && <PageActions actions={actions} customButtonProps={customButtonProps} />}
</Flex>
</Flex>
</Box>
@@ -6,8 +6,6 @@ export const defaultPrivileges: PrivilegeEntity = {
ALLOW_CREATE: true,
ALLOW_EDIT: true,
ALLOW_DELETE: true,
ALLOW_DUPLICATE: true,
ALLOW_SAVE: true,
ALLOW_PRINT: true,
ALLOW_PRINT_COPY: true,
@@ -190,12 +190,13 @@ export interface EnterpriseFormLifecycleHooks<E extends BaseEntity, TFormData =
afterGetData?: (data: E) => Promise<TFormData>;
}
export interface EnterpriseIndexPageConfig {
interface BasePageConfig {
children?: ReactNode;
px?: string | number;
py?: string | number;
pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>;
}
export interface EnterpriseIndexPageConfig extends BasePageConfig {
customPageActions?: (actions: PageActionsProps['actions']) => PageActionsProps['actions'];
onClickCreate?: (key: string) => void;
}
@@ -218,14 +219,28 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext
presetDuplicate?: (data: E) => Promise<Partial<E>>;
}
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> {
children?: ReactNode;
showPageHeader?: boolean;
useDefaultPadding?: boolean;
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
editMode?: 'FULL' | 'PARTIAL';
pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>;
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
onClickCreate?: () => void;
onClickDuplicate?: (data: E) => void;
onClickEdit?: (data: E) => void;
onClickDelete?: (data: E) => void;
// Master Data Feature
onClickActivate?: (data: E) => void;
onClickDeactivate?: (data: E) => void;
// Transaction Feature
onClickConfirm?: (data: E) => void;
onClickCancel?: (data: E) => void;
onClickRollback?: (data: E) => void;
onClickHold?: (data: E) => void;
showHighlightData?: boolean;
showHighlightDataOnBreadcrumbs?: boolean;
highlightDataKey?: string;
}
export interface PrivilegeEntity {
@@ -233,8 +248,6 @@ export interface PrivilegeEntity {
ALLOW_CREATE: boolean;
ALLOW_EDIT: boolean;
ALLOW_DELETE: boolean;
ALLOW_DUPLICATE: boolean;
ALLOW_SAVE: boolean;
ALLOW_PRINT: boolean;
ALLOW_PRINT_COPY: boolean;
@@ -6,6 +6,8 @@ export interface DetailPageContextValue<E extends BaseEntity = BaseEntity> {
isLoading: boolean;
reload: () => Promise<void>;
isPartialEdit: boolean;
isActiveEditMode: boolean;
setIsActiveEditMode: (value: boolean) => void;
}
export const DetailPageContext = createContext<DetailPageContextValue<any> | null>(null);
@@ -6,4 +6,5 @@ export * from './hooks/use-module.context';
export * from './providers/module.provider';
export * from './providers/index-page.provider';
export * from './providers/detail-page.provider';
export * from './components/module-page-header';
@@ -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}>