feat: add full page index component with pagination and actions

- Implemented FullPagePageIndex component with mock data for database clusters.
- Added pagination and bulk actions for managing database clusters.
- Created navigation context hooks for detail, edit, duplicate, and create actions.
- Introduced a new store for managing state in full-page and single-page modules.

feat: add navigation localization files

- Added English and Indonesian localization files for navigation menu items.
- Included translations for various modules including CRM, Sales, Supply Chain, and more.

feat: create system information shortcuts component

- Developed Shortcut component to display keyboard shortcuts with search functionality.
- Implemented System component to show placeholder information when system details are unavailable.
- Added localization for shortcuts and system information in English and Indonesian.

feat: implement global theme store

- Created a Zustand store for managing theme color scheme with localStorage persistence.

feat: add module page header component

- Developed ModulePageHeader component for consistent page header across modules.
- Included breadcrumb navigation, title, description, and action buttons.

feat: define default privileges for enterprise module

- Established default privileges for CRUD operations and other actions in the enterprise module.
This commit is contained in:
Firman Ramdhani
2026-07-10 22:58:55 +07:00
parent 430b231360
commit 2b8f9a9cbc
55 changed files with 2809 additions and 354 deletions
@@ -0,0 +1,310 @@
import React, { useCallback, useMemo } from 'react';
import { Title, Breadcrumbs, Anchor, Box, Text, ThemeIcon, Flex, Divider, ActionIcon, Tooltip } from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks';
import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon
import { PageActions, PageActionsProps } from '../../../components';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BreadcrumbItem {
label: string;
href?: string;
type: 'text' | 'link';
}
export interface ModulePageHeaderProps {
title?: string;
description?: React.ReactNode;
icon?: LucideIcon;
badges?: React.ReactNode;
breadcrumbs?: BreadcrumbItem[];
actions?: PageActionsProps['actions'];
showPageHeader?: boolean;
disableMinimize?: boolean;
moduleKey: string;
}
// ---------------------------------------------------------------------------
// Shared transition style applied to all animated wrappers
// ---------------------------------------------------------------------------
const TRANSITION_STYLE: React.CSSProperties = {
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
};
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface BreadcrumbBarProps {
breadcrumbs: BreadcrumbItem[];
}
function BreadcrumbBar({ breadcrumbs }: BreadcrumbBarProps) {
return (
<Breadcrumbs
style={{ flexWrap: 'wrap' }}
visibleFrom="sm"
separator={
<ChevronRight
size={12}
strokeWidth={3}
style={{
color: 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))',
}}
/>
}
>
{breadcrumbs.map((item, index) => {
const isText = item.type === 'text';
const isLast = index === breadcrumbs.length - 1;
const sharedProps = {
key: index,
c: !isLast ? ('dimmed' as const) : undefined,
size: 'xs' as const,
fw: 500,
style: {
color: isLast ? 'var(--mantine-color-text)' : undefined,
transition: 'color 0.2s ease',
letterSpacing: '0.2px',
},
};
return !isText ? (
<Anchor {...sharedProps} href={item.href}>
{item.label}
</Anchor>
) : (
<Text {...sharedProps}>{item.label}</Text>
);
})}
</Breadcrumbs>
);
}
// ---------------------------------------------------------------------------
// HeaderToggle
// ---------------------------------------------------------------------------
interface HeaderToggleProps {
isMinimized: boolean;
onToggle: () => void;
}
function HeaderToggle({ isMinimized, onToggle }: HeaderToggleProps) {
// const ToggleIcon = isMinimized ? ChevronDown : ChevronUp;
const ToggleIcon = isMinimized ? Maximize2 : Minimize2;
const label = isMinimized ? 'Expand header' : 'Collapse header';
return (
<Tooltip label={label} position="bottom-end" withArrow openDelay={400}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
radius="md"
onClick={onToggle}
aria-label={label}
style={{
opacity: 0.6,
...TRANSITION_STYLE,
}}
>
<ToggleIcon size={13} strokeWidth={2} />
</ActionIcon>
</Tooltip>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ModulePageHeader(_props: ModulePageHeaderProps) {
const {
title,
description,
icon: Icon,
badges,
breadcrumbs,
actions,
showPageHeader = true,
disableMinimize = false,
moduleKey,
} = _props;
const [isMinimized, setIsMinimized] = useLocalStorage<boolean>({
key: `page-header-minimized__${btoa(moduleKey)}`,
defaultValue: false,
getInitialValueInEffect: false,
});
if (!showPageHeader) {
return null;
}
const showToggle = !disableMinimize;
const handleToggle = useCallback(() => setIsMinimized((v) => !v), [setIsMinimized]);
const compactButtonProps = useMemo(() => () => ({ size: 'xs' as const }), []);
// -------------------------------------------------------------------------
// Compact / Toolbar mode
// -------------------------------------------------------------------------
if (isMinimized) {
return (
<Box mb={0} mt="xs" pb={6} style={TRANSITION_STYLE} className="module-page-header">
<Flex
direction="row"
justify="space-between"
align="center"
gap="sm"
wrap="nowrap"
style={{ minHeight: 36, ...TRANSITION_STYLE }}
>
{/* Left cluster: title + badges */}
<Flex gap="xs" align="center" style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
fw={600}
fz={{ base: 'lg', sm: 'xl' }}
truncate="end"
style={{
color: 'var(--mantine-color-text)',
letterSpacing: '-0.2px',
lineHeight: 1.3,
...TRANSITION_STYLE,
}}
>
{title}
</Text>
)}
{badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
</Flex>
{/* Right: actions + inline toggle (separated by divider) */}
<Flex align="center" gap="sm" style={{ flexShrink: 0 }}>
{actions && <PageActions actions={actions} customButtonProps={compactButtonProps} />}
{showToggle && (
<>
{actions && actions.length > 0 && (
<Divider orientation="vertical" style={{ height: 20, opacity: 0.4 }} />
)}
<HeaderToggle isMinimized={isMinimized} onToggle={handleToggle} />
</>
)}
</Flex>
</Flex>
</Box>
);
}
// -------------------------------------------------------------------------
// Expanded / Full mode
// -------------------------------------------------------------------------
// Helper variable agar kode lebih bersih
const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0;
return (
<Box mb={0} mt="xs" pb={18} style={TRANSITION_STYLE} className="module-page-header">
{/* 1. Top Navigation Row: Breadcrumbs & Toggle */}
{((breadcrumbs && breadcrumbs.length) || showToggle) && (
<Flex
align="center"
justify="space-between"
mb={{ base: showToggle ? 'xs' : 0, sm: hasBreadcrumbs || showToggle ? 'md' : 0 }}
>
<Box>{hasBreadcrumbs && <BreadcrumbBar breadcrumbs={breadcrumbs} />}</Box>
{showToggle && (
<Box ml="auto">
<HeaderToggle isMinimized={isMinimized} onToggle={handleToggle} />
</Box>
)}
</Flex>
)}
{/* 2. Main Header Row 3-column layout */}
<Flex
direction="row"
justify="space-between"
align={{ base: 'stretch', sm: 'flex-start' }}
gap={{ base: 'md', sm: 'md' }}
wrap="nowrap"
style={TRANSITION_STYLE}
>
<Flex gap="md" align={{ base: 'flex-start', sm: 'flex-start' }} style={{ flex: 1, minWidth: 0 }}>
{Icon && (
<ThemeIcon
w={{ base: 40, sm: 48 }}
h={{ base: 40, sm: 48 }}
radius="md"
variant="light"
color="brand"
visibleFrom="sm"
style={{
flexShrink: 0,
border: '1px solid light-dark(var(--mantine-color-brand-1), var(--mantine-color-brand-6))',
boxShadow: 'light-dark(0 4px 12px rgba(0,0,0,0.03), 0 4px 12px rgba(0,0,0,0.2))',
...TRANSITION_STYLE,
}}
>
<Icon size={24} strokeWidth={1.5} />
</ThemeIcon>
)}
{(title || badges || description) && (
<Box style={{ flex: 1, minWidth: 0, ...TRANSITION_STYLE }}>
<Flex gap="xs" align="center" wrap="wrap">
{title && (
<Title
order={2}
fw={600}
fz={{ base: 20, sm: 24 }}
lh={{ base: 1.3, sm: 1.2 }}
style={{
color: 'var(--mantine-color-text)',
letterSpacing: '-0.3px',
...TRANSITION_STYLE,
}}
>
{title}
</Title>
)}
{badges && <Box>{badges}</Box>}
</Flex>
{description && (
<Text
fz={{ base: 'xs', sm: 'sm' }}
c="dimmed"
fw={400}
lineClamp={2}
mt={4}
style={{
wordBreak: 'break-word',
...TRANSITION_STYLE,
}}
>
{description}
</Text>
)}
</Box>
)}
</Flex>
<Flex
align="center"
gap="xs"
mt={{ base: 'xs', sm: 0 }}
ml={{ base: 'lg', sm: 0 }}
style={{ flexShrink: 0, alignSelf: 'center' }}
>
{actions && <PageActions actions={actions} />}
</Flex>
</Flex>
</Box>
);
}
@@ -0,0 +1,26 @@
import { PrivilegeEntity } from '../entities/entity';
export const defaultPrivileges: PrivilegeEntity = {
ALLOW_VIEW: true,
ALLOW_CREATE: true,
ALLOW_EDIT: true,
ALLOW_DELETE: true,
ALLOW_DUPLICATE: true,
ALLOW_SAVE: true,
ALLOW_PRINT: true,
ALLOW_PRINT_COPY: true,
ALLOW_APPROVAL: true,
ALLOW_ACTIVATE: true,
ALLOW_DEACTIVATE: true,
ALLOW_CONFIRM: true,
ALLOW_CANCEL: true,
ALLOW_ROLLBACK: true,
ALLOW_HOLD: true,
ALLOW_LOGS: true,
ALLOW_NOTES: true,
};
@@ -0,0 +1 @@
export * from './default-privilege';
@@ -1,5 +1,7 @@
import { ReactNode } from 'react';
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
import type { ModulePageHeaderProps } from '../components/module-page-header';
import { PageActionsProps } from '../../../components';
// ---------------------------------------------------------------------------
// Module Constants & Base Types
@@ -87,6 +89,7 @@ export interface DraftConfig {
* @property singlePageDetailConfig Optional configuration for single-page detail modals or drawers.
*/
export interface ModuleConfigEntity<E extends BaseEntity = BaseEntity> {
_data?: E; // Fix unused generic
moduleKey: string;
webUrl: string;
apiUrl: string;
@@ -104,6 +107,7 @@ export interface ModuleConfigEntity<E extends BaseEntity = BaseEntity> {
export interface ConfigSlice<E extends BaseEntity = BaseEntity> {
config: ModuleConfigEntity<E>;
privileges: PrivilegeEntity;
}
export interface DataServiceSlice<
@@ -184,15 +188,21 @@ export interface EnterpriseFormLifecycleHooks<E extends BaseEntity, TFormData =
}
export interface EnterpriseIndexPageConfig<E extends BaseEntity = BaseEntity> {
_data?: E; // Fix unused generic
children?: ReactNode;
showPageHeader?: boolean;
useDefaultPadding?: boolean;
customHiddenActions?: (selected: E[], defaultHidden: string[]) => string[];
/** Strongly typed event handler to prevent arbitrary string usage for actions. */
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
filterDrawerContent?: ReactNode;
px?: string | number;
py?: string | number;
registerRefreshCallback?: (callback: () => void) => void;
// customHiddenActions?: (selected: E[], defaultHidden: string[]) => string[];
// /** Strongly typed event handler to prevent arbitrary string usage for actions. */
// onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
// filterDrawerContent?: ReactNode;
// registerRefreshCallback?: (callback: () => void) => void;
pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>;
// actions?: PageActionsProps['actions'];
customPageActions?: (actions: PageActionsProps['actions']) => PageActionsProps['actions'];
onClickCreate?: (key: string) => void;
}
export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends EnterpriseFormLifecycleHooks<E> {
@@ -223,3 +233,27 @@ export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> {
editMode?: 'FULL' | 'PARTIAL';
afterGetData?: (data: E) => Promise<unknown>;
}
export interface PrivilegeEntity {
ALLOW_VIEW: boolean;
ALLOW_CREATE: boolean;
ALLOW_EDIT: boolean;
ALLOW_DELETE: boolean;
ALLOW_DUPLICATE: boolean;
ALLOW_SAVE: boolean;
ALLOW_PRINT: boolean;
ALLOW_PRINT_COPY: boolean;
ALLOW_APPROVAL: boolean;
ALLOW_ACTIVATE: boolean;
ALLOW_DEACTIVATE: boolean;
ALLOW_CONFIRM: boolean;
ALLOW_CANCEL: boolean;
ALLOW_ROLLBACK: boolean;
ALLOW_HOLD: boolean;
ALLOW_LOGS: boolean;
ALLOW_NOTES: boolean;
}
@@ -2,13 +2,14 @@ import { createContext, useContext } from 'react';
import type { BaseEntity } from '@repo/core-api/data-services';
export interface IndexPageContextValue<E extends BaseEntity = BaseEntity> {
renderRowActions: (row: E) => React.ReactNode;
refreshGrid: () => void;
// State for batch modals
isConfirmModalOpen: boolean;
setIsConfirmModalOpen: (open: boolean) => void;
isDeleteModalOpen: boolean;
setIsDeleteModalOpen: (open: boolean) => void;
_data?: E; // Fix unused generic
// renderRowActions: (row: E) => React.ReactNode;
// refreshGrid: () => void;
// // State for batch modals
// isConfirmModalOpen: boolean;
// setIsConfirmModalOpen: (open: boolean) => void;
// isDeleteModalOpen: boolean;
// setIsDeleteModalOpen: (open: boolean) => void;
}
export const IndexPageContext = createContext<IndexPageContextValue<any> | null>(null);
@@ -1,5 +1,9 @@
export * from './constant/';
export * from './entities/entity';
export * from './hooks/use-module.context';
export * from './providers/module.provider';
export * from './providers/index-page.provider';
export * from './components/module-page-header';
@@ -0,0 +1,97 @@
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';
import { ModulePageHeader } from '../components/module-page-header';
import { useCallback, useEffect, useMemo } from 'react';
import { Plus } from 'lucide-react';
import {
useEnterpriseModuleConfigContext,
useEnterpriseModuleNavigationContext,
useEnterpriseModuleTranslationContext,
} from '../hooks/use-module.context';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Detect macOS / iOS for displaying platform-specific shortcut labels. */
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
/** Platform-aware shortcut label for the Create action. */
const CREATE_SHORTCUT_LABEL = IS_MAC ? '⇧⌘N' : 'Ctrl+Shift+N';
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function EnterpriseIndexPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseIndexPageConfig<E>) {
const { children, pageHeaderProps, px, py, customPageActions, onClickCreate } = props;
const { t } = useEnterpriseModuleTranslationContext();
const navigation = useEnterpriseModuleNavigationContext();
const { config, privileges } = useEnterpriseModuleConfigContext();
const { moduleKey } = config;
const { ALLOW_CREATE } = privileges;
// Stable reference so the useEffect doesn't re-attach on every render.
const handleActionClick = useCallback(
(key: string) => {
if (key === ModuleAction.CREATE && ALLOW_CREATE) {
if (onClickCreate) onClickCreate(key);
else navigation.navigateToCreate();
}
},
[onClickCreate, navigation, ALLOW_CREATE],
);
// -------------------------------------------------------------------------
// Global keyboard shortcut: Ctrl+Shift+N / ⌘+Shift+N → Create
// -------------------------------------------------------------------------
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]);
// -------------------------------------------------------------------------
// Action definitions
// -------------------------------------------------------------------------
const pageActions = useMemo(() => {
const actions: PageActionProps[] = [];
if (ALLOW_CREATE) {
actions.push({
key: ModuleAction.CREATE,
label: t('common:actions.create'),
icon: <Plus size={16} />,
intent: 'primary',
variant: 'filled',
shortcutLabel: CREATE_SHORTCUT_LABEL,
onClick: (key) => handleActionClick(key),
});
}
return customPageActions ? customPageActions(actions) : (actions as any[]);
}, [t, customPageActions, handleActionClick, ALLOW_CREATE]);
return (
<IndexPageContext.Provider value={{}}>
<CorePageContainer
px={px}
py={py}
headerSlot={<ModulePageHeader actions={pageActions} {...pageHeaderProps} moduleKey={moduleKey} />}
>
{children}
</CorePageContainer>
</IndexPageContext.Provider>
);
}
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from '@repo/core-i18n';
import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
import { ModuleConfigEntity, SinglePageFormState, SinglePageModalState } from '../entities/entity';
import { ConfigSlice, ModuleConfigEntity, SinglePageFormState, SinglePageModalState } from '../entities/entity';
import {
EnterpriseConfigContext,
EnterpriseDataServiceContext,
@@ -12,6 +12,7 @@ import {
EnterpriseModalContext,
EnterpriseTranslationContext,
} from '../hooks/use-module.context';
import { defaultPrivileges } from '../constant/default-privilege';
export interface EnterpriseModuleProviderProps<E extends BaseEntity> {
children: ReactNode;
@@ -26,15 +27,18 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
// ---------------------------------------------------------------------------
// 1. Config Slice (Static)
// ---------------------------------------------------------------------------
const configSlice = useMemo(() => ({ config }), [config]);
const configSlice: ConfigSlice = useMemo(() => {
return {
config,
// FIXME => IMPLEMENT PRIVILEGE
privileges: defaultPrivileges,
};
}, [config]);
// ---------------------------------------------------------------------------
// 1b. Translation Slice (Dedicated context — decoupled from config)
// ---------------------------------------------------------------------------
const namespaces = useMemo(
() => [config.translationNamespace, 'common'],
[config.translationNamespace],
);
const namespaces = useMemo(() => [config.translationNamespace, 'common'], [config.translationNamespace]);
const { t } = useTranslation(namespaces);
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
@@ -132,6 +136,17 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
const { ALLOW_VIEW } = configSlice.privileges;
useEffect(() => {
if (!ALLOW_VIEW) navigate('/403', { replace: true });
}, [ALLOW_VIEW, navigate]);
if (!ALLOW_VIEW) {
return null;
}
return (
<EnterpriseConfigContext.Provider value={configSlice}>
<EnterpriseTranslationContext.Provider value={translationSlice}>