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
@@ -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.DEACTIVATE,
label: t('common:actions.deactivate'),
icon: <XCircle size={14} />,
onClick: () => onActionClick(ModuleAction.DEACTIVATE, data),
});
} else if (status === 'inactive') {
actions.push({
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
icon: <CheckCircle size={14} />,
onClick: () => onActionClick(ModuleAction.ACTIVATE, data),
key: ModuleAction.EDIT,
label: t('common:actions.edit'),
tooltip: t('common:actions.edit'),
icon: <Edit2 size={14} />,
onClick: () => onActionClick(ModuleAction.EDIT, data),
});
}
// Delete
actions.push({
key: ModuleAction.DELETE,
label: t('common:actions.delete'),
icon: <Trash2 size={14} />,
color: 'red',
onClick: () => onActionClick(ModuleAction.DELETE, 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),
});
}
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),
});
}
}
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} />,
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,