From 7ce4ced7d627fe4bbc4b54c0e212e08d4ea065e1 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:28:58 +0700 Subject: [PATCH] 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. --- apps/web/index.html | 2 +- .../presentation/components/data-table.tsx | 6 +- .../presentation/locales/en/full-page.json | 3 + .../presentation/locales/id/full-page.json | 5 +- .../pages/full-page.page.detail copy.tsx | 131 +++++ .../pages/full-page.page.detail.tsx | 134 +---- .../pages/full-page.page.index.tsx | 3 +- packages/core-api/src/data-services/types.ts | 1 + packages/core-i18n/src/locales/en/common.json | 2 + packages/core-i18n/src/locales/id/common.json | 2 + .../components/actions-tools/page-actions.tsx | 19 +- .../ui/src/components/actions-tools/types.ts | 5 +- .../components/system-pages/coming-soon.tsx | 9 +- .../src/components/system-pages/forbidden.tsx | 10 +- .../components/system-pages/maintenance.tsx | 9 +- .../src/components/system-pages/not-found.tsx | 10 +- .../components/module-page-header.tsx | 51 +- .../constant/default-privilege.ts | 2 - .../enterprise-module/entities/entity.ts | 33 +- .../hooks/use-detail-page.context.ts | 2 + .../foundations/enterprise-module/index.ts | 1 + .../providers/detail-page.provider.tsx | 483 +++++++++++++++++- .../providers/index-page.provider.tsx | 3 +- .../providers/module.provider.tsx | 23 +- 24 files changed, 741 insertions(+), 208 deletions(-) create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail copy.tsx diff --git a/apps/web/index.html b/apps/web/index.html index 9d591cc..eed3f7d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,7 +5,7 @@ - Vite + React + Applications
diff --git a/apps/web/src/apps/modules/example/full-page/presentation/components/data-table.tsx b/apps/web/src/apps/modules/example/full-page/presentation/components/data-table.tsx index 4076718..4f88c99 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/components/data-table.tsx +++ b/apps/web/src/apps/modules/example/full-page/presentation/components/data-table.tsx @@ -7,7 +7,6 @@ import { Text, Group, Button, - Divider, TextInput, Pagination, Select, @@ -16,7 +15,7 @@ import { import { PageActions } from '@repo/ui/components'; import { useEnterpriseModuleTranslationContext, useEnterpriseModuleNavigationContext } from '@repo/ui/foundations'; 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 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() { const { t } = useEnterpriseModuleTranslationContext(); - const { navigateToDetail, navigateToEdit, navigateToDuplicate, navigateToCreate } = - useEnterpriseModuleNavigationContext(); + const { navigateToDetail, navigateToEdit, navigateToDuplicate } = useEnterpriseModuleNavigationContext(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json index 159078d..b61f453 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json +++ b/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json @@ -1,5 +1,8 @@ { "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 for detailed forms.", "fields": { "status": "Status", diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json index 7912506..e9345d3 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json +++ b/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json @@ -1,6 +1,9 @@ { "title": "Halaman Penuh", - "description": "Contoh modul <1>tata letak halaman penuh 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 untuk formulir detail.", "fields": { "status": "Status", "name": "Nama", diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail copy.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail copy.tsx new file mode 100644 index 0000000..c29969d --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail copy.tsx @@ -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 ( + + {/* --- INLINED PAGE HEADER --- */} + + } + > + + Acme Corp + + + Infrastructure + + + Database Clusters + + + prod-db-01 + + + + + + + + + prod-db-01 + + } + style={{ textTransform: 'capitalize' }} + > + Running + + + + + PostgreSQL v15.4 · us-east-1 · Created Oct 12, 2023 + + + + + + + + + + + + + + + + {/* --- END INLINED PAGE HEADER --- */} + + + + General Information + + + View the complete details and configuration for this entity. + + + + + + + + {t('fields.code')} + + WID-001 + + + + {t('fields.name')} + + Dashboard Widget + + + + {t('fields.status')} + + + ACTIVE + + + + + {t('fields.description')} + + Main dashboard widget + + + + + ); +} diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx index c29969d..f1b1bc9 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx @@ -1,131 +1,19 @@ -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'; +import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import { FullPageModuleConfig } from '../../domain/constants'; export default function FullPagePageDetail() { const { t } = useEnterpriseModuleTranslationContext(); return ( - - {/* --- INLINED PAGE HEADER --- */} - - } - > - - Acme Corp - - - Infrastructure - - - Database Clusters - - - prod-db-01 - - + - - - - - prod-db-01 - - } - style={{ textTransform: 'capitalize' }} - > - Running - - - - - PostgreSQL v15.4 · us-east-1 · Created Oct 12, 2023 - - - - - - - - - - - - - - - - {/* --- END INLINED PAGE HEADER --- */} - - - - General Information - - - View the complete details and configuration for this entity. - - - - - - - - {t('fields.code')} - - WID-001 - - - - {t('fields.name')} - - Dashboard Widget - - - - {t('fields.status')} - - - ACTIVE - - - - - {t('fields.description')} - - Main dashboard widget - - - - + breadcrumbs: [ + { label: t('nav:example-module'), type: 'text' }, + { label: t('nav:example-full-page'), type: 'link', href: `${FullPageModuleConfig.webUrl}/index` }, + ], + }} + > ); } diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx index 43fca04..a757fa8 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx @@ -2,7 +2,6 @@ import { EnterpriseIndexPageProvider, useEnterpriseModuleTranslationContext } fr import { Trans } from '@repo/core-i18n'; import { Badge, Text } from '@repo/ui/components'; import { Activity, LayoutDashboard } from 'lucide-react'; -import { FullPageModuleConfig } from '../../domain/constants'; import { DataTable } from '../components/data-table'; export default function FullPagePageIndex() { @@ -37,7 +36,7 @@ export default function FullPagePageIndex() { icon: LayoutDashboard, breadcrumbs: [ { 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' }, ], }} > diff --git a/packages/core-api/src/data-services/types.ts b/packages/core-api/src/data-services/types.ts index 9febaea..9742701 100644 --- a/packages/core-api/src/data-services/types.ts +++ b/packages/core-api/src/data-services/types.ts @@ -10,6 +10,7 @@ import type { IDataTransformer } from './base-data.transformer'; */ export interface BaseEntity { id?: string; + [key: string]: any; } // ─── API URL Map ──────────────────────────────────────────────── diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index 366eea6..c61e03c 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -52,6 +52,8 @@ "activate": "Activate", "deactivate": "Deactivate", "duplicate": "Duplicate", + "rollback": "Rollback", + "hold": "Hold", "back": "Back", "reload": "Reload" }, diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index 6b8fe60..b8642e4 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -52,6 +52,8 @@ "activate": "Aktifkan", "deactivate": "Nonaktifkan", "duplicate": "Duplikat", + "rollback": "Rollback", + "hold": "Hold", "back": "Kembali", "reload": "Muat Ulang" }, diff --git a/packages/ui/src/components/actions-tools/page-actions.tsx b/packages/ui/src/components/actions-tools/page-actions.tsx index 0fa2d34..f573388 100644 --- a/packages/ui/src/components/actions-tools/page-actions.tsx +++ b/packages/ui/src/components/actions-tools/page-actions.tsx @@ -60,13 +60,8 @@ export const PageActions = memo(function PageActions({ actions = [], customButto {/* Shortcuts on the main button remain hidden in the Tooltip */} - {action.shortcutLabel ? ( - + {action.tooltipLabel ? ( + {ButtonWithDropdown} ) : ( @@ -110,14 +105,8 @@ export const PageActions = memo(function PageActions({ actions = [], customButto ); - return action.shortcutLabel ? ( - + return action.tooltipLabel ? ( + {StandaloneButton} ) : ( diff --git a/packages/ui/src/components/actions-tools/types.ts b/packages/ui/src/components/actions-tools/types.ts index 324c3ba..abf0459 100644 --- a/packages/ui/src/components/actions-tools/types.ts +++ b/packages/ui/src/components/actions-tools/types.ts @@ -39,8 +39,9 @@ export interface PageActionProps extends BaseAction { variant?: 'filled' | 'light' | 'outline' | 'default' | 'subtle' | 'transparent'; /** Nested actions rendered as a Dropdown Menu below the main button. */ 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; } /** diff --git a/packages/ui/src/components/system-pages/coming-soon.tsx b/packages/ui/src/components/system-pages/coming-soon.tsx index 42904d1..e07164a 100644 --- a/packages/ui/src/components/system-pages/coming-soon.tsx +++ b/packages/ui/src/components/system-pages/coming-soon.tsx @@ -1,12 +1,13 @@ interface ComingSoonProps { showActions?: boolean; + height?: StyleProp; } -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 ( - +
@@ -22,7 +23,7 @@ export function ComingSoon({ showActions = false }: ComingSoonProps) { {showActions && ( - )} diff --git a/packages/ui/src/components/system-pages/forbidden.tsx b/packages/ui/src/components/system-pages/forbidden.tsx index b997382..0644a4b 100644 --- a/packages/ui/src/components/system-pages/forbidden.tsx +++ b/packages/ui/src/components/system-pages/forbidden.tsx @@ -4,9 +4,10 @@ interface ForbiddenProps { onClickGoBack?(): void; onClickBackToHome?(): void; homeUrl?: string; + height?: StyleProp; } -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'; export function Forbidden({ @@ -15,6 +16,7 @@ export function Forbidden({ onClickGoBack, onClickBackToHome, homeUrl, + height = '100vh', }: ForbiddenProps) { const navigate = useNavigate(); @@ -28,7 +30,7 @@ export function Forbidden({ else if (homeUrl) navigate(homeUrl, { replace: true }); } return ( - +
@@ -47,13 +49,13 @@ export function Forbidden({ {(showActionsBack || showActionsHome) && ( {showActionsBack && ( - )} {showActionsHome && ( - )} diff --git a/packages/ui/src/components/system-pages/maintenance.tsx b/packages/ui/src/components/system-pages/maintenance.tsx index a4308d6..3619735 100644 --- a/packages/ui/src/components/system-pages/maintenance.tsx +++ b/packages/ui/src/components/system-pages/maintenance.tsx @@ -1,12 +1,13 @@ interface MaintenanceProps { showActions?: boolean; + height?: StyleProp; } -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 ( - +
@@ -23,7 +24,7 @@ export function Maintenance({ showActions = false }: MaintenanceProps) { {showActions && ( - )} diff --git a/packages/ui/src/components/system-pages/not-found.tsx b/packages/ui/src/components/system-pages/not-found.tsx index 794d38d..38e230e 100644 --- a/packages/ui/src/components/system-pages/not-found.tsx +++ b/packages/ui/src/components/system-pages/not-found.tsx @@ -4,9 +4,10 @@ interface NotFoundProps { onClickGoBack?(): void; onClickBackToHome?(): void; homeUrl?: string; + height?: StyleProp; } -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'; export function NotFound({ @@ -15,6 +16,7 @@ export function NotFound({ onClickGoBack, onClickBackToHome, homeUrl, + height = '100vh', }: NotFoundProps) { const navigate = useNavigate(); @@ -29,7 +31,7 @@ export function NotFound({ } return ( - +
@@ -47,13 +49,13 @@ export function NotFound({ {(showActionsBack || showActionsHome) && ( {showActionsBack && ( - )} {showActionsHome && ( - )} diff --git a/packages/ui/src/foundations/enterprise-module/components/module-page-header.tsx b/packages/ui/src/foundations/enterprise-module/components/module-page-header.tsx index 9985b04..87cad8a 100644 --- a/packages/ui/src/foundations/enterprise-module/components/module-page-header.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/module-page-header.tsx @@ -1,5 +1,17 @@ -import React, { useCallback, useMemo } from 'react'; -import { Title, Breadcrumbs, Anchor, Box, Text, ThemeIcon, Flex, Divider, ActionIcon, Tooltip } from '@mantine/core'; +import React, { ReactNode, useCallback, useMemo } from 'react'; +import { + Title, + TitleProps, + 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'; @@ -15,15 +27,20 @@ export interface BreadcrumbItem { } export interface ModulePageHeaderProps { - title?: string; + title?: ReactNode; + titleProps?: Omit; + miniTitleProps?: Omit; description?: React.ReactNode; icon?: LucideIcon; badges?: React.ReactNode; breadcrumbs?: BreadcrumbItem[]; - actions?: PageActionsProps['actions']; + showPageHeader?: boolean; disableMinimize?: boolean; moduleKey: string; + + actions?: PageActionsProps['actions']; + customButtonProps?: PageActionsProps['customButtonProps']; } // --------------------------------------------------------------------------- @@ -46,7 +63,7 @@ function BreadcrumbBar({ breadcrumbs }: BreadcrumbBarProps) { return ( 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 @@ -167,19 +193,21 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { {/* Left cluster: title + badges */} {title && ( - {title} - + )} {badges && {badges}} @@ -262,12 +290,15 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { {title} @@ -302,7 +333,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { ml={{ base: 'lg', sm: 0 }} style={{ flexShrink: 0, alignSelf: 'center' }} > - {actions && <PageActions actions={actions} />} + {actions && <PageActions actions={actions} customButtonProps={customButtonProps} />} </Flex> </Flex> </Box> diff --git a/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts b/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts index aaf6a9d..1cc649c 100644 --- a/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts +++ b/packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts @@ -6,8 +6,6 @@ export const defaultPrivileges: PrivilegeEntity = { ALLOW_CREATE: true, ALLOW_EDIT: true, ALLOW_DELETE: true, - ALLOW_DUPLICATE: true, - ALLOW_SAVE: true, ALLOW_PRINT: true, ALLOW_PRINT_COPY: true, diff --git a/packages/ui/src/foundations/enterprise-module/entities/entity.ts b/packages/ui/src/foundations/enterprise-module/entities/entity.ts index e3fe025..c8e5ec1 100644 --- a/packages/ui/src/foundations/enterprise-module/entities/entity.ts +++ b/packages/ui/src/foundations/enterprise-module/entities/entity.ts @@ -190,12 +190,13 @@ export interface EnterpriseFormLifecycleHooks<E extends BaseEntity, TFormData = afterGetData?: (data: E) => Promise<TFormData>; } -export interface EnterpriseIndexPageConfig { +interface BasePageConfig { children?: ReactNode; px?: string | number; py?: string | number; - pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>; +} +export interface EnterpriseIndexPageConfig extends BasePageConfig { customPageActions?: (actions: PageActionsProps['actions']) => PageActionsProps['actions']; onClickCreate?: (key: string) => void; } @@ -218,14 +219,28 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext presetDuplicate?: (data: E) => Promise<Partial<E>>; } -export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> { - children?: ReactNode; - showPageHeader?: boolean; - useDefaultPadding?: boolean; +export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig { editMode?: 'FULL' | 'PARTIAL'; - - pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>; 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 { @@ -233,8 +248,6 @@ export interface PrivilegeEntity { ALLOW_CREATE: boolean; ALLOW_EDIT: boolean; ALLOW_DELETE: boolean; - ALLOW_DUPLICATE: boolean; - ALLOW_SAVE: boolean; ALLOW_PRINT: boolean; ALLOW_PRINT_COPY: boolean; diff --git a/packages/ui/src/foundations/enterprise-module/hooks/use-detail-page.context.ts b/packages/ui/src/foundations/enterprise-module/hooks/use-detail-page.context.ts index 4190f8e..fa376e3 100644 --- a/packages/ui/src/foundations/enterprise-module/hooks/use-detail-page.context.ts +++ b/packages/ui/src/foundations/enterprise-module/hooks/use-detail-page.context.ts @@ -6,6 +6,8 @@ export interface DetailPageContextValue<E extends BaseEntity = BaseEntity> { isLoading: boolean; reload: () => Promise<void>; isPartialEdit: boolean; + isActiveEditMode: boolean; + setIsActiveEditMode: (value: boolean) => void; } export const DetailPageContext = createContext<DetailPageContextValue<any> | null>(null); diff --git a/packages/ui/src/foundations/enterprise-module/index.ts b/packages/ui/src/foundations/enterprise-module/index.ts index fd3770e..e55a323 100644 --- a/packages/ui/src/foundations/enterprise-module/index.ts +++ b/packages/ui/src/foundations/enterprise-module/index.ts @@ -6,4 +6,5 @@ export * from './hooks/use-module.context'; export * from './providers/module.provider'; export * from './providers/index-page.provider'; +export * from './providers/detail-page.provider'; export * from './components/module-page-header'; diff --git a/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx index db13773..c70de73 100644 --- a/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx +++ b/packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx @@ -1,17 +1,488 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { BaseEntity } from '@repo/core-api/data-services'; +import { Check, CheckCircle, Copy, Edit2, PauseCircle, Plus, RotateCcw, Trash2, X, XCircle } from 'lucide-react'; + import { DetailPageContext } from '../hooks/use-detail-page.context'; -import { EnterpriseDetailPageConfig } from '../entities/entity'; +import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType } from '../entities/entity'; +import { CorePageContainer, PageActionProps } from '../../../components'; +import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header'; +import { + useEnterpriseModuleConfigContext, + useEnterpriseModuleDataServiceContext, + useEnterpriseModuleNavigationContext, + useEnterpriseModuleTranslationContext, +} from '../hooks/use-module.context'; +import { shortcutsData } from '../../../constants'; export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) { const { children, - showPageHeader, - useDefaultPadding, - editMode, - + editMode = 'FULL', pageHeaderProps, + px, + py, customPageActions, + onClickCreate, + onClickDuplicate, + onClickEdit, + onClickDelete, + onClickActivate, + onClickDeactivate, + onClickConfirm, + onClickCancel, + onClickRollback, + onClickHold, + + showHighlightData = true, + showHighlightDataOnBreadcrumbs = true, + highlightDataKey = 'code', } = props; - return <DetailPageContext.Provider value={{}}></DetailPageContext.Provider>; + const { t } = useEnterpriseModuleTranslationContext(); + const navigation = useEnterpriseModuleNavigationContext(); + const { config, privileges, IS_MACOS } = useEnterpriseModuleConfigContext(); + const { dataServices } = useEnterpriseModuleDataServiceContext<E>(); + + const [isActiveEditMode, setIsActiveEditMode] = useState<boolean>(false); + + const params = useParams(); + const dataId = params.dataId; + + const { moduleKey, moduleType } = config; + + const [detailData, setDetailData] = useState<E | any>({ id: 1, code: 'ABC-001' }); + const [isLoading, setIsLoading] = useState(false); + + const loadData = useCallback(async () => { + if (!dataId) return; + setIsLoading(true); + try { + const response = await dataServices.getOne(dataId); + if (response && response.data) { + setDetailData(response.data as E); + } + } catch (error) { + console.error('Failed to load detail data', error); + } finally { + setIsLoading(false); + } + }, [dataId, dataServices]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // --------------------------------------------------------------------------- + // 1. Stub Handlers (Sudah diperbaiki typonya & lengkap) + // --------------------------------------------------------------------------- + async function handleDelete(data: E): Promise<void> { + // implementation later + console.log({ data }); + } + + async function handleActivate(data: E): Promise<void> { + // implementation later + console.log({ data }); + } + + async function handleDeactivate(data: E): Promise<void> { + // implementation later + console.log({ data }); + } + + async function handleConfirm(data: E): Promise<void> { + // implementation later + console.log({ data }); + } + + async function handleCancel(data: E): Promise<void> { + // implementation later + console.log({ data }); + } + + async function handleRollback(data: E): Promise<void> { + // implementation later + console.log({ data }); + } + + async function handleHold(data: E): Promise<void> { + // implementation later + console.log({ data }); + } + + // --------------------------------------------------------------------------- + // 2. Action Dispatcher (Optimized with Switch Case & Complete Deps) + // --------------------------------------------------------------------------- + const handleActionClick = useCallback( + async (key: string) => { + // Guard utama: Untuk aksi selain CREATE, pastikan dataId dan detailData sudah ada + const hasValidData = Boolean(dataId && detailData); + const currentData = detailData as E; + + switch (key) { + // --- CREATE --- + case ModuleAction.CREATE: + if (!privileges.ALLOW_CREATE) return; + if (onClickCreate) onClickCreate(); + else navigation.navigateToCreate(); + break; + + // --- EDIT --- + case ModuleAction.EDIT: + if (!privileges.ALLOW_EDIT || !hasValidData) return; + if (onClickEdit) onClickEdit(currentData); + else if (editMode === 'FULL') navigation.navigateToEdit(dataId!); + else setIsActiveEditMode(true); + break; + + // --- DUPLICATE --- + case ModuleAction.DUPLICATE: + if (!privileges.ALLOW_CREATE || !hasValidData) return; + if (onClickDuplicate) onClickDuplicate(currentData); + else navigation.navigateToDuplicate(dataId!); + break; + + // --- DELETE --- + case ModuleAction.DELETE: + if (!privileges.ALLOW_DELETE || !hasValidData) return; + if (onClickDelete) onClickDelete(currentData); + else await handleDelete(currentData); + break; + + // --- ACTIVATE --- + case ModuleAction.ACTIVATE: + if (!privileges.ALLOW_ACTIVATE || !hasValidData) return; + if (onClickActivate) onClickActivate(currentData); + else await handleActivate(currentData); + break; + + // --- DEACTIVATE --- + case ModuleAction.DEACTIVATE: + if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return; + if (onClickDeactivate) onClickDeactivate(currentData); + else await handleDeactivate(currentData); + break; + + // --- CONFIRM (Tambahan Baru) --- + case ModuleAction.CONFIRM: + if (!privileges.ALLOW_CONFIRM || !hasValidData) return; + if (onClickConfirm) onClickConfirm(currentData); + else await handleConfirm(currentData); + break; + + // --- CANCEL (Tambahan Baru) --- + case ModuleAction.CANCEL: + if (!privileges.ALLOW_CANCEL || !hasValidData) return; + if (onClickCancel) onClickCancel(currentData); + else await handleCancel(currentData); + break; + + // --- ROLLBACK (Tambahan Baru) --- + case ModuleAction.ROLLBACK: + if (!privileges.ALLOW_ROLLBACK || !hasValidData) return; + if (onClickRollback) onClickRollback(currentData); + else await handleRollback(currentData); + break; + + // --- HOLD (Tambahan Baru) --- + case ModuleAction.HOLD: + if (!privileges.ALLOW_HOLD || !hasValidData) return; + if (onClickHold) onClickHold(currentData); + else await handleHold(currentData); + break; + + default: + console.warn(`[ActionHandler] Unhandled action key: ${key}`); + break; + } + }, + [ + // Semua dependensi wajib dimasukkan agar terhindar dari bug Stale Closure + navigation, + privileges, + dataId, + detailData, + editMode, + setIsActiveEditMode, + onClickCreate, + onClickEdit, + onClickDuplicate, + onClickDelete, + onClickActivate, + onClickDeactivate, + onClickConfirm, + onClickCancel, + onClickRollback, + onClickHold, + ], + ); + + /** Platform-aware shortcut label for the Create action. */ + const CREATE_SHORTCUT_LABEL = useMemo(() => { + const shortcutData = shortcutsData.find((s) => s.key === 'collapse_sidebar'); + return IS_MACOS ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' '); + }, [IS_MACOS]); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + const isShortcut = (e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'n'; + if (!isShortcut) return; + + // Prevent browser default (e.g., Chrome's "new incognito window"). + e.preventDefault(); + e.stopPropagation(); + + handleActionClick(ModuleAction.CREATE); + } + + window.addEventListener('keydown', onKeyDown, { capture: true }); + return () => window.removeEventListener('keydown', onKeyDown, { capture: true }); + }, [handleActionClick]); + + const pageActions = useMemo(() => { + const { + ALLOW_CREATE, + ALLOW_EDIT, + ALLOW_DELETE, + ALLOW_ACTIVATE, + ALLOW_DEACTIVATE, + ALLOW_CONFIRM, + ALLOW_CANCEL, + ALLOW_ROLLBACK, + ALLOW_HOLD, + } = privileges; + + const isTransaction = moduleType === 'TRANSACTION'; + const isMasterData = moduleType === 'MASTER_DATA'; + + // 1. Declare action with Privilege & Module Type conditions directly + const rawActions = [ + ALLOW_DELETE && { + key: ModuleAction.DELETE, + label: t('common:actions.delete'), + icon: <Trash2 size={16} />, + intent: 'destructive', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + ALLOW_CREATE && { + key: ModuleAction.DUPLICATE, + label: t('common:actions.duplicate'), + icon: <Copy size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + ALLOW_EDIT && { + key: ModuleAction.EDIT, + label: t('common:actions.edit'), + icon: <Edit2 size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + + { key: 'DIVIDER_1', type: 'divider' }, // Will be cleared if empty + + // Transaction Group + isTransaction && + ALLOW_HOLD && { + key: ModuleAction.HOLD, + label: t('common:actions.hold'), + icon: <PauseCircle size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + isTransaction && + ALLOW_ROLLBACK && { + key: ModuleAction.ROLLBACK, + label: t('common:actions.rollback'), + icon: <RotateCcw size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + isTransaction && + ALLOW_CANCEL && { + key: ModuleAction.CANCEL, + label: t('common:actions.cancel'), + icon: <X size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + isTransaction && + ALLOW_CONFIRM && { + key: ModuleAction.CONFIRM, + label: t('common:actions.confirm'), + icon: <Check size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + + // Master Data Group + isMasterData && + ALLOW_DEACTIVATE && { + key: ModuleAction.DEACTIVATE, + label: t('common:actions.deactivate'), + icon: <XCircle size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + isMasterData && + ALLOW_ACTIVATE && { + key: ModuleAction.ACTIVATE, + label: t('common:actions.activate'), + tooltipLabel: t('common:actions.activate'), + icon: <CheckCircle size={16} />, + intent: 'default', + variant: 'subtle', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + + { key: 'DIVIDER_2', type: 'divider' }, + + ALLOW_CREATE && { + key: ModuleAction.CREATE, + label: t('common:actions.create'), + tooltipLabel: `${t('common:actions.create')} (${CREATE_SHORTCUT_LABEL})`, + + icon: <Plus size={16} />, + intent: 'primary', + variant: 'filled', + onClick: (key: ModuleActionType) => handleActionClick(key), + }, + ].filter(Boolean) as PageActionProps[]; // Remove all false/null/undefined + + // 2. Smart Divider Cleaning Algorithm + const cleanedActions: PageActionProps[] = []; + + for (let i = 0; i < rawActions.length; i++) { + const current = rawActions[i]; + + if (current.type === 'divider') { + // Ignore if this divider is at the front (beginning of the array) + if (cleanedActions.length === 0) continue; + + // Ignore if the previous item is also a divider (prevents nesting: || ) + if (cleanedActions[cleanedActions.length - 1].type === 'divider') continue; + + // Ignore if after this divider there are no action buttons at all (prevent at the end: | ) + const hasActionAfter = rawActions.slice(i + 1).some((a) => a.type !== 'divider'); + if (!hasActionAfter) continue; + } + + cleanedActions.push(current); + } + + // 3. Inject custom actions + return customPageActions && detailData ? customPageActions(detailData, cleanedActions) : cleanedActions; + }, [t, customPageActions, handleActionClick, privileges, moduleType, detailData]); + + const contextValue = useMemo( + () => ({ + detailData, + isLoading, + reload: loadData, + isPartialEdit: editMode === 'PARTIAL', + isActiveEditMode, + setIsActiveEditMode, + }), + [detailData, isLoading, loadData, editMode, isActiveEditMode, setIsActiveEditMode], + ); + + function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) { + const staticTitle = pageProvide?.title; + if (!showHighlight) { + return { flatTitle: staticTitle, title: staticTitle }; + } else { + const highlightData = detailData[key]; + const flatTitle = `${staticTitle} | ${highlightData}`; + + return { + flatTitle, + title: ( + <span> + {staticTitle} + {highlightData && ( + <span + style={{ + fontWeight: 400, + marginLeft: '8px', + color: 'var(--mantine-color-dimmed)', + }} + > + | {highlightData} + </span> + )} + </span> + ), + }; + } + } + + function makeBreadcrumbs( + showHighlight: boolean, + key: string, + pageProvide: ModulePageHeaderProps | any, + detailData: any, + ) { + const staticBreadcrumbs = pageProvide.breadcrumbs ?? []; + if (!showHighlight || staticBreadcrumbs.length === 0) { + return pageProvide.breadcrumbs; + } else { + const highlightData = detailData[key]; + const breadcrumbs = [ + ...staticBreadcrumbs, + { + type: 'link', + label: `${highlightData}`, + href: `${config.webUrl}/detail/${dataId}`, + }, + ]; + return breadcrumbs; + } + } + + const pageHeaderPropsValue = useMemo(() => { + const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData); + document.title = title.flatTitle; + const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData); + return { ...pageHeaderProps, title: title.title, breadcrumbs: breadcrumbs }; + }, [pageHeaderProps, dataId, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]); + + return ( + <DetailPageContext.Provider value={contextValue}> + <CorePageContainer + px={px} + py={py} + headerSlot={ + <ModulePageHeader + customButtonProps={(action) => { + return { + size: 'xs', + p: action.key === ModuleAction.CREATE ? undefined : 5, + style: { fontSize: 12 }, + }; + }} + actions={pageActions} + {...pageHeaderPropsValue} + moduleKey={moduleKey} + titleProps={{ + fz: { base: 16, sm: 18 }, + }} + miniTitleProps={{ + fz: { base: 'md', sm: 'lg' }, + }} + /> + } + > + {children} + </CorePageContainer> + </DetailPageContext.Provider> + ); } diff --git a/packages/ui/src/foundations/enterprise-module/providers/index-page.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/index-page.provider.tsx index 72f19e4..f29206a 100644 --- a/packages/ui/src/foundations/enterprise-module/providers/index-page.provider.tsx +++ b/packages/ui/src/foundations/enterprise-module/providers/index-page.provider.tsx @@ -1,4 +1,3 @@ -import { BaseEntity } from '@repo/core-api/data-services'; import { EnterpriseIndexPageConfig, ModuleAction } from '../entities/entity'; import { IndexPageContext } from '../hooks/use-index-page.context'; import { CorePageContainer, PageActionProps } from '../../../components'; @@ -70,7 +69,7 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) { icon: <Plus size={16} />, intent: 'primary', variant: 'filled', - shortcutLabel: CREATE_SHORTCUT_LABEL, + tooltipLabel: `${t('common:actions.create')} (${CREATE_SHORTCUT_LABEL})`, onClick: (key) => handleActionClick(key), }); } diff --git a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx index 2d69565..80fb658 100644 --- a/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx +++ b/packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect, ReactNode } from 'react'; +import { useMemo, useState, ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from '@repo/core-i18n'; import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services'; @@ -13,6 +13,7 @@ import { EnterpriseTranslationContext, } from '../hooks/use-module.context'; import { defaultPrivileges } from '../constant/default-privilege'; +import { Forbidden } from '../../../components'; export interface EnterpriseModuleProviderProps<E extends BaseEntity> { children: ReactNode; @@ -47,12 +48,12 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]); // Set document title — uses explicit tabTitle or falls back to translation key 'title' - useEffect(() => { - const title = config.tabTitle || t('title'); - if (title) { - document.title = title; - } - }, [config.tabTitle, t]); + // useEffect(() => { + // const title = config.tabTitle || t('title'); + // if (title) { + // document.title = title; + // } + // }, [config.tabTitle, t]); // --------------------------------------------------------------------------- // 2. Data Service Slice (Stable refs) @@ -143,13 +144,7 @@ export function EnterpriseModuleProvider<E extends BaseEntity>(props: Enterprise const { ALLOW_VIEW } = configSlice.privileges; - useEffect(() => { - if (!ALLOW_VIEW) navigate('/403', { replace: true }); - }, [ALLOW_VIEW, navigate]); - - if (!ALLOW_VIEW) { - return null; - } + if (!ALLOW_VIEW) return <Forbidden homeUrl="/app" height={500} />; return ( <EnterpriseConfigContext.Provider value={configSlice}>