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",
"reload": "Reload",
"filter": "Filter",
"setting": "Setting"
"setting": "Setting",
"detail": "Detail",
"view": "View"
},
"confirmDialog": {
"delete": {
@@ -58,7 +58,9 @@
"back": "Kembali",
"reload": "Muat Ulang",
"filter": "Filter",
"setting": "Pengaturan"
"setting": "Pengaturan",
"detail": "Detail",
"view": "Lihat"
},
"confirmDialog": {
"delete": {
@@ -40,10 +40,12 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
}
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)
if (action.children && action.children.length > 0) {
const ButtonWithDropdown = (
const ButtonWithDropdown = showLabel ? (
<Button
variant={action.variant || 'transparent'}
color={action?.color ? action.color : getIntentColor(action.intent)}
@@ -55,14 +57,23 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
>
{action.label}
</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 (
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target>
{/* Shortcuts on the main button remain hidden in the Tooltip */}
{action.tooltipLabel ? (
<Tooltip position="bottom" label={`${action.tooltipLabel}`} withArrow openDelay={500}>
{tooltipContent ? (
<Tooltip position="bottom" label={tooltipContent} withArrow openDelay={500}>
{ButtonWithDropdown}
</Tooltip>
) : (
@@ -92,7 +103,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
}
// 2. Regular Button (Standalone)
const StandaloneButton = (
const StandaloneButton = showLabel ? (
<Button
variant={action.variant || 'transparent'}
color={action?.color ? action.color : getIntentColor(action.intent)}
@@ -104,10 +115,21 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
>
{action.label}
</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 ? (
<Tooltip position="bottom" key={action.key} label={`${action.tooltipLabel}`} withArrow openDelay={500}>
return tooltipContent ? (
<Tooltip position="bottom" key={action.key} label={tooltipContent} withArrow openDelay={500}>
{StandaloneButton}
</Tooltip>
) : (
@@ -8,6 +8,7 @@ export interface RowActionsProps {
/** Array of configured row-level actions. */
actions: RowActionProps[];
showLabels?: boolean;
responsiveView?: boolean;
}
/**
@@ -16,34 +17,54 @@ export interface RowActionsProps {
*
* @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.
* Wraps the icon in a Tooltip if the configuration provides one.
* Helper function to render a standalone item.
*/
const renderIcon = (action: RowActionProps, fallbackKey: string) => {
const renderItem = (action: RowActionProps, fallbackKey: string) => {
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
key={action.key}
variant={'transparent'}
key={actionKey}
variant="subtle"
color={getIntentColor(action.intent)}
leftSection={action.icon}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
onClick={handleClick}
size="xs"
pr="xs"
pl="xs"
pt={0}
pb={0}
>
{showLabels && action.label}
{action.label}
</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 ? (
<Tooltip key={`tooltip-${actionKey}`} label={action.tooltip} withArrow withinPortal>
<Tooltip key={`tooltip-${actionKey}`} label={action.tooltip} withArrow withinPortal zIndex={9999}>
{iconBtn}
</Tooltip>
) : (
@@ -51,20 +72,16 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
);
};
return (
<Box style={{ display: 'inline-flex' }}>
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
<Group gap={0} wrap="nowrap" visibleFrom="sm">
{actions.map((action, index) => {
const renderFlatActions = () => {
return actions.map((action, index) => {
if (action.type === 'divider') {
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) {
return (
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target>{renderIcon(action, `action-${index}`)}</Menu.Target>
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover" zIndex={9999}>
<Menu.Target>{renderItem(action, `action-${index}`)}</Menu.Target>
<Menu.Dropdown>
{action.children.map((child, childIndex) => {
if (child.type === 'divider') {
@@ -76,7 +93,10 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
onClick={(e) => {
e.stopPropagation();
child.onClick?.(child.key || '');
}}
>
{child.label}
</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>
{/* --- MOBILE VIEW (hidden on desktop devices) --- */}
{responsiveView && (
<Group gap={0} wrap="nowrap" hiddenFrom="sm">
<Menu position="bottom-end" withArrow withinPortal>
<Menu position="bottom-end" withArrow withinPortal zIndex={9999}>
<Menu.Target>
<ActionIcon variant="transparent" size="md">
<MoreVertical size={16} />
@@ -119,8 +148,11 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
style={{ paddingLeft: '1.5rem' }} // Indent nested items
onClick={(e) => {
e.stopPropagation();
child.onClick?.(child.key || '');
}}
style={{ paddingLeft: '1.5rem' }}
mt="sm"
mb="sm"
>
@@ -138,7 +170,10 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
leftSection={action.icon}
color={getIntentColor(action.intent)}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(action.key || '');
}}
mt="sm"
mb="sm"
>
@@ -149,6 +184,7 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
</Menu.Dropdown>
</Menu>
</Group>
)}
</Box>
);
});
@@ -44,6 +44,8 @@ export interface PageActionProps extends BaseAction {
/** Human-readable keyboard tooltip label (e.g., '⇧⌘N'). Shown in tooltip. */
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) },
};
export function StatusBadge({ status, label, getCustomConfig, color, leftSection, ...rest }: StatusBadgeProps) {
export function StatusBadge({ status, label, getCustomConfig, color, ...rest }: StatusBadgeProps) {
if (!status) return null;
const normalizedStatus = status?.toLowerCase() || '';
@@ -222,7 +222,6 @@ export function StatusBadge({ status, label, getCustomConfig, color, leftSection
style={{ textTransform: 'capitalize' }}
variant={customConfig.variant ? customConfig.variant : 'light'}
color={color || customConfig.color || defaultConfig.color}
leftSection={leftSection || customConfig.leftSection || defaultConfig.leftSection}
{...rest}
>
{label || (status ? status.replace(/-/g, ' ') : 'Unknown')}
@@ -1,77 +1,151 @@
import { useMemo } from 'react';
import { Menu, ActionIcon } from '@mantine/core';
import { MoreVertical, Eye, Trash2, CheckCircle, XCircle } from 'lucide-react';
import { Eye, Trash2, CheckCircle, XCircle, Edit2, Copy, PauseCircle, RotateCcw, X, Check } from 'lucide-react';
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 {
data: any;
rowIndex: number;
onActionClick: (action: ModuleActionType, data: any) => void;
onActionClick: (action: ModuleActionType | 'VIEW', data: any) => void;
statusKey?: string;
customActions?: (data: any, defaultActions: any[]) => any[];
customActions?: (data: any, defaultActions: RowActionProps[]) => RowActionProps[];
}
export function RowActionMenu({ data, onActionClick, statusKey = 'status', customActions }: RowActionMenuProps) {
const { t } = useEnterpriseModuleTranslationContext();
const { privileges, config } = useEnterpriseModuleConfigContext();
const { moduleType } = config;
const status = data?.[statusKey]?.toLowerCase();
const defaultActions = useMemo(() => {
const actions: any[] = [];
const defaultActions = useMemo<RowActionProps[]>(() => {
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
actions.push({
key: 'VIEW',
label: t('common:actions.detail'),
tooltip: t('common:actions.detail'),
icon: <Eye size={14} />,
onClick: () => onActionClick('VIEW' as any, data),
});
// Active/Inactive toggle
if (status === 'active') {
if (ALLOW_EDIT) {
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({
key: ModuleAction.DEACTIVATE,
label: t('common:actions.deactivate'),
tooltip: t('common:actions.deactivate'),
icon: <XCircle size={14} />,
onClick: () => onActionClick(ModuleAction.DEACTIVATE, data),
});
} else if (status === 'inactive') {
}
if (ALLOW_ACTIVATE && isDataInActive) {
actions.push({
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
tooltip: t('common:actions.activate'),
icon: <CheckCircle size={14} />,
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({
key: ModuleAction.DELETE,
label: t('common:actions.delete'),
tooltip: t('common:actions.delete'),
icon: <Trash2 size={14} />,
color: 'red',
intent: 'destructive',
onClick: () => onActionClick(ModuleAction.DELETE, data),
});
}
return actions;
}, [status, t, onActionClick, data]);
}, [status, t, onActionClick, data, privileges, moduleType]);
const finalActions = customActions ? customActions(data, defaultActions) : defaultActions;
return (
<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>
);
return <RowActions actions={finalActions} responsiveView={false} />;
}
@@ -1,4 +1,4 @@
import { useMemo, useCallback, useRef } from 'react';
import { useMemo, useCallback, useRef, useState } from 'react';
import { AgGridReactProps } from 'ag-grid-react';
import {
ColDef,
@@ -16,12 +16,17 @@ import { Box } from '@mantine/core';
import { DataGrid, StatusBadge } from '../../../../components';
import {
useEnterpriseModuleConfigContext,
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleSelectionContext,
useEnterpriseModuleTranslationContext,
useEnterpriseModuleNavigationContext,
} from '../../hooks/use-module.context';
import { BaseEntity } from '@repo/core-api/data-services';
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-react';
@@ -60,6 +65,30 @@ export interface EnterpriseDataTableProps<E extends BaseEntity> extends Omit<AgG
noRowsMessage?: string;
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...',
noRowsMessage = 'No records found',
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
} = props;
@@ -86,6 +138,11 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
const navigation = useEnterpriseModuleNavigationContext();
const { config } = useEnterpriseModuleConfigContext();
const { moduleType } = config;
const isTransaction = moduleType === 'TRANSACTION';
// ---------------------------------------------------------------------------
// Local UI State
@@ -94,12 +151,186 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Reference to the AG Grid API for programmatic interaction
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
// ---------------------------------------------------------------------------
// Determine the number of rows per page based on metadata, defaulting to 10
const perPage = useMemo(() => {
console.log({ metaData });
return metaData?.limit ?? 10;
}, [metaData]);
@@ -108,23 +339,91 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Ensure column definitions are referentially stable
const finalColumnDefs = useMemo<ColDef<E>[]>(() => {
const masterDetailColumn: ColDef<E> = { maxWidth: 50, sortable: false, cellRenderer: 'agGroupCellRenderer' };
const statusColumn: ColDef<E> = {
maxWidth: 130,
field: 'status' as any,
headerName: t('common:fields.status'),
cellRenderer: ({ value }: any) => <StatusBadge status={value} />,
// Dedicated Checkbox Column (Pinned to the far left)
const selectionColumn: ColDef<any> = {
colId: 'selection_column',
maxWidth: 40,
pinned: 'left',
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(
Boolean,
);
// Define the Master-Detail collapse/expand column
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];
}, [columnDefs, props.masterDetail]);
sortable: false, // Disable sorting
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
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)
@@ -240,7 +539,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
columnDefs={finalColumnDefs}
defaultColDef={defaultColDef}
animateRows={true}
rowSelection={{ mode: 'multiRow', checkboxes: true, copySelectedRows: false, headerCheckbox: false }}
rowSelection={{ mode: 'multiRow', checkboxes: false, copySelectedRows: false, headerCheckbox: false }}
enableCellTextSelection={true}
onSelectionChanged={handleSelectionChanged}
onGridReady={onGridReady}
@@ -252,6 +551,14 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
{...restAgGridProps}
/>
</Box>
{/* Action Confirmation Modal */}
<ActionConfirmationModal<E>
modalState={actionModalState}
onClose={closeActionModal}
onExecute={executeAction}
t={t}
/>
</Box>
);
}
@@ -238,7 +238,6 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
// Expanded / Full mode
// -------------------------------------------------------------------------
// Helper variable agar kode lebih bersih
const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0;
return (
@@ -274,7 +274,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
// ---------------------------------------------------------------------------
const handleActionClick = useCallback(
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 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,
privileges,
dataId,