Merge pull request 'core/page-provider' (#27) from core/page-provider into main

Reviewed-on: eigen/fe-monorepo-template#27
This commit is contained in:
2026-07-23 09:00:00 +00:00
22 changed files with 1960 additions and 260 deletions
@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { SimpleGrid } from '@repo/ui/components';
import { FieldTextInput } from '@repo/ui/form';
import { UseFormReturn, useWatch } from 'react-hook-form';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: any }) => {
const codeValue = useWatch({
control: form.control,
name: 'code',
});
useEffect(() => {
if (!codeValue) {
form.setValue('name', '');
form.clearErrors('name');
}
}, [codeValue, form]);
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={form.control}
name="code"
label={t('fields.code')}
placeholder={`Enter ${t('fields.code')}`}
/>
<FieldTextInput
control={form.control}
name="name"
label={t('fields.name')}
placeholder={`Enter ${t('fields.name')}`}
disabled={!codeValue}
/>
</SimpleGrid>
);
};
@@ -4,10 +4,27 @@ import {
EnterpriseDataTable, EnterpriseDataTable,
} from '@repo/ui/foundations'; } from '@repo/ui/foundations';
import { ColDef } from '@repo/ui/components'; import { ColDef } from '@repo/ui/components';
import { z } from 'zod';
import { Trans } from '@repo/core-i18n'; import { Trans } from '@repo/core-i18n';
import { Text } from '@repo/ui/components'; import { Text } from '@repo/ui/components';
import { LayoutDashboard } from 'lucide-react'; import { LayoutDashboard } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { FilterFormContent } from '../components/filter-content';
const filterSchema = z
.object({
code: z.string().optional(),
name: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.code && !data.name) {
ctx.addIssue({
path: ['name'],
code: z.ZodIssueCode.custom,
message: 'Name is required when code is provided',
});
}
});
export default function FullPagePageIndex() { export default function FullPagePageIndex() {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
@@ -19,6 +36,16 @@ export default function FullPagePageIndex() {
]; ];
}, [t]); }, [t]);
const filterConfig = useMemo(() => {
return {
schema: filterSchema,
renderBody: (form: any) => {
if (!form) return null;
return <FilterFormContent form={form} t={t} />;
},
};
}, [t]);
return ( return (
<EnterpriseIndexPageProvider <EnterpriseIndexPageProvider
pageHeaderProps={{ pageHeaderProps={{
@@ -34,7 +61,7 @@ export default function FullPagePageIndex() {
], ],
}} }}
> >
<EnterpriseDataTable columnDefs={columnDefs} /> <EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
</EnterpriseIndexPageProvider> </EnterpriseIndexPageProvider>
); );
} }
@@ -279,10 +279,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Delete multiple entities by IDs. */ /** Delete multiple entities by IDs. Optionally sends form data as `meta` in the request body. */
batchDelete(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchDelete(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDelete, { return this.execute<void>(DESCRIPTORS.batchDelete, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -296,10 +296,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Activate multiple entities. */ /** Activate multiple entities. Optionally sends form data as `meta` in the request body. */
batchActivate(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchActivate(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchActivate, { return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -311,10 +311,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Deactivate multiple entities. */ /** Deactivate multiple entities. Optionally sends form data as `meta` in the request body. */
batchDeactivate(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchDeactivate(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchDeactivate, { return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -328,10 +328,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Confirm processing of multiple data records. */ /** Confirm processing of multiple data records. Optionally sends form data as `meta` in the request body. */
batchConfirmData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchConfirmData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchConfirmData, { return this.execute<void>(DESCRIPTORS.batchConfirmData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -343,10 +343,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Cancel processing of multiple data records. */ /** Cancel processing of multiple data records. Optionally sends form data as `meta` in the request body. */
batchCancelData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchCancelData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchCancelData, { return this.execute<void>(DESCRIPTORS.batchCancelData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -360,10 +360,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Rollback multiple transactions. */ /** Rollback multiple transactions. Optionally sends form data as `meta` in the request body. */
batchRollbackData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchRollbackData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchRollbackData, { return this.execute<void>(DESCRIPTORS.batchRollbackData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
@@ -375,10 +375,10 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
}); });
} }
/** Hold multiple transactions. */ /** Hold multiple transactions. Optionally sends form data as `meta` in the request body. */
batchHoldData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { batchHoldData(ids: EntityId[], meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
return this.execute<void>(DESCRIPTORS.batchHoldData, { return this.execute<void>(DESCRIPTORS.batchHoldData, {
config: { ...config, data: { ids } }, config: { ...config, data: { ids, ...(meta ? { meta } : {}) } },
}); });
} }
} }
@@ -33,22 +33,22 @@ export const DEFAULT_METHODS: RequestMethodMap = {
createMethod: 'POST', createMethod: 'POST',
editMethod: 'PUT', editMethod: 'PUT',
deleteMethod: 'DELETE', deleteMethod: 'DELETE',
batchDeleteMethod: 'POST', batchDeleteMethod: 'PUT',
activateMethod: 'PATCH', activateMethod: 'PATCH',
batchActivateMethod: 'POST', batchActivateMethod: 'PUT',
deactivateMethod: 'PATCH', deactivateMethod: 'PATCH',
batchDeactivateMethod: 'POST', batchDeactivateMethod: 'PUT',
confirmDataMethod: 'PATCH', confirmDataMethod: 'PATCH',
batchConfirmDataMethod: 'POST', batchConfirmDataMethod: 'PUT',
cancelDataMethod: 'PATCH', cancelDataMethod: 'PATCH',
batchCancelDataMethod: 'POST', batchCancelDataMethod: 'PUT',
rollbackDataMethod: 'PATCH', rollbackDataMethod: 'PATCH',
batchRollbackDataMethod: 'POST', batchRollbackDataMethod: 'PUT',
holdDataMethod: 'PATCH', holdDataMethod: 'PATCH',
batchHoldDataMethod: 'POST', batchHoldDataMethod: 'PUT',
}; };
// ─── Operation Descriptors ────────────────────────────────────── // ─── Operation Descriptors ──────────────────────────────────────
@@ -40,8 +40,11 @@
"collapseAll": "Collapse all menu", "collapseAll": "Collapse all menu",
"searchMenu": "Search menu", "searchMenu": "Search menu",
"searchData": "Search data", "searchData": "Search data",
"searchPlaceholder": "Type to search & press Enter...",
"collapse": "Collapse", "collapse": "Collapse",
"expandSidebar": "Expand Sidebar", "expandSidebar": "Expand Sidebar",
"filterTitle": "Filter {{module}}",
"tableSettingTitle": "Table Setting {{module}}",
"actions": { "actions": {
"create": "Create New", "create": "Create New",
"edit": "Edit", "edit": "Edit",
@@ -58,7 +61,13 @@
"back": "Back", "back": "Back",
"reload": "Reload", "reload": "Reload",
"filter": "Filter", "filter": "Filter",
"setting": "Setting" "setting": "Setting",
"detail": "Detail",
"view": "View",
"close": "Close",
"progress": "Progress",
"reset": "Reset",
"undo": "Undo"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -96,6 +105,14 @@
"continueEditing": "Continue Editing", "continueEditing": "Continue Editing",
"discardDraft": "Start Fresh" "discardDraft": "Start Fresh"
}, },
"bulkAction": {
"totalData": "Total Data",
"totalSuccess": "Success",
"totalFailed": "Failed",
"messages": "Messages",
"selectedData": "{{count}} data selected",
"batchProgress": "Processing batch {{current}} of {{total}}"
},
"notifications": { "notifications": {
"successTitle": "Success", "successTitle": "Success",
"errorTitle": "Error", "errorTitle": "Error",
@@ -40,8 +40,11 @@
"collapseAll": "Tutup semua menu", "collapseAll": "Tutup semua menu",
"searchMenu": "Cari menu", "searchMenu": "Cari menu",
"searchData": "Cari data", "searchData": "Cari data",
"searchPlaceholder": "Ketik pencarian & tekan Enter...",
"collapse": "Tutup", "collapse": "Tutup",
"expandSidebar": "Perluas Sidebar", "expandSidebar": "Perluas Bilah Sisi",
"filterTitle": "Filter {{module}}",
"tableSettingTitle": "Pengaturan Tabel {{module}}",
"actions": { "actions": {
"create": "Buat Baru", "create": "Buat Baru",
"edit": "Ubah", "edit": "Ubah",
@@ -58,7 +61,13 @@
"back": "Kembali", "back": "Kembali",
"reload": "Muat Ulang", "reload": "Muat Ulang",
"filter": "Filter", "filter": "Filter",
"setting": "Pengaturan" "setting": "Pengaturan",
"detail": "Detail",
"view": "Lihat",
"close": "Tutup",
"progress": "Progres",
"reset": "Atur Ulang",
"undo": "Kembalikan"
}, },
"confirmDialog": { "confirmDialog": {
"delete": { "delete": {
@@ -96,6 +105,14 @@
"continueEditing": "Lanjutkan", "continueEditing": "Lanjutkan",
"discardDraft": "Mulai Baru" "discardDraft": "Mulai Baru"
}, },
"bulkAction": {
"totalData": "Total Data",
"totalSuccess": "Berhasil",
"totalFailed": "Gagal",
"messages": "Pesan",
"selectedData": "{{count}} data terpilih",
"batchProgress": "Memproses batch {{current}} dari {{total}}"
},
"notifications": { "notifications": {
"successTitle": "Berhasil", "successTitle": "Berhasil",
"errorTitle": "Galat", "errorTitle": "Galat",
@@ -1,5 +1,5 @@
import { memo, Fragment } from 'react'; import { memo, Fragment } from 'react';
import { Group, Button, Menu, Divider, ActionIcon, Box, ButtonProps, Tooltip } from '@mantine/core'; import { Group, Button, Menu, Divider, ActionIcon, Box, ButtonProps, Tooltip, MantineSpacing } from '@mantine/core';
import { ChevronDown, MoreVertical } from 'lucide-react'; import { ChevronDown, MoreVertical } from 'lucide-react';
import { PageActionProps } from './types'; import { PageActionProps } from './types';
import { getIntentColor } from './utils'; import { getIntentColor } from './utils';
@@ -8,13 +8,16 @@ export interface PageActionsProps {
/** Array of configured page-level actions. */ /** Array of configured page-level actions. */
actions?: PageActionProps[]; actions?: PageActionProps[];
customButtonProps?: (action: PageActionProps) => ButtonProps; customButtonProps?: (action: PageActionProps) => ButtonProps;
gapActionDesktop?:MantineSpacing;
gapActionMobile?:MantineSpacing
} }
/** /**
* A responsive and flexible presentational component for page-level actions. * A responsive and flexible presentational component for page-level actions.
* Automatically adapts layout based on screen size. * Automatically adapts layout based on screen size.
*/ */
export const PageActions = memo(function PageActions({ actions = [], customButtonProps }: PageActionsProps) { export const PageActions = memo(function PageActions(props: PageActionsProps) {
const { actions = [], customButtonProps, gapActionDesktop='xs', gapActionMobile='sm' }=props;
if (!actions || actions?.length === 0) { if (!actions || actions?.length === 0) {
return null; return null;
} }
@@ -33,17 +36,19 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
return ( return (
<Box style={{ display: 'inline-flex' }}> <Box style={{ display: 'inline-flex' }}>
{/* --- DESKTOP VIEW --- */} {/* --- DESKTOP VIEW --- */}
<Group gap="xs" wrap="nowrap" visibleFrom="md"> <Group gap={gapActionDesktop} wrap="nowrap" visibleFrom="md">
{actions.map((action, index) => { {actions.map((action, index) => {
if (action.type === 'divider') { if (action.type === 'divider') {
return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />; return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />;
} }
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 +60,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 +106,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 +118,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>
) : ( ) : (
@@ -117,7 +142,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
</Group> </Group>
{/* --- MOBILE VIEW --- */} {/* --- MOBILE VIEW --- */}
<Group gap="xs" wrap="nowrap" hiddenFrom="md"> <Group gap={gapActionMobile} wrap="nowrap" hiddenFrom="md">
<Menu position="bottom-end" withArrow withinPortal> <Menu position="bottom-end" withArrow withinPortal>
<Menu.Target> <Menu.Target>
<ActionIcon variant="outline" size="md"> <ActionIcon variant="outline" size="md">
@@ -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 handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
action.onClick?.(action.key || '');
};
if (isButton) {
return (
<Button
key={actionKey}
variant="subtle"
color={getIntentColor(action.intent)}
leftSection={action.icon}
disabled={action.disabled}
onClick={handleClick}
size="xs"
>
{action.label}
</Button>
);
}
const iconBtn = ( const iconBtn = (
<Button <ActionIcon
key={action.key} key={actionKey}
variant={'transparent'} variant="subtle"
color={getIntentColor(action.intent)} color={getIntentColor(action.intent)}
leftSection={action.icon}
disabled={action.disabled} disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')} onClick={handleClick}
size="xs" size="md"
pr="xs"
pl="xs"
pt={0}
pb={0}
> >
{showLabels && action.label} {action.icon}
</Button> </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,104 +72,119 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
); );
}; };
const renderFlatActions = () => {
return actions.map((action, index) => {
if (action.type === 'divider') {
return <Divider key={`divider-${index}`} orientation="vertical" mr="xs" ml="xs" />;
}
if (action.children && action.children.length > 0) {
return (
<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') {
return <Menu.Divider key={`child-divider-${childIndex}`} />;
}
return (
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={(e) => {
e.stopPropagation();
child.onClick?.(child.key || '');
}}
>
{child.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
}
return renderItem(action, `action-${index}`);
});
};
return ( return (
<Box style={{ display: 'inline-flex' }}> <Box style={{ display: 'inline-flex' }}>
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */} {/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
<Group gap={0} wrap="nowrap" visibleFrom="sm"> <Group gap={0} wrap="nowrap" visibleFrom={responsiveView ? 'sm' : undefined}>
{actions.map((action, index) => { {renderFlatActions()}
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.Dropdown>
{action.children.map((child, childIndex) => {
if (child.type === 'divider') {
return <Menu.Divider key={`child-divider-${childIndex}`} />;
}
return (
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
>
{child.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
}
return renderIcon(action, `action-${index}`);
})}
</Group> </Group>
{/* --- MOBILE VIEW (hidden on desktop devices) --- */} {/* --- MOBILE VIEW (hidden on desktop devices) --- */}
<Group gap={0} wrap="nowrap" hiddenFrom="sm">
<Menu position="bottom-end" withArrow withinPortal>
<Menu.Target>
<ActionIcon variant="transparent" size="md">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
}
if (action.children && action.children.length > 0) { {responsiveView && (
<Group gap={0} wrap="nowrap" hiddenFrom="sm">
<Menu position="bottom-end" withArrow withinPortal zIndex={9999}>
<Menu.Target>
<ActionIcon variant="transparent" size="md">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
}
if (action.children && action.children.length > 0) {
return (
<Fragment key={action.key}>
<Menu.Label>{action.label}</Menu.Label>
{action.children.map((child, childIndex) => {
if (child.type === 'divider') {
return <Menu.Divider key={`mobile-child-divider-${childIndex}`} mt="xs" mb="xs" />;
}
return (
<Menu.Item
key={child.key}
leftSection={child.icon}
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={(e) => {
e.stopPropagation();
child.onClick?.(child.key || '');
}}
style={{ paddingLeft: '1.5rem' }}
mt="sm"
mb="sm"
>
{child.label}
</Menu.Item>
);
})}
</Fragment>
);
}
return ( return (
<Fragment key={action.key}> <Menu.Item
<Menu.Label>{action.label}</Menu.Label> key={action.key}
{action.children.map((child, childIndex) => { leftSection={action.icon}
if (child.type === 'divider') { color={getIntentColor(action.intent)}
return <Menu.Divider key={`mobile-child-divider-${childIndex}`} mt="xs" mb="xs" />; disabled={action.disabled}
} onClick={(e) => {
return ( e.stopPropagation();
<Menu.Item action.onClick?.(action.key || '');
key={child.key} }}
leftSection={child.icon} mt="sm"
color={getIntentColor(child.intent)} mb="sm"
disabled={child.disabled} >
onClick={() => child.onClick?.(child.key || '')} {action.label}
style={{ paddingLeft: '1.5rem' }} // Indent nested items </Menu.Item>
mt="sm"
mb="sm"
>
{child.label}
</Menu.Item>
);
})}
</Fragment>
); );
} })}
</Menu.Dropdown>
return ( </Menu>
<Menu.Item </Group>
key={action.key} )}
leftSection={action.icon}
color={getIntentColor(action.intent)}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
mt="sm"
mb="sm"
>
{action.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
</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')}
@@ -50,7 +50,7 @@ export const ACTION_TRANSLATION_MAP: Record<string, { titleKey: string; confirmK
}, },
[ModuleAction.CANCEL]: { [ModuleAction.CANCEL]: {
titleKey: 'common:confirmDialog.cancel.title', titleKey: 'common:confirmDialog.cancel.title',
confirmKey: 'common:actions.cancel_action', confirmKey: 'common:actions.cancel',
descriptionKey: 'common:confirmDialog.cancel.description', descriptionKey: 'common:confirmDialog.cancel.description',
}, },
[ModuleAction.ROLLBACK]: { [ModuleAction.ROLLBACK]: {
@@ -0,0 +1,381 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Box, Button, Group, Modal, Progress, Stack, Text } from '@mantine/core';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { BaseEntity } from '@repo/core-api/data-services';
import type {
ActionModalConfig,
BulkActionModalState,
BulkActionResult,
ModuleActionType,
} from '../../entities/entity';
import { ModuleAction } from '../../entities/entity';
import { ACTION_TRANSLATION_MAP } from '../action-confirmation-modal';
import { EntityId } from '../../../../../../core-api/src/data-services/types';
import SummaryPanel, { AggregatedResult } from './summary-pannel';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BulkActionConfirmationModalProps<E extends BaseEntity = BaseEntity> {
/** Current modal state (opened, action, data[], config) */
modalState: BulkActionModalState<E>;
/** Close the modal */
onClose: () => void;
/**
* Execute the bulk action for a single batch of IDs.
*
* The component handles chunking internally — this callback is called
* once per chunk. The consumer is responsible for calling the correct
* batch method on their data services (e.g., `batchDelete`, `batchActivate`).
*
* @param action - The action type being performed
* @param ids - IDs for this particular chunk
* @param meta - Optional form data from the confirmation form
*/
onExecute: (action: ModuleActionType, ids: EntityId[], meta?: Record<string, unknown>) => Promise<BulkActionResult>;
/** Translation function scoped to [moduleNamespace, 'common'] */
t: (key: string, options?: Record<string, unknown>) => string;
/**
* Maximum number of IDs per batch request.
* @default 20
*/
batchSize?: number;
}
// ---------------------------------------------------------------------------
// Processing Phase Type
// ---------------------------------------------------------------------------
type ProcessingPhase = 'idle' | 'processing' | 'completed';
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
/**
* Splits an array into smaller chunks of a given size.
* Pure function — no side effects.
*/
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
// ---------------------------------------------------------------------------
// Inner Form Body (memoized to prevent re-renders of the entire modal)
// ---------------------------------------------------------------------------
const ModalFormBody = React.memo(function ModalFormBody<
TMeta extends Record<string, unknown> = Record<string, unknown>,
>(props: {
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) {
return <>{props.renderBody(props.form)}</>;
}) as <TMeta extends Record<string, unknown>>(props: {
renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) => React.ReactElement;
// ---------------------------------------------------------------------------
// BulkActionConfirmationModal
// ---------------------------------------------------------------------------
/**
* Controlled confirmation modal for bulk lifecycle actions (batch delete, activate, etc.).
*
* Processes selected rows in configurable chunks, providing real-time progress
* feedback and an aggregated success/failure summary upon completion.
*
* Supports three body modes (identical to `ActionConfirmationModal`):
* 1. **Simple** — no custom body, just title + selected count + confirm/cancel.
* 2. **Static body** — custom body without form (informational content).
* 3. **Form body** — custom body with RHF FormProvider for validation + meta payload.
*
* Processing lifecycle:
* 1. **Idle** — User reviews selected items and optionally fills a form.
* 2. **Processing** — Batches are sent sequentially with progress bar updates.
* 3. **Completed** — Summary panel shows total/success/failed counts + messages.
*
* @performance
* - Modal is NOT mounted when `opened === false` (uses `keepMounted={false}`).
* - Form is created with `mode: 'onSubmit'` to avoid re-renders on every keystroke.
* - Inner body and summary panel are wrapped in React.memo to isolate re-renders.
* - Batch processing uses sequential iteration (not Promise.all) to avoid server overload.
*/
export function BulkActionConfirmationModal<E extends BaseEntity = BaseEntity>(
props: BulkActionConfirmationModalProps<E>,
) {
const { modalState, onClose, onExecute, t, batchSize = 20 } = props;
const { opened, action, data, config } = modalState;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const [phase, setPhase] = useState<ProcessingPhase>('idle');
const [progress, setProgress] = useState(0);
const [, setCurrentBatch] = useState(0);
const [, setTotalBatches] = useState(0);
const [aggregatedResult, setAggregatedResult] = useState<AggregatedResult | null>(null);
// Ref to track cancellation requests during processing
const abortRef = useRef(false);
// ---------------------------------------------------------------------------
// Form Setup (identical to single-action modal)
// ---------------------------------------------------------------------------
const hasForm = Boolean(config?.schema && config?.defaultValues);
const form = useForm<Record<string, unknown>>({
mode: 'onSubmit',
resolver: config?.schema ? zodResolver(config.schema as any) : undefined,
defaultValues: config?.defaultValues ?? {},
});
// ---------------------------------------------------------------------------
// Translation Resolution
// ---------------------------------------------------------------------------
const translations = useMemo(() => {
const actionKey = action ?? '';
const map = ACTION_TRANSLATION_MAP[actionKey];
return {
title: config?.title ?? (map ? t(map.titleKey) : (action ?? '')),
confirmLabel: config?.confirmLabel ?? (map ? t(map.confirmKey) : t('common:actions.confirm')),
cancelLabel: config?.cancelLabel ?? t('common:actions.cancel'),
description: map ? t(map.descriptionKey) : '',
};
}, [action, config?.title, config?.confirmLabel, config?.cancelLabel, t]);
// ---------------------------------------------------------------------------
// Confirm Button Color (identical to single-action modal)
// ---------------------------------------------------------------------------
const confirmColor = useMemo(() => {
if (action === ModuleAction.DELETE) return 'red';
return 'brand';
}, [action]);
// ---------------------------------------------------------------------------
// Progress Bar Color
// ---------------------------------------------------------------------------
const progressColor = useMemo(() => {
if (phase !== 'completed' || !aggregatedResult) return 'brand';
if (aggregatedResult.totalFailed === 0) return 'green';
if (aggregatedResult.totalSuccess === 0) return 'red';
return 'orange'; // partial success
}, [phase, aggregatedResult]);
// ---------------------------------------------------------------------------
// Batch Processing Engine
// ---------------------------------------------------------------------------
const processBatches = useCallback(
async (meta?: Record<string, unknown>) => {
if (!action || !data || data.length === 0) return;
// Extract IDs from selected entities
const ids: EntityId[] = data.map((item) => item.id!).filter(Boolean);
if (ids.length === 0) return;
// Split into chunks
const chunks = chunkArray(ids, batchSize);
const batchCount = chunks.length;
// Reset state for processing
abortRef.current = false;
setPhase('processing');
setProgress(0);
setCurrentBatch(0);
setTotalBatches(batchCount);
const accumulated: AggregatedResult = {
totalItems: ids.length,
totalSuccess: 0,
totalFailed: 0,
messages: [],
};
// Process chunks sequentially to avoid server overload
for (let i = 0; i < chunks.length; i++) {
if (abortRef.current) break;
setCurrentBatch(i + 1);
try {
const result = await onExecute(action, chunks[i], meta);
accumulated.totalSuccess += result.total_success;
accumulated.totalFailed += result.total_failed;
if (result.messages?.length) {
accumulated.messages.push(...result.messages);
}
} catch (error: any) {
// Count entire chunk as failed on network/unexpected errors
accumulated.totalFailed += chunks[i].length;
const errorMessage: string = error?.message ?? JSON.stringify(error);
accumulated.messages.push(errorMessage);
}
// Update progress after each batch
const progressPercent = ((i + 1) / batchCount) * 100;
setProgress(Number(progressPercent.toFixed(2)));
}
setAggregatedResult(accumulated);
setPhase('completed');
},
[action, data, batchSize, onExecute],
);
// ---------------------------------------------------------------------------
// Confirm Handler
// ---------------------------------------------------------------------------
const handleConfirmClick = useCallback(async () => {
if (!action || !data || data.length === 0) return;
if (hasForm) {
// Trigger RHF validation, then process if valid
const isValid = await form.trigger();
if (!isValid) return;
const formValues = form.getValues();
await processBatches(formValues);
} else {
// No form — process directly
await processBatches(undefined);
}
}, [action, data, hasForm, form, processBatches]);
// ---------------------------------------------------------------------------
// Close Handler
// ---------------------------------------------------------------------------
const handleClose = useCallback(() => {
// Prevent close while actively processing
if (phase === 'processing') return;
// Reset all state
abortRef.current = true;
setPhase('idle');
setProgress(0);
setCurrentBatch(0);
setTotalBatches(0);
setAggregatedResult(null);
form.reset();
onClose();
}, [phase, form, onClose]);
// ---------------------------------------------------------------------------
// Derived UI State
// ---------------------------------------------------------------------------
const isProcessing = phase === 'processing';
const isCompleted = phase === 'completed';
const selectedCount = data?.length ?? 0;
return (
<Modal
opened={opened}
onClose={handleClose}
title={translations.title}
size={config?.size ?? 'lg'}
centered
closeOnClickOutside={!isProcessing}
closeOnEscape={!isProcessing}
keepMounted={false}
padding="lg"
styles={{
title: { fontWeight: 600, fontSize: 'var(--mantine-font-size-xl)' },
header: { paddingBottom: 'var(--mantine-spacing-md)' },
}}
>
<Stack gap="xl">
{/* Body: custom or default description */}
{phase === 'idle' && (
<>
{config?.renderBody ? (
hasForm ? (
<FormProvider {...form}>
<ModalFormBody renderBody={config.renderBody} form={form} />
</FormProvider>
) : (
<ModalFormBody renderBody={config.renderBody} />
)
) : (
<Stack gap="sm">
{translations.description && (
<Text size="md" c="dimmed">
{translations.description}
</Text>
)}
<Text size="sm" fw={500}>
{t('common:bulkAction.selectedData', { count: selectedCount })}
</Text>
</Stack>
)}
</>
)}
{/* Progress section (visible during processing and after completion) */}
{(isProcessing || isCompleted) && (
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={600} c={progressColor}>
{t('common:actions.progress')}
</Text>
<Text size="sm" fw={600} c={progressColor}>
{progress}%
</Text>
</Group>
<Progress
value={progress}
color={progressColor}
size="md"
radius="xl"
animated={isProcessing}
striped={isProcessing}
/>
</Box>
)}
{/* Summary panel (visible after completion) */}
{isCompleted && aggregatedResult && <SummaryPanel result={aggregatedResult} t={t} />}
{/* Footer actions */}
<Group justify="flex-end" mt="sm">
<Button
size="xs"
variant={!isCompleted ? 'default' : undefined}
onClick={handleClose}
disabled={isProcessing}
>
{isCompleted ? t('common:actions.close') : translations.cancelLabel}
</Button>
{!isCompleted && (
<Button
size="xs"
color={confirmColor}
onClick={handleConfirmClick}
loading={isProcessing}
disabled={isProcessing || selectedCount === 0}
>
{translations.confirmLabel}
</Button>
)}
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,104 @@
import React from 'react';
import { Paper, Stack, Group, ThemeIcon, Text, SimpleGrid, ScrollArea } from '@mantine/core';
import { Info, Check, X } from 'lucide-react'; // Atau tabler-icons, sesuaikan dengan library Anda
export interface AggregatedResult {
totalItems: number;
totalSuccess: number;
totalFailed: number;
messages: string[];
}
const SummaryPanel = React.memo(function SummaryPanel({
result,
t,
}: {
result: AggregatedResult;
t: (key: string) => string;
}) {
const messages = result.messages ?? [];
return (
<Paper p="sm" radius="md" withBorder>
<Stack gap="sm">
{/* Stats row - Grid for compact and even distribution */}
<SimpleGrid cols={3} spacing="xs">
{/* Total Items Card */}
<Paper py="sm" px="md" radius="sm" withBorder bg="blue.0">
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<ThemeIcon variant="transparent" color="blue.7" size="sm">
<Info size={16} strokeWidth={2.5} />
</ThemeIcon>
<Text size="xs" fw={600} c="blue.9">
{t('common:bulkAction.totalData')}
</Text>
</Group>
<Text size="sm" fw={700} c="blue.9">
{result.totalItems}
</Text>
</Group>
</Paper>
{/* Total Success Card */}
<Paper py="sm" px="md" radius="sm" withBorder bg="green.0">
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<ThemeIcon variant="transparent" color="green.7" size="sm">
<Check size={16} strokeWidth={2.5} />
</ThemeIcon>
<Text size="xs" fw={600} c="green.9">
{t('common:bulkAction.totalSuccess')}
</Text>
</Group>
<Text size="sm" fw={700} c="green.9">
{result.totalSuccess}
</Text>
</Group>
</Paper>
{/* Total Failed Card */}
<Paper py="sm" px="md" radius="sm" withBorder bg="red.0">
<Group gap="xs" justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap">
<ThemeIcon variant="transparent" color="red.7" size="sm">
<X size={16} strokeWidth={2.5} />
</ThemeIcon>
<Text size="xs" fw={600} c="red.9">
{t('common:bulkAction.totalFailed')}
</Text>
</Group>
<Text size="sm" fw={700} c="red.9">
{result.totalFailed}
</Text>
</Group>
</Paper>
</SimpleGrid>
{/* Messages list area (Log style) */}
{messages.length > 0 && (
<Stack gap={4} mt={4}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
{t('common:bulkAction.messages')}
</Text>
{/* Mengunci tinggi maksimal dan menambahkan scroll jika pesan banyak */}
<ScrollArea h={messages.length > 3 ? 120 : undefined} type="auto" offsetScrollbars>
<Stack gap={6}>
{messages.map((msg, idx) => (
<Paper key={idx} p={8} px="sm" radius="sm" bg="white" withBorder>
<Text size="xs" c="gray.7" lh={1.4}>
{msg}
</Text>
</Paper>
))}
</Stack>
</ScrollArea>
</Stack>
)}
</Stack>
</Paper>
);
});
export default SummaryPanel;
@@ -1,61 +1,162 @@
import { useMemo } from 'react'; import { useMemo, useCallback } from 'react';
import { ModuleAction, ModuleActionType } from '../../../entities/entity'; import { ModuleAction, ModuleActionType } from '../../../entities/entity';
import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context'; import {
import { Trash2, CheckCircle, XCircle } from 'lucide-react'; useEnterpriseModuleTranslationContext,
import { PageActionProps } from '../../../../../components'; useEnterpriseModuleConfigContext,
} from '../../../hooks/use-module.context';
import { Trash2, CheckCircle, XCircle, PauseCircle, RotateCcw, X, Check } from 'lucide-react';
import { PageActions, PageActionProps } from '../../../../../components';
export interface UseBulkActionsProps { // ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BulkActionMenuProps {
selectedRows: any[]; selectedRows: any[];
onActionClick: (action: ModuleActionType, data: any[]) => void; onActionClick: (action: ModuleActionType, data: any[]) => void;
statusKey?: string; statusKey?: string;
customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[]; customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[];
} }
export function useBulkActions({ // ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Renders bulk action icon buttons for selected rows in the data table header.
*
* Actions are derived from the module type (MASTER_DATA vs TRANSACTION),
* privilege checks, and the status of selected rows — mirroring the
* row-level action logic in `RowActionMenu`.
*
* All actions have `showLabel: false` so they render as icon-only
* `ActionIcon` buttons via the `PageActions` component.
*
* @performance
* - Uses component pattern (not hook) to isolate re-renders from the parent DataTable.
* - The parent only re-renders the BulkActionMenu — not the entire grid — when selection changes.
*/
export function BulkActionMenu({
selectedRows, selectedRows,
onActionClick, onActionClick,
statusKey = 'status', statusKey = 'status',
customBulkActions, customBulkActions,
}: UseBulkActionsProps) { }: BulkActionMenuProps) {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { privileges, config } = useEnterpriseModuleConfigContext();
const { moduleType } = config;
return useMemo(() => { // Stabilise the callback reference so the memo only depends on `onActionClick`
const handleClick = useCallback(
(action: ModuleActionType) => () => onActionClick(action, selectedRows),
[onActionClick, selectedRows],
);
const actions = useMemo(() => {
if (!selectedRows || selectedRows.length === 0) return []; if (!selectedRows || selectedRows.length === 0) return [];
const { ALLOW_DELETE, ALLOW_ACTIVATE, ALLOW_DEACTIVATE, ALLOW_CONFIRM, ALLOW_CANCEL, ALLOW_ROLLBACK, ALLOW_HOLD } =
privileges;
const isTransaction = moduleType === 'TRANSACTION';
const isMasterData = moduleType === 'MASTER_DATA';
const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active'); const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active');
const hasInactive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'inactive'); const hasInactive = selectedRows.some(
(row) => row[statusKey]?.toLowerCase() === 'inactive' || row[statusKey]?.toLowerCase() === 'draft',
);
const defaultActions: PageActionProps[] = []; const defaultActions: PageActionProps[] = [];
if (hasActive) { // --- Master Data Lifecycle Actions ---
defaultActions.push({ if (isMasterData) {
key: ModuleAction.DEACTIVATE, if (ALLOW_DEACTIVATE && hasActive) {
label: t('common:actions.deactivate'), defaultActions.push({
icon: <XCircle size={16} />, key: ModuleAction.DEACTIVATE,
variant: 'default', label: t('common:actions.deactivate'),
onClick: () => onActionClick(ModuleAction.DEACTIVATE, selectedRows), icon: <XCircle size={16} />,
}); variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.DEACTIVATE),
});
}
if (ALLOW_ACTIVATE && hasInactive) {
defaultActions.push({
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
icon: <CheckCircle size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.ACTIVATE),
});
}
} }
if (hasInactive) { // --- Transaction Lifecycle Actions ---
defaultActions.push({ if (isTransaction) {
key: ModuleAction.ACTIVATE, if (ALLOW_HOLD) {
label: t('common:actions.activate'), defaultActions.push({
icon: <CheckCircle size={16} />, key: ModuleAction.HOLD,
variant: 'default', label: t('common:actions.hold'),
onClick: () => onActionClick(ModuleAction.ACTIVATE, selectedRows), icon: <PauseCircle size={16} />,
}); variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.HOLD),
});
}
if (ALLOW_ROLLBACK) {
defaultActions.push({
key: ModuleAction.ROLLBACK,
label: t('common:actions.rollback'),
icon: <RotateCcw size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.ROLLBACK),
});
}
if (ALLOW_CANCEL) {
defaultActions.push({
key: ModuleAction.CANCEL,
label: t('common:actions.cancel'),
icon: <X size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.CANCEL),
});
}
if (ALLOW_CONFIRM) {
defaultActions.push({
key: ModuleAction.CONFIRM,
label: t('common:actions.confirm'),
icon: <Check size={16} />,
variant: 'default',
showLabel: false,
onClick: handleClick(ModuleAction.CONFIRM),
});
}
} }
defaultActions.push({ // --- Delete (always last, universal) ---
key: ModuleAction.DELETE, if (ALLOW_DELETE) {
label: t('common:actions.delete'), defaultActions.push({
icon: <Trash2 size={16} />, key: ModuleAction.DELETE,
variant: 'outline', label: t('common:actions.delete'),
intent: 'destructive', icon: <Trash2 size={16} />,
onClick: () => onActionClick(ModuleAction.DELETE, selectedRows), variant: 'outline',
}); intent: 'destructive',
showLabel: false,
onClick: handleClick(ModuleAction.DELETE),
});
}
return customBulkActions ? customBulkActions(selectedRows, defaultActions) : defaultActions; return customBulkActions ? customBulkActions(selectedRows, defaultActions) : defaultActions;
}, [selectedRows, statusKey, t, onActionClick, customBulkActions]); }, [selectedRows, statusKey, t, handleClick, privileges, moduleType, customBulkActions]);
if (actions.length === 0) return null;
return <PageActions actions={actions} />;
} }
@@ -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({ actions.push({
key: ModuleAction.DEACTIVATE, key: ModuleAction.EDIT,
label: t('common:actions.deactivate'), label: t('common:actions.edit'),
icon: <XCircle size={14} />, tooltip: t('common:actions.edit'),
onClick: () => onActionClick(ModuleAction.DEACTIVATE, data), icon: <Edit2 size={14} />,
}); onClick: () => onActionClick(ModuleAction.EDIT, data),
} else if (status === 'inactive') {
actions.push({
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
icon: <CheckCircle size={14} />,
onClick: () => onActionClick(ModuleAction.ACTIVATE, data),
}); });
} }
// Delete if (ALLOW_CREATE) {
actions.push({ actions.push({
key: ModuleAction.DELETE, key: ModuleAction.DUPLICATE,
label: t('common:actions.delete'), label: t('common:actions.duplicate'),
icon: <Trash2 size={14} />, tooltip: t('common:actions.duplicate'),
color: 'red', icon: <Copy size={14} />,
onClick: () => onActionClick(ModuleAction.DELETE, data), 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; 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>
);
} }
@@ -0,0 +1,162 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Drawer, DrawerProps, Group, Stack, Text, ScrollArea } from '@mantine/core';
import { useForm, FormProvider, UseFormReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { ZodType } from 'zod';
import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context';
/**
* Configuration for the filter drawer content and behavior.
* @template TMeta - The schema type for the react-hook-form
*/
export interface TableFilterConfig<TMeta extends Record<string, unknown> = Record<string, unknown>> {
/** Renders the body of the filter drawer */
renderBody?: (form?: UseFormReturn<TMeta>) => React.ReactNode;
/** Zod schema for form validation */
schema?: ZodType<TMeta>;
/** Default values for the form fields */
defaultValues?: TMeta;
/** Additional props to pass to the underlying Mantine Drawer */
drawerProps?: Omit<DrawerProps, 'opened' | 'onClose'>;
}
export interface TableFilterDrawerProps {
opened: boolean;
onClose: () => void;
title: string;
config?: TableFilterConfig<any>;
currentFilterData: any;
onFilter: (data: any) => void;
}
const DrawerFormBody = React.memo(function DrawerFormBody<
TMeta extends Record<string, unknown> = Record<string, unknown>,
>({
renderBody,
form,
}: {
renderBody: NonNullable<TableFilterConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) {
return <>{renderBody(form)}</>;
}) as <TMeta extends Record<string, unknown>>(props: {
renderBody: NonNullable<TableFilterConfig<TMeta>['renderBody']>;
form?: ReturnType<typeof useForm<TMeta>>;
}) => React.ReactElement;
export function TableFilterDrawer({
opened,
onClose,
title,
config,
onFilter,
currentFilterData,
}: TableFilterDrawerProps) {
const { t } = useEnterpriseModuleTranslationContext();
// We have a form if a renderBody is provided
const hasForm = Boolean(config?.renderBody);
const form = useForm<Record<string, unknown>>({
mode: 'onSubmit',
resolver: config?.schema ? zodResolver(config.schema as any) : undefined,
defaultValues: config?.defaultValues ?? {},
});
const [previousValues, setPreviousValues] = useState<Record<string, unknown> | null>(null);
const [hasReset, setHasReset] = useState(false);
useEffect(() => {
if (opened && hasForm) {
form.reset({
...config?.defaultValues,
...(currentFilterData || {}),
});
setHasReset(false);
setPreviousValues(null);
}
}, [opened]); // Only reset when opened
const handleApply = useCallback(async () => {
if (hasForm) {
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
onFilter(values);
} else {
onFilter({});
}
onClose();
}, [hasForm, form, onFilter, onClose]);
const handleReset = useCallback(() => {
if (hasForm) {
setPreviousValues(form.getValues());
// Ensure all fields are explicitly cleared
const cleared = Object.keys(form.getValues()).reduce((acc, key) => {
acc[key] = '';
return acc;
}, {} as Record<string, unknown>);
form.reset({ ...cleared, ...(config?.defaultValues || {}) });
setHasReset(true);
}
}, [hasForm, form, config]);
const handleUndoReset = useCallback(() => {
if (previousValues) {
form.reset(previousValues);
setHasReset(false);
setPreviousValues(null);
}
}, [form, previousValues]);
return (
<Drawer
opened={opened}
onClose={onClose}
title={
<Text fw={600} size="lg">
{title}
</Text>
}
position="right"
keepMounted={false}
size="md"
{...config?.drawerProps}
>
<Stack h="calc(100dvh - 60px)" gap={0}>
<ScrollArea flex={1} p="sm">
{config?.renderBody ? (
hasForm ? (
<FormProvider {...form}>
<DrawerFormBody renderBody={config.renderBody} form={form} />
</FormProvider>
) : (
<DrawerFormBody renderBody={config.renderBody} />
)
) : (
<Text c="dimmed" size="sm">
Filter configuration will go here.
</Text>
)}
</ScrollArea>
<Group justify="flex-end" p="sm" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
{hasReset && (
<Button size="sm" variant="outline" onClick={handleUndoReset} mr="auto">
{t('common:actions.undo')}
</Button>
)}
<Button size="sm" variant="default" onClick={handleReset}>
{t('common:actions.reset')}
</Button>
<Button size="sm" onClick={handleApply}>
{t('common:actions.filter')}
</Button>
</Group>
</Stack>
</Drawer>
);
}
@@ -0,0 +1,24 @@
import { Drawer, Text } from '@mantine/core';
export interface TableSettingDrawerProps {
opened: boolean;
onClose: () => void;
title: string;
}
export function TableSettingDrawer({ opened, onClose, title }: TableSettingDrawerProps) {
return (
<Drawer
opened={opened}
onClose={onClose}
title={<Text fw={600}>{title}</Text>}
position="right"
size="md"
padding="md"
>
<Text c="dimmed" size="sm">
Table setting configuration will go here.
</Text>
</Drawer>
);
}
@@ -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,
@@ -11,21 +11,43 @@ import {
DefaultMenuItem, DefaultMenuItem,
StatusBar, StatusBar,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { Box } from '@mantine/core'; import { Box, Group, TextInput, Indicator, CloseButton } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Search, Filter, Settings } from 'lucide-react';
import { DataGrid, StatusBadge } from '../../../../components'; import { DataGrid, StatusBadge, PageActions } from '../../../../components';
import type { PageActionProps } 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 type { BaseEntity } from '@repo/core-api/data-services';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import {
ModuleActionType,
ModuleAction,
ActionModalState,
ActionModalConfig,
BulkActionModalState,
BulkActionResult,
} from '../../entities/entity';
import { RowActionMenu } from './components/row-actions';
import { BulkActionMenu } from './components/bulk-actions';
import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../action-confirmation-modal';
import { BulkActionConfirmationModal } from '../bulk-action-confirmation';
import { TableFilterDrawer, TableFilterConfig } from './components/table-filter-drawer';
import { TableSettingDrawer } from './components/table-setting-drawer';
import { EntityId } from '../../../../../../core-api/src/data-services/types';
export * from 'ag-grid-community'; export * from 'ag-grid-community';
export * from 'ag-grid-react'; export * from 'ag-grid-react';
export type { TableFilterConfig };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types & Interfaces // Types & Interfaces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -60,6 +82,56 @@ 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;
// Bulk action props
/** Custom function to modify/extend the default bulk action buttons shown in the table header. */
customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[];
/** Maximum number of IDs per batch request during bulk operations. @default 20 */
batchSize?: number;
// Bulk action custom flow callbacks (mirrors single-action onClick* pattern)
onBulkClickDelete?: (data: E[]) => void;
onBulkClickActivate?: (data: E[]) => void;
onBulkClickDeactivate?: (data: E[]) => void;
onBulkClickConfirm?: (data: E[]) => void;
onBulkClickCancel?: (data: E[]) => void;
onBulkClickRollback?: (data: E[]) => void;
onBulkClickHold?: (data: E[]) => void;
/**
* The query parameter key used for search.
* @default 'q'
*/
searchKey?: string;
/**
* Configuration for the Filter Drawer
*/
filterConfig?: TableFilterConfig<any>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -77,6 +149,42 @@ 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,
searchKey = 'q',
filterConfig,
// Bulk action props
customBulkActions,
batchSize,
onBulkClickDelete,
onBulkClickActivate,
onBulkClickDeactivate,
onBulkClickConfirm,
onBulkClickCancel,
onBulkClickRollback,
onBulkClickHold,
...restAgGridProps ...restAgGridProps
} = props; } = props;
@@ -85,21 +193,395 @@ 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 { selectedRows, setSelectedRows, metaData, setMetaData, filterData, setFilterData } =
useEnterpriseModuleSelectionContext<E>();
const navigation = useEnterpriseModuleNavigationContext();
const { config } = useEnterpriseModuleConfigContext();
const { moduleType } = config;
const isTransaction = moduleType === 'TRANSACTION';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Local UI State // Local UI State
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const [openedFilter, { open: openFilter, close: closeFilter }] = useDisclosure(false);
const [openedSetting, { open: openSetting, close: closeSetting }] = useDisclosure(false);
const moduleTitle = config.tabTitle || t(`${config.translationNamespace}:title`);
// 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);
// ---------------------------------------------------------------------------
// Search & Filter State
// ---------------------------------------------------------------------------
const searchRef = useRef<string>((filterData?.[searchKey] as string) || '');
const filterRef = useRef<Record<string, any>>(
(() => {
if (!filterData) return {};
const copy = { ...filterData };
delete copy[searchKey];
return copy;
})(),
);
const [searchValue, setSearchValue] = useState(searchRef.current);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.currentTarget.value);
}, []);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
searchRef.current = searchValue;
gridApiRef.current?.refreshServerSide({ purge: true });
}
},
[searchValue],
);
const handleSearchClear = useCallback(() => {
setSearchValue('');
if (searchRef.current !== '') {
searchRef.current = '';
gridApiRef.current?.refreshServerSide({ purge: true });
}
}, []);
const activeFilterCount = useMemo(() => {
if (!filterData) return 0;
const filterKeys = filterConfig?.defaultValues
? Object.keys(filterConfig.defaultValues)
: Object.keys(filterData).filter(
(key) => key !== searchKey && !['page', 'limit', 'order_by', 'order_type'].includes(key),
);
return filterKeys.filter((key) => {
const val = filterData[key];
if (val === undefined || val === null || val === '') return false;
if (Array.isArray(val) && val.length === 0) return false;
return true;
}).length;
}, [filterData, searchKey, filterConfig]);
const handleFilterApply = useCallback((data: any) => {
filterRef.current = data || {};
gridApiRef.current?.refreshServerSide({ purge: true });
}, []);
// ---------------------------------------------------------------------------
// 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],
);
// ---------------------------------------------------------------------------
// Bulk Action Modal State & Handlers
// ---------------------------------------------------------------------------
const CLOSED_BULK_MODAL: BulkActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: [] }), []);
const [bulkModalState, setBulkModalState] = useState<BulkActionModalState<E>>(CLOSED_BULK_MODAL);
const openBulkActionModal = useCallback(
(action: ModuleActionType, data: E[]) => {
const config = modalConfigMap[action as keyof typeof modalConfigMap];
setBulkModalState({ opened: true, action, data, config });
},
[modalConfigMap],
);
const closeBulkActionModal = useCallback(() => {
setBulkModalState(CLOSED_BULK_MODAL);
// Refresh grid data after bulk action completes
if (gridApiRef.current) {
gridApiRef.current.refreshServerSide({ purge: false });
}
// Clear selected rows
setSelectedRows([]);
if (gridApiRef.current) {
gridApiRef.current.deselectAll();
}
}, [CLOSED_BULK_MODAL, setSelectedRows]);
const handleBulkActionClick = useCallback(
(action: ModuleActionType, data: E[]) => {
switch (action) {
case ModuleAction.DELETE:
if (onBulkClickDelete) onBulkClickDelete(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.ACTIVATE:
if (onBulkClickActivate) onBulkClickActivate(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.DEACTIVATE:
if (onBulkClickDeactivate) onBulkClickDeactivate(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.CONFIRM:
if (onBulkClickConfirm) onBulkClickConfirm(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.CANCEL:
if (onBulkClickCancel) onBulkClickCancel(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.ROLLBACK:
if (onBulkClickRollback) onBulkClickRollback(data);
else openBulkActionModal(action, data);
break;
case ModuleAction.HOLD:
if (onBulkClickHold) onBulkClickHold(data);
else openBulkActionModal(action, data);
break;
default:
openBulkActionModal(action, data);
break;
}
},
[
openBulkActionModal,
onBulkClickDelete,
onBulkClickActivate,
onBulkClickDeactivate,
onBulkClickConfirm,
onBulkClickCancel,
onBulkClickRollback,
onBulkClickHold,
],
);
/**
* Executes a single batch chunk of the bulk action.
* Called by BulkActionConfirmationModal per chunk.
*/
const executeBulkAction = useCallback(
async (action: ModuleActionType, ids: EntityId[], meta?: Record<string, unknown>): Promise<BulkActionResult> => {
try {
switch (action) {
case ModuleAction.DELETE:
await dataServices.batchDelete(ids, meta);
break;
case ModuleAction.ACTIVATE:
await dataServices.batchActivate(ids, meta);
break;
case ModuleAction.DEACTIVATE:
await dataServices.batchDeactivate(ids, meta);
break;
case ModuleAction.CONFIRM:
await dataServices.batchConfirmData(ids, meta);
break;
case ModuleAction.CANCEL:
await dataServices.batchCancelData(ids, meta);
break;
case ModuleAction.ROLLBACK:
await dataServices.batchRollbackData(ids, meta);
break;
case ModuleAction.HOLD:
await dataServices.batchHoldData(ids, meta);
break;
default:
console.warn(`[executeBulkAction] Unhandled action: ${action}`);
return { total_items: ids.length, total_success: 0, total_failed: ids.length };
}
return {
total_items: ids.length,
total_success: ids.length,
total_failed: 0,
};
} catch (error: any) {
return {
total_items: ids.length,
total_success: 0,
total_failed: ids.length,
messages: [error?.message || 'Unknown error'],
};
}
},
[dataServices],
);
// ---------------------------------------------------------------------------
// Action Handlers
// ---------------------------------------------------------------------------
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 +590,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)
@@ -136,8 +686,8 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
try { try {
const request = params.request; const request = params.request;
// Calculate the current page based on the start row and per-page limit const limit = perPage;
const page = Math.floor((request.startRow ?? 0) / perPage) + 1; const page = Math.floor((request.startRow ?? 0) / limit) + 1;
// Extract sorting information from the request // Extract sorting information from the request
const sortModel = request.sortModel[0]; const sortModel = request.sortModel[0];
@@ -145,7 +695,18 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
const orderType = sortModel?.sort?.toUpperCase(); const orderType = sortModel?.sort?.toUpperCase();
// Prepare the request parameters for the API call // Prepare the request parameters for the API call
const requestParams = { page, limit: perPage, order_by: orderBy, order_type: orderType }; const requestParams: Record<string, any> = {
page,
limit,
order_by: orderBy,
order_type: orderType,
...filterRef.current,
};
if (searchRef.current) {
requestParams[searchKey] = searchRef.current;
}
const response = await dataServices.getMany({ params: requestParams }); const response = await dataServices.getMany({ params: requestParams });
if (!response.data?.data) throw new Error('Invalid response'); if (!response.data?.data) throw new Error('Invalid response');
@@ -156,6 +717,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Update the global metadata state // Update the global metadata state
setMetaData(meta); setMetaData(meta);
setFilterData({ ...filterRef.current, [searchKey]: searchRef.current });
// Pass the retrieved data back to AG Grid // Pass the retrieved data back to AG Grid
params.success({ rowData, rowCount }); params.success({ rowData, rowCount });
@@ -166,7 +728,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
} }
}, },
}), }),
[dataServices, perPage, setMetaData, t], [dataServices, perPage, setMetaData, setFilterData, searchKey, t],
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -181,9 +743,17 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
if (params.api) { if (params.api) {
// Attach the server-side datasource to the grid API // Attach the server-side datasource to the grid API
params.api.setGridOption('serverSideDatasource', datasource); params.api.setGridOption('serverSideDatasource', datasource);
// Synchronously jump to the restored page immediately after attaching the datasource.
// This ensures the grid doesn't reset our page back to 1.
// NOTE: This relies on `serverSideInitialRowCount` being provided so the grid knows
// there are enough pages to jump to!
if (isPaginated && metaData?.page && metaData.page > 1) {
params.api.paginationGoToPage(metaData.page - 1);
}
} }
}, },
[datasource, setSelectedRows], [datasource, setSelectedRows, isPaginated, metaData],
); );
// Triggered whenever the row selection in the grid changes // Triggered whenever the row selection in the grid changes
@@ -222,6 +792,64 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
return ( return (
<Box> <Box>
{/* TABLE HEADER (Search, Filter, Bulk Actions) */}
<Group justify="space-between" align="center" mb="sm">
<Group gap="sm">
<TextInput
size="sm"
w={{ base: '100%', sm: 350 }}
placeholder={t('common:searchPlaceholder')}
leftSection={<Search size={16} />}
rightSection={
searchValue ? (
<CloseButton
size="sm"
onMouseDown={(e) => e.preventDefault()}
onClick={handleSearchClear}
aria-label={t('common:actions.clear')}
/>
) : null
}
value={searchValue}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
/>
<PageActions
actions={[
{ type: 'divider', key: 'search-divider' },
{
key: 'filter',
icon: (
<Indicator disabled={activeFilterCount === 0} size={8} offset={2}>
<Filter size={16} />
</Indicator>
),
variant: 'default',
showLabel: false,
tooltipLabel: t('common:actions.filter'),
onClick: openFilter,
},
{
key: 'setting',
icon: <Settings size={16} />,
variant: 'default',
showLabel: false,
tooltipLabel: t('common:actions.setting'),
onClick: openSetting,
},
]}
/>
</Group>
{/* BULK ACTION TOOLBAR — shown when rows are selected */}
<BulkActionMenu
selectedRows={selectedRows as E[]}
onActionClick={handleBulkActionClick as (action: ModuleActionType, data: any[]) => void}
statusKey={statusKey}
customBulkActions={customBulkActions}
/>
</Group>
{/* GRID CONTAINER */} {/* GRID CONTAINER */}
<Box <Box
className="erp-data-grid-container" className="erp-data-grid-container"
@@ -237,10 +865,11 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
pagination={isPaginated} pagination={isPaginated}
paginationPageSize={isPaginated ? perPage : undefined} paginationPageSize={isPaginated ? perPage : undefined}
paginationPageSizeSelector={isPaginated ? [10, 15, 20, 50] : undefined} paginationPageSizeSelector={isPaginated ? [10, 15, 20, 50] : undefined}
serverSideInitialRowCount={metaData?.total ?? undefined}
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 +881,37 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
{...restAgGridProps} {...restAgGridProps}
/> />
</Box> </Box>
{/* Single-Row Action Confirmation Modal */}
<ActionConfirmationModal<E>
modalState={actionModalState}
onClose={closeActionModal}
onExecute={executeAction}
t={t}
/>
{/* Bulk Action Confirmation Modal */}
<BulkActionConfirmationModal<E>
modalState={bulkModalState}
onClose={closeBulkActionModal}
onExecute={executeBulkAction}
t={t}
batchSize={batchSize}
/>
<TableFilterDrawer
opened={openedFilter}
onClose={closeFilter}
title={t('common:filterTitle', { module: moduleTitle })}
config={filterConfig}
currentFilterData={filterData}
onFilter={handleFilterApply}
/>
<TableSettingDrawer
opened={openedSetting}
onClose={closeSetting}
title={t('common:tableSettingTitle', { module: moduleTitle })}
/>
</Box> </Box>
); );
} }
@@ -13,6 +13,7 @@ import {
Tooltip, Tooltip,
} from '@mantine/core'; } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { Link } from 'react-router-dom';
import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon
import { PageActions, PageActionsProps } from '../../../../components'; import { PageActions, PageActionsProps } from '../../../../components';
@@ -91,7 +92,7 @@ function BreadcrumbBar({ breadcrumbs }: BreadcrumbBarProps) {
}; };
return !isText ? ( return !isText ? (
<Anchor key={index} {...sharedProps} href={item.href}> <Anchor component={Link} key={index} {...sharedProps} to={item.href || '#'}>
{item.label} {item.label}
</Anchor> </Anchor>
) : ( ) : (
@@ -238,7 +239,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 (
@@ -302,6 +302,40 @@ export interface ActionModalState<E extends BaseEntity = BaseEntity> {
config?: ActionModalConfig<any>; config?: ActionModalConfig<any>;
} }
// ---------------------------------------------------------------------------
// Bulk Action Confirmation Modal Configuration
// ---------------------------------------------------------------------------
/**
* Internal state for the bulk action confirmation modal.
*
* Unlike `ActionModalState` which holds a single entity, this state holds
* an array of selected entities for batch processing.
*
* @template E The base database entity.
* @internal
*/
export interface BulkActionModalState<E extends BaseEntity = BaseEntity> {
opened: boolean;
action: ModuleActionType | null;
/** The selected rows to process in bulk. */
data: E[];
config?: ActionModalConfig<any>;
}
/**
* Aggregated result from a bulk batch operation.
*
* Each batch call returns one of these, and the component aggregates
* them across all chunks to display a final summary.
*/
export interface BulkActionResult {
total_items: number;
total_success: number;
total_failed: number;
messages?: string[];
}
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig { export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
editMode?: 'FULL' | 'PARTIAL'; editMode?: 'FULL' | 'PARTIAL';
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions']; customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
@@ -9,4 +9,5 @@ export * from './providers/index-page.provider';
export * from './providers/detail-page.provider'; export * from './providers/detail-page.provider';
export * from './components/module-page-header'; export * from './components/module-page-header';
export * from './components/action-confirmation-modal'; export * from './components/action-confirmation-modal';
export * from './components/bulk-action-confirmation';
export * from './components/data-table'; export * from './components/data-table';
@@ -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,