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:
@@ -100,12 +100,12 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('confirmProcessTransaction() resolves to /:id/confirm-data', async () => {
|
||||
await services.confirmProcessTransaction('99');
|
||||
it('confirmData() resolves to /:id/confirm', async () => {
|
||||
await services.confirmData('99');
|
||||
|
||||
expect(mockClient.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: '/bookings/99/confirm-data',
|
||||
url: '/bookings/99/confirm',
|
||||
method: 'PATCH',
|
||||
}),
|
||||
);
|
||||
@@ -525,17 +525,13 @@ describe('BaseRemoteDataServices — Data Transformer Integration', () => {
|
||||
|
||||
it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => {
|
||||
(mockClient.request as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: '1', booking_code: 'BK001', customer_name: 'Alice' },
|
||||
],
|
||||
data: [{ id: '1', booking_code: 'BK001', customer_name: 'Alice' }],
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await services.getMany();
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' },
|
||||
]);
|
||||
expect(result.data).toEqual([{ id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }]);
|
||||
});
|
||||
|
||||
it('create() uses transformCreatePayload hook (strips id)', async () => {
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"view": "View",
|
||||
"preferences": "Preferences",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"lightMode": "Light",
|
||||
"darkMode": "Dark",
|
||||
"application": "Application",
|
||||
"myProfile": "My Profile",
|
||||
"workspace": "Workspace",
|
||||
@@ -37,6 +40,44 @@
|
||||
"collapseAll": "Collapse all menu",
|
||||
"searchMenu": "Search menu",
|
||||
"collapse": "Collapse",
|
||||
"expandSidebar": "Expand Sidebar"
|
||||
"expandSidebar": "Expand Sidebar",
|
||||
"actions": {
|
||||
"create": "Create New",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"save": "Save",
|
||||
"print": "Print",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"duplicate": "Duplicate",
|
||||
"back": "Back",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"title": "Are you sure?",
|
||||
"deleteMessage": "This action cannot be undone.",
|
||||
"confirmButton": "Confirm",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"draft": {
|
||||
"recoveryTitle": "Draft Found",
|
||||
"recoveryMessage": "You have an unsaved draft from {{date}}. Would you like to continue editing?",
|
||||
"continueEditing": "Continue Editing",
|
||||
"discardDraft": "Start Fresh"
|
||||
},
|
||||
"notifications": {
|
||||
"saveSuccess": "Data saved successfully",
|
||||
"deleteSuccess": "Data deleted successfully",
|
||||
"actionSuccess": "{{action}} completed successfully",
|
||||
"actionFailed": "Failed to {{action}}: {{message}}"
|
||||
},
|
||||
"privilege": {
|
||||
"error": {
|
||||
"accessDeniedTitle": "Access Denied",
|
||||
"noCreateAccess": "You do not have the required permission to create data in this module."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
"view": "Lihat",
|
||||
"preferences": "Preferensi",
|
||||
"language": "Bahasa",
|
||||
"theme": "Tema",
|
||||
"lightMode": "Terang",
|
||||
"darkMode": "Gelap",
|
||||
"application": "Aplikasi",
|
||||
"myProfile": "Profil Saya",
|
||||
"workspace": "Ruang Kerja",
|
||||
@@ -37,6 +40,44 @@
|
||||
"collapseAll": "Tutup semua menu",
|
||||
"searchMenu": "Cari menu",
|
||||
"collapse": "Tutup",
|
||||
"expandSidebar": "Perluas Sidebar"
|
||||
"expandSidebar": "Perluas Sidebar",
|
||||
"actions": {
|
||||
"create": "Buat Baru",
|
||||
"edit": "Ubah",
|
||||
"delete": "Hapus",
|
||||
"save": "Simpan",
|
||||
"print": "Cetak",
|
||||
"confirm": "Konfirmasi",
|
||||
"cancel": "Batal",
|
||||
"activate": "Aktifkan",
|
||||
"deactivate": "Nonaktifkan",
|
||||
"duplicate": "Duplikat",
|
||||
"back": "Kembali",
|
||||
"reload": "Muat Ulang"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"title": "Apakah Anda yakin?",
|
||||
"deleteMessage": "Tindakan ini tidak dapat dibatalkan.",
|
||||
"confirmButton": "Konfirmasi",
|
||||
"cancelButton": "Batal"
|
||||
},
|
||||
"draft": {
|
||||
"recoveryTitle": "Draf Ditemukan",
|
||||
"recoveryMessage": "Anda memiliki draf yang belum disimpan dari {{date}}. Apakah Anda ingin melanjutkan?",
|
||||
"continueEditing": "Lanjutkan",
|
||||
"discardDraft": "Mulai Baru"
|
||||
},
|
||||
"notifications": {
|
||||
"saveSuccess": "Data berhasil disimpan",
|
||||
"deleteSuccess": "Data berhasil dihapus",
|
||||
"actionSuccess": "{{action}} berhasil diselesaikan",
|
||||
"actionFailed": "Gagal {{action}}: {{message}}"
|
||||
},
|
||||
"privilege": {
|
||||
"error": {
|
||||
"accessDeniedTitle": "Akses Ditolak",
|
||||
"noCreateAccess": "Anda tidak memiliki hak akses untuk menambah data di modul ini."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@mantine/core": "^8.3.15",
|
||||
"@mantine/hooks": "^8.3.15",
|
||||
"@mantine/notifications": "^8.3.15",
|
||||
"@mantine/tiptap": "^9.3.2",
|
||||
"@repo/core-api": "workspace:^",
|
||||
"@repo/core-i18n": "workspace:*",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MantineProvider, createTheme, mergeThemeOverrides } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { brandColors, errorColors, warningColors, successColors, infoColors } from '../theme/tokens/colors';
|
||||
import { brandColors, errorColors, warningColors, successColors, infoColors, darkColors } from '../theme/tokens/colors';
|
||||
import { typography } from '../theme/tokens/typography';
|
||||
import { radius } from '../theme/tokens/radius';
|
||||
import { compactDensity, standardDensity } from '../theme/tokens/density';
|
||||
@@ -21,12 +21,23 @@ const densityMap = {
|
||||
|
||||
export function ThemeProvider({ children, colorScheme = 'light', density = 'compact' }: ThemeProviderProps) {
|
||||
const baseTheme = createTheme({
|
||||
/**
|
||||
* Elegant Off-Black for Light Mode text.
|
||||
*
|
||||
* Mantine maps this value to `--mantine-color-black` and uses it as the
|
||||
* default `--mantine-color-text` in Light Mode. Pure #000000 creates
|
||||
* harsh contrast against white backgrounds, causing eye fatigue in
|
||||
* prolonged ERP usage. #1A1B1E is a warm charcoal that maintains
|
||||
* excellent readability (WCAG AAA on white) while feeling softer.
|
||||
*/
|
||||
black: '#1A1B1E',
|
||||
colors: {
|
||||
brand: brandColors,
|
||||
error: errorColors,
|
||||
warning: warningColors,
|
||||
success: successColors,
|
||||
info: infoColors,
|
||||
dark: darkColors,
|
||||
},
|
||||
primaryColor: 'brand',
|
||||
fontFamily: typography.fontFamily,
|
||||
|
||||
+24
-17
@@ -4,6 +4,7 @@
|
||||
/* Import Mantine core and TipTap extensions */
|
||||
@import '@mantine/core/styles.css';
|
||||
@import '@mantine/tiptap/styles.css';
|
||||
@import '@mantine/notifications/styles.css';
|
||||
|
||||
/* Initialize Tailwind CSS v4 engine */
|
||||
@import 'tailwindcss';
|
||||
@@ -27,9 +28,9 @@
|
||||
Mantine's 0-9 scale for seamless theming.
|
||||
Usage: `bg-brand-500`, `text-error-700`
|
||||
========================================= */
|
||||
|
||||
|
||||
/* Brand Colors */
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
--color-brand-50: var(--mantine-color-brand-0);
|
||||
--color-brand-100: var(--mantine-color-brand-1);
|
||||
--color-brand-200: var(--mantine-color-brand-2);
|
||||
--color-brand-300: var(--mantine-color-brand-3);
|
||||
@@ -41,7 +42,7 @@
|
||||
--color-brand-900: var(--mantine-color-brand-9);
|
||||
|
||||
/* Error Colors (Red/Danger) */
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
--color-error-50: var(--mantine-color-error-0);
|
||||
--color-error-100: var(--mantine-color-error-1);
|
||||
--color-error-200: var(--mantine-color-error-2);
|
||||
--color-error-300: var(--mantine-color-error-3);
|
||||
@@ -53,7 +54,7 @@
|
||||
--color-error-900: var(--mantine-color-error-9);
|
||||
|
||||
/* Warning Colors (Yellow/Orange) */
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
--color-warning-50: var(--mantine-color-warning-0);
|
||||
--color-warning-100: var(--mantine-color-warning-1);
|
||||
--color-warning-200: var(--mantine-color-warning-2);
|
||||
--color-warning-300: var(--mantine-color-warning-3);
|
||||
@@ -65,7 +66,7 @@
|
||||
--color-warning-900: var(--mantine-color-warning-9);
|
||||
|
||||
/* Success Colors (Green) */
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
--color-success-50: var(--mantine-color-success-0);
|
||||
--color-success-100: var(--mantine-color-success-1);
|
||||
--color-success-200: var(--mantine-color-success-2);
|
||||
--color-success-300: var(--mantine-color-success-3);
|
||||
@@ -77,7 +78,7 @@
|
||||
--color-success-900: var(--mantine-color-success-9);
|
||||
|
||||
/* Info Colors (Blue/Cyan) */
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
--color-info-50: var(--mantine-color-info-0);
|
||||
--color-info-100: var(--mantine-color-info-1);
|
||||
--color-info-200: var(--mantine-color-info-2);
|
||||
--color-info-300: var(--mantine-color-info-3);
|
||||
@@ -108,7 +109,7 @@
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
--breakpoint-2xl: 96rem;
|
||||
|
||||
|
||||
--container-3xs: 16rem;
|
||||
--container-2xs: 18rem;
|
||||
--container-xs: 20rem;
|
||||
@@ -130,16 +131,16 @@
|
||||
========================================= */
|
||||
--text-xs: var(--mantine-font-size-xs);
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
|
||||
|
||||
--text-sm: var(--mantine-font-size-sm);
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
|
||||
|
||||
--text-base: var(--mantine-font-size-md);
|
||||
--text-base--line-height: calc(1.5 / 1);
|
||||
|
||||
|
||||
--text-lg: var(--mantine-font-size-lg);
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
|
||||
|
||||
--text-xl: var(--mantine-font-size-xl);
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
|
||||
@@ -195,7 +196,7 @@
|
||||
--radius-md: var(--mantine-radius-md);
|
||||
--radius-lg: var(--mantine-radius-lg);
|
||||
--radius-xl: var(--mantine-radius-xl);
|
||||
|
||||
|
||||
--radius-2xl: 1rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--radius-4xl: 2rem;
|
||||
@@ -260,19 +261,25 @@
|
||||
--animate-bounce: bounce 1s infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes ping {
|
||||
75%, 100% {
|
||||
75%,
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.5; }
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
@@ -294,4 +301,4 @@
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +77,31 @@ export const grayColors: MantineColorsTuple = [
|
||||
'#1f2937',
|
||||
'#111827',
|
||||
];
|
||||
|
||||
/**
|
||||
* Elegant Dark Mode surface palette.
|
||||
*
|
||||
* Replaces Mantine's default `dark` scale which uses near-pure-black values
|
||||
* that cause excessive contrast and eye strain. This scale uses a subtle
|
||||
* bluish-charcoal (slate) undertone inspired by Tailwind's Slate palette,
|
||||
* giving dark mode a warmer, more refined feel typical of premium enterprise
|
||||
* applications (Figma, Linear, Notion).
|
||||
*
|
||||
* Index mapping in Mantine v7:
|
||||
* dark[0] → lightest text on dark bg dark[5] → surface borders
|
||||
* dark[6] → card/surface bg dark[7] → main app bg
|
||||
* dark[8] → deeper bg (sidebars) dark[9] → deepest bg
|
||||
*/
|
||||
export const darkColors: MantineColorsTuple = [
|
||||
'#C9CCD1', // 0 – Light text / captions on dark bg
|
||||
'#ADB1B8', // 1 – Secondary text
|
||||
'#8E939B', // 2 – Tertiary / placeholder text
|
||||
'#5E6370', // 3 – Subtle borders, disabled text
|
||||
'#3D4250', // 4 – Elevated borders
|
||||
'#2E3341', // 5 – Surface borders, dividers
|
||||
'#252A37', // 6 – Card / component surface
|
||||
'#1C2030', // 7 – Main app background
|
||||
'#151828', // 8 – Deep background (sidebar, nav)
|
||||
'#0F1120', // 9 – Deepest background
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user