feat: enhance enterprise module with new actions and UI improvements
- Added support for rollback and hold actions in the enterprise module. - Updated tooltip labels for action buttons to improve user experience. - Refactored PageActions component to use tooltipLabel instead of shortcutLabel. - Introduced height prop for system pages (Coming Soon, Forbidden, Maintenance, Not Found) for better layout control. - Improved ModulePageHeader to accept custom button properties and enhanced title handling. - Cleaned up default privileges by removing unused actions (ALLOW_DUPLICATE, ALLOW_SAVE). - Implemented detail page provider with comprehensive action handling for CRUD operations. - Created a new full-page detail component for better data presentation.
This commit is contained in:
@@ -1,17 +1,488 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { Check, CheckCircle, Copy, Edit2, PauseCircle, Plus, RotateCcw, Trash2, X, XCircle } from 'lucide-react';
|
||||
|
||||
import { DetailPageContext } from '../hooks/use-detail-page.context';
|
||||
import { EnterpriseDetailPageConfig } from '../entities/entity';
|
||||
import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType } from '../entities/entity';
|
||||
import { CorePageContainer, PageActionProps } from '../../../components';
|
||||
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
|
||||
import {
|
||||
useEnterpriseModuleConfigContext,
|
||||
useEnterpriseModuleDataServiceContext,
|
||||
useEnterpriseModuleNavigationContext,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
} from '../hooks/use-module.context';
|
||||
import { shortcutsData } from '../../../constants';
|
||||
|
||||
export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) {
|
||||
const {
|
||||
children,
|
||||
showPageHeader,
|
||||
useDefaultPadding,
|
||||
editMode,
|
||||
|
||||
editMode = 'FULL',
|
||||
pageHeaderProps,
|
||||
px,
|
||||
py,
|
||||
customPageActions,
|
||||
onClickCreate,
|
||||
onClickDuplicate,
|
||||
onClickEdit,
|
||||
onClickDelete,
|
||||
onClickActivate,
|
||||
onClickDeactivate,
|
||||
onClickConfirm,
|
||||
onClickCancel,
|
||||
onClickRollback,
|
||||
onClickHold,
|
||||
|
||||
showHighlightData = true,
|
||||
showHighlightDataOnBreadcrumbs = true,
|
||||
highlightDataKey = 'code',
|
||||
} = props;
|
||||
|
||||
return <DetailPageContext.Provider value={{}}></DetailPageContext.Provider>;
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const navigation = useEnterpriseModuleNavigationContext();
|
||||
const { config, privileges, IS_MACOS } = useEnterpriseModuleConfigContext();
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
|
||||
|
||||
const [isActiveEditMode, setIsActiveEditMode] = useState<boolean>(false);
|
||||
|
||||
const params = useParams();
|
||||
const dataId = params.dataId;
|
||||
|
||||
const { moduleKey, moduleType } = config;
|
||||
|
||||
const [detailData, setDetailData] = useState<E | any>({ id: 1, code: 'ABC-001' });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!dataId) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await dataServices.getOne(dataId);
|
||||
if (response && response.data) {
|
||||
setDetailData(response.data as E);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load detail data', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [dataId, dataServices]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Stub Handlers (Sudah diperbaiki typonya & lengkap)
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handleDelete(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
}
|
||||
|
||||
async function handleActivate(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
}
|
||||
|
||||
async function handleDeactivate(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
}
|
||||
|
||||
async function handleConfirm(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
}
|
||||
|
||||
async function handleCancel(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
}
|
||||
|
||||
async function handleRollback(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
}
|
||||
|
||||
async function handleHold(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Action Dispatcher (Optimized with Switch Case & Complete Deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
const handleActionClick = useCallback(
|
||||
async (key: string) => {
|
||||
// Guard utama: Untuk aksi selain CREATE, pastikan dataId dan detailData sudah ada
|
||||
const hasValidData = Boolean(dataId && detailData);
|
||||
const currentData = detailData as E;
|
||||
|
||||
switch (key) {
|
||||
// --- CREATE ---
|
||||
case ModuleAction.CREATE:
|
||||
if (!privileges.ALLOW_CREATE) return;
|
||||
if (onClickCreate) onClickCreate();
|
||||
else navigation.navigateToCreate();
|
||||
break;
|
||||
|
||||
// --- EDIT ---
|
||||
case ModuleAction.EDIT:
|
||||
if (!privileges.ALLOW_EDIT || !hasValidData) return;
|
||||
if (onClickEdit) onClickEdit(currentData);
|
||||
else if (editMode === 'FULL') navigation.navigateToEdit(dataId!);
|
||||
else setIsActiveEditMode(true);
|
||||
break;
|
||||
|
||||
// --- DUPLICATE ---
|
||||
case ModuleAction.DUPLICATE:
|
||||
if (!privileges.ALLOW_CREATE || !hasValidData) return;
|
||||
if (onClickDuplicate) onClickDuplicate(currentData);
|
||||
else navigation.navigateToDuplicate(dataId!);
|
||||
break;
|
||||
|
||||
// --- DELETE ---
|
||||
case ModuleAction.DELETE:
|
||||
if (!privileges.ALLOW_DELETE || !hasValidData) return;
|
||||
if (onClickDelete) onClickDelete(currentData);
|
||||
else await handleDelete(currentData);
|
||||
break;
|
||||
|
||||
// --- ACTIVATE ---
|
||||
case ModuleAction.ACTIVATE:
|
||||
if (!privileges.ALLOW_ACTIVATE || !hasValidData) return;
|
||||
if (onClickActivate) onClickActivate(currentData);
|
||||
else await handleActivate(currentData);
|
||||
break;
|
||||
|
||||
// --- DEACTIVATE ---
|
||||
case ModuleAction.DEACTIVATE:
|
||||
if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return;
|
||||
if (onClickDeactivate) onClickDeactivate(currentData);
|
||||
else await handleDeactivate(currentData);
|
||||
break;
|
||||
|
||||
// --- CONFIRM (Tambahan Baru) ---
|
||||
case ModuleAction.CONFIRM:
|
||||
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
|
||||
if (onClickConfirm) onClickConfirm(currentData);
|
||||
else await handleConfirm(currentData);
|
||||
break;
|
||||
|
||||
// --- CANCEL (Tambahan Baru) ---
|
||||
case ModuleAction.CANCEL:
|
||||
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
|
||||
if (onClickCancel) onClickCancel(currentData);
|
||||
else await handleCancel(currentData);
|
||||
break;
|
||||
|
||||
// --- ROLLBACK (Tambahan Baru) ---
|
||||
case ModuleAction.ROLLBACK:
|
||||
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
|
||||
if (onClickRollback) onClickRollback(currentData);
|
||||
else await handleRollback(currentData);
|
||||
break;
|
||||
|
||||
// --- HOLD (Tambahan Baru) ---
|
||||
case ModuleAction.HOLD:
|
||||
if (!privileges.ALLOW_HOLD || !hasValidData) return;
|
||||
if (onClickHold) onClickHold(currentData);
|
||||
else await handleHold(currentData);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`[ActionHandler] Unhandled action key: ${key}`);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
// Semua dependensi wajib dimasukkan agar terhindar dari bug Stale Closure
|
||||
navigation,
|
||||
privileges,
|
||||
dataId,
|
||||
detailData,
|
||||
editMode,
|
||||
setIsActiveEditMode,
|
||||
onClickCreate,
|
||||
onClickEdit,
|
||||
onClickDuplicate,
|
||||
onClickDelete,
|
||||
onClickActivate,
|
||||
onClickDeactivate,
|
||||
onClickConfirm,
|
||||
onClickCancel,
|
||||
onClickRollback,
|
||||
onClickHold,
|
||||
],
|
||||
);
|
||||
|
||||
/** Platform-aware shortcut label for the Create action. */
|
||||
const CREATE_SHORTCUT_LABEL = useMemo(() => {
|
||||
const shortcutData = shortcutsData.find((s) => s.key === 'collapse_sidebar');
|
||||
return IS_MACOS ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
|
||||
}, [IS_MACOS]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
const isShortcut = (e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'n';
|
||||
if (!isShortcut) return;
|
||||
|
||||
// Prevent browser default (e.g., Chrome's "new incognito window").
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
handleActionClick(ModuleAction.CREATE);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||
}, [handleActionClick]);
|
||||
|
||||
const pageActions = useMemo(() => {
|
||||
const {
|
||||
ALLOW_CREATE,
|
||||
ALLOW_EDIT,
|
||||
ALLOW_DELETE,
|
||||
ALLOW_ACTIVATE,
|
||||
ALLOW_DEACTIVATE,
|
||||
ALLOW_CONFIRM,
|
||||
ALLOW_CANCEL,
|
||||
ALLOW_ROLLBACK,
|
||||
ALLOW_HOLD,
|
||||
} = privileges;
|
||||
|
||||
const isTransaction = moduleType === 'TRANSACTION';
|
||||
const isMasterData = moduleType === 'MASTER_DATA';
|
||||
|
||||
// 1. Declare action with Privilege & Module Type conditions directly
|
||||
const rawActions = [
|
||||
ALLOW_DELETE && {
|
||||
key: ModuleAction.DELETE,
|
||||
label: t('common:actions.delete'),
|
||||
icon: <Trash2 size={16} />,
|
||||
intent: 'destructive',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
ALLOW_CREATE && {
|
||||
key: ModuleAction.DUPLICATE,
|
||||
label: t('common:actions.duplicate'),
|
||||
icon: <Copy size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
ALLOW_EDIT && {
|
||||
key: ModuleAction.EDIT,
|
||||
label: t('common:actions.edit'),
|
||||
icon: <Edit2 size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
|
||||
{ key: 'DIVIDER_1', type: 'divider' }, // Will be cleared if empty
|
||||
|
||||
// Transaction Group
|
||||
isTransaction &&
|
||||
ALLOW_HOLD && {
|
||||
key: ModuleAction.HOLD,
|
||||
label: t('common:actions.hold'),
|
||||
icon: <PauseCircle size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
isTransaction &&
|
||||
ALLOW_ROLLBACK && {
|
||||
key: ModuleAction.ROLLBACK,
|
||||
label: t('common:actions.rollback'),
|
||||
icon: <RotateCcw size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
isTransaction &&
|
||||
ALLOW_CANCEL && {
|
||||
key: ModuleAction.CANCEL,
|
||||
label: t('common:actions.cancel'),
|
||||
icon: <X size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
isTransaction &&
|
||||
ALLOW_CONFIRM && {
|
||||
key: ModuleAction.CONFIRM,
|
||||
label: t('common:actions.confirm'),
|
||||
icon: <Check size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
|
||||
// Master Data Group
|
||||
isMasterData &&
|
||||
ALLOW_DEACTIVATE && {
|
||||
key: ModuleAction.DEACTIVATE,
|
||||
label: t('common:actions.deactivate'),
|
||||
icon: <XCircle size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
isMasterData &&
|
||||
ALLOW_ACTIVATE && {
|
||||
key: ModuleAction.ACTIVATE,
|
||||
label: t('common:actions.activate'),
|
||||
tooltipLabel: t('common:actions.activate'),
|
||||
icon: <CheckCircle size={16} />,
|
||||
intent: 'default',
|
||||
variant: 'subtle',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
|
||||
{ key: 'DIVIDER_2', type: 'divider' },
|
||||
|
||||
ALLOW_CREATE && {
|
||||
key: ModuleAction.CREATE,
|
||||
label: t('common:actions.create'),
|
||||
tooltipLabel: `${t('common:actions.create')} (${CREATE_SHORTCUT_LABEL})`,
|
||||
|
||||
icon: <Plus size={16} />,
|
||||
intent: 'primary',
|
||||
variant: 'filled',
|
||||
onClick: (key: ModuleActionType) => handleActionClick(key),
|
||||
},
|
||||
].filter(Boolean) as PageActionProps[]; // Remove all false/null/undefined
|
||||
|
||||
// 2. Smart Divider Cleaning Algorithm
|
||||
const cleanedActions: PageActionProps[] = [];
|
||||
|
||||
for (let i = 0; i < rawActions.length; i++) {
|
||||
const current = rawActions[i];
|
||||
|
||||
if (current.type === 'divider') {
|
||||
// Ignore if this divider is at the front (beginning of the array)
|
||||
if (cleanedActions.length === 0) continue;
|
||||
|
||||
// Ignore if the previous item is also a divider (prevents nesting: || )
|
||||
if (cleanedActions[cleanedActions.length - 1].type === 'divider') continue;
|
||||
|
||||
// Ignore if after this divider there are no action buttons at all (prevent at the end: | )
|
||||
const hasActionAfter = rawActions.slice(i + 1).some((a) => a.type !== 'divider');
|
||||
if (!hasActionAfter) continue;
|
||||
}
|
||||
|
||||
cleanedActions.push(current);
|
||||
}
|
||||
|
||||
// 3. Inject custom actions
|
||||
return customPageActions && detailData ? customPageActions(detailData, cleanedActions) : cleanedActions;
|
||||
}, [t, customPageActions, handleActionClick, privileges, moduleType, detailData]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
detailData,
|
||||
isLoading,
|
||||
reload: loadData,
|
||||
isPartialEdit: editMode === 'PARTIAL',
|
||||
isActiveEditMode,
|
||||
setIsActiveEditMode,
|
||||
}),
|
||||
[detailData, isLoading, loadData, editMode, isActiveEditMode, setIsActiveEditMode],
|
||||
);
|
||||
|
||||
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||
const staticTitle = pageProvide?.title;
|
||||
if (!showHighlight) {
|
||||
return { flatTitle: staticTitle, title: staticTitle };
|
||||
} else {
|
||||
const highlightData = detailData[key];
|
||||
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||
|
||||
return {
|
||||
flatTitle,
|
||||
title: (
|
||||
<span>
|
||||
{staticTitle}
|
||||
{highlightData && (
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
marginLeft: '8px',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
| {highlightData}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeBreadcrumbs(
|
||||
showHighlight: boolean,
|
||||
key: string,
|
||||
pageProvide: ModulePageHeaderProps | any,
|
||||
detailData: any,
|
||||
) {
|
||||
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||
if (!showHighlight || staticBreadcrumbs.length === 0) {
|
||||
return pageProvide.breadcrumbs;
|
||||
} else {
|
||||
const highlightData = detailData[key];
|
||||
const breadcrumbs = [
|
||||
...staticBreadcrumbs,
|
||||
{
|
||||
type: 'link',
|
||||
label: `${highlightData}`,
|
||||
href: `${config.webUrl}/detail/${dataId}`,
|
||||
},
|
||||
];
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
const pageHeaderPropsValue = useMemo(() => {
|
||||
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
|
||||
document.title = title.flatTitle;
|
||||
const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData);
|
||||
return { ...pageHeaderProps, title: title.title, breadcrumbs: breadcrumbs };
|
||||
}, [pageHeaderProps, dataId, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]);
|
||||
|
||||
return (
|
||||
<DetailPageContext.Provider value={contextValue}>
|
||||
<CorePageContainer
|
||||
px={px}
|
||||
py={py}
|
||||
headerSlot={
|
||||
<ModulePageHeader
|
||||
customButtonProps={(action) => {
|
||||
return {
|
||||
size: 'xs',
|
||||
p: action.key === ModuleAction.CREATE ? undefined : 5,
|
||||
style: { fontSize: 12 },
|
||||
};
|
||||
}}
|
||||
actions={pageActions}
|
||||
{...pageHeaderPropsValue}
|
||||
moduleKey={moduleKey}
|
||||
titleProps={{
|
||||
fz: { base: 16, sm: 18 },
|
||||
}}
|
||||
miniTitleProps={{
|
||||
fz: { base: 'md', sm: 'lg' },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</CorePageContainer>
|
||||
</DetailPageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
import { EnterpriseIndexPageConfig, ModuleAction } from '../entities/entity';
|
||||
import { IndexPageContext } from '../hooks/use-index-page.context';
|
||||
import { CorePageContainer, PageActionProps } from '../../../components';
|
||||
@@ -70,7 +69,7 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
|
||||
icon: <Plus size={16} />,
|
||||
intent: 'primary',
|
||||
variant: 'filled',
|
||||
shortcutLabel: CREATE_SHORTCUT_LABEL,
|
||||
tooltipLabel: `${t('common:actions.create')} (${CREATE_SHORTCUT_LABEL})`,
|
||||
onClick: (key) => handleActionClick(key),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, useEffect, ReactNode } from 'react';
|
||||
import { useMemo, useState, ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
EnterpriseTranslationContext,
|
||||
} from '../hooks/use-module.context';
|
||||
import { defaultPrivileges } from '../constant/default-privilege';
|
||||
import { Forbidden } from '../../../components';
|
||||
|
||||
export interface EnterpriseModuleProviderProps<E extends BaseEntity> {
|
||||
children: ReactNode;
|
||||
@@ -47,12 +48,12 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
|
||||
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
|
||||
|
||||
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
|
||||
useEffect(() => {
|
||||
const title = config.tabTitle || t('title');
|
||||
if (title) {
|
||||
document.title = title;
|
||||
}
|
||||
}, [config.tabTitle, t]);
|
||||
// useEffect(() => {
|
||||
// const title = config.tabTitle || t('title');
|
||||
// if (title) {
|
||||
// document.title = title;
|
||||
// }
|
||||
// }, [config.tabTitle, t]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Data Service Slice (Stable refs)
|
||||
@@ -143,13 +144,7 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
|
||||
|
||||
const { ALLOW_VIEW } = configSlice.privileges;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ALLOW_VIEW) navigate('/403', { replace: true });
|
||||
}, [ALLOW_VIEW, navigate]);
|
||||
|
||||
if (!ALLOW_VIEW) {
|
||||
return null;
|
||||
}
|
||||
if (!ALLOW_VIEW) return <Forbidden homeUrl="/app" height={500} />;
|
||||
|
||||
return (
|
||||
<EnterpriseConfigContext.Provider value={configSlice}>
|
||||
|
||||
Reference in New Issue
Block a user