feat: add StatusBadge component and integrate action confirmation modal
- Introduced StatusBadge component for displaying various status indicators with customizable colors and icons. - Updated ModulePageHeader to conditionally render badges based on props. - Implemented ActionConfirmationModal for handling lifecycle actions (delete, activate, etc.) with support for forms and custom body rendering. - Enhanced EnterpriseDetailPageProvider to manage action confirmation modal state and execution of actions. - Added new modal configurations for various actions in EnterpriseDetailPageConfig. - Updated theme provider to include ModalsProvider and Notifications for better user feedback.
This commit is contained in:
@@ -21,5 +21,6 @@ export const FullPageModuleConfig: ModuleConfigEntity = {
|
|||||||
moduleCategory: 'FULL_PAGE',
|
moduleCategory: 'FULL_PAGE',
|
||||||
|
|
||||||
/** */
|
/** */
|
||||||
moduleType: 'MASTER_DATA',
|
// moduleType: 'MASTER_DATA',
|
||||||
|
moduleType: 'TRANSACTION',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"create_page_title": "New Full Page",
|
"create_page_title": "New Full Page",
|
||||||
"duplicate_page_title": "Duplicate Full Page",
|
"duplicate_page_title": "Duplicate Full Page",
|
||||||
"description": "An example module of a <1>full page layout</1> for detailed forms.",
|
"description": "An example module of a <1>full page layout</1> for detailed forms.",
|
||||||
|
"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": {
|
"fields": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"create_page_title": "Buat Halaman Penuh Baru",
|
"create_page_title": "Buat Halaman Penuh Baru",
|
||||||
"duplicate_page_title": "Duplikat Halaman Penuh",
|
"duplicate_page_title": "Duplikat Halaman Penuh",
|
||||||
"description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.",
|
"description": "Contoh penerapan <1>tata letak halaman penuh</1> 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": {
|
"fields": {
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"name": "Nama",
|
"name": "Nama",
|
||||||
|
|||||||
+49
-2
@@ -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 { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { FullPageModuleConfig } from '../../domain/constants';
|
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() {
|
export default function FullPagePageDetail() {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const [detailData, setDetailData] = useState();
|
||||||
|
console.log({ detailData });
|
||||||
return (
|
return (
|
||||||
<EnterpriseDetailPageProvider
|
<EnterpriseDetailPageProvider
|
||||||
|
onDetailLoaded={detailData}
|
||||||
|
cancelModalConfig={{
|
||||||
|
title: t('common:confirmDialog.cancel.title'),
|
||||||
|
schema: cancelSchema,
|
||||||
|
defaultValues: { reason: '' },
|
||||||
|
renderBody: (form) => (
|
||||||
|
<FieldTextarea
|
||||||
|
name="reason"
|
||||||
|
control={form!.control}
|
||||||
|
label="Reason for Cancellation"
|
||||||
|
placeholder="Please provide a reason..."
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
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: <Sparkles size={12} strokeWidth={2.5} />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
pageHeaderProps={{
|
pageHeaderProps={{
|
||||||
title: t('detail_page_title'),
|
title: t('detail_page_title'),
|
||||||
|
description: t('detail_page_description'),
|
||||||
|
// showBadges: false,
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
{ label: t('nav:example-module'), type: 'text' },
|
{ label: t('nav:example-module'), type: 'text' },
|
||||||
{ label: t('nav:example-full-page'), type: 'link', href: `${FullPageModuleConfig.webUrl}/index` },
|
{ label: t('nav:example-full-page'), type: 'link', href: `${FullPageModuleConfig.webUrl}/index` },
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
|
StatusBadge,
|
||||||
|
STATUS_DATA,
|
||||||
} from '@repo/ui/components';
|
} from '@repo/ui/components';
|
||||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText, LayoutDashboard } from 'lucide-react';
|
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText, LayoutDashboard } from 'lucide-react';
|
||||||
import { Globe } from 'lucide-react';
|
import { Globe } from 'lucide-react';
|
||||||
@@ -249,6 +251,25 @@ export default function ShowcaseView({ density, setDensity }: ShowcaseViewProps)
|
|||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<div>
|
||||||
|
<Title order={4} mb="xs">
|
||||||
|
Enterprise Status Badges
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Pre-configured status badges for transaction and master data.
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<StatusBadge status={STATUS_DATA.DRAFT} />
|
||||||
|
<StatusBadge status={STATUS_DATA.PENDING} />
|
||||||
|
<StatusBadge status={STATUS_DATA.PROCESS} />
|
||||||
|
<StatusBadge status={STATUS_DATA.APPROVED} />
|
||||||
|
<StatusBadge status={STATUS_DATA.CANCELLED} />
|
||||||
|
<StatusBadge status={STATUS_DATA.WAITING} />
|
||||||
|
<StatusBadge status={STATUS_DATA.ON_HOLD} />
|
||||||
|
<StatusBadge status={STATUS_DATA.REFUNDED} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
<div>
|
<div>
|
||||||
<Title order={4} mb="md">
|
<Title order={4} mb="md">
|
||||||
Buttons
|
Buttons
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
RequestDescriptor,
|
RequestDescriptor,
|
||||||
ExecuteOptions,
|
ExecuteOptions,
|
||||||
DataServicesConfig,
|
DataServicesConfig,
|
||||||
|
EntityId,
|
||||||
} from './types';
|
} from './types';
|
||||||
import type { IDataTransformer } from './base-data.transformer';
|
import type { IDataTransformer } from './base-data.transformer';
|
||||||
import type { ApiResponse } from '../http-client/types';
|
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 a single entity by ID. Optionally sends form data as `meta` in the request body. */
|
||||||
delete(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
delete(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.delete, {
|
return this.execute<void>(DESCRIPTORS.delete, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config: { ...config, ...(meta ? { data: { meta } } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete multiple entities by IDs. */
|
/** 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, {
|
return this.execute<void>(DESCRIPTORS.batchDelete, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
@@ -259,31 +260,31 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
|
|||||||
|
|
||||||
// ─── Activation Lifecycle ─────────────────────────────────────
|
// ─── Activation Lifecycle ─────────────────────────────────────
|
||||||
|
|
||||||
/** Activate a single entity. */
|
/** Activate a single entity. Optionally sends form data as `meta` in the request body. */
|
||||||
activate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
activate(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.activate, {
|
return this.execute<void>(DESCRIPTORS.activate, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config: { ...config, ...(meta ? { data: { meta } } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Activate multiple entities. */
|
/** 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, {
|
return this.execute<void>(DESCRIPTORS.batchActivate, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deactivate a single entity. */
|
/** Deactivate a single entity. Optionally sends form data as `meta` in the request body. */
|
||||||
deactivate(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
deactivate(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.deactivate, {
|
return this.execute<void>(DESCRIPTORS.deactivate, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config: { ...config, ...(meta ? { data: { meta } } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deactivate multiple entities. */
|
/** 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, {
|
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
@@ -291,31 +292,31 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
|
|||||||
|
|
||||||
// ─── Data Processing Lifecycle ────────────────────────────────
|
// ─── Data Processing Lifecycle ────────────────────────────────
|
||||||
|
|
||||||
/** Confirm processing of a single data record. */
|
/** Confirm processing of a single data record. Optionally sends form data as `meta` in the request body. */
|
||||||
confirmData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
confirmData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.confirmData, {
|
return this.execute<void>(DESCRIPTORS.confirmData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config: { ...config, ...(meta ? { data: { meta } } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confirm processing of multiple data records. */
|
/** 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, {
|
return this.execute<void>(DESCRIPTORS.batchConfirmData, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel processing of a single data record. */
|
/** Cancel processing of a single data record. Optionally sends form data as `meta` in the request body. */
|
||||||
cancelData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
cancelData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.cancelData, {
|
return this.execute<void>(DESCRIPTORS.cancelData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config: { ...config, ...(meta ? { data: { meta } } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel processing of multiple data records. */
|
/** 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, {
|
return this.execute<void>(DESCRIPTORS.batchCancelData, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
@@ -323,31 +324,31 @@ export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity,
|
|||||||
|
|
||||||
// ─── Transaction Lifecycle ────────────────────────────────────
|
// ─── Transaction Lifecycle ────────────────────────────────────
|
||||||
|
|
||||||
/** Rollback a transaction. */
|
/** Rollback a transaction. Optionally sends form data as `meta` in the request body. */
|
||||||
rollbackData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
rollbackData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.rollbackData, {
|
return this.execute<void>(DESCRIPTORS.rollbackData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config: { ...config, ...(meta ? { data: { meta } } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rollback multiple transactions. */
|
/** 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, {
|
return this.execute<void>(DESCRIPTORS.batchRollbackData, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hold a transaction. */
|
/** Hold a transaction. Optionally sends form data as `meta` in the request body. */
|
||||||
holdData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
holdData(id: EntityId, meta?: Record<string, unknown>, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.holdData, {
|
return this.execute<void>(DESCRIPTORS.holdData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config: { ...config, ...(meta ? { data: { meta } } : {}) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hold multiple transactions. */
|
/** 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, {
|
return this.execute<void>(DESCRIPTORS.batchHoldData, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import type { IDataTransformer } from './base-data.transformer';
|
|||||||
* Minimal entity contract. All domain entities must have
|
* Minimal entity contract. All domain entities must have
|
||||||
* an optional `id` field for CRUD operations.
|
* an optional `id` field for CRUD operations.
|
||||||
*/
|
*/
|
||||||
|
export type EntityId = string | number;
|
||||||
|
|
||||||
export interface BaseEntity {
|
export interface BaseEntity {
|
||||||
id?: string;
|
id?: EntityId;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@ export interface RequestDescriptor {
|
|||||||
*/
|
*/
|
||||||
export interface ExecuteOptions {
|
export interface ExecuteOptions {
|
||||||
/** Dynamic URL parameters (e.g., `{ id: '42' }`). */
|
/** Dynamic URL parameters (e.g., `{ id: '42' }`). */
|
||||||
variableURL?: Record<string, string>;
|
variableURL?: Record<string, any>;
|
||||||
/** Additional Axios request config (params, data, headers, etc). */
|
/** Additional Axios request config (params, data, headers, etc). */
|
||||||
config?: AxiosRequestConfig;
|
config?: AxiosRequestConfig;
|
||||||
/** Per-request telemetry context for custom spans, tags, events. */
|
/** Per-request telemetry context for custom spans, tags, events. */
|
||||||
|
|||||||
@@ -58,10 +58,34 @@
|
|||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
},
|
},
|
||||||
"confirmDialog": {
|
"confirmDialog": {
|
||||||
"title": "Are you sure?",
|
"delete": {
|
||||||
"deleteMessage": "This action cannot be undone.",
|
"title": "Delete Item",
|
||||||
"confirmButton": "Confirm",
|
"description": "Are you sure you want to delete this item? This action cannot be undone."
|
||||||
"cancelButton": "Cancel"
|
},
|
||||||
|
"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": {
|
"draft": {
|
||||||
"recoveryTitle": "Draft Found",
|
"recoveryTitle": "Draft Found",
|
||||||
@@ -70,6 +94,8 @@
|
|||||||
"discardDraft": "Start Fresh"
|
"discardDraft": "Start Fresh"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
"successTitle": "Success",
|
||||||
|
"errorTitle": "Error",
|
||||||
"saveSuccess": "Data saved successfully",
|
"saveSuccess": "Data saved successfully",
|
||||||
"deleteSuccess": "Data deleted successfully",
|
"deleteSuccess": "Data deleted successfully",
|
||||||
"actionSuccess": "{{action}} completed successfully",
|
"actionSuccess": "{{action}} completed successfully",
|
||||||
|
|||||||
@@ -47,8 +47,8 @@
|
|||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
"print": "Cetak",
|
"print": "Cetak",
|
||||||
"confirm": "Konfirmasi",
|
"confirm": "Confirm",
|
||||||
"cancel": "Batal",
|
"cancel": "Cancel",
|
||||||
"activate": "Aktifkan",
|
"activate": "Aktifkan",
|
||||||
"deactivate": "Nonaktifkan",
|
"deactivate": "Nonaktifkan",
|
||||||
"duplicate": "Duplikat",
|
"duplicate": "Duplikat",
|
||||||
@@ -58,10 +58,34 @@
|
|||||||
"reload": "Muat Ulang"
|
"reload": "Muat Ulang"
|
||||||
},
|
},
|
||||||
"confirmDialog": {
|
"confirmDialog": {
|
||||||
"title": "Apakah Anda yakin?",
|
"delete": {
|
||||||
"deleteMessage": "Tindakan ini tidak dapat dibatalkan.",
|
"title": "Hapus Data",
|
||||||
"confirmButton": "Konfirmasi",
|
"description": "Apakah Anda yakin ingin menghapus data ini? Tindakan ini tidak dapat dibatalkan."
|
||||||
"cancelButton": "Batal"
|
},
|
||||||
|
"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": {
|
"draft": {
|
||||||
"recoveryTitle": "Draf Ditemukan",
|
"recoveryTitle": "Draf Ditemukan",
|
||||||
@@ -70,6 +94,8 @@
|
|||||||
"discardDraft": "Mulai Baru"
|
"discardDraft": "Mulai Baru"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
|
"successTitle": "Berhasil",
|
||||||
|
"errorTitle": "Galat",
|
||||||
"saveSuccess": "Data berhasil disimpan",
|
"saveSuccess": "Data berhasil disimpan",
|
||||||
"deleteSuccess": "Data berhasil dihapus",
|
"deleteSuccess": "Data berhasil dihapus",
|
||||||
"actionSuccess": "{{action}} berhasil diselesaikan",
|
"actionSuccess": "{{action}} berhasil diselesaikan",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@hookform/resolvers": "^5.0.1",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@mantine/core": "^8.3.15",
|
"@mantine/core": "^8.3.15",
|
||||||
"@mantine/hooks": "^8.3.15",
|
"@mantine/hooks": "^8.3.15",
|
||||||
|
"@mantine/modals": "^8.3.15",
|
||||||
"@mantine/notifications": "^8.3.15",
|
"@mantine/notifications": "^8.3.15",
|
||||||
"@mantine/tiptap": "^9.3.2",
|
"@mantine/tiptap": "^9.3.2",
|
||||||
"@repo/core-api": "workspace:^",
|
"@repo/core-api": "workspace:^",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
|
|||||||
return {
|
return {
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
radius: 'md',
|
radius: 'md',
|
||||||
|
fw: 500,
|
||||||
style: isPremiumGlow
|
style: isPremiumGlow
|
||||||
? { boxShadow: '0 4px 14px 0 color-mix(in srgb, var(--mantine-primary-color-filled) 40%, transparent)' }
|
? { boxShadow: '0 4px 14px 0 color-mix(in srgb, var(--mantine-primary-color-filled) 40%, transparent)' }
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -32,7 +33,7 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
|
|||||||
return (
|
return (
|
||||||
<Box style={{ display: 'inline-flex' }}>
|
<Box style={{ display: 'inline-flex' }}>
|
||||||
{/* --- DESKTOP VIEW --- */}
|
{/* --- DESKTOP VIEW --- */}
|
||||||
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
<Group gap="xs" wrap="nowrap" visibleFrom="md">
|
||||||
{actions.map((action, index) => {
|
{actions.map((action, index) => {
|
||||||
if (action.type === 'divider') {
|
if (action.type === 'divider') {
|
||||||
return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />;
|
return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />;
|
||||||
@@ -116,14 +117,14 @@ export const PageActions = memo(function PageActions({ actions = [], customButto
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* --- MOBILE VIEW --- */}
|
{/* --- MOBILE VIEW --- */}
|
||||||
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
<Group gap="xs" wrap="nowrap" hiddenFrom="md">
|
||||||
<Menu position="bottom-end" withArrow withinPortal>
|
<Menu position="bottom-end" withArrow withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="outline" size="md">
|
<ActionIcon variant="outline" size="md">
|
||||||
<MoreVertical size={16} />
|
<MoreVertical size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown px={'xl'}>
|
<Menu.Dropdown px={'xl'} w={180}>
|
||||||
{actions.map((action, index) => {
|
{actions.map((action, index) => {
|
||||||
if (action.type === 'divider') {
|
if (action.type === 'divider') {
|
||||||
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
|
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 (
|
return (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={action.key}
|
key={action.key}
|
||||||
leftSection={action.icon}
|
leftSection={action.icon}
|
||||||
color={getIntentColor(action.intent)}
|
color={realColor}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
onClick={() => action.onClick?.(action.key || '')}
|
onClick={() => action.onClick?.(action.key || '')}
|
||||||
mt="sm"
|
mt="sm"
|
||||||
mb="sm"
|
mb="sm"
|
||||||
fw={600}
|
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './system-pages/maintenance';
|
|||||||
export * from './system-pages/not-found';
|
export * from './system-pages/not-found';
|
||||||
export * from './core-app-shell';
|
export * from './core-app-shell';
|
||||||
export * from './actions-tools';
|
export * from './actions-tools';
|
||||||
|
export * from './status-badge';
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './status-badge.component';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+222
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ export interface ModulePageHeaderProps {
|
|||||||
miniTitleProps?: Omit<TitleProps, 'children'>;
|
miniTitleProps?: Omit<TitleProps, 'children'>;
|
||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
|
|
||||||
badges?: React.ReactNode;
|
badges?: React.ReactNode;
|
||||||
|
showBadges?: boolean;
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
breadcrumbs?: BreadcrumbItem[];
|
||||||
|
|
||||||
showPageHeader?: boolean;
|
showPageHeader?: boolean;
|
||||||
@@ -148,6 +150,8 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
|||||||
description,
|
description,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
badges,
|
badges,
|
||||||
|
showBadges = true,
|
||||||
|
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
actions,
|
actions,
|
||||||
customButtonProps,
|
customButtonProps,
|
||||||
@@ -209,7 +213,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
|||||||
{title}
|
{title}
|
||||||
</Title>
|
</Title>
|
||||||
)}
|
)}
|
||||||
{badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
|
{showBadges && badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Right: actions + inline toggle (separated by divider) */}
|
{/* Right: actions + inline toggle (separated by divider) */}
|
||||||
@@ -304,7 +308,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
|||||||
{title}
|
{title}
|
||||||
</Title>
|
</Title>
|
||||||
)}
|
)}
|
||||||
{badges && <Box>{badges}</Box>}
|
{showBadges && badges && <Box>{badges}</Box>}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{description && (
|
{description && (
|
||||||
@@ -312,7 +316,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
|||||||
fz={{ base: 'xs', sm: 'sm' }}
|
fz={{ base: 'xs', sm: 'sm' }}
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
fw={400}
|
fw={400}
|
||||||
lineClamp={2}
|
lineClamp={1}
|
||||||
mt={4}
|
mt={4}
|
||||||
style={{
|
style={{
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { ReactNode } from 'react';
|
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 { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||||
import type { ModulePageHeaderProps } from '../components/module-page-header';
|
import type { ModulePageHeaderProps } from '../components/module-page-header';
|
||||||
import { PageActionsProps } from '../../../components';
|
import { PageActionsProps } from '../../../components';
|
||||||
@@ -219,9 +221,84 @@ export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> ext
|
|||||||
presetDuplicate?: (data: E) => Promise<Partial<E>>;
|
presetDuplicate?: (data: E) => Promise<Partial<E>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<string, unknown>).
|
||||||
|
*/
|
||||||
|
export interface ActionModalConfig<TMeta extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
|
/** 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<TMeta>`
|
||||||
|
* instance. Use `Field*` components from `@repo/ui/form` bound to this form.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Static body (no form)
|
||||||
|
* renderBody: () => <Text>Are you sure you want to delete this item?</Text>
|
||||||
|
*
|
||||||
|
* // Form body
|
||||||
|
* renderBody: (form) => (
|
||||||
|
* <FieldTextarea name="reason" control={form.control} label="Reason" />
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
renderBody?: (form?: UseFormReturn<TMeta>) => ReactNode;
|
||||||
|
/** Zod schema for validating the form body. When omitted, no validation is applied */
|
||||||
|
schema?: ZodType<TMeta>;
|
||||||
|
/** 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<E extends BaseEntity = BaseEntity> {
|
||||||
|
opened: boolean;
|
||||||
|
action: ModuleActionType | null;
|
||||||
|
data: E | null;
|
||||||
|
config?: ActionModalConfig<any>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
|
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
|
||||||
editMode?: 'FULL' | 'PARTIAL';
|
editMode?: 'FULL' | 'PARTIAL';
|
||||||
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
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<import('@mantine/core').BadgeProps>;
|
||||||
|
|
||||||
onClickCreate?: () => void;
|
onClickCreate?: () => void;
|
||||||
onClickDuplicate?: (data: E) => void;
|
onClickDuplicate?: (data: E) => void;
|
||||||
@@ -238,9 +315,14 @@ export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> e
|
|||||||
onClickRollback?: (data: E) => void;
|
onClickRollback?: (data: E) => void;
|
||||||
onClickHold?: (data: E) => void;
|
onClickHold?: (data: E) => void;
|
||||||
|
|
||||||
showHighlightData?: boolean;
|
// Action Modal Configurations
|
||||||
showHighlightDataOnBreadcrumbs?: boolean;
|
deleteModalConfig?: ActionModalConfig;
|
||||||
highlightDataKey?: string;
|
activateModalConfig?: ActionModalConfig;
|
||||||
|
deactivateModalConfig?: ActionModalConfig;
|
||||||
|
confirmModalConfig?: ActionModalConfig;
|
||||||
|
cancelModalConfig?: ActionModalConfig;
|
||||||
|
rollbackModalConfig?: ActionModalConfig;
|
||||||
|
holdModalConfig?: ActionModalConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrivilegeEntity {
|
export interface PrivilegeEntity {
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from './providers/module.provider';
|
|||||||
export * from './providers/index-page.provider';
|
export * from './providers/index-page.provider';
|
||||||
export * from './providers/detail-page.provider';
|
export * from './providers/detail-page.provider';
|
||||||
export * from './components/module-page-header';
|
export * from './components/module-page-header';
|
||||||
|
export * from './components/action-confirmation-modal';
|
||||||
|
|||||||
@@ -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 { Check, CheckCircle, Copy, Edit2, PauseCircle, Plus, RotateCcw, Trash2, X, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { DetailPageContext } from '../hooks/use-detail-page.context';
|
import { DetailPageContext } from '../hooks/use-detail-page.context';
|
||||||
import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType } from '../entities/entity';
|
import { EnterpriseDetailPageConfig, ModuleAction, ModuleActionType, ActionModalState } from '../entities/entity';
|
||||||
import { CorePageContainer, PageActionProps } from '../../../components';
|
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
|
||||||
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
|
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
|
||||||
|
import { ActionConfirmationModal, ACTION_TRANSLATION_MAP } from '../components/action-confirmation-modal';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
useEnterpriseModuleConfigContext,
|
useEnterpriseModuleConfigContext,
|
||||||
useEnterpriseModuleDataServiceContext,
|
useEnterpriseModuleDataServiceContext,
|
||||||
@@ -22,6 +24,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
pageHeaderProps,
|
pageHeaderProps,
|
||||||
px,
|
px,
|
||||||
py,
|
py,
|
||||||
|
|
||||||
|
showHighlightData = true,
|
||||||
|
showHighlightDataOnBreadcrumbs = true,
|
||||||
|
highlightDataKey = 'code',
|
||||||
|
statusKey = 'status',
|
||||||
|
getCustomStatusConfig,
|
||||||
|
onDetailLoaded,
|
||||||
|
|
||||||
customPageActions,
|
customPageActions,
|
||||||
onClickCreate,
|
onClickCreate,
|
||||||
onClickDuplicate,
|
onClickDuplicate,
|
||||||
@@ -34,9 +44,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
onClickRollback,
|
onClickRollback,
|
||||||
onClickHold,
|
onClickHold,
|
||||||
|
|
||||||
showHighlightData = true,
|
// Modal configurations per action
|
||||||
showHighlightDataOnBreadcrumbs = true,
|
deleteModalConfig,
|
||||||
highlightDataKey = 'code',
|
activateModalConfig,
|
||||||
|
deactivateModalConfig,
|
||||||
|
confirmModalConfig,
|
||||||
|
cancelModalConfig,
|
||||||
|
rollbackModalConfig,
|
||||||
|
holdModalConfig,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
@@ -51,7 +66,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
|
|
||||||
const { moduleKey, moduleType } = config;
|
const { moduleKey, moduleType } = config;
|
||||||
|
|
||||||
const [detailData, setDetailData] = useState<E | any>({ id: 1, code: 'ABC-001' });
|
const [detailData, setDetailData] = useState<E | any>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@@ -60,9 +75,16 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
try {
|
try {
|
||||||
const response = await dataServices.getOne(dataId);
|
const response = await dataServices.getOne(dataId);
|
||||||
if (response && response.data) {
|
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) {
|
} 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);
|
console.error('Failed to load detail data', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -74,41 +96,178 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 1. Stub Handlers (Sudah diperbaiki typonya & lengkap)
|
// 1. Action Confirmation Modal State & Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function handleDelete(data: E): Promise<void> {
|
|
||||||
// implementation later
|
/** Default (closed) modal state — stable reference to avoid re-creating on every render. */
|
||||||
console.log({ data });
|
const CLOSED_MODAL: ActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: null }), []);
|
||||||
|
|
||||||
|
const [actionModalState, setActionModalState] = useState<ActionModalState<E>>(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<string, unknown>) => {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleActivate(data: E): Promise<void> {
|
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
||||||
// implementation later
|
const defaultSuccessMessage = t('common:notifications.actionSuccess', { action: t(actionKey) });
|
||||||
console.log({ data });
|
|
||||||
|
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 handleDeactivate(data: E): Promise<void> {
|
function handleActivate(data: E): void {
|
||||||
// implementation later
|
openActionModal(ModuleAction.ACTIVATE, data);
|
||||||
console.log({ data });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirm(data: E): Promise<void> {
|
function handleDeactivate(data: E): void {
|
||||||
// implementation later
|
openActionModal(ModuleAction.DEACTIVATE, data);
|
||||||
console.log({ data });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(data: E): Promise<void> {
|
function handleConfirm(data: E): void {
|
||||||
// implementation later
|
openActionModal(ModuleAction.CONFIRM, data);
|
||||||
console.log({ data });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRollback(data: E): Promise<void> {
|
function handleCancel(data: E): void {
|
||||||
// implementation later
|
openActionModal(ModuleAction.CANCEL, data);
|
||||||
console.log({ data });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleHold(data: E): Promise<void> {
|
function handleRollback(data: E): void {
|
||||||
// implementation later
|
openActionModal(ModuleAction.ROLLBACK, data);
|
||||||
console.log({ data });
|
}
|
||||||
|
|
||||||
|
function handleHold(data: E): void {
|
||||||
|
openActionModal(ModuleAction.HOLD, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -164,28 +323,28 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
else await handleDeactivate(currentData);
|
else await handleDeactivate(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- CONFIRM (Tambahan Baru) ---
|
// --- CONFIRM ---
|
||||||
case ModuleAction.CONFIRM:
|
case ModuleAction.CONFIRM:
|
||||||
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
|
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
|
||||||
if (onClickConfirm) onClickConfirm(currentData);
|
if (onClickConfirm) onClickConfirm(currentData);
|
||||||
else await handleConfirm(currentData);
|
else await handleConfirm(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- CANCEL (Tambahan Baru) ---
|
// --- CANCEL ---
|
||||||
case ModuleAction.CANCEL:
|
case ModuleAction.CANCEL:
|
||||||
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
|
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
|
||||||
if (onClickCancel) onClickCancel(currentData);
|
if (onClickCancel) onClickCancel(currentData);
|
||||||
else await handleCancel(currentData);
|
else await handleCancel(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- ROLLBACK (Tambahan Baru) ---
|
// --- ROLLBACK ---
|
||||||
case ModuleAction.ROLLBACK:
|
case ModuleAction.ROLLBACK:
|
||||||
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
|
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
|
||||||
if (onClickRollback) onClickRollback(currentData);
|
if (onClickRollback) onClickRollback(currentData);
|
||||||
else await handleRollback(currentData);
|
else await handleRollback(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- HOLD (Tambahan Baru) ---
|
// --- HOLD ---
|
||||||
case ModuleAction.HOLD:
|
case ModuleAction.HOLD:
|
||||||
if (!privileges.ALLOW_HOLD || !hasValidData) return;
|
if (!privileges.ALLOW_HOLD || !hasValidData) return;
|
||||||
if (onClickHold) onClickHold(currentData);
|
if (onClickHold) onClickHold(currentData);
|
||||||
@@ -397,10 +556,10 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
|
|
||||||
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||||
const staticTitle = pageProvide?.title;
|
const staticTitle = pageProvide?.title;
|
||||||
if (!showHighlight) {
|
if (!showHighlight || !detailData) {
|
||||||
return { flatTitle: staticTitle, title: staticTitle };
|
return { flatTitle: staticTitle, title: staticTitle };
|
||||||
} else {
|
} else {
|
||||||
const highlightData = detailData[key];
|
const highlightData = detailData && detailData[key];
|
||||||
const flatTitle = `${staticTitle} | ${highlightData}`;
|
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -413,7 +572,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
style={{
|
style={{
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
marginLeft: '8px',
|
marginLeft: '8px',
|
||||||
color: 'var(--mantine-color-dimmed)',
|
// color: 'var(--mantine-color-dimmed)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
| {highlightData}
|
| {highlightData}
|
||||||
@@ -432,10 +591,10 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
detailData: any,
|
detailData: any,
|
||||||
) {
|
) {
|
||||||
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||||
if (!showHighlight || staticBreadcrumbs.length === 0) {
|
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
||||||
return pageProvide.breadcrumbs;
|
return pageProvide.breadcrumbs;
|
||||||
} else {
|
} else {
|
||||||
const highlightData = detailData[key];
|
const highlightData = detailData && detailData[key];
|
||||||
const breadcrumbs = [
|
const breadcrumbs = [
|
||||||
...staticBreadcrumbs,
|
...staticBreadcrumbs,
|
||||||
{
|
{
|
||||||
@@ -478,11 +637,27 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
miniTitleProps={{
|
miniTitleProps={{
|
||||||
fz: { base: 'md', sm: 'lg' },
|
fz: { base: 'md', sm: 'lg' },
|
||||||
}}
|
}}
|
||||||
|
badges={
|
||||||
|
detailData ? (
|
||||||
|
<StatusBadge
|
||||||
|
status={detailData[statusKey as keyof typeof detailData] as string}
|
||||||
|
getCustomConfig={getCustomStatusConfig}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</CorePageContainer>
|
</CorePageContainer>
|
||||||
|
|
||||||
|
{/* Action Confirmation Modal */}
|
||||||
|
<ActionConfirmationModal<E>
|
||||||
|
modalState={actionModalState}
|
||||||
|
onClose={closeActionModal}
|
||||||
|
onExecute={executeAction}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
</DetailPageContext.Provider>
|
</DetailPageContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { MantineProvider, createTheme, mergeThemeOverrides } from '@mantine/core';
|
import { MantineProvider, createTheme, mergeThemeOverrides } from '@mantine/core';
|
||||||
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
|
import { Notifications } from '@mantine/notifications';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { brandColors, errorColors, warningColors, successColors, infoColors, darkColors } from '../theme/tokens/colors';
|
import { brandColors, errorColors, warningColors, successColors, infoColors, darkColors } from '../theme/tokens/colors';
|
||||||
import { typography } from '../theme/tokens/typography';
|
import { typography } from '../theme/tokens/typography';
|
||||||
@@ -51,7 +53,10 @@ export function ThemeProvider({ children, colorScheme = 'light', density = 'comp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider theme={mergedTheme} forceColorScheme={colorScheme} defaultColorScheme={colorScheme}>
|
<MantineProvider theme={mergedTheme} forceColorScheme={colorScheme} defaultColorScheme={colorScheme}>
|
||||||
|
<ModalsProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<Notifications position="top-right" autoClose={4000} zIndex={1000} />
|
||||||
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+17
@@ -446,6 +446,9 @@ importers:
|
|||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^8.3.15
|
specifier: ^8.3.15
|
||||||
version: 8.3.15(react@19.2.3)
|
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':
|
'@mantine/notifications':
|
||||||
specifier: ^8.3.15
|
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)
|
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
|
react: 19.2.3
|
||||||
dev: false
|
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):
|
/@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==}
|
resolution: {integrity: sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user