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',
|
||||
|
||||
/** */
|
||||
moduleType: 'MASTER_DATA',
|
||||
// moduleType: 'MASTER_DATA',
|
||||
moduleType: 'TRANSACTION',
|
||||
} as const;
|
||||
|
||||
@@ -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</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": {
|
||||
"status": "Status",
|
||||
"name": "Name",
|
||||
|
||||
@@ -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</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": {
|
||||
"status": "Status",
|
||||
"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 { 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 (
|
||||
<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={{
|
||||
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` },
|
||||
|
||||
@@ -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)
|
||||
</Group>
|
||||
</div>
|
||||
<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>
|
||||
<Title order={4} mb="md">
|
||||
Buttons
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'>;
|
||||
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}
|
||||
</Title>
|
||||
)}
|
||||
{badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
|
||||
{showBadges && badges && <Box style={{ flexShrink: 0 }}>{badges}</Box>}
|
||||
</Flex>
|
||||
|
||||
{/* Right: actions + inline toggle (separated by divider) */}
|
||||
@@ -304,7 +308,7 @@ export function ModulePageHeader(_props: ModulePageHeaderProps) {
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
{badges && <Box>{badges}</Box>}
|
||||
{showBadges && badges && <Box>{badges}</Box>}
|
||||
</Flex>
|
||||
|
||||
{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',
|
||||
|
||||
@@ -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<E extends BaseEntity = BaseEntity> ext
|
||||
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 {
|
||||
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<import('@mantine/core').BadgeProps>;
|
||||
|
||||
onClickCreate?: () => void;
|
||||
onClickDuplicate?: (data: E) => void;
|
||||
@@ -238,9 +315,14 @@ export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> 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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<E extends BaseEntity = BaseEntity>(
|
||||
pageHeaderProps,
|
||||
px,
|
||||
py,
|
||||
|
||||
showHighlightData = true,
|
||||
showHighlightDataOnBreadcrumbs = true,
|
||||
highlightDataKey = 'code',
|
||||
statusKey = 'status',
|
||||
getCustomStatusConfig,
|
||||
onDetailLoaded,
|
||||
|
||||
customPageActions,
|
||||
onClickCreate,
|
||||
onClickDuplicate,
|
||||
@@ -34,9 +44,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
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<E extends BaseEntity = BaseEntity>(
|
||||
|
||||
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 loadData = useCallback(async () => {
|
||||
@@ -60,9 +75,16 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
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<E extends BaseEntity = BaseEntity>(
|
||||
}, [loadData]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Stub Handlers (Sudah diperbaiki typonya & lengkap)
|
||||
// 1. Action Confirmation Modal State & Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function handleDelete(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
|
||||
/** Default (closed) modal state — stable reference to avoid re-creating on every render. */
|
||||
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;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleActivate(data: E): void {
|
||||
openActionModal(ModuleAction.ACTIVATE, data);
|
||||
}
|
||||
|
||||
async function handleDeactivate(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleDeactivate(data: E): void {
|
||||
openActionModal(ModuleAction.DEACTIVATE, data);
|
||||
}
|
||||
|
||||
async function handleConfirm(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleConfirm(data: E): void {
|
||||
openActionModal(ModuleAction.CONFIRM, data);
|
||||
}
|
||||
|
||||
async function handleCancel(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleCancel(data: E): void {
|
||||
openActionModal(ModuleAction.CANCEL, data);
|
||||
}
|
||||
|
||||
async function handleRollback(data: E): Promise<void> {
|
||||
// implementation later
|
||||
console.log({ data });
|
||||
function handleRollback(data: E): void {
|
||||
openActionModal(ModuleAction.ROLLBACK, data);
|
||||
}
|
||||
|
||||
async function handleHold(data: E): Promise<void> {
|
||||
// implementation later
|
||||
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);
|
||||
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<E extends BaseEntity = BaseEntity>(
|
||||
|
||||
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<E extends BaseEntity = BaseEntity>(
|
||||
style={{
|
||||
fontWeight: 400,
|
||||
marginLeft: '8px',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
// color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
| {highlightData}
|
||||
@@ -432,10 +591,10 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
||||
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<E extends BaseEntity = BaseEntity>(
|
||||
miniTitleProps={{
|
||||
fz: { base: 'md', sm: 'lg' },
|
||||
}}
|
||||
badges={
|
||||
detailData ? (
|
||||
<StatusBadge
|
||||
status={detailData[statusKey as keyof typeof detailData] as string}
|
||||
getCustomConfig={getCustomStatusConfig}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</CorePageContainer>
|
||||
|
||||
{/* Action Confirmation Modal */}
|
||||
<ActionConfirmationModal<E>
|
||||
modalState={actionModalState}
|
||||
onClose={closeActionModal}
|
||||
onExecute={executeAction}
|
||||
t={t}
|
||||
/>
|
||||
</DetailPageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<MantineProvider theme={mergedTheme} forceColorScheme={colorScheme} defaultColorScheme={colorScheme}>
|
||||
{children}
|
||||
<ModalsProvider>
|
||||
{children}
|
||||
<Notifications position="top-right" autoClose={4000} zIndex={1000} />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+17
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user