diff --git a/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts index 7caf90c..9c03319 100644 --- a/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts +++ b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts @@ -21,5 +21,6 @@ export const FullPageModuleConfig: ModuleConfigEntity = { moduleCategory: 'FULL_PAGE', /** */ - moduleType: 'MASTER_DATA', + // moduleType: 'MASTER_DATA', + moduleType: 'TRANSACTION', } as const; 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 b61f453..791cecd 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 @@ -4,6 +4,9 @@ "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.", + "detail_page_description": "View and manage detailed information for this record.", + "create_page_description": "Fill out the form below to add a new record to the system.", + "duplicate_page_description": "Copy and modify information from an existing record to quickly create a new one.", "fields": { "status": "Status", "name": "Name", 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 e9345d3..5ced49d 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 @@ -4,6 +4,9 @@ "create_page_title": "Buat Halaman Penuh Baru", "duplicate_page_title": "Duplikat Halaman Penuh", "description": "Contoh penerapan <1>tata letak halaman penuh untuk formulir detail.", + "detail_page_description": "Lihat dan kelola informasi terperinci terkait data ini.", + "create_page_description": "Lengkapi formulir di bawah ini untuk menambahkan data baru ke dalam sistem.", + "duplicate_page_description": "Salin dan sesuaikan informasi dari data yang sudah ada untuk mempercepat pembuatan data baru.", "fields": { "status": "Status", "name": "Nama", 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 f1b1bc9..2bad270 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,14 +1,61 @@ +import { z } from 'zod'; +import { FieldTextarea, STATUS_DATA } from '@repo/ui/components'; +import { Sparkles } from 'lucide-react'; import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { FullPageModuleConfig } from '../../domain/constants'; +import { useState } from 'react'; + +const cancelSchema = z.object({ + reason: z.string().min(1, 'Reason is required'), +}); export default function FullPagePageDetail() { const { t } = useEnterpriseModuleTranslationContext(); - + const [detailData, setDetailData] = useState(); + console.log({ detailData }); return ( ( + + ), + confirmLabel: 'Submit Cancellation', + successMessage: 'The record was successfully cancelled.', + errorMessage: (error: any) => { + // Example of parsing a standard axios/backend network error + const backendMessage = error?.response?.data?.message; + return backendMessage + ? `Custom Cancellation failed: ${backendMessage}` + : 'Custom Failed to cancel the record due to a system error.'; + }, + }} + // By default it looks for 'status' in detailData, but you can change it here: + statusKey="status" + // Example of custom badge injection + getCustomStatusConfig={(status) => { + if (status === STATUS_DATA.DRAFT) { + return { + color: 'violet', + variant: 'outline', + leftSection: , + }; + } + return {}; + }} pageHeaderProps={{ title: t('detail_page_title'), - + description: t('detail_page_description'), + // showBadges: false, 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/showcase/showcase-view.tsx b/apps/web/src/apps/showcase/showcase-view.tsx index 3e0377f..d8cfe3c 100644 --- a/apps/web/src/apps/showcase/showcase-view.tsx +++ b/apps/web/src/apps/showcase/showcase-view.tsx @@ -23,6 +23,8 @@ import { Tabs, Box, Paper, + StatusBadge, + STATUS_DATA, } from '@repo/ui/components'; import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText, LayoutDashboard } from 'lucide-react'; import { Globe } from 'lucide-react'; @@ -249,6 +251,25 @@ export default function ShowcaseView({ density, setDensity }: ShowcaseViewProps) +
+ + Enterprise Status Badges + + + Pre-configured status badges for transaction and master data. + + + + + + + + + + + +
+
Buttons diff --git a/packages/core-api/src/data-services/base-remote.data-services.ts b/packages/core-api/src/data-services/base-remote.data-services.ts index b2a9165..ca06e61 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.ts @@ -6,6 +6,7 @@ import type { RequestDescriptor, ExecuteOptions, DataServicesConfig, + EntityId, } from './types'; import type { IDataTransformer } from './base-data.transformer'; import type { ApiResponse } from '../http-client/types'; @@ -242,16 +243,16 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity, }); } - /** Delete a single entity by ID. */ - delete(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + /** Delete a single entity by ID. Optionally sends form data as `meta` in the request body. */ + delete(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.delete, { variableURL: { id }, - config, + config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Delete multiple entities by IDs. */ - batchDelete(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + batchDelete(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.batchDelete, { config: { ...config, data: { ids } }, }); @@ -259,31 +260,31 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity, // ─── Activation Lifecycle ───────────────────────────────────── - /** Activate a single entity. */ - activate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + /** Activate a single entity. Optionally sends form data as `meta` in the request body. */ + activate(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.activate, { variableURL: { id }, - config, + config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Activate multiple entities. */ - batchActivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + batchActivate(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.batchActivate, { config: { ...config, data: { ids } }, }); } - /** Deactivate a single entity. */ - deactivate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + /** Deactivate a single entity. Optionally sends form data as `meta` in the request body. */ + deactivate(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.deactivate, { variableURL: { id }, - config, + config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Deactivate multiple entities. */ - batchDeactivate(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + batchDeactivate(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.batchDeactivate, { config: { ...config, data: { ids } }, }); @@ -291,31 +292,31 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity, // ─── Data Processing Lifecycle ──────────────────────────────── - /** Confirm processing of a single data record. */ - confirmData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + /** Confirm processing of a single data record. Optionally sends form data as `meta` in the request body. */ + confirmData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.confirmData, { variableURL: { id }, - config, + config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Confirm processing of multiple data records. */ - batchConfirmData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + batchConfirmData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.batchConfirmData, { config: { ...config, data: { ids } }, }); } - /** Cancel processing of a single data record. */ - cancelData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + /** Cancel processing of a single data record. Optionally sends form data as `meta` in the request body. */ + cancelData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.cancelData, { variableURL: { id }, - config, + config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Cancel processing of multiple data records. */ - batchCancelData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + batchCancelData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.batchCancelData, { config: { ...config, data: { ids } }, }); @@ -323,31 +324,31 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity, // ─── Transaction Lifecycle ──────────────────────────────────── - /** Rollback a transaction. */ - rollbackData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + /** Rollback a transaction. Optionally sends form data as `meta` in the request body. */ + rollbackData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.rollbackData, { variableURL: { id }, - config, + config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Rollback multiple transactions. */ - batchRollbackData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + batchRollbackData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.batchRollbackData, { config: { ...config, data: { ids } }, }); } - /** Hold a transaction. */ - holdData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + /** Hold a transaction. Optionally sends form data as `meta` in the request body. */ + holdData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.holdData, { variableURL: { id }, - config, + config: { ...config, ...(meta ? { data: { meta } } : {}) }, }); } /** Hold multiple transactions. */ - batchHoldData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { + batchHoldData(ids: EntityId[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> { return this.execute<void>(DESCRIPTORS.batchHoldData, { config: { ...config, data: { ids } }, }); diff --git a/packages/core-api/src/data-services/types.ts b/packages/core-api/src/data-services/types.ts index 9742701..164189e 100644 --- a/packages/core-api/src/data-services/types.ts +++ b/packages/core-api/src/data-services/types.ts @@ -8,8 +8,10 @@ import type { IDataTransformer } from './base-data.transformer'; * Minimal entity contract. All domain entities must have * an optional `id` field for CRUD operations. */ +export type EntityId = string | number; + export interface BaseEntity { - id?: string; + id?: EntityId; [key: string]: any; } @@ -95,7 +97,7 @@ export interface RequestDescriptor { */ export interface ExecuteOptions { /** Dynamic URL parameters (e.g., `{ id: '42' }`). */ - variableURL?: Record<string, string>; + variableURL?: Record<string, any>; /** Additional Axios request config (params, data, headers, etc). */ config?: AxiosRequestConfig; /** Per-request telemetry context for custom spans, tags, events. */ diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index c61e03c..79954bc 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -58,10 +58,34 @@ "reload": "Reload" }, "confirmDialog": { - "title": "Are you sure?", - "deleteMessage": "This action cannot be undone.", - "confirmButton": "Confirm", - "cancelButton": "Cancel" + "delete": { + "title": "Delete Item", + "description": "Are you sure you want to delete this item? This action cannot be undone." + }, + "activate": { + "title": "Activate Item", + "description": "Are you sure you want to activate this item?" + }, + "deactivate": { + "title": "Deactivate Item", + "description": "Are you sure you want to deactivate this item?" + }, + "confirm": { + "title": "Confirm Data", + "description": "Are you sure you want to confirm this data?" + }, + "cancel": { + "title": "Cancel Data", + "description": "Are you sure you want to cancel this data?" + }, + "rollback": { + "title": "Rollback Data", + "description": "Are you sure you want to rollback this data?" + }, + "hold": { + "title": "Hold Data", + "description": "Are you sure you want to hold this data?" + } }, "draft": { "recoveryTitle": "Draft Found", @@ -70,6 +94,8 @@ "discardDraft": "Start Fresh" }, "notifications": { + "successTitle": "Success", + "errorTitle": "Error", "saveSuccess": "Data saved successfully", "deleteSuccess": "Data deleted successfully", "actionSuccess": "{{action}} completed successfully", diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index b8642e4..62f187c 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -47,8 +47,8 @@ "delete": "Hapus", "save": "Simpan", "print": "Cetak", - "confirm": "Konfirmasi", - "cancel": "Batal", + "confirm": "Confirm", + "cancel": "Cancel", "activate": "Aktifkan", "deactivate": "Nonaktifkan", "duplicate": "Duplikat", @@ -58,10 +58,34 @@ "reload": "Muat Ulang" }, "confirmDialog": { - "title": "Apakah Anda yakin?", - "deleteMessage": "Tindakan ini tidak dapat dibatalkan.", - "confirmButton": "Konfirmasi", - "cancelButton": "Batal" + "delete": { + "title": "Hapus Data", + "description": "Apakah Anda yakin ingin menghapus data ini? Tindakan ini tidak dapat dibatalkan." + }, + "activate": { + "title": "Aktifkan Data", + "description": "Apakah Anda yakin ingin mengaktifkan data ini?" + }, + "deactivate": { + "title": "Nonaktifkan Data", + "description": "Apakah Anda yakin ingin menonaktifkan data ini?" + }, + "confirm": { + "title": "Confirm Data", + "description": "Apakah Anda yakin ingin mengkonfirmasi data ini?" + }, + "cancel": { + "title": "Cancel Data", + "description": "Apakah Anda yakin ingin membatalkan tindakan ini?" + }, + "rollback": { + "title": "Rollback Data", + "description": "Apakah Anda yakin ingin mengembalikan data ini?" + }, + "hold": { + "title": "Hold Data", + "description": "Apakah Anda yakin ingin menahan data ini?" + } }, "draft": { "recoveryTitle": "Draf Ditemukan", @@ -70,6 +94,8 @@ "discardDraft": "Mulai Baru" }, "notifications": { + "successTitle": "Berhasil", + "errorTitle": "Galat", "saveSuccess": "Data berhasil disimpan", "deleteSuccess": "Data berhasil dihapus", "actionSuccess": "{{action}} berhasil diselesaikan", diff --git a/packages/ui/package.json b/packages/ui/package.json index 44b02dc..1a2aece 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,6 +25,7 @@ "@hookform/resolvers": "^5.0.1", "@mantine/core": "^8.3.15", "@mantine/hooks": "^8.3.15", + "@mantine/modals": "^8.3.15", "@mantine/notifications": "^8.3.15", "@mantine/tiptap": "^9.3.2", "@repo/core-api": "workspace:^", diff --git a/packages/ui/src/components/actions-tools/page-actions.tsx b/packages/ui/src/components/actions-tools/page-actions.tsx index f573388..cdc8780 100644 --- a/packages/ui/src/components/actions-tools/page-actions.tsx +++ b/packages/ui/src/components/actions-tools/page-actions.tsx @@ -23,6 +23,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto return { size: 'sm', radius: 'md', + fw: 500, style: isPremiumGlow ? { boxShadow: '0 4px 14px 0 color-mix(in srgb, var(--mantine-primary-color-filled) 40%, transparent)' } : undefined, @@ -32,7 +33,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto return ( <Box style={{ display: 'inline-flex' }}> {/* --- DESKTOP VIEW --- */} - <Group gap="xs" wrap="nowrap" visibleFrom="sm"> + <Group gap="xs" wrap="nowrap" visibleFrom="md"> {actions.map((action, index) => { if (action.type === 'divider') { return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />; @@ -116,14 +117,14 @@ export const PageActions = memo(function PageActions({ actions = [], customButto </Group> {/* --- MOBILE VIEW --- */} - <Group gap="xs" wrap="nowrap" hiddenFrom="sm"> + <Group gap="xs" wrap="nowrap" hiddenFrom="md"> <Menu position="bottom-end" withArrow withinPortal> <Menu.Target> <ActionIcon variant="outline" size="md"> <MoreVertical size={16} /> </ActionIcon> </Menu.Target> - <Menu.Dropdown px={'xl'}> + <Menu.Dropdown px={'xl'} w={180}> {actions.map((action, index) => { if (action.type === 'divider') { return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />; @@ -156,16 +157,18 @@ export const PageActions = memo(function PageActions({ actions = [], customButto ); } + const color = getIntentColor(action.intent); + const realColor = color === undefined ? 'brand' : color; + return ( <Menu.Item key={action.key} leftSection={action.icon} - color={getIntentColor(action.intent)} + color={realColor} disabled={action.disabled} onClick={() => action.onClick?.(action.key || '')} mt="sm" mb="sm" - fw={600} > {action.label} </Menu.Item> diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index ce5579e..4027151 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -11,3 +11,4 @@ export * from './system-pages/maintenance'; export * from './system-pages/not-found'; export * from './core-app-shell'; export * from './actions-tools'; +export * from './status-badge'; diff --git a/packages/ui/src/components/status-badge/index.ts b/packages/ui/src/components/status-badge/index.ts new file mode 100644 index 0000000..ec4be4f --- /dev/null +++ b/packages/ui/src/components/status-badge/index.ts @@ -0,0 +1 @@ +export * from './status-badge.component'; diff --git a/packages/ui/src/components/status-badge/status-badge.component.tsx b/packages/ui/src/components/status-badge/status-badge.component.tsx new file mode 100644 index 0000000..73cd894 --- /dev/null +++ b/packages/ui/src/components/status-badge/status-badge.component.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import { Badge, BadgeProps } from '@mantine/core'; +import { + CheckCircle2, + XCircle, + Clock, + Edit2, + Loader2, + Package, + AlertCircle, + RotateCcw, + Check, + CreditCard, + Send, + Users, +} from 'lucide-react'; + +export enum STATUS_DATA { + DRAFT = 'draft', + + ACTIVE = 'active', + ACTIVATED = 'activated', + + INACTIVE = 'inactive', + DEACTIVATED = 'deactivated', + + REQUEST = 'request', + REQUESTING = 'requesting', + REQUESTED = 'requested', + + APPROVAL = 'approval', + APPROVED = 'approved', + + OPEN = 'open', + OPENED = 'opened', + + PENDING = 'pending', + + WAIT = 'wait', + WAITING = 'waiting', + WAITLIST = 'wait-list', + + PROCESS = 'process', + PROCESSING = 'processing', + IN_PROCESS = 'in-process', + PROCESSED = 'processed', + + CLOSE = 'close', + CLOSED = 'closed', + + CANCEL = 'cancel', + CANCELLED = 'cancelled', + + COMPLETE = 'complete', + COMPLETED = 'completed', + + TODO = 'todo', + DONE = 'done', + PARTIAL_DONE = 'partial-done', + + ON_HOLD = 'on-hold', + + SHIPMENT = 'shipment', + SHIPPING = 'shipping', + SHIPPED = 'shipped', + + EXPIRED = 'expired', + + REFUND = 'refund', + REFUNDED = 'refunded', + PARTIAL_REFUND = 'partial-refund', + PARTIALLY_REFUNDED = 'partially-refunded', + REFUND_PROCESSING = 'refund-processing', + CANCEL_REFUNDED = 'cancel-refunded', + REFUND_PROCESS = 'refund-processing', + + TRANSFERRED = 'transferred', + + SETTLEMENT = 'settlement', + SETTLED = 'settled', + SETTLED_FREE = 'settled-free', + + REJECTION = 'rejection', + REJECTED = 'rejected', + + PAYMENT = 'payment', + PAID = 'paid', + + ISSUE = 'issue', + PROBLEM = 'problem', + RESOLVED = 'resolved', + + CHECKOUT = 'checkout', + INVOICE = 'invoice', + INVOICED = 'invoiced', + + JOINED = 'joined', + INVITED = 'invited', + + PRESENT = 'present', +} + +export type StatusBadgeConfigCallback = (status: string) => Partial<BadgeProps>; + +export interface StatusBadgeProps extends Omit<BadgeProps, 'color' | 'leftSection'> { + /** The status string or STATUS_DATA enum value */ + status: STATUS_DATA | string; + /** Optional custom label, overrides default status string */ + label?: string; + /** Callback to provide custom badge props (color, icon) for a specific status */ + getCustomConfig?: StatusBadgeConfigCallback; + /** Overrides default and custom color */ + color?: string; + /** Overrides default and custom leftSection icon */ + leftSection?: React.ReactNode; +} + +const ICON_SIZE = 12; +const STROKE_WIDTH = 2.5; + +const getIcon = (IconCmp: any) => <IconCmp size={ICON_SIZE} strokeWidth={STROKE_WIDTH} />; + +export const DEFAULT_STATUS_MAP: Record<string, BadgeProps> = { + // #B6B6B6 + [STATUS_DATA.DRAFT]: { color: '#B6B6B6', leftSection: getIcon(Edit2) }, + [STATUS_DATA.CHECKOUT]: { color: '#B6B6B6', leftSection: getIcon(CreditCard) }, + + // #66BB6A + [STATUS_DATA.ACTIVE]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.ACTIVATED]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.APPROVAL]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.APPROVED]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.COMPLETE]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.COMPLETED]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.DONE]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.PARTIAL_DONE]: { color: '#66BB6A', leftSection: getIcon(Clock) }, + [STATUS_DATA.JOINED]: { color: '#66BB6A', leftSection: getIcon(Users) }, + [STATUS_DATA.RESOLVED]: { color: '#66BB6A', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.PAID]: { color: '#66BB6A', leftSection: getIcon(CreditCard) }, + + // #EF5350 + [STATUS_DATA.INACTIVE]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.DEACTIVATED]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.CLOSE]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.CLOSED]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.CANCELLED]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.CANCEL_REFUNDED]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.REJECTION]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.REJECTED]: { color: '#EF5350', leftSection: getIcon(XCircle) }, + [STATUS_DATA.ISSUE]: { color: '#EF5350', leftSection: getIcon(AlertCircle) }, + [STATUS_DATA.PROBLEM]: { color: '#EF5350', leftSection: getIcon(AlertCircle) }, + + // #42A5F5 + [STATUS_DATA.REQUEST]: { color: '#42A5F5', leftSection: getIcon(Send) }, + [STATUS_DATA.REQUESTING]: { color: '#42A5F5', leftSection: getIcon(Send) }, + [STATUS_DATA.REQUESTED]: { color: '#42A5F5', leftSection: getIcon(Send) }, + [STATUS_DATA.OPEN]: { color: '#42A5F5', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.OPENED]: { color: '#42A5F5', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.PROCESS]: { color: '#42A5F5', leftSection: getIcon(Loader2) }, + [STATUS_DATA.PROCESSING]: { color: '#42A5F5', leftSection: getIcon(Loader2) }, + [STATUS_DATA.IN_PROCESS]: { color: '#42A5F5', leftSection: getIcon(Loader2) }, + [STATUS_DATA.PROCESSED]: { color: '#42A5F5', leftSection: getIcon(Check) }, + [STATUS_DATA.SHIPMENT]: { color: '#42A5F5', leftSection: getIcon(Package) }, + [STATUS_DATA.SHIPPING]: { color: '#42A5F5', leftSection: getIcon(Package) }, + [STATUS_DATA.SHIPPED]: { color: '#42A5F5', leftSection: getIcon(Check) }, + [STATUS_DATA.PAYMENT]: { color: '#42A5F5', leftSection: getIcon(CreditCard) }, + [STATUS_DATA.INVOICE]: { color: '#42A5F5', leftSection: getIcon(CreditCard) }, + [STATUS_DATA.INVOICED]: { color: '#42A5F5', leftSection: getIcon(CreditCard) }, + + // #DFAB45 + [STATUS_DATA.PENDING]: { color: '#DFAB45', leftSection: getIcon(Clock) }, + + // #6C5DD0 + [STATUS_DATA.WAIT]: { color: '#6C5DD0', leftSection: getIcon(Clock) }, + [STATUS_DATA.WAITING]: { color: '#6C5DD0', leftSection: getIcon(Clock) }, + [STATUS_DATA.WAITLIST]: { color: '#6C5DD0', leftSection: getIcon(Clock) }, + + // #D26DA9 + [STATUS_DATA.CANCEL]: { color: '#D26DA9', leftSection: getIcon(XCircle) }, + [STATUS_DATA.TRANSFERRED]: { color: '#D26DA9', leftSection: getIcon(Send) }, + + // #9575CD + [STATUS_DATA.TODO]: { color: '#9575CD', leftSection: getIcon(Clock) }, + + // #FFB74D + [STATUS_DATA.ON_HOLD]: { color: '#FFB74D', leftSection: getIcon(Clock) }, + + // #787878 + [STATUS_DATA.EXPIRED]: { color: '#787878', leftSection: getIcon(AlertCircle) }, + + // #FE725E + [STATUS_DATA.REFUND]: { color: '#FE725E', leftSection: getIcon(RotateCcw) }, + [STATUS_DATA.REFUNDED]: { color: '#FE725E', leftSection: getIcon(RotateCcw) }, + [STATUS_DATA.PARTIAL_REFUND]: { color: '#FE725E', leftSection: getIcon(RotateCcw) }, + [STATUS_DATA.PARTIALLY_REFUNDED]: { color: '#FE725E', leftSection: getIcon(RotateCcw) }, + [STATUS_DATA.INVITED]: { color: '#FE725E', leftSection: getIcon(Users) }, + + // #D7A29A + [STATUS_DATA.REFUND_PROCESSING]: { color: '#D7A29A', leftSection: getIcon(Loader2) }, + + // #5DADD0 + [STATUS_DATA.SETTLEMENT]: { color: '#5DADD0', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.SETTLED]: { color: '#5DADD0', leftSection: getIcon(CheckCircle2) }, + [STATUS_DATA.SETTLED_FREE]: { color: '#5DADD0', leftSection: getIcon(CheckCircle2) }, + + // #E2B43E + [STATUS_DATA.PRESENT]: { color: '#E2B43E', leftSection: getIcon(Users) }, +}; + +export function StatusBadge({ status, label, getCustomConfig, color, leftSection, ...rest }: StatusBadgeProps) { + if (!status) return null; + + const normalizedStatus = status?.toLowerCase() || ''; + const defaultConfig = DEFAULT_STATUS_MAP[normalizedStatus] || { color: 'gray' }; + const customConfig = getCustomConfig ? getCustomConfig(normalizedStatus) : {}; + + return ( + <Badge + size="sm" + radius="sm" + fw={600} + style={{ textTransform: 'capitalize' }} + variant={customConfig.variant ? customConfig.variant : 'light'} + color={color || customConfig.color || defaultConfig.color} + leftSection={leftSection || customConfig.leftSection || defaultConfig.leftSection} + {...rest} + > + {label || (status ? status.replace(/-/g, ' ') : 'Unknown')} + </Badge> + ); +} diff --git a/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal.tsx b/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal.tsx new file mode 100644 index 0000000..0b52c3b --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/action-confirmation-modal.tsx @@ -0,0 +1,222 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Button, Group, Modal, Stack, Text } from '@mantine/core'; +import { useForm, FormProvider } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { BaseEntity } from '@repo/core-api/data-services'; + +import type { ActionModalConfig, ActionModalState, ModuleActionType } from '../entities/entity'; +import { ModuleAction } from '../entities/entity'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ActionConfirmationModalProps<E extends BaseEntity = BaseEntity> { + /** Current modal state (opened, action, data, config) */ + modalState: ActionModalState<E>; + /** Close the modal */ + onClose: () => void; + /** Execute the action with optional meta from form */ + onExecute: (action: ModuleActionType, data: E, meta?: Record<string, unknown>) => Promise<void>; + /** Translation function scoped to [moduleNamespace, 'common'] */ + t: (key: string, options?: Record<string, unknown>) => string; +} + +// --------------------------------------------------------------------------- +// Default Translation Keys per Action +// --------------------------------------------------------------------------- + +export const ACTION_TRANSLATION_MAP: Record<string, { titleKey: string; confirmKey: string; descriptionKey: string }> = + { + [ModuleAction.DELETE]: { + titleKey: 'common:confirmDialog.delete.title', + confirmKey: 'common:actions.delete', + descriptionKey: 'common:confirmDialog.delete.description', + }, + [ModuleAction.ACTIVATE]: { + titleKey: 'common:confirmDialog.activate.title', + confirmKey: 'common:actions.activate', + descriptionKey: 'common:confirmDialog.activate.description', + }, + [ModuleAction.DEACTIVATE]: { + titleKey: 'common:confirmDialog.deactivate.title', + confirmKey: 'common:actions.deactivate', + descriptionKey: 'common:confirmDialog.deactivate.description', + }, + [ModuleAction.CONFIRM]: { + titleKey: 'common:confirmDialog.confirm.title', + confirmKey: 'common:actions.confirm', + descriptionKey: 'common:confirmDialog.confirm.description', + }, + [ModuleAction.CANCEL]: { + titleKey: 'common:confirmDialog.cancel.title', + confirmKey: 'common:actions.cancel_action', + descriptionKey: 'common:confirmDialog.cancel.description', + }, + [ModuleAction.ROLLBACK]: { + titleKey: 'common:confirmDialog.rollback.title', + confirmKey: 'common:actions.rollback', + descriptionKey: 'common:confirmDialog.rollback.description', + }, + [ModuleAction.HOLD]: { + titleKey: 'common:confirmDialog.hold.title', + confirmKey: 'common:actions.hold', + descriptionKey: 'common:confirmDialog.hold.description', + }, + }; + +// --------------------------------------------------------------------------- +// Inner Form Body (memoized to prevent re-renders of the entire modal) +// --------------------------------------------------------------------------- + +const ModalFormBody = React.memo(function ModalFormBody< + TMeta extends Record<string, unknown> = Record<string, unknown>, +>({ + renderBody, + form, +}: { + renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>; + form?: ReturnType<typeof useForm<TMeta>>; +}) { + return <>{renderBody(form)}</>; +}) as <TMeta extends Record<string, unknown>>(props: { + renderBody: NonNullable<ActionModalConfig<TMeta>['renderBody']>; + form?: ReturnType<typeof useForm<TMeta>>; +}) => React.ReactElement; + +// --------------------------------------------------------------------------- +// ActionConfirmationModal +// --------------------------------------------------------------------------- + +/** + * Controlled confirmation modal for lifecycle actions (delete, activate, etc.). + * + * Supports three modes: + * 1. **Simple** — no custom body, just title + default description + confirm/cancel. + * 2. **Static body** — custom body without form (informational content). + * 3. **Form body** — custom body with RHF FormProvider for validation + meta payload. + * + * @performance + * - Modal is NOT mounted when `opened === false` (uses `keepMounted={false}`). + * - Form is created with `mode: 'onSubmit'` to avoid re-renders on every keystroke. + * - Inner body is wrapped in React.memo to isolate re-renders from modal state changes. + */ +export function ActionConfirmationModal<E extends BaseEntity = BaseEntity>(props: ActionConfirmationModalProps<E>) { + const { modalState, onClose, onExecute, t } = props; + const { opened, action, data, config } = modalState; + + const [isSubmitting, setIsSubmitting] = useState(false); + + // Determine if this modal has a form (schema + defaultValues provided) + const hasForm = Boolean(config?.schema && config?.defaultValues); + + // Create form instance only when form mode is active. + // `useForm` is always called (Rules of Hooks) but resolver/defaultValues + // are conditionally set so it becomes a no-op when hasForm is false. + const form = useForm<Record<string, unknown>>({ + mode: 'onSubmit', + resolver: config?.schema ? zodResolver(config.schema as any) : undefined, + defaultValues: config?.defaultValues ?? {}, + }); + + // Resolve translation keys for the current action + const translations = useMemo(() => { + const actionKey = action ?? ''; + const map = ACTION_TRANSLATION_MAP[actionKey]; + + return { + title: config?.title ?? (map ? t(map.titleKey) : (action ?? '')), + confirmLabel: config?.confirmLabel ?? (map ? t(map.confirmKey) : t('common:actions.confirm')), + cancelLabel: config?.cancelLabel ?? t('common:actions.cancel'), + description: map ? t(map.descriptionKey) : '', + }; + }, [action, config?.title, config?.confirmLabel, config?.cancelLabel, t]); + + // Determine button color based on action type + const confirmColor = useMemo(() => { + if (action === ModuleAction.DELETE) return 'red'; + // if (action === ModuleAction.DEACTIVATE || action === ModuleAction.CANCEL) return 'orange'; + return 'brand'; + }, [action]); + + // Handle confirm button click + const handleConfirmClick = useCallback(async () => { + if (!action || !data) return; + + if (hasForm) { + // Trigger RHF validation, then execute if valid + const isValid = await form.trigger(); + if (!isValid) return; + + const formValues = form.getValues(); + setIsSubmitting(true); + try { + await onExecute(action, data, formValues); + } finally { + setIsSubmitting(false); + } + } else { + // No form — execute directly + setIsSubmitting(true); + try { + await onExecute(action, data, undefined); + } finally { + setIsSubmitting(false); + } + } + }, [action, data, hasForm, form, onExecute]); + + // Handle modal close (prevent close while submitting) + const handleClose = useCallback(() => { + if (isSubmitting) return; + form.reset(); + onClose(); + }, [isSubmitting, form, onClose]); + + return ( + <Modal + opened={opened} + onClose={handleClose} + title={translations.title} + size={config?.size ?? 'md'} + centered + closeOnClickOutside={!isSubmitting} + closeOnEscape={!isSubmitting} + keepMounted={false} + padding="lg" + styles={{ + title: { fontWeight: 600, fontSize: 'var(--mantine-font-size-xl)' }, + header: { paddingBottom: 'var(--mantine-spacing-md)' }, + }} + > + <Stack gap="xl"> + {/* Body: custom or default description */} + {config?.renderBody ? ( + hasForm ? ( + <FormProvider {...form}> + <ModalFormBody renderBody={config.renderBody} form={form} /> + </FormProvider> + ) : ( + <ModalFormBody renderBody={config.renderBody} /> + ) + ) : ( + translations.description && ( + <Text size="md" c="dimmed"> + {translations.description} + </Text> + ) + )} + + {/* Footer actions */} + <Group justify="flex-end" mt="sm"> + <Button size="xs" variant="default" onClick={handleClose} disabled={isSubmitting}> + {translations.cancelLabel} + </Button> + <Button size="xs" color={confirmColor} onClick={handleConfirmClick} loading={isSubmitting}> + {translations.confirmLabel} + </Button> + </Group> + </Stack> + </Modal> + ); +} 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 87cad8a..4cb2098 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 @@ -32,7 +32,9 @@ export interface ModulePageHeaderProps { miniTitleProps?: Omit<TitleProps, 'children'>; description?: React.ReactNode; icon?: LucideIcon; + badges?: React.ReactNode; + showBadges?: boolean; breadcrumbs?: BreadcrumbItem[]; showPageHeader?: boolean; @@ -148,6 +150,8 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { description, icon: Icon, badges, + showBadges = true, + breadcrumbs, actions, customButtonProps, @@ -209,7 +213,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { {title} )} - {badges && {badges}} + {showBadges && badges && {badges}} {/* Right: actions + inline toggle (separated by divider) */} @@ -304,7 +308,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { {title} )} - {badges && {badges}} + {showBadges && badges && {badges}} {description && ( @@ -312,7 +316,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) { fz={{ base: 'xs', sm: 'sm' }} c="dimmed" fw={400} - lineClamp={2} + lineClamp={1} mt={4} style={{ wordBreak: 'break-word', diff --git a/packages/ui/src/foundations/enterprise-module/entities/entity.ts b/packages/ui/src/foundations/enterprise-module/entities/entity.ts index c8e5ec1..f1ab09b 100644 --- a/packages/ui/src/foundations/enterprise-module/entities/entity.ts +++ b/packages/ui/src/foundations/enterprise-module/entities/entity.ts @@ -1,4 +1,6 @@ import { ReactNode } from 'react'; +import type { UseFormReturn } from 'react-hook-form'; +import type { ZodType } from 'zod'; import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services'; import type { ModulePageHeaderProps } from '../components/module-page-header'; import { PageActionsProps } from '../../../components'; @@ -219,9 +221,84 @@ export interface EnterpriseFormPageConfig ext presetDuplicate?: (data: E) => Promise>; } +// --------------------------------------------------------------------------- +// Action Confirmation Modal Configuration +// --------------------------------------------------------------------------- + +/** + * Configuration for the confirmation modal shown before executing a lifecycle action. + * + * Supports three modes: + * 1. **Simple confirmation** — no `renderBody`, just a confirm/cancel dialog. + * 2. **Static body** — `renderBody` returns static JSX (text, checkboxes, etc.). + * 3. **Form body** — `renderBody` receives an RHF `UseFormReturn` instance. + * The implementer renders `Field*` components bound to the form. + * Form data is validated via `schema` before submission and sent as `meta`. + * + * @template TMeta The shape of the form data (defaults to Record). + */ +export interface ActionModalConfig = Record> { + /** Modal title override. Defaults to action-specific translation (e.g., "Delete Item?") */ + title?: string; + /** + * Custom modal body renderer. + * + * When provided WITHOUT `schema`/`defaultValues`, receives `undefined` — render static content. + * When provided WITH `schema`/`defaultValues`, receives a fully-typed `UseFormReturn` + * instance. Use `Field*` components from `@repo/ui/form` bound to this form. + * + * @example + * ```tsx + * // Static body (no form) + * renderBody: () => Are you sure you want to delete this item? + * + * // Form body + * renderBody: (form) => ( + * + * ) + * ``` + */ + renderBody?: (form?: UseFormReturn) => ReactNode; + /** Zod schema for validating the form body. When omitted, no validation is applied */ + schema?: ZodType; + /** Default values for the form. Required when schema is provided */ + defaultValues?: TMeta; + /** Confirm button label override. Defaults to action translation */ + confirmLabel?: string; + /** Cancel button label override. Defaults to t('common:actions.cancel') */ + cancelLabel?: string; + /** Size of the modal. @default 'md' */ + size?: string | number; + /** Custom success message or a function to generate it after action completes successfully */ + successMessage?: string | ((data?: any) => string); + /** Custom error message or a function to extract it from the network error */ + errorMessage?: string | ((error: any) => string); +} + +/** + * Internal state for the action confirmation modal managed by the detail page provider. + * @internal + */ +export interface ActionModalState { + opened: boolean; + action: ModuleActionType | null; + data: E | null; + config?: ActionModalConfig; +} + export interface EnterpriseDetailPageConfig extends BasePageConfig { editMode?: 'FULL' | 'PARTIAL'; customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions']; + onDetailLoaded?: (data: E) => void; + + showHighlightData?: boolean; + showHighlightDataOnBreadcrumbs?: boolean; + highlightDataKey?: string; + + /** Key to reference status data in the entity, used to render the status badge automatically. @default 'status' */ + statusKey?: string; + /** Custom callback to provide dynamic status badge properties */ + getCustomStatusConfig?: (status: string) => Partial; onClickCreate?: () => void; onClickDuplicate?: (data: E) => void; @@ -238,9 +315,14 @@ export interface EnterpriseDetailPageConfig e onClickRollback?: (data: E) => void; onClickHold?: (data: E) => void; - showHighlightData?: boolean; - showHighlightDataOnBreadcrumbs?: boolean; - highlightDataKey?: string; + // Action Modal Configurations + deleteModalConfig?: ActionModalConfig; + activateModalConfig?: ActionModalConfig; + deactivateModalConfig?: ActionModalConfig; + confirmModalConfig?: ActionModalConfig; + cancelModalConfig?: ActionModalConfig; + rollbackModalConfig?: ActionModalConfig; + holdModalConfig?: ActionModalConfig; } export interface PrivilegeEntity { diff --git a/packages/ui/src/foundations/enterprise-module/index.ts b/packages/ui/src/foundations/enterprise-module/index.ts index e55a323..fb8ba94 100644 --- a/packages/ui/src/foundations/enterprise-module/index.ts +++ b/packages/ui/src/foundations/enterprise-module/index.ts @@ -8,3 +8,4 @@ export * from './providers/module.provider'; export * from './providers/index-page.provider'; export * from './providers/detail-page.provider'; export * from './components/module-page-header'; +export * from './components/action-confirmation-modal'; 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 c70de73..2588c43 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 @@ -4,9 +4,11 @@ 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, ModuleAction, ModuleActionType } from '../entities/entity'; -import { CorePageContainer, PageActionProps } from '../../../components'; +import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType, ActionModalState } from '../entities/entity'; +import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components'; import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header'; +import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../components/action-confirmation-modal'; +import { notifications } from '@mantine/notifications'; import { useEnterpriseModuleConfigContext, useEnterpriseModuleDataServiceContext, @@ -22,6 +24,14 @@ export function EnterpriseDetailPageProvider( pageHeaderProps, px, py, + + showHighlightData = true, + showHighlightDataOnBreadcrumbs = true, + highlightDataKey = 'code', + statusKey = 'status', + getCustomStatusConfig, + onDetailLoaded, + customPageActions, onClickCreate, onClickDuplicate, @@ -34,9 +44,14 @@ export function EnterpriseDetailPageProvider( onClickRollback, onClickHold, - showHighlightData = true, - showHighlightDataOnBreadcrumbs = true, - highlightDataKey = 'code', + // Modal configurations per action + deleteModalConfig, + activateModalConfig, + deactivateModalConfig, + confirmModalConfig, + cancelModalConfig, + rollbackModalConfig, + holdModalConfig, } = props; const { t } = useEnterpriseModuleTranslationContext(); @@ -51,7 +66,7 @@ export function EnterpriseDetailPageProvider( const { moduleKey, moduleType } = config; - const [detailData, setDetailData] = useState({ id: 1, code: 'ABC-001' }); + const [detailData, setDetailData] = useState(); const [isLoading, setIsLoading] = useState(false); const loadData = useCallback(async () => { @@ -60,9 +75,16 @@ export function EnterpriseDetailPageProvider( try { const response = await dataServices.getOne(dataId); if (response && response.data) { - setDetailData(response.data as E); + const data = response.data; + setDetailData(data as E); + if (onDetailLoaded) onDetailLoaded(data as E); } } catch (error) { + // FIXME => remove this example later; + + const data = { id: 1, code: 'ABCD-0001', status: 'open' } as any; + setDetailData(data as E); + if (onDetailLoaded) onDetailLoaded(data as E); console.error('Failed to load detail data', error); } finally { setIsLoading(false); @@ -74,41 +96,178 @@ export function EnterpriseDetailPageProvider( }, [loadData]); // --------------------------------------------------------------------------- - // 1. Stub Handlers (Sudah diperbaiki typonya & lengkap) + // 1. Action Confirmation Modal State & Handlers // --------------------------------------------------------------------------- - async function handleDelete(data: E): Promise { - // implementation later - console.log({ data }); + + /** Default (closed) modal state — stable reference to avoid re-creating on every render. */ + const CLOSED_MODAL: ActionModalState = useMemo(() => ({ opened: false, action: null, data: null }), []); + + const [actionModalState, setActionModalState] = useState>(CLOSED_MODAL); + + /** Map action type → modal config provided by the implementer. */ + const modalConfigMap = useMemo( + () => ({ + [ModuleAction.DELETE]: deleteModalConfig, + [ModuleAction.ACTIVATE]: activateModalConfig, + [ModuleAction.DEACTIVATE]: deactivateModalConfig, + [ModuleAction.CONFIRM]: confirmModalConfig, + [ModuleAction.CANCEL]: cancelModalConfig, + [ModuleAction.ROLLBACK]: rollbackModalConfig, + [ModuleAction.HOLD]: holdModalConfig, + }), + [ + deleteModalConfig, + activateModalConfig, + deactivateModalConfig, + confirmModalConfig, + cancelModalConfig, + rollbackModalConfig, + holdModalConfig, + ], + ); + + /** Open the confirmation modal for the given action. */ + const openActionModal = useCallback( + (action: ModuleActionType, data: E) => { + const config = modalConfigMap[action as keyof typeof modalConfigMap]; + setActionModalState({ opened: true, action, data, config }); + }, + [modalConfigMap], + ); + + /** Close the confirmation modal and reset state. */ + const closeActionModal = useCallback(() => { + setActionModalState(CLOSED_MODAL); + }, [CLOSED_MODAL]); + + /** + * Execute a lifecycle action against the data service. + * + * Called by ActionConfirmationModal after the user confirms. + * Dispatches to the correct `dataServices.*` method based on action type. + * Passes optional `meta` (form data from the modal body) wrapped in the request payload. + * + * Post-action behavior: + * - DELETE → navigates back to index (record no longer exists) + * - All others → reloads detail data (record still exists, status changed) + */ + const executeAction = useCallback( + async (action: ModuleActionType, data: E, meta?: Record) => { + const id = data.id; + if (!id) return; + + const currentConfig = modalConfigMap[action as keyof typeof modalConfigMap]; + + try { + switch (action) { + case ModuleAction.DELETE: + await dataServices.delete(id, meta); + break; + + case ModuleAction.ACTIVATE: + await dataServices.activate(id, meta); + break; + + case ModuleAction.DEACTIVATE: + await dataServices.deactivate(id, meta); + break; + + case ModuleAction.CONFIRM: + await dataServices.confirmData(id, meta); + break; + + case ModuleAction.CANCEL: + await dataServices.cancelData(id, meta); + break; + + case ModuleAction.ROLLBACK: + await dataServices.rollbackData(id, meta); + break; + + case ModuleAction.HOLD: + await dataServices.holdData(id, meta); + break; + + default: + console.warn(`[executeAction] Unhandled action: ${action}`); + return; + } + + const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`; + const defaultSuccessMessage = t('common:notifications.actionSuccess', { action: t(actionKey) }); + + const customSuccessMessage = + typeof currentConfig?.successMessage === 'function' + ? currentConfig.successMessage(data) + : currentConfig?.successMessage; + + notifications.show({ + title: t('common:notifications.successTitle', { defaultValue: 'Success' }), + message: customSuccessMessage || defaultSuccessMessage, + color: 'teal', + }); + + if (action === ModuleAction.DELETE) { + closeActionModal(); + navigation.navigateToIndex(); + } else { + closeActionModal(); + await loadData(); + } + } catch (error: any) { + const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`; + const defaultErrorMessage = t('common:notifications.actionFailed', { + action: t(actionKey), + message: error?.message || 'Unknown error', + }); + + const customErrorMessage = + typeof currentConfig?.errorMessage === 'function' + ? currentConfig.errorMessage(error) + : currentConfig?.errorMessage; + + notifications.show({ + title: t('common:notifications.errorTitle', { defaultValue: 'Error' }), + message: customErrorMessage || defaultErrorMessage, + color: 'red', + }); + + // Rethrow the error so the modal knows the submission failed + // and re-enables the buttons for the user to try again or cancel + throw error; + } + }, + [dataServices, closeActionModal, navigation, loadData, modalConfigMap, t], + ); + + // --- Handler functions that open the confirmation modal --- + + function handleDelete(data: E): void { + openActionModal(ModuleAction.DELETE, data); } - async function handleActivate(data: E): Promise { - // implementation later - console.log({ data }); + function handleActivate(data: E): void { + openActionModal(ModuleAction.ACTIVATE, data); } - async function handleDeactivate(data: E): Promise { - // implementation later - console.log({ data }); + function handleDeactivate(data: E): void { + openActionModal(ModuleAction.DEACTIVATE, data); } - async function handleConfirm(data: E): Promise { - // implementation later - console.log({ data }); + function handleConfirm(data: E): void { + openActionModal(ModuleAction.CONFIRM, data); } - async function handleCancel(data: E): Promise { - // implementation later - console.log({ data }); + function handleCancel(data: E): void { + openActionModal(ModuleAction.CANCEL, data); } - async function handleRollback(data: E): Promise { - // implementation later - console.log({ data }); + function handleRollback(data: E): void { + openActionModal(ModuleAction.ROLLBACK, data); } - async function handleHold(data: E): Promise { - // implementation later - console.log({ data }); + function handleHold(data: E): void { + openActionModal(ModuleAction.HOLD, data); } // --------------------------------------------------------------------------- @@ -164,28 +323,28 @@ export function EnterpriseDetailPageProvider( else await handleDeactivate(currentData); break; - // --- CONFIRM (Tambahan Baru) --- + // --- CONFIRM --- case ModuleAction.CONFIRM: if (!privileges.ALLOW_CONFIRM || !hasValidData) return; if (onClickConfirm) onClickConfirm(currentData); else await handleConfirm(currentData); break; - // --- CANCEL (Tambahan Baru) --- + // --- CANCEL --- case ModuleAction.CANCEL: if (!privileges.ALLOW_CANCEL || !hasValidData) return; if (onClickCancel) onClickCancel(currentData); else await handleCancel(currentData); break; - // --- ROLLBACK (Tambahan Baru) --- + // --- ROLLBACK --- case ModuleAction.ROLLBACK: if (!privileges.ALLOW_ROLLBACK || !hasValidData) return; if (onClickRollback) onClickRollback(currentData); else await handleRollback(currentData); break; - // --- HOLD (Tambahan Baru) --- + // --- HOLD --- case ModuleAction.HOLD: if (!privileges.ALLOW_HOLD || !hasValidData) return; if (onClickHold) onClickHold(currentData); @@ -397,10 +556,10 @@ export function EnterpriseDetailPageProvider( function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) { const staticTitle = pageProvide?.title; - if (!showHighlight) { + if (!showHighlight || !detailData) { return { flatTitle: staticTitle, title: staticTitle }; } else { - const highlightData = detailData[key]; + const highlightData = detailData && detailData[key]; const flatTitle = `${staticTitle} | ${highlightData}`; return { @@ -413,7 +572,7 @@ export function EnterpriseDetailPageProvider( style={{ fontWeight: 400, marginLeft: '8px', - color: 'var(--mantine-color-dimmed)', + // color: 'var(--mantine-color-dimmed)', }} > | {highlightData} @@ -432,10 +591,10 @@ export function EnterpriseDetailPageProvider( detailData: any, ) { const staticBreadcrumbs = pageProvide.breadcrumbs ?? []; - if (!showHighlight || staticBreadcrumbs.length === 0) { + if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) { return pageProvide.breadcrumbs; } else { - const highlightData = detailData[key]; + const highlightData = detailData && detailData[key]; const breadcrumbs = [ ...staticBreadcrumbs, { @@ -478,11 +637,27 @@ export function EnterpriseDetailPageProvider( miniTitleProps={{ fz: { base: 'md', sm: 'lg' }, }} + badges={ + detailData ? ( + + ) : undefined + } /> } > {children} + + {/* Action Confirmation Modal */} + + modalState={actionModalState} + onClose={closeActionModal} + onExecute={executeAction} + t={t} + /> ); } diff --git a/packages/ui/src/provider/theme-provider.tsx b/packages/ui/src/provider/theme-provider.tsx index 3f4d0b1..107ea26 100644 --- a/packages/ui/src/provider/theme-provider.tsx +++ b/packages/ui/src/provider/theme-provider.tsx @@ -1,4 +1,6 @@ import { MantineProvider, createTheme, mergeThemeOverrides } from '@mantine/core'; +import { ModalsProvider } from '@mantine/modals'; +import { Notifications } from '@mantine/notifications'; import React from 'react'; import { brandColors, errorColors, warningColors, successColors, infoColors, darkColors } from '../theme/tokens/colors'; import { typography } from '../theme/tokens/typography'; @@ -51,7 +53,10 @@ export function ThemeProvider({ children, colorScheme = 'light', density = 'comp return ( - {children} + + {children} + + ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c97fb2b..03f0b35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -446,6 +446,9 @@ importers: '@mantine/hooks': specifier: ^8.3.15 version: 8.3.15(react@19.2.3) + '@mantine/modals': + specifier: ^8.3.15 + version: 8.3.18(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(react-dom@19.2.3)(react@19.2.3) '@mantine/notifications': specifier: ^8.3.15 version: 8.3.18(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(react-dom@19.2.3)(react@19.2.3) @@ -1939,6 +1942,20 @@ packages: react: 19.2.3 dev: false + /@mantine/modals@8.3.18(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q==} + peerDependencies: + '@mantine/core': 8.3.18 + '@mantine/hooks': 8.3.18 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + dependencies: + '@mantine/core': 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3) + '@mantine/hooks': 8.3.15(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + dev: false + /@mantine/notifications@8.3.18(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(react-dom@19.2.3)(react@19.2.3): resolution: {integrity: sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==} peerDependencies: