feat: enhance internationalization with new translation keys, improve action handling in data tables, and add responsive view support for row actions

This commit is contained in:
Firman Ramdhani
2026-07-22 15:35:39 +07:00
parent 9a1422ef28
commit 0243b6aa96
10 changed files with 623 additions and 181 deletions
@@ -58,7 +58,9 @@
"back": "Back", "back": "Back",
"reload": "Reload", "reload": "Reload",
"filter": "Filter", "filter": "Filter",
"setting": "Setting" "setting": "Setting",
"detail": "Detail",
"view": "View"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -58,7 +58,9 @@
"back": "Kembali", "back": "Kembali",
"reload": "Muat Ulang", "reload": "Muat Ulang",
"filter": "Filter", "filter": "Filter",
"setting": "Pengaturan" "setting": "Pengaturan",
"detail": "Detail",
"view": "Lihat"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -40,10 +40,12 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
} }
const isPremiumGlow = action.intent === 'primary' && action.variant === 'filled'; const isPremiumGlow = action.intent === 'primary' && action.variant === 'filled';
const showLabel = action.showLabel !== false;
const tooltipContent = action.tooltipLabel || (!showLabel ? action.label : undefined);
// 1. Button with Dropdown (Menu.Target) // 1. Button with Dropdown (Menu.Target)
if (action.children && action.children.length > 0) { if (action.children && action.children.length > 0) {
const ButtonWithDropdown = ( const ButtonWithDropdown = showLabel ? (
<Button <Button
variant={action.variant || 'transparent'} variant={action.variant || 'transparent'}
color={action?.color ? action.color : getIntentColor(action.intent)} color={action?.color ? action.color : getIntentColor(action.intent)}
@@ -55,14 +57,23 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
> >
{action.label} {action.label}
</Button> </Button>
) : (
<ActionIcon
variant={action.variant || 'transparent'}
color={action?.color ? action.color : getIntentColor(action.intent)}
disabled={action.disabled}
size="lg"
style={isPremiumGlow ? defaultButtonStyle(true).style : undefined}
>
{action.icon}
</ActionIcon>
); );
return ( return (
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover"> <Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target> <Menu.Target>
{/* Shortcuts on the main button remain hidden in the Tooltip */} {tooltipContent ? (
{action.tooltipLabel ? ( <Tooltip position="bottom" label={tooltipContent} withArrow openDelay={500}>
<Tooltip position="bottom" label={`${action.tooltipLabel}`} withArrow openDelay={500}>
{ButtonWithDropdown} {ButtonWithDropdown}
</Tooltip> </Tooltip>
) : ( ) : (
@@ -92,7 +103,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
} }
// 2. Regular Button (Standalone) // 2. Regular Button (Standalone)
const StandaloneButton = ( const StandaloneButton = showLabel ? (
<Button <Button
variant={action.variant || 'transparent'} variant={action.variant || 'transparent'}
color={action?.color ? action.color : getIntentColor(action.intent)} color={action?.color ? action.color : getIntentColor(action.intent)}
@@ -104,10 +115,21 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
> >
{action.label} {action.label}
</Button> </Button>
) : (
<ActionIcon
variant={action.variant || 'transparent'}
color={action?.color ? action.color : getIntentColor(action.intent)}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
size="lg"
style={isPremiumGlow ? defaultButtonStyle(true).style : undefined}
>
{action.icon}
</ActionIcon>
); );
return action.tooltipLabel ? ( return tooltipContent ? (
<Tooltip position="bottom" key={action.key} label={`${action.tooltipLabel}`} withArrow openDelay={500}> <Tooltip position="bottom" key={action.key} label={tooltipContent} withArrow openDelay={500}>
{StandaloneButton} {StandaloneButton}
</Tooltip> </Tooltip>
) : ( ) : (
@@ -8,6 +8,7 @@ export interface RowActionsProps {
/** Array of configured row-level actions. */ /** Array of configured row-level actions. */
actions: RowActionProps[]; actions: RowActionProps[];
showLabels?: boolean; showLabels?: boolean;
responsiveView?: boolean;
} }
/** /**
@@ -16,34 +17,54 @@ export interface RowActionsProps {
* *
* @performance Wrapped in React.memo to guarantee zero overhead inside large lists/grids. * @performance Wrapped in React.memo to guarantee zero overhead inside large lists/grids.
*/ */
export const RowActions = memo(function RowActions({ actions = [], showLabels = false }: RowActionsProps) { export const RowActions = memo(function RowActions({
actions = [],
showLabels = false,
responsiveView = true,
}: RowActionsProps) {
/** /**
* Helper function to render a standalone icon button. * Helper function to render a standalone item.
* Wraps the icon in a Tooltip if the configuration provides one.
*/ */
const renderIcon = (action: RowActionProps, fallbackKey: string) => { const renderItem = (action: RowActionProps, fallbackKey: string) => {
const actionKey = action.key || fallbackKey; const actionKey = action.key || fallbackKey;
const isButton = showLabels;
const iconBtn = ( const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
action.onClick?.(action.key || '');
};
if (isButton) {
return (
<Button <Button
key={action.key} key={actionKey}
variant={'transparent'} variant="subtle"
color={getIntentColor(action.intent)} color={getIntentColor(action.intent)}
leftSection={action.icon} leftSection={action.icon}
disabled={action.disabled} disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')} onClick={handleClick}
size="xs" size="xs"
pr="xs"
pl="xs"
pt={0}
pb={0}
> >
{showLabels && action.label} {action.label}
</Button> </Button>
); );
}
const iconBtn = (
<ActionIcon
key={actionKey}
variant="subtle"
color={getIntentColor(action.intent)}
disabled={action.disabled}
onClick={handleClick}
size="md"
>
{action.icon}
</ActionIcon>
);
return action.tooltip ? ( return action.tooltip ? (
<Tooltip key={`tooltip-${actionKey}`} label={action.tooltip} withArrow withinPortal> <Tooltip key={`tooltip-${actionKey}`} label={action.tooltip} withArrow withinPortal zIndex={9999}>
{iconBtn} {iconBtn}
</Tooltip> </Tooltip>
) : ( ) : (
@@ -51,20 +72,16 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
); );
}; };
return ( const renderFlatActions = () => {
<Box style={{ display: 'inline-flex' }}> return actions.map((action, index) => {
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
<Group gap={0} wrap="nowrap" visibleFrom="sm">
{actions.map((action, index) => {
if (action.type === 'divider') { if (action.type === 'divider') {
return <Divider key={`divider-${index}`} orientation="vertical" mr="xs" ml="xs" />; return <Divider key={`divider-${index}`} orientation="vertical" mr="xs" ml="xs" />;
} }
// Render Dropdown Menu for actions with children
if (action.children && action.children.length > 0) { if (action.children && action.children.length > 0) {
return ( return (
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover"> <Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover" zIndex={9999}>
<Menu.Target>{renderIcon(action, `action-${index}`)}</Menu.Target> <Menu.Target>{renderItem(action, `action-${index}`)}</Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
{action.children.map((child, childIndex) => { {action.children.map((child, childIndex) => {
if (child.type === 'divider') { if (child.type === 'divider') {
@@ -76,7 +93,10 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
leftSection={child.icon} leftSection={child.icon}
color={getIntentColor(child.intent)} color={getIntentColor(child.intent)}
disabled={child.disabled} disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')} onClick={(e) => {
e.stopPropagation();
child.onClick?.(child.key || '');
}}
> >
{child.label} {child.label}
</Menu.Item> </Menu.Item>
@@ -87,13 +107,22 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
); );
} }
return renderIcon(action, `action-${index}`); return renderItem(action, `action-${index}`);
})} });
};
return (
<Box style={{ display: 'inline-flex' }}>
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
<Group gap={0} wrap="nowrap" visibleFrom={responsiveView ? 'sm' : undefined}>
{renderFlatActions()}
</Group> </Group>
{/* --- MOBILE VIEW (hidden on desktop devices) --- */} {/* --- MOBILE VIEW (hidden on desktop devices) --- */}
{responsiveView && (
<Group gap={0} wrap="nowrap" hiddenFrom="sm"> <Group gap={0} wrap="nowrap" hiddenFrom="sm">
<Menu position="bottom-end" withArrow withinPortal> <Menu position="bottom-end" withArrow withinPortal zIndex={9999}>
<Menu.Target> <Menu.Target>
<ActionIcon variant="transparent" size="md"> <ActionIcon variant="transparent" size="md">
<MoreVertical size={16} /> <MoreVertical size={16} />
@@ -119,8 +148,11 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
leftSection={child.icon} leftSection={child.icon}
color={getIntentColor(child.intent)} color={getIntentColor(child.intent)}
disabled={child.disabled} disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')} onClick={(e) => {
style={{ paddingLeft: '1.5rem' }} // Indent nested items e.stopPropagation();
child.onClick?.(child.key || '');
}}
style={{ paddingLeft: '1.5rem' }}
mt="sm" mt="sm"
mb="sm" mb="sm"
> >
@@ -138,7 +170,10 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
leftSection={action.icon} leftSection={action.icon}
color={getIntentColor(action.intent)} color={getIntentColor(action.intent)}
disabled={action.disabled} disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')} onClick={(e) => {
e.stopPropagation();
action.onClick?.(action.key || '');
}}
mt="sm" mt="sm"
mb="sm" mb="sm"
> >
@@ -149,6 +184,7 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
)}
</Box> </Box>
); );
}); });
@@ -44,6 +44,8 @@ export interface PageActionProps extends BaseAction {
/** Human-readable keyboard tooltip label (e.g., '⇧⌘N'). Shown in tooltip. */ /** Human-readable keyboard tooltip label (e.g., '⇧⌘N'). Shown in tooltip. */
tooltipLabel?: string; tooltipLabel?: string;
/** Whether to show the text label for the main action button. Defaults to true. If false, renders as ActionIcon. */
showLabel?: boolean;
} }
/** /**
@@ -207,7 +207,7 @@ export const DEFAULT_STATUS_MAP: Record<string, BadgeProps> = {
[STATUS_DATA.PRESENT]: { color: '#E2B43E', leftSection: getIcon(Users) }, [STATUS_DATA.PRESENT]: { color: '#E2B43E', leftSection: getIcon(Users) },
}; };
export function StatusBadge({ status, label, getCustomConfig, color, leftSection, ...rest }: StatusBadgeProps) { export function StatusBadge({ status, label, getCustomConfig, color, ...rest }: StatusBadgeProps) {
if (!status) return null; if (!status) return null;
const normalizedStatus = status?.toLowerCase() || ''; const normalizedStatus = status?.toLowerCase() || '';
@@ -222,7 +222,6 @@ export function StatusBadge({ status, label, getCustomConfig, color, leftSection
style={{ textTransform: 'capitalize' }} style={{ textTransform: 'capitalize' }}
variant={customConfig.variant ? customConfig.variant : 'light'} variant={customConfig.variant ? customConfig.variant : 'light'}
color={color || customConfig.color || defaultConfig.color} color={color || customConfig.color || defaultConfig.color}
leftSection={leftSection || customConfig.leftSection || defaultConfig.leftSection}
{...rest} {...rest}
> >
{label || (status ? status.replace(/-/g, ' ') : 'Unknown')} {label || (status ? status.replace(/-/g, ' ') : 'Unknown')}
@@ -1,77 +1,151 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Menu, ActionIcon } from '@mantine/core'; import { Eye, Trash2, CheckCircle, XCircle, Edit2, Copy, PauseCircle, RotateCcw, X, Check } from 'lucide-react';
import { MoreVertical, Eye, Trash2, CheckCircle, XCircle } from 'lucide-react';
import { ModuleAction, ModuleActionType } from '../../../entities/entity'; import { ModuleAction, ModuleActionType } from '../../../entities/entity';
import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context'; import {
useEnterpriseModuleTranslationContext,
useEnterpriseModuleConfigContext,
} from '../../../hooks/use-module.context';
import { RowActionProps, RowActions } from '../../../../../components';
export interface RowActionMenuProps { export interface RowActionMenuProps {
data: any; data: any;
rowIndex: number; onActionClick: (action: ModuleActionType | 'VIEW', data: any) => void;
onActionClick: (action: ModuleActionType, data: any) => void;
statusKey?: string; statusKey?: string;
customActions?: (data: any, defaultActions: any[]) => any[]; customActions?: (data: any, defaultActions: RowActionProps[]) => RowActionProps[];
} }
export function RowActionMenu({ data, onActionClick, statusKey = 'status', customActions }: RowActionMenuProps) { export function RowActionMenu({ data, onActionClick, statusKey = 'status', customActions }: RowActionMenuProps) {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { privileges, config } = useEnterpriseModuleConfigContext();
const { moduleType } = config;
const status = data?.[statusKey]?.toLowerCase(); const status = data?.[statusKey]?.toLowerCase();
const defaultActions = useMemo(() => { const defaultActions = useMemo<RowActionProps[]>(() => {
const actions: any[] = []; const actions: RowActionProps[] = [];
const {
ALLOW_EDIT,
ALLOW_DELETE,
ALLOW_CREATE,
ALLOW_ACTIVATE,
ALLOW_DEACTIVATE,
ALLOW_CONFIRM,
ALLOW_CANCEL,
ALLOW_ROLLBACK,
ALLOW_HOLD,
} = privileges;
const isTransaction = moduleType === 'TRANSACTION';
const isMasterData = moduleType === 'MASTER_DATA';
const isDataActive = status === 'active';
const isDataInActive = status === 'inactive' || status === 'draft';
// View Details // View Details
actions.push({ actions.push({
key: 'VIEW', key: 'VIEW',
label: t('common:actions.detail'), label: t('common:actions.detail'),
tooltip: t('common:actions.detail'),
icon: <Eye size={14} />, icon: <Eye size={14} />,
onClick: () => onActionClick('VIEW' as any, data), onClick: () => onActionClick('VIEW' as any, data),
}); });
// Active/Inactive toggle if (ALLOW_EDIT) {
if (status === 'active') { actions.push({
key: ModuleAction.EDIT,
label: t('common:actions.edit'),
tooltip: t('common:actions.edit'),
icon: <Edit2 size={14} />,
onClick: () => onActionClick(ModuleAction.EDIT, data),
});
}
if (ALLOW_CREATE) {
actions.push({
key: ModuleAction.DUPLICATE,
label: t('common:actions.duplicate'),
tooltip: t('common:actions.duplicate'),
icon: <Copy size={14} />,
onClick: () => onActionClick(ModuleAction.DUPLICATE, data),
});
}
if (isMasterData) {
if (ALLOW_DEACTIVATE && isDataActive) {
actions.push({ actions.push({
key: ModuleAction.DEACTIVATE, key: ModuleAction.DEACTIVATE,
label: t('common:actions.deactivate'), label: t('common:actions.deactivate'),
tooltip: t('common:actions.deactivate'),
icon: <XCircle size={14} />, icon: <XCircle size={14} />,
onClick: () => onActionClick(ModuleAction.DEACTIVATE, data), onClick: () => onActionClick(ModuleAction.DEACTIVATE, data),
}); });
} else if (status === 'inactive') { }
if (ALLOW_ACTIVATE && isDataInActive) {
actions.push({ actions.push({
key: ModuleAction.ACTIVATE, key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'), label: t('common:actions.activate'),
tooltip: t('common:actions.activate'),
icon: <CheckCircle size={14} />, icon: <CheckCircle size={14} />,
onClick: () => onActionClick(ModuleAction.ACTIVATE, data), onClick: () => onActionClick(ModuleAction.ACTIVATE, data),
}); });
} }
}
// Delete if (isTransaction) {
if (ALLOW_HOLD) {
actions.push({
key: ModuleAction.HOLD,
label: t('common:actions.hold'),
tooltip: t('common:actions.hold'),
icon: <PauseCircle size={14} />,
onClick: () => onActionClick(ModuleAction.HOLD, data),
});
}
if (ALLOW_ROLLBACK) {
actions.push({
key: ModuleAction.ROLLBACK,
label: t('common:actions.rollback'),
tooltip: t('common:actions.rollback'),
icon: <RotateCcw size={14} />,
onClick: () => onActionClick(ModuleAction.ROLLBACK, data),
});
}
if (ALLOW_CANCEL) {
actions.push({
key: ModuleAction.CANCEL,
label: t('common:actions.cancel'),
tooltip: t('common:actions.cancel'),
icon: <X size={14} />,
onClick: () => onActionClick(ModuleAction.CANCEL, data),
});
}
if (ALLOW_CONFIRM) {
actions.push({
key: ModuleAction.CONFIRM,
label: t('common:actions.confirm'),
tooltip: t('common:actions.confirm'),
icon: <Check size={14} />,
onClick: () => onActionClick(ModuleAction.CONFIRM, data),
});
}
}
if (ALLOW_DELETE) {
actions.push({ actions.push({
key: ModuleAction.DELETE, key: ModuleAction.DELETE,
label: t('common:actions.delete'), label: t('common:actions.delete'),
tooltip: t('common:actions.delete'),
icon: <Trash2 size={14} />, icon: <Trash2 size={14} />,
color: 'red', intent: 'destructive',
onClick: () => onActionClick(ModuleAction.DELETE, data), onClick: () => onActionClick(ModuleAction.DELETE, data),
}); });
}
return actions; return actions;
}, [status, t, onActionClick, data]); }, [status, t, onActionClick, data, privileges, moduleType]);
const finalActions = customActions ? customActions(data, defaultActions) : defaultActions; const finalActions = customActions ? customActions(data, defaultActions) : defaultActions;
return ( return <RowActions actions={finalActions} responsiveView={false} />;
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{finalActions.map((action, idx) => (
<Menu.Item key={action.key || idx} color={action.color} leftSection={action.icon} onClick={action.onClick}>
{action.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
} }
@@ -1,4 +1,4 @@
import { useMemo, useCallback, useRef } from 'react'; import { useMemo, useCallback, useRef, useState } from 'react';
import { AgGridReactProps } from 'ag-grid-react'; import { AgGridReactProps } from 'ag-grid-react';
import { import {
ColDef, ColDef,
@@ -16,12 +16,17 @@ import { Box } from '@mantine/core';
import { DataGrid, StatusBadge } from '../../../../components'; import { DataGrid, StatusBadge } from '../../../../components';
import { import {
useEnterpriseModuleConfigContext,
useEnterpriseModuleDataServiceContext, useEnterpriseModuleDataServiceContext,
useEnterpriseModuleSelectionContext, useEnterpriseModuleSelectionContext,
useEnterpriseModuleTranslationContext, useEnterpriseModuleTranslationContext,
useEnterpriseModuleNavigationContext,
} from '../../hooks/use-module.context'; } from '../../hooks/use-module.context';
import { BaseEntity } from '@repo/core-api/data-services'; import { BaseEntity } from '@repo/core-api/data-services';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { ModuleActionType, ModuleAction, ActionModalState, ActionModalConfig } from '../../entities/entity';
import { RowActionMenu } from './components/row-actions';
import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal';
export * from 'ag-grid-community'; export * from 'ag-grid-community';
export * from 'ag-grid-react'; export * from 'ag-grid-react';
@@ -60,6 +65,30 @@ export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgG
noRowsMessage?: string; noRowsMessage?: string;
showStatusbar?: boolean; showStatusbar?: boolean;
customPrefixColumn?: (col: ColDef<E>[]) => ColDef<E>[];
// Action related props
statusKey?: string;
customRowActions?: (data: E, defaultActions: any[]) => any[];
onClickView?: (data: E) => void;
onClickEdit?: (data: E) => void;
onClickDuplicate?: (data: E) => void;
onClickDelete?: (data: E) => void;
onClickActivate?: (data: E) => void;
onClickDeactivate?: (data: E) => void;
onClickConfirm?: (data: E) => void;
onClickCancel?: (data: E) => void;
onClickRollback?: (data: E) => void;
onClickHold?: (data: E) => void;
deleteModalConfig?: ActionModalConfig;
activateModalConfig?: ActionModalConfig;
deactivateModalConfig?: ActionModalConfig;
confirmModalConfig?: ActionModalConfig;
cancelModalConfig?: ActionModalConfig;
rollbackModalConfig?: ActionModalConfig;
holdModalConfig?: ActionModalConfig;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -77,6 +106,29 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
loadingMessage = 'Loading data...', loadingMessage = 'Loading data...',
noRowsMessage = 'No records found', noRowsMessage = 'No records found',
showStatusbar, showStatusbar,
customPrefixColumn,
// new action props
statusKey = 'status',
customRowActions,
onClickView,
onClickEdit,
onClickDuplicate,
onClickDelete,
onClickActivate,
onClickDeactivate,
onClickConfirm,
onClickCancel,
onClickRollback,
onClickHold,
deleteModalConfig,
activateModalConfig,
deactivateModalConfig,
confirmModalConfig,
cancelModalConfig,
rollbackModalConfig,
holdModalConfig,
...restAgGridProps ...restAgGridProps
} = props; } = props;
@@ -86,6 +138,11 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>(); const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>(); const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
const navigation = useEnterpriseModuleNavigationContext();
const { config } = useEnterpriseModuleConfigContext();
const { moduleType } = config;
const isTransaction = moduleType === 'TRANSACTION';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Local UI State // Local UI State
@@ -94,12 +151,186 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Reference to the AG Grid API for programmatic interaction // Reference to the AG Grid API for programmatic interaction
const gridApiRef = useRef<GridApi<E> | null>(null); const gridApiRef = useRef<GridApi<E> | null>(null);
// ---------------------------------------------------------------------------
// Action Handlers & Modal State
// ---------------------------------------------------------------------------
const CLOSED_MODAL: ActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: null }), []);
const [actionModalState, setActionModalState] = useState<ActionModalState<E>>(CLOSED_MODAL);
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,
],
);
const openActionModal = useCallback(
(action: ModuleActionType, data: E) => {
const config = modalConfigMap[action as keyof typeof modalConfigMap];
setActionModalState({ opened: true, action, data, config });
},
[modalConfigMap],
);
const closeActionModal = useCallback(() => {
setActionModalState(CLOSED_MODAL);
}, [CLOSED_MODAL]);
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'),
message: customSuccessMessage || defaultSuccessMessage,
color: 'teal',
});
closeActionModal();
if (gridApiRef.current) {
gridApiRef.current.refreshServerSide({ purge: false });
}
} 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'),
message: customErrorMessage || defaultErrorMessage,
color: 'red',
});
throw error;
}
},
[dataServices, closeActionModal, modalConfigMap, t],
);
const handleActionClick = useCallback(
(action: ModuleActionType | 'VIEW', data: E) => {
switch (action) {
case 'VIEW':
if (onClickView) onClickView(data);
else navigation.navigateToDetail(data.id as string);
break;
case ModuleAction.EDIT:
if (onClickEdit) onClickEdit(data);
else navigation.navigateToEdit(data.id as string);
break;
case ModuleAction.DUPLICATE:
if (onClickDuplicate) onClickDuplicate(data);
else navigation.navigateToDuplicate(data.id as string);
break;
case ModuleAction.DELETE:
if (onClickDelete) onClickDelete(data);
else openActionModal(ModuleAction.DELETE, data);
break;
case ModuleAction.ACTIVATE:
if (onClickActivate) onClickActivate(data);
else openActionModal(ModuleAction.ACTIVATE, data);
break;
case ModuleAction.DEACTIVATE:
if (onClickDeactivate) onClickDeactivate(data);
else openActionModal(ModuleAction.DEACTIVATE, data);
break;
case ModuleAction.CONFIRM:
if (onClickConfirm) onClickConfirm(data);
else openActionModal(ModuleAction.CONFIRM, data);
break;
case ModuleAction.CANCEL:
if (onClickCancel) onClickCancel(data);
else openActionModal(ModuleAction.CANCEL, data);
break;
case ModuleAction.ROLLBACK:
if (onClickRollback) onClickRollback(data);
else openActionModal(ModuleAction.ROLLBACK, data);
break;
case ModuleAction.HOLD:
if (onClickHold) onClickHold(data);
else openActionModal(ModuleAction.HOLD, data);
break;
default:
console.warn(`[ActionHandler] Unhandled action key: ${action}`);
break;
}
},
[
navigation,
onClickView,
onClickEdit,
onClickDuplicate,
onClickDelete,
onClickActivate,
onClickDeactivate,
onClickConfirm,
onClickCancel,
onClickRollback,
onClickHold,
openActionModal,
],
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Derived State & Configuration // Derived State & Configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Determine the number of rows per page based on metadata, defaulting to 10 // Determine the number of rows per page based on metadata, defaulting to 10
const perPage = useMemo(() => { const perPage = useMemo(() => {
console.log({ metaData });
return metaData?.limit ?? 10; return metaData?.limit ?? 10;
}, [metaData]); }, [metaData]);
@@ -108,23 +339,91 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Ensure column definitions are referentially stable // Ensure column definitions are referentially stable
const finalColumnDefs = useMemo<ColDef<E>[]>(() => { const finalColumnDefs = useMemo<ColDef<E>[]>(() => {
const masterDetailColumn: ColDef<E> = { maxWidth: 50, sortable: false, cellRenderer: 'agGroupCellRenderer' }; // Dedicated Checkbox Column (Pinned to the far left)
const statusColumn: ColDef<E> = { const selectionColumn: ColDef<any> = {
maxWidth: 130, colId: 'selection_column',
field: 'status' as any, maxWidth: 40,
headerName: t('common:fields.status'), pinned: 'left',
cellRenderer: ({ value }: any) => <StatusBadge status={value} />, sortable: false,
filter: false,
suppressHeaderMenuButton: true,
checkboxSelection: true, // Manually enable checkbox selection specifically for this column
suppressMovable: true,
}; };
const prefixColumn: ColDef<E>[] = [props.masterDetail ? masterDetailColumn : (null as any), statusColumn].filter( // Define the Master-Detail collapse/expand column
Boolean, const masterDetailColumn: ColDef<E> = {
); colId: 'master_detail_column',
pinned: 'left', // Pin to the right so it remains visible during horizontal scrolling
maxWidth: 50,
return [...prefixColumn, ...columnDefs]; sortable: false, // Disable sorting
}, [columnDefs, props.masterDetail]); filter: false, // Disable filtering
suppressHeaderMenuButton: true, // Suppress menu to keep the header clean
suppressMovable: true,
cellRenderer: 'agGroupCellRenderer',
};
// Define the Action column (for Edit, Delete, View, etc.)
const actionColumn: ColDef<any> = {
colId: 'action_column',
pinned: 'left', // Pin to the right so it remains visible during horizontal scrolling,
width: 180,
minWidth: 100,
sortable: false, // Disable sorting
filter: false, // Disable filtering
suppressHeaderMenuButton: true, // Suppress menu to keep the header clean
suppressSizeToFit: true, // Prevent this column from stretching if you call api.sizeColumnsToFit()
suppressMovable: true,
headerName: t('common:fields.action'),
cellRenderer: (params: any) => {
if (!params.data) return null;
return (
<RowActionMenu
data={params.data}
onActionClick={handleActionClick}
statusKey={statusKey}
customActions={customRowActions}
/>
);
},
};
// Define the default Status column
const statusColumn: ColDef<E> = {
colId: 'status',
maxWidth: 130,
suppressHeaderMenuButton: true, // Suppress menu to keep the header clean
field: 'status' as any,
headerName: t('common:fields.status'),
cellRenderer: ({ value }: any) => <StatusBadge status={value} size="md" variant="outline" />,
};
const prefixColumn: ColDef<E>[] = [
selectionColumn,
props.masterDetail ? masterDetailColumn : (null as any),
actionColumn,
statusColumn,
].filter(Boolean);
return [...(customPrefixColumn ? customPrefixColumn(prefixColumn) : prefixColumn), ...columnDefs];
}, [
columnDefs,
props.masterDetail,
isTransaction,
customPrefixColumn,
t,
statusKey,
customRowActions,
handleActionClick,
]);
// Default configuration applied to all columns in the grid // Default configuration applied to all columns in the grid
const defaultColDef = useMemo<ColDef>(() => ({ flex: 1, minWidth: 100, sortable: true, resizable: true }), []); const defaultColDef = useMemo<ColDef>(() => ({ flex: 1, minWidth: 40, sortable: true, resizable: true }), []);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data Source (Server-Side Row Model) // Data Source (Server-Side Row Model)
@@ -240,7 +539,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
columnDefs={finalColumnDefs} columnDefs={finalColumnDefs}
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
animateRows={true} animateRows={true}
rowSelection={{ mode: 'multiRow', checkboxes: true, copySelectedRows: false, headerCheckbox: false }} rowSelection={{ mode: 'multiRow', checkboxes: false, copySelectedRows: false, headerCheckbox: false }}
enableCellTextSelection={true} enableCellTextSelection={true}
onSelectionChanged={handleSelectionChanged} onSelectionChanged={handleSelectionChanged}
onGridReady={onGridReady} onGridReady={onGridReady}
@@ -252,6 +551,14 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
{...restAgGridProps} {...restAgGridProps}
/> />
</Box> </Box>
{/* Action Confirmation Modal */}
<ActionConfirmationModal<E>
modalState={actionModalState}
onClose={closeActionModal}
onExecute={executeAction}
t={t}
/>
</Box> </Box>
); );
} }
@@ -238,7 +238,6 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
// Expanded / Full mode // Expanded / Full mode
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helper variable agar kode lebih bersih
const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0; const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0;
return ( return (
@@ -274,7 +274,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const handleActionClick = useCallback( const handleActionClick = useCallback(
async (key: string) => { async (key: string) => {
// Guard utama: Untuk aksi selain CREATE, pastikan dataId dan detailData sudah ada // Main guard: For actions other than CREATE, make sure dataId and detailData exist.
const hasValidData = Boolean(dataId && detailData); const hasValidData = Boolean(dataId && detailData);
const currentData = detailData as E; const currentData = detailData as E;
@@ -356,7 +356,6 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
} }
}, },
[ [
// Semua dependensi wajib dimasukkan agar terhindar dari bug Stale Closure
navigation, navigation,
privileges, privileges,
dataId, dataId,