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
@@ -1,51 +1,77 @@
import { memo, Fragment } from 'react';
import { Group, Button, Menu, Divider, ActionIcon, Box } from '@mantine/core';
import { ChevronDown, MoreVertical, X } from 'lucide-react';
import { PageAction } from './types';
import { Group, Button, Menu, Divider, ActionIcon, Box, ButtonProps, Tooltip } from '@mantine/core';
import { ChevronDown, MoreVertical } from 'lucide-react';
import { PageActionProps } from './types';
import { getIntentColor } from './utils';
export interface PageActionsProps {
/** Array of configured page-level actions. */
actions: PageAction[];
/** Optional callback triggered when the close (X) button is clicked. */
onClose?: () => void;
actions?: PageActionProps[];
customButtonProps?: (action: PageActionProps) => ButtonProps;
}
/**
* A responsive and flexible presentational component for page-level actions.
* Automatically adapts layout based on screen size:
* - Desktop: Renders a horizontal toolbar with buttons and dividers.
* - Mobile: Renders a single Menu dropdown containing all actions.
*
* @performance Wrapped in React.memo to prevent unnecessary re-renders.
* Automatically adapts layout based on screen size.
*/
export const PageActions = memo(function PageActions({ actions }: PageActionsProps) {
export const PageActions = memo(function PageActions({ actions = [], customButtonProps }: PageActionsProps) {
if (!actions || actions?.length === 0) {
return null;
}
function defaultButtonStyle(isPremiumGlow: boolean) {
return {
size: 'sm',
radius: 'md',
style: isPremiumGlow
? { boxShadow: '0 4px 14px 0 color-mix(in srgb, var(--mantine-primary-color-filled) 40%, transparent)' }
: undefined,
};
}
return (
<Box style={{ display: 'inline-flex' }}>
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
{/* --- DESKTOP VIEW --- */}
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />;
}
// Render Dropdown Menu for actions with children
const isPremiumGlow = action.intent === 'primary' && action.variant === 'filled';
// 1. Button with Dropdown (Menu.Target)
if (action.children && action.children.length > 0) {
const ButtonWithDropdown = (
<Button
variant={action.variant || 'transparent'}
color={getIntentColor(action.intent)}
leftSection={action.icon}
rightSection={<ChevronDown size={14} />}
disabled={action.disabled}
{...defaultButtonStyle(isPremiumGlow)}
{...(customButtonProps ? customButtonProps(action) : {})}
>
{action.label}
</Button>
);
return (
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target>
<Button
variant={action.variant || 'transparent'}
color={getIntentColor(action.intent)}
leftSection={action.icon}
rightSection={<ChevronDown size={14} />}
disabled={action.disabled}
size="xs"
pr="sm"
pl="sm"
>
{action.label}
</Button>
{/* Shortcuts on the main button remain hidden in the Tooltip */}
{action.shortcutLabel ? (
<Tooltip
position="bottom"
label={`${action.label} (${action.shortcutLabel})`}
withArrow
openDelay={500}
>
{ButtonWithDropdown}
</Tooltip>
) : (
ButtonWithDropdown
)}
</Menu.Target>
<Menu.Dropdown>
{action.children.map((child, childIndex) => {
@@ -69,33 +95,46 @@ export const PageActions = memo(function PageActions({ actions }: PageActionsPro
);
}
return (
// 2. Regular Button (Standalone)
const StandaloneButton = (
<Button
key={action.key}
variant={action.variant || 'transparent'}
color={getIntentColor(action.intent)}
leftSection={action.icon}
disabled={action.disabled}
onClick={() => action.onClick?.(action.key || '')}
size="xs"
pr="sm"
pl="sm"
{...defaultButtonStyle(isPremiumGlow)}
{...(customButtonProps ? customButtonProps(action) : {})}
>
{action.label}
</Button>
);
return action.shortcutLabel ? (
<Tooltip
position="bottom"
key={action.key}
label={`${action.label} (${action.shortcutLabel})`}
withArrow
openDelay={500}
>
{StandaloneButton}
</Tooltip>
) : (
<Fragment key={action.key}>{StandaloneButton}</Fragment>
);
})}
</Group>
{/* --- MOBILE VIEW (hidden on desktop devices) --- */}
{/* --- MOBILE VIEW --- */}
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
<Menu position="bottom-end" withArrow withinPortal>
<Menu.Target>
<ActionIcon variant="transparent" size="md">
<ActionIcon variant="outline" size="md">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Dropdown px={'xl'}>
{actions.map((action, index) => {
if (action.type === 'divider') {
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
@@ -116,7 +155,7 @@ export const PageActions = memo(function PageActions({ actions }: PageActionsPro
color={getIntentColor(child.intent)}
disabled={child.disabled}
onClick={() => child.onClick?.(child.key || '')}
style={{ paddingLeft: '1.5rem' }} // Indent nested items
style={{ paddingLeft: '1.5rem' }}
mt="sm"
mb="sm"
>
@@ -137,6 +176,7 @@ export const PageActions = memo(function PageActions({ actions }: PageActionsPro
onClick={() => action.onClick?.(action.key || '')}
mt="sm"
mb="sm"
fw={600}
>
{action.label}
</Menu.Item>
@@ -1,12 +1,12 @@
import { Fragment, memo } from 'react';
import { Group, ActionIcon, Tooltip, Menu, Divider, Box, Button } from '@mantine/core';
import { RowAction } from './types';
import { RowActionProps } from './types';
import { getIntentColor } from './utils';
import { MoreVertical } from 'lucide-react';
export interface RowActionsProps {
/** Array of configured row-level actions. */
actions: RowAction[];
actions: RowActionProps[];
showLabels?: boolean;
}
@@ -21,7 +21,7 @@ export const RowActions = memo(function RowActions({ actions = [], showLabels =
* Helper function to render a standalone icon button.
* Wraps the icon in a Tooltip if the configuration provides one.
*/
const renderIcon = (action: RowAction, fallbackKey: string) => {
const renderIcon = (action: RowActionProps, fallbackKey: string) => {
const actionKey = action.key || fallbackKey;
const iconBtn = (
@@ -32,13 +32,15 @@ export interface BaseAction {
* Specifically designed for toolbars, page headers, or detailed forms.
* Enforces the presence of a text label (unless type is divider) and supports button-specific visual variants.
*/
export interface PageAction extends BaseAction {
export interface PageActionProps extends BaseAction {
/** Text label displayed on the button. Required for 'action' type. */
label?: string;
/** Specifies the Mantine button variant. Defaults to 'subtle'. */
variant?: 'filled' | 'light' | 'outline' | 'default' | 'subtle' | 'transparent';
/** Nested actions rendered as a Dropdown Menu below the main button. */
children?: PageAction[];
children?: PageActionProps[];
/** Human-readable keyboard shortcut label (e.g., '⇧⌘N'). Shown in tooltip. */
shortcutLabel?: string;
}
/**
@@ -47,11 +49,11 @@ export interface PageAction extends BaseAction {
* Labels are optional (utilized inside dropdowns), supports hover tooltips,
* and allows nested action hierarchies (e.g., Kebab menus).
*/
export interface RowAction extends BaseAction {
export interface RowActionProps extends BaseAction {
/** Optional text, primarily used when rendered inside a nested menu item. */
label?: string;
/** Optional text displayed on hover. */
tooltip?: string;
/** Nested actions that will be rendered inside a dropdown menu. */
children?: RowAction[];
children?: RowActionProps[];
}
@@ -3,13 +3,24 @@ import { AppShell, Flex, Box, Text, CloseButton } from '@mantine/core';
import { CoreAppShellProvider, useCoreAppShell } from './core-app-shell-context';
import { CoreAppShellConfig, CoreAppShellSlots, CoreAppShellDimensions } from './types';
// const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
// utilityBarHeight: 32,
// headerHeight: 60,
// // sidebarWidth: 260,
// sidebarWidth: 240,
// sidebarMiniWidth: 70,
// sidebarRailWidth: 54,
// // asideWidth: 260,
// asideWidth: 240,
// };
const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
utilityBarHeight: 32,
headerHeight: 60,
sidebarWidth: 260,
sidebarMiniWidth: 80,
sidebarRailWidth: 54,
asideWidth: 260,
headerHeight: 56,
sidebarWidth: 256,
sidebarMiniWidth: 64,
sidebarRailWidth: 56,
asideWidth: 280,
};
interface CoreAppShellInnerProps {
@@ -62,7 +73,6 @@ function CoreAppShellInner({ slots = {}, children }: CoreAppShellInnerProps) {
return `calc(${dims.headerHeight}${typeof dims.headerHeight === 'number' ? 'px' : ''} + ${dims.utilityBarHeight}${typeof dims.utilityBarHeight === 'number' ? 'px' : ''})`;
}, [dims.headerHeight, dims.utilityBarHeight, showUtilityBar]);
console.log({ totalHeaderHeight, dimensions });
return (
<AppShell
layout={appShellLayout}
@@ -124,7 +134,7 @@ function CoreAppShellInner({ slots = {}, children }: CoreAppShellInnerProps) {
h="100%"
style={{
flexShrink: 0,
borderRight: '1px solid var(--mantine-color-default-border)',
borderRight: '1px solid var(--app-shell-border-color)',
}}
>
{slots.sidebarRail}
@@ -147,7 +157,7 @@ function CoreAppShellInner({ slots = {}, children }: CoreAppShellInnerProps) {
justify="space-between"
p="md"
pb="sm"
style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}
style={{ borderBottom: '1px solid var(--app-shell-border-color)' }}
>
<Text fw={700}>Menu</Text>
<CloseButton onClick={toggleMobile} size="md" aria-label="Close menu" />
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { Box, Container, Stack, ContainerProps } from '@mantine/core';
import { ReactNode, useEffect, useState } from 'react';
import { Box, Container, Stack, ContainerProps, Divider } from '@mantine/core';
export interface CorePageContainerProps extends ContainerProps {
headerSlot?: ReactNode;
@@ -9,34 +9,63 @@ export interface CorePageContainerProps extends ContainerProps {
export function CorePageContainer({
headerSlot,
stickyHeader = false,
stickyHeader = true,
children,
px = "md",
py = "md",
px = { base: 'md', sm: 'xl' },
py = { base: 'md', sm: 'lg' },
...others
}: CorePageContainerProps) {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
if (!stickyHeader) return;
const handleScroll = () => setIsScrolled(window.scrollY > 10);
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, [stickyHeader]);
return (
<Box m="calc(var(--mantine-spacing-md) * -1)">
<Stack gap={0}>
<Box
m="calc(var(--mantine-spacing-md) * -1)"
style={{
display: 'flex',
flexDirection: 'column',
minHeight: 'calc(100vh - var(--app-shell-header-offset, 0px) - var(--app-shell-footer-offset, 0px))',
}}
>
<Stack gap={0} flex={1}>
{headerSlot && (
<Box
pos={stickyHeader ? 'sticky' : 'relative'}
top={stickyHeader ? 'var(--app-shell-header-offset, 0px)' : undefined}
bg="var(--mantine-color-body)"
style={{
position: stickyHeader ? 'sticky' : 'static',
top: stickyHeader ? 'var(--app-shell-header-offset, 0px)' : undefined,
zIndex: stickyHeader ? 10 : undefined,
backgroundColor: 'var(--mantine-color-body)',
borderBottom: '1px solid var(--mantine-color-default-border)',
zIndex: stickyHeader ? 10 : 1,
boxShadow: isScrolled ? 'var(--mantine-shadow-sm)' : 'none',
transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
}}
>
<Container fluid px={px} py={py}>
<Container fluid px={px} pt={{ base: 'sm', sm: 'sm' }}>
{headerSlot}
{!isScrolled && (
<Divider
mt={0}
mb={0}
styles={{
root: { borderColor: 'light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))' },
}}
/>
)}
</Container>
</Box>
)}
<Container fluid px={px} py={py} w="100%" {...others}>
{children}
</Container>
<Box flex={1} pos="relative">
<Container fluid px={px} py={py} w="100%" h="100%" {...others}>
{children}
</Container>
</Box>
</Stack>
</Box>
);
@@ -1,11 +1,32 @@
interface ForbiddenProps {
showActionsBack?: boolean;
showActionsHome?: boolean;
onClickGoBack?(): void;
onClickBackToHome?(): void;
homeUrl?: string;
}
import { Title, Text, Button, Container, Stack, Group, Center } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
export function Forbidden({ showActionsBack = true, showActionsHome = true }: ForbiddenProps) {
export function Forbidden({
showActionsBack = true,
showActionsHome = true,
onClickGoBack,
onClickBackToHome,
homeUrl,
}: ForbiddenProps) {
const navigate = useNavigate();
function onBack() {
if (onClickGoBack) onClickGoBack();
else navigate(-1);
}
function onBackHome() {
if (onClickBackToHome) onClickBackToHome();
else if (homeUrl) navigate(homeUrl, { replace: true });
}
return (
<Container size="sm" h="100vh" pos="relative">
<Center h="100%">
@@ -26,13 +47,13 @@ export function Forbidden({ showActionsBack = true, showActionsHome = true }: Fo
{(showActionsBack || showActionsHome) && (
<Group mt="xl" justify="center">
{showActionsBack && (
<Button variant="default" size="md" onClick={() => window.history.back()}>
<Button variant="default" size="md" onClick={onBack}>
Go Back
</Button>
)}
{showActionsHome && (
<Button component="a" href="/" color="brand" size="md">
<Button color="brand" size="md" onClick={onBackHome}>
Back to Home
</Button>
)}
@@ -1,11 +1,33 @@
interface NotFoundProps {
showActionsBack?: boolean;
showActionsHome?: boolean;
onClickGoBack?(): void;
onClickBackToHome?(): void;
homeUrl?: string;
}
import { Title, Text, Button, Container, Stack, Group, Center } from '@mantine/core';
import { useNavigate } from 'react-router-dom';
export function NotFound({
showActionsBack = true,
showActionsHome = true,
onClickGoBack,
onClickBackToHome,
homeUrl,
}: NotFoundProps) {
const navigate = useNavigate();
function onBack() {
if (onClickGoBack) onClickGoBack();
else navigate(-1);
}
function onBackHome() {
if (onClickBackToHome) onClickBackToHome();
else if (homeUrl) navigate(homeUrl, { replace: true });
}
export function NotFound({ showActionsBack = true, showActionsHome = true }: NotFoundProps) {
return (
<Container size="sm" h="100vh" pos="relative">
<Center h="100%">
@@ -25,13 +47,13 @@ export function NotFound({ showActionsBack = true, showActionsHome = true }: Not
{(showActionsBack || showActionsHome) && (
<Group mt="xl" justify="center">
{showActionsBack && (
<Button variant="default" size="md" onClick={() => window.history.back()}>
<Button variant="default" size="md" onClick={onBack}>
Go Back
</Button>
)}
{showActionsHome && (
<Button component="a" href="/" color="brand" size="md">
<Button color="brand" size="md" onClick={onBackHome}>
Back to Home
</Button>
)}