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:
Firman Ramdhani
2026-07-15 15:28:58 +07:00
parent 383e2627e0
commit 7ce4ced7d6
24 changed files with 741 additions and 208 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
<title>Vite + React</title> <title>Applications</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -7,7 +7,6 @@ import {
Text, Text,
Group, Group,
Button, Button,
Divider,
TextInput, TextInput,
Pagination, Pagination,
Select, Select,
@@ -16,7 +15,7 @@ import {
import { PageActions } from '@repo/ui/components'; import { PageActions } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useEnterpriseModuleNavigationContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, useEnterpriseModuleNavigationContext } from '@repo/ui/foundations';
import { FullPageEntity } from '../../domain/entities'; import { FullPageEntity } from '../../domain/entities';
import { Edit2, Eye, Copy, Trash2, Search, Filter, Plus, CheckCircle2, Ban } from 'lucide-react'; import { Edit2, Eye, Copy, Trash2, Search, Filter, CheckCircle2, Ban } from 'lucide-react';
// TODO: Replace this mock data with real API data loaded from DataService via useEnterpriseModuleDataServiceContext // TODO: Replace this mock data with real API data loaded from DataService via useEnterpriseModuleDataServiceContext
const MOCK_DATA: FullPageEntity[] = Array.from({ length: 50 }).map((_, i) => ({ const MOCK_DATA: FullPageEntity[] = Array.from({ length: 50 }).map((_, i) => ({
@@ -29,8 +28,7 @@ const MOCK_DATA: FullPageEntity[] = Array.from({ length: 50 }).map((_, i) => ({
export function DataTable() { export function DataTable() {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { navigateToDetail, navigateToEdit, navigateToDuplicate, navigateToCreate } = const { navigateToDetail, navigateToEdit, navigateToDuplicate } = useEnterpriseModuleNavigationContext();
useEnterpriseModuleNavigationContext();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
@@ -1,5 +1,8 @@
{ {
"title": "Full Page", "title": "Full Page",
"detail_page_title": "Detail Full Page",
"create_page_title": "New Full Page",
"duplicate_page_title": "Duplicate Full Page",
"description": "An example module of a <1>full page layout</1> for detailed forms.", "description": "An example module of a <1>full page layout</1> for detailed forms.",
"fields": { "fields": {
"status": "Status", "status": "Status",
@@ -1,6 +1,9 @@
{ {
"title": "Halaman Penuh", "title": "Halaman Penuh",
"description": "Contoh modul <1>tata letak halaman penuh</1> untuk formulir detail.", "detail_page_title": "Detail Halaman Penuh",
"create_page_title": "Buat Halaman Penuh Baru",
"duplicate_page_title": "Duplikat Halaman Penuh",
"description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.",
"fields": { "fields": {
"status": "Status", "status": "Status",
"name": "Nama", "name": "Nama",
@@ -0,0 +1,131 @@
import { Paper, SimpleGrid, Box, Text, Group, Badge, Divider, Breadcrumbs, Anchor, Flex, Title, ActionIcon, Button } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { ChevronRight, CheckCircle2, Trash2, Edit2, Play } from 'lucide-react';
export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<Box pb="xl">
{/* --- INLINED PAGE HEADER --- */}
<Box mb="xl" mt="xs">
<Breadcrumbs
mb="md"
separator={<ChevronRight size={12} strokeWidth={3} style={{ color: 'var(--mantine-color-gray-5)' }} />}
>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Acme Corp
</Anchor>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Infrastructure
</Anchor>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Database Clusters
</Anchor>
<Text size="xs" fw={600} style={{ color: 'light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0))', letterSpacing: '0.2px' }}>
prod-db-01
</Text>
</Breadcrumbs>
<Group justify="space-between" align="center" wrap="nowrap">
<Flex gap="md" align="center">
<Box>
<Group gap="xs" align="center" mb={4}>
<Title
order={2}
fw={600}
style={{
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
letterSpacing: '-0.3px',
}}
>
prod-db-01
</Title>
<Badge
variant="light"
color="teal"
size="sm"
radius="sm"
fw={600}
leftSection={<CheckCircle2 size={10} strokeWidth={3} />}
style={{ textTransform: 'capitalize' }}
>
Running
</Badge>
</Group>
<Text size="sm" c="dimmed" fw={400}>
PostgreSQL v15.4 · <Text span fw={500} c="var(--mantine-color-text)">us-east-1</Text> · Created Oct 12, 2023
</Text>
</Box>
</Flex>
<Group gap="sm">
<ActionIcon variant="light" color="red" size="lg" radius="md" aria-label="Delete">
<Trash2 size={18} strokeWidth={2} />
</ActionIcon>
<Button
variant="default"
radius="md"
leftSection={<Edit2 size={16} strokeWidth={2} style={{ color: 'var(--mantine-color-dimmed)' }} />}
>
Edit
</Button>
<Button
variant="filled"
color="indigo"
radius="md"
leftSection={<Play size={16} strokeWidth={2.5} />}
style={{ boxShadow: '0 4px 14px 0 rgba(76, 110, 245, 0.39)' }}
>
Restart
</Button>
</Group>
</Group>
<Divider mt={28} mb={0} color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
</Box>
{/* --- END INLINED PAGE HEADER --- */}
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text size="lg" fw={600} mb="xs" style={{ color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))', letterSpacing: '-0.3px' }}>
General Information
</Text>
<Text size="sm" c="dimmed" mb="xl">
View the complete details and configuration for this entity.
</Text>
<Divider mb="xl" color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xl" verticalSpacing="xl">
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.code')}
</Text>
<Text size="sm" fw={500}>WID-001</Text>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.name')}
</Text>
<Text size="sm" fw={500}>Dashboard Widget</Text>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.status')}
</Text>
<Badge variant="light" color="teal" size="sm" radius="sm" fw={600}>
ACTIVE
</Badge>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.description')}
</Text>
<Text size="sm" fw={500}>Main dashboard widget</Text>
</Box>
</SimpleGrid>
</Paper>
</Box>
);
}
@@ -1,131 +1,19 @@
import { Paper, SimpleGrid, Box, Text, Group, Badge, Divider, Breadcrumbs, Anchor, Flex, Title, ActionIcon, Button } from '@repo/ui/components'; import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { FullPageModuleConfig } from '../../domain/constants';
import { ChevronRight, CheckCircle2, Trash2, Edit2, Play } from 'lucide-react';
export default function FullPagePageDetail() { export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
return ( return (
<Box pb="xl"> <EnterpriseDetailPageProvider
{/* --- INLINED PAGE HEADER --- */} pageHeaderProps={{
<Box mb="xl" mt="xs"> title: t('detail_page_title'),
<Breadcrumbs
mb="md"
separator={<ChevronRight size={12} strokeWidth={3} style={{ color: 'var(--mantine-color-gray-5)' }} />}
>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Acme Corp
</Anchor>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Infrastructure
</Anchor>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Database Clusters
</Anchor>
<Text size="xs" fw={600} style={{ color: 'light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0))', letterSpacing: '0.2px' }}>
prod-db-01
</Text>
</Breadcrumbs>
<Group justify="space-between" align="center" wrap="nowrap"> breadcrumbs: [
<Flex gap="md" align="center"> { label: t('nav:example-module'), type: 'text' },
<Box> { label: t('nav:example-full-page'), type: 'link', href: `${FullPageModuleConfig.webUrl}/index` },
<Group gap="xs" align="center" mb={4}> ],
<Title }}
order={2} ></EnterpriseDetailPageProvider>
fw={600}
style={{
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
letterSpacing: '-0.3px',
}}
>
prod-db-01
</Title>
<Badge
variant="light"
color="teal"
size="sm"
radius="sm"
fw={600}
leftSection={<CheckCircle2 size={10} strokeWidth={3} />}
style={{ textTransform: 'capitalize' }}
>
Running
</Badge>
</Group>
<Text size="sm" c="dimmed" fw={400}>
PostgreSQL v15.4 · <Text span fw={500} c="var(--mantine-color-text)">us-east-1</Text> · Created Oct 12, 2023
</Text>
</Box>
</Flex>
<Group gap="sm">
<ActionIcon variant="light" color="red" size="lg" radius="md" aria-label="Delete">
<Trash2 size={18} strokeWidth={2} />
</ActionIcon>
<Button
variant="default"
radius="md"
leftSection={<Edit2 size={16} strokeWidth={2} style={{ color: 'var(--mantine-color-dimmed)' }} />}
>
Edit
</Button>
<Button
variant="filled"
color="indigo"
radius="md"
leftSection={<Play size={16} strokeWidth={2.5} />}
style={{ boxShadow: '0 4px 14px 0 rgba(76, 110, 245, 0.39)' }}
>
Restart
</Button>
</Group>
</Group>
<Divider mt={28} mb={0} color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
</Box>
{/* --- END INLINED PAGE HEADER --- */}
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text size="lg" fw={600} mb="xs" style={{ color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))', letterSpacing: '-0.3px' }}>
General Information
</Text>
<Text size="sm" c="dimmed" mb="xl">
View the complete details and configuration for this entity.
</Text>
<Divider mb="xl" color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xl" verticalSpacing="xl">
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.code')}
</Text>
<Text size="sm" fw={500}>WID-001</Text>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.name')}
</Text>
<Text size="sm" fw={500}>Dashboard Widget</Text>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.status')}
</Text>
<Badge variant="light" color="teal" size="sm" radius="sm" fw={600}>
ACTIVE
</Badge>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.description')}
</Text>
<Text size="sm" fw={500}>Main dashboard widget</Text>
</Box>
</SimpleGrid>
</Paper>
</Box>
); );
} }
@@ -2,7 +2,6 @@ import { EnterpriseIndexPageProvider, useEnterpriseModuleTranslationContext } fr
import { Trans } from '@repo/core-i18n'; import { Trans } from '@repo/core-i18n';
import { Badge, Text } from '@repo/ui/components'; import { Badge, Text } from '@repo/ui/components';
import { Activity, LayoutDashboard } from 'lucide-react'; import { Activity, LayoutDashboard } from 'lucide-react';
import { FullPageModuleConfig } from '../../domain/constants';
import { DataTable } from '../components/data-table'; import { DataTable } from '../components/data-table';
export default function FullPagePageIndex() { export default function FullPagePageIndex() {
@@ -37,7 +36,7 @@ export default function FullPagePageIndex() {
icon: LayoutDashboard, icon: LayoutDashboard,
breadcrumbs: [ breadcrumbs: [
{ label: t('nav:example-module'), type: 'text' }, { label: t('nav:example-module'), type: 'text' },
{ label: t('nav:example-full-page'), type: 'link', href: `${FullPageModuleConfig.webUrl}/index` }, { label: t('nav:example-full-page'), type: 'text' },
], ],
}} }}
> >
@@ -10,6 +10,7 @@ import type { IDataTransformer } from './base-data.transformer';
*/ */
export interface BaseEntity { export interface BaseEntity {
id?: string; id?: string;
[key: string]: any;
} }
// ─── API URL Map ──────────────────────────────────────────────── // ─── API URL Map ────────────────────────────────────────────────
@@ -52,6 +52,8 @@
"activate": "Activate", "activate": "Activate",
"deactivate": "Deactivate", "deactivate": "Deactivate",
"duplicate": "Duplicate", "duplicate": "Duplicate",
"rollback": "Rollback",
"hold": "Hold",
"back": "Back", "back": "Back",
"reload": "Reload" "reload": "Reload"
}, },
@@ -52,6 +52,8 @@
"activate": "Aktifkan", "activate": "Aktifkan",
"deactivate": "Nonaktifkan", "deactivate": "Nonaktifkan",
"duplicate": "Duplikat", "duplicate": "Duplikat",
"rollback": "Rollback",
"hold": "Hold",
"back": "Kembali", "back": "Kembali",
"reload": "Muat Ulang" "reload": "Muat Ulang"
}, },
@@ -60,13 +60,8 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover"> <Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
<Menu.Target> <Menu.Target>
{/* Shortcuts on the main button remain hidden in the Tooltip */} {/* Shortcuts on the main button remain hidden in the Tooltip */}
{action.shortcutLabel ? ( {action.tooltipLabel ? (
<Tooltip <Tooltip position="bottom" label={`${action.tooltipLabel}`} withArrow openDelay={500}>
position="bottom"
label={`${action.label} (${action.shortcutLabel})`}
withArrow
openDelay={500}
>
{ButtonWithDropdown} {ButtonWithDropdown}
</Tooltip> </Tooltip>
) : ( ) : (
@@ -110,14 +105,8 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
</Button> </Button>
); );
return action.shortcutLabel ? ( return action.tooltipLabel ? (
<Tooltip <Tooltip position="bottom" key={action.key} label={`${action.tooltipLabel}`} withArrow openDelay={500}>
position="bottom"
key={action.key}
label={`${action.label} (${action.shortcutLabel})`}
withArrow
openDelay={500}
>
{StandaloneButton} {StandaloneButton}
</Tooltip> </Tooltip>
) : ( ) : (
@@ -39,8 +39,9 @@ export interface PageActionProps extends BaseAction {
variant?: 'filled' | 'light' | 'outline' | 'default' | 'subtle' | 'transparent'; variant?: 'filled' | 'light' | 'outline' | 'default' | 'subtle' | 'transparent';
/** Nested actions rendered as a Dropdown Menu below the main button. */ /** Nested actions rendered as a Dropdown Menu below the main button. */
children?: PageActionProps[]; children?: PageActionProps[];
/** Human-readable keyboard shortcut label (e.g., '⇧⌘N'). Shown in tooltip. */
shortcutLabel?: string; /** Human-readable keyboard tooltip label (e.g., '⇧⌘N'). Shown in tooltip. */
tooltipLabel?: string;
} }
/** /**
@@ -1,12 +1,13 @@
interface ComingSoonProps { interface ComingSoonProps {
showActions?: boolean; showActions?: boolean;
height?: StyleProp<React.CSSProperties['height']>;
} }
import { Title, Text, Button, Container, Stack, Center } from '@mantine/core'; import { Title, Text, Button, Container, Stack, Center, StyleProp } from '@mantine/core';
export function ComingSoon({ showActions = false }: ComingSoonProps) { export function ComingSoon({ showActions = false, height = '100vh' }: ComingSoonProps) {
return ( return (
<Container size="sm" h="100vh" pos="relative"> <Container size="sm" h={height} pos="relative">
<Center h="100%"> <Center h="100%">
<Stack align="center" ta="center" gap="md"> <Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)"> <Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -22,7 +23,7 @@ export function ComingSoon({ showActions = false }: ComingSoonProps) {
</Text> </Text>
{showActions && ( {showActions && (
<Button component="a" href="/" color="brand" size="md" mt="xl"> <Button component="a" href="/" color="brand" size="sm" mt="xl">
Back to Home Back to Home
</Button> </Button>
)} )}
@@ -4,9 +4,10 @@ interface ForbiddenProps {
onClickGoBack?(): void; onClickGoBack?(): void;
onClickBackToHome?(): void; onClickBackToHome?(): void;
homeUrl?: string; homeUrl?: string;
height?: StyleProp<React.CSSProperties['height']>;
} }
import { Title, Text, Button, Container, Stack, Group, Center } from '@mantine/core'; import { Title, Text, Button, Container, Stack, Group, Center, StyleProp } from '@mantine/core';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
export function Forbidden({ export function Forbidden({
@@ -15,6 +16,7 @@ export function Forbidden({
onClickGoBack, onClickGoBack,
onClickBackToHome, onClickBackToHome,
homeUrl, homeUrl,
height = '100vh',
}: ForbiddenProps) { }: ForbiddenProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -28,7 +30,7 @@ export function Forbidden({
else if (homeUrl) navigate(homeUrl, { replace: true }); else if (homeUrl) navigate(homeUrl, { replace: true });
} }
return ( return (
<Container size="sm" h="100vh" pos="relative"> <Container size="sm" h={height} pos="relative">
<Center h="100%"> <Center h="100%">
<Stack align="center" ta="center" gap="md"> <Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)"> <Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -47,13 +49,13 @@ export function Forbidden({
{(showActionsBack || showActionsHome) && ( {(showActionsBack || showActionsHome) && (
<Group mt="xl" justify="center"> <Group mt="xl" justify="center">
{showActionsBack && ( {showActionsBack && (
<Button variant="default" size="md" onClick={onBack}> <Button variant="default" size="sm" onClick={onBack}>
Go Back Go Back
</Button> </Button>
)} )}
{showActionsHome && ( {showActionsHome && (
<Button color="brand" size="md" onClick={onBackHome}> <Button color="brand" size="sm" onClick={onBackHome}>
Back to Home Back to Home
</Button> </Button>
)} )}
@@ -1,12 +1,13 @@
interface MaintenanceProps { interface MaintenanceProps {
showActions?: boolean; showActions?: boolean;
height?: StyleProp<React.CSSProperties['height']>;
} }
import { Title, Text, Button, Container, Stack, Center } from '@mantine/core'; import { Title, Text, Button, Container, Stack, Center, StyleProp } from '@mantine/core';
export function Maintenance({ showActions = false }: MaintenanceProps) { export function Maintenance({ showActions = false, height = '100vh' }: MaintenanceProps) {
return ( return (
<Container size="sm" h="100vh" pos="relative"> <Container size="sm" h={height} pos="relative">
<Center h="100%"> <Center h="100%">
<Stack align="center" ta="center" gap="md"> <Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)"> <Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -23,7 +24,7 @@ export function Maintenance({ showActions = false }: MaintenanceProps) {
</Text> </Text>
{showActions && ( {showActions && (
<Button component="a" href="/" color="brand" size="md" mt="xl"> <Button component="a" href="/" color="brand" size="sm" mt="xl">
Back to Home Back to Home
</Button> </Button>
)} )}
@@ -4,9 +4,10 @@ interface NotFoundProps {
onClickGoBack?(): void; onClickGoBack?(): void;
onClickBackToHome?(): void; onClickBackToHome?(): void;
homeUrl?: string; homeUrl?: string;
height?: StyleProp<React.CSSProperties['height']>;
} }
import { Title, Text, Button, Container, Stack, Group, Center } from '@mantine/core'; import { Title, Text, Button, Container, Stack, Group, Center, StyleProp } from '@mantine/core';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
export function NotFound({ export function NotFound({
@@ -15,6 +16,7 @@ export function NotFound({
onClickGoBack, onClickGoBack,
onClickBackToHome, onClickBackToHome,
homeUrl, homeUrl,
height = '100vh',
}: NotFoundProps) { }: NotFoundProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -29,7 +31,7 @@ export function NotFound({
} }
return ( return (
<Container size="sm" h="100vh" pos="relative"> <Container size="sm" h={height} pos="relative">
<Center h="100%"> <Center h="100%">
<Stack align="center" ta="center" gap="md"> <Stack align="center" ta="center" gap="md">
<Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)"> <Text size="sm" fw={500} tt="uppercase" c="dimmed" lts="var(--mantine-spacing-xs)">
@@ -47,13 +49,13 @@ export function NotFound({
{(showActionsBack || showActionsHome) && ( {(showActionsBack || showActionsHome) && (
<Group mt="xl" justify="center"> <Group mt="xl" justify="center">
{showActionsBack && ( {showActionsBack && (
<Button variant="default" size="md" onClick={onBack}> <Button variant="default" size="sm" onClick={onBack}>
Go Back Go Back
</Button> </Button>
)} )}
{showActionsHome && ( {showActionsHome && (
<Button color="brand" size="md" onClick={onBackHome}> <Button color="brand" size="sm" onClick={onBackHome}>
Back to Home Back to Home
</Button> </Button>
)} )}
@@ -1,5 +1,17 @@
import React, { useCallback, useMemo } from 'react'; import React, { ReactNode, useCallback, useMemo } from 'react';
import { Title, Breadcrumbs, Anchor, Box, Text, ThemeIcon, Flex, Divider, ActionIcon, Tooltip } from '@mantine/core'; import {
Title,
TitleProps,
Breadcrumbs,
Anchor,
Box,
Text,
ThemeIcon,
Flex,
Divider,
ActionIcon,
Tooltip,
} from '@mantine/core';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon import { ChevronRight, LucideIcon, Maximize2, Minimize2 } from 'lucide-react'; // <-- Update Import Icon
import { PageActions, PageActionsProps } from '../../../components'; import { PageActions, PageActionsProps } from '../../../components';
@@ -15,15 +27,20 @@ export interface BreadcrumbItem {
} }
export interface ModulePageHeaderProps { export interface ModulePageHeaderProps {
title?: string; title?: ReactNode;
titleProps?: Omit<TitleProps, 'children'>;
miniTitleProps?: Omit<TitleProps, 'children'>;
description?: React.ReactNode; description?: React.ReactNode;
icon?: LucideIcon; icon?: LucideIcon;
badges?: React.ReactNode; badges?: React.ReactNode;
breadcrumbs?: BreadcrumbItem[]; breadcrumbs?: BreadcrumbItem[];
actions?: PageActionsProps['actions'];
showPageHeader?: boolean; showPageHeader?: boolean;
disableMinimize?: boolean; disableMinimize?: boolean;
moduleKey: string; moduleKey: string;
actions?: PageActionsProps['actions'];
customButtonProps?: PageActionsProps['customButtonProps'];
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -46,7 +63,7 @@ function BreadcrumbBar({ breadcrumbs }: BreadcrumbBarProps) {
return ( return (
<Breadcrumbs <Breadcrumbs
style={{ flexWrap: 'wrap' }} style={{ flexWrap: 'wrap' }}
visibleFrom="sm" // visibleFrom="xs"
separator={ separator={
<ChevronRight <ChevronRight
size={12} size={12}
@@ -126,11 +143,14 @@ function HeaderToggle({ isMinimized, onToggle }: HeaderToggleProps) {
export function ModulePageHeader(_props: ModulePageHeaderProps) { export function ModulePageHeader(_props: ModulePageHeaderProps) {
const { const {
title, title,
titleProps,
miniTitleProps,
description, description,
icon: Icon, icon: Icon,
badges, badges,
breadcrumbs, breadcrumbs,
actions, actions,
customButtonProps,
showPageHeader = true, showPageHeader = true,
disableMinimize = false, disableMinimize = false,
moduleKey, moduleKey,
@@ -148,7 +168,13 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
const showToggle = !disableMinimize; const showToggle = !disableMinimize;
const handleToggle = useCallback(() => setIsMinimized((v) => !v), [setIsMinimized]); const handleToggle = useCallback(() => setIsMinimized((v) => !v), [setIsMinimized]);
const compactButtonProps = useMemo(() => () => ({ size: 'xs' as const }), []); const compactButtonProps = useMemo(
() => (action: any) => {
if (customButtonProps) return customButtonProps(action);
return { size: 'xs' as const };
},
[customButtonProps],
);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Compact / Toolbar mode // Compact / Toolbar mode
@@ -167,19 +193,21 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
{/* Left cluster: title + badges */} {/* Left cluster: title + badges */}
<Flex gap="xs" align="center" style={{ flex: 1, minWidth: 0 }}> <Flex gap="xs" align="center" style={{ flex: 1, minWidth: 0 }}>
{title && ( {title && (
<Text <Title
fw={600} fw={600}
fz={{ base: 'lg', sm: 'xl' }} fz={{ base: 'lg', sm: 'xl' }}
truncate="end" lineClamp={1}
{...(miniTitleProps ?? {})}
style={{ style={{
color: 'var(--mantine-color-text)', color: 'var(--mantine-color-text)',
letterSpacing: '-0.2px', letterSpacing: '-0.2px',
lineHeight: 1.3, lineHeight: 1.3,
...TRANSITION_STYLE, ...TRANSITION_STYLE,
...miniTitleProps?.style,
}} }}
> >
{title} {title}
</Text> </Title>
)} )}
{badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>} {badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
</Flex> </Flex>
@@ -262,12 +290,15 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
<Title <Title
order={2} order={2}
fw={600} fw={600}
lineClamp={1}
fz={{ base: 20, sm: 24 }} fz={{ base: 20, sm: 24 }}
lh={{ base: 1.3, sm: 1.2 }} lh={{ base: 1.3, sm: 1.2 }}
{...(titleProps ?? {})}
style={{ style={{
color: 'var(--mantine-color-text)', color: 'var(--mantine-color-text)',
letterSpacing: '-0.3px', letterSpacing: '-0.3px',
...TRANSITION_STYLE, ...TRANSITION_STYLE,
...titleProps?.style,
}} }}
> >
{title} {title}
@@ -302,7 +333,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
ml={{ base: 'lg', sm: 0 }} ml={{ base: 'lg', sm: 0 }}
style={{ flexShrink: 0, alignSelf: 'center' }} style={{ flexShrink: 0, alignSelf: 'center' }}
> >
{actions && <PageActions actions={actions} />} {actions && <PageActions actions={actions} customButtonProps={customButtonProps} />}
</Flex> </Flex>
</Flex> </Flex>
</Box> </Box>
@@ -6,8 +6,6 @@ export const defaultPrivileges: PrivilegeEntity = {
ALLOW_CREATE: true, ALLOW_CREATE: true,
ALLOW_EDIT: true, ALLOW_EDIT: true,
ALLOW_DELETE: true, ALLOW_DELETE: true,
ALLOW_DUPLICATE: true,
ALLOW_SAVE: true,
ALLOW_PRINT: true, ALLOW_PRINT: true,
ALLOW_PRINT_COPY: true, ALLOW_PRINT_COPY: true,
@@ -190,12 +190,13 @@ export interface EnterpriseFormLifecycleHooks<E extends BaseEntity, TFormData =
afterGetData?: (data: E) => Promise<TFormData>; afterGetData?: (data: E) => Promise<TFormData>;
} }
export interface EnterpriseIndexPageConfig { interface BasePageConfig {
children?: ReactNode; children?: ReactNode;
px?: string | number; px?: string | number;
py?: string | number; py?: string | number;
pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>; pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>;
}
export interface EnterpriseIndexPageConfig extends BasePageConfig {
customPageActions?: (actions: PageActionsProps['actions']) => PageActionsProps['actions']; customPageActions?: (actions: PageActionsProps['actions']) => PageActionsProps['actions'];
onClickCreate?: (key: string) => void; onClickCreate?: (key: string) => void;
} }
@@ -218,14 +219,28 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext
presetDuplicate?: (data: E) => Promise<Partial<E>>; presetDuplicate?: (data: E) => Promise<Partial<E>>;
} }
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> { export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
children?: ReactNode;
showPageHeader?: boolean;
useDefaultPadding?: boolean;
editMode?: 'FULL' | 'PARTIAL'; editMode?: 'FULL' | 'PARTIAL';
pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>;
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions']; customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
onClickCreate?: () => void;
onClickDuplicate?: (data: E) => void;
onClickEdit?: (data: E) => void;
onClickDelete?: (data: E) => void;
// Master Data Feature
onClickActivate?: (data: E) => void;
onClickDeactivate?: (data: E) => void;
// Transaction Feature
onClickConfirm?: (data: E) => void;
onClickCancel?: (data: E) => void;
onClickRollback?: (data: E) => void;
onClickHold?: (data: E) => void;
showHighlightData?: boolean;
showHighlightDataOnBreadcrumbs?: boolean;
highlightDataKey?: string;
} }
export interface PrivilegeEntity { export interface PrivilegeEntity {
@@ -233,8 +248,6 @@ export interface PrivilegeEntity {
ALLOW_CREATE: boolean; ALLOW_CREATE: boolean;
ALLOW_EDIT: boolean; ALLOW_EDIT: boolean;
ALLOW_DELETE: boolean; ALLOW_DELETE: boolean;
ALLOW_DUPLICATE: boolean;
ALLOW_SAVE: boolean;
ALLOW_PRINT: boolean; ALLOW_PRINT: boolean;
ALLOW_PRINT_COPY: boolean; ALLOW_PRINT_COPY: boolean;
@@ -6,6 +6,8 @@ export interface DetailPageContextValue<E extends BaseEntity = BaseEntity> {
isLoading: boolean; isLoading: boolean;
reload: () => Promise<void>; reload: () => Promise<void>;
isPartialEdit: boolean; isPartialEdit: boolean;
isActiveEditMode: boolean;
setIsActiveEditMode: (value: boolean) => void;
} }
export const DetailPageContext = createContext<DetailPageContextValue<any> | null>(null); export const DetailPageContext = createContext<DetailPageContextValue<any> | null>(null);
@@ -6,4 +6,5 @@ export * from './hooks/use-module.context';
export * from './providers/module.provider'; export * from './providers/module.provider';
export * from './providers/index-page.provider'; export * from './providers/index-page.provider';
export * from './providers/detail-page.provider';
export * from './components/module-page-header'; export * from './components/module-page-header';
@@ -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 { 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 { 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>) { export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) {
const { const {
children, children,
showPageHeader, editMode = 'FULL',
useDefaultPadding,
editMode,
pageHeaderProps, pageHeaderProps,
px,
py,
customPageActions, customPageActions,
onClickCreate,
onClickDuplicate,
onClickEdit,
onClickDelete,
onClickActivate,
onClickDeactivate,
onClickConfirm,
onClickCancel,
onClickRollback,
onClickHold,
showHighlightData = true,
showHighlightDataOnBreadcrumbs = true,
highlightDataKey = 'code',
} = props; } = 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 { EnterpriseIndexPageConfig, ModuleAction } from '../entities/entity';
import { IndexPageContext } from '../hooks/use-index-page.context'; import { IndexPageContext } from '../hooks/use-index-page.context';
import { CorePageContainer, PageActionProps } from '../../../components'; import { CorePageContainer, PageActionProps } from '../../../components';
@@ -70,7 +69,7 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
icon: <Plus size={16} />, icon: <Plus size={16} />,
intent: 'primary', intent: 'primary',
variant: 'filled', variant: 'filled',
shortcutLabel: CREATE_SHORTCUT_LABEL, tooltipLabel: `${t('common:actions.create')} (${CREATE_SHORTCUT_LABEL})`,
onClick: (key) => handleActionClick(key), 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 { useNavigate } from 'react-router-dom';
import { useTranslation } from '@repo/core-i18n'; import { useTranslation } from '@repo/core-i18n';
import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services'; import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
@@ -13,6 +13,7 @@ import {
EnterpriseTranslationContext, EnterpriseTranslationContext,
} from '../hooks/use-module.context'; } from '../hooks/use-module.context';
import { defaultPrivileges } from '../constant/default-privilege'; import { defaultPrivileges } from '../constant/default-privilege';
import { Forbidden } from '../../../components';
export interface EnterpriseModuleProviderProps<E extends BaseEntity> { export interface EnterpriseModuleProviderProps<E extends BaseEntity> {
children: ReactNode; 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]); 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' // Set document title — uses explicit tabTitle or falls back to translation key 'title'
useEffect(() => { // useEffect(() => {
const title = config.tabTitle || t('title'); // const title = config.tabTitle || t('title');
if (title) { // if (title) {
document.title = title; // document.title = title;
} // }
}, [config.tabTitle, t]); // }, [config.tabTitle, t]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 2. Data Service Slice (Stable refs) // 2. Data Service Slice (Stable refs)
@@ -143,13 +144,7 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise
const { ALLOW_VIEW } = configSlice.privileges; const { ALLOW_VIEW } = configSlice.privileges;
useEffect(() => { if (!ALLOW_VIEW) return <Forbidden homeUrl="/app" height={500} />;
if (!ALLOW_VIEW) navigate('/403', { replace: true });
}, [ALLOW_VIEW, navigate]);
if (!ALLOW_VIEW) {
return null;
}
return ( return (
<EnterpriseConfigContext.Provider value={configSlice}> <EnterpriseConfigContext.Provider value={configSlice}>