feat: add RowActions component for enhanced row-level actions in data grids
- Introduced RowActions component to manage row-level actions with tooltips and dropdown menus. - Created types for row actions and page actions to standardize action properties. - Implemented utility function to map action intents to Mantine theme colors. - Updated CoreAppShell component to support optional slots for better flexibility. - Added enterprise module structure with context hooks for managing module state and actions. - Implemented draft management for forms to enhance user experience during data entry. - Established context providers for detail, form, and index pages to streamline data handling. - Updated dependencies to ensure compatibility with the latest versions.
This commit is contained in:
Vendored
+1
-1
@@ -4,5 +4,5 @@
|
|||||||
"mode": "auto"
|
"mode": "auto"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"cSpell.words": ["mantine", "Menlo", "Millis", "Pandang", "Segoe", "Ujung", "WITA"]
|
"cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "WITA"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { defineConfig } from 'vitepress'
|
import { defineConfig } from 'vitepress';
|
||||||
import { withMermaid } from 'vitepress-plugin-mermaid'
|
import { withMermaid } from 'vitepress-plugin-mermaid';
|
||||||
|
|
||||||
const config = withMermaid(
|
const config = withMermaid(
|
||||||
defineConfig({
|
defineConfig({
|
||||||
// title: "Frontend Monorepo",
|
// title: "Frontend Monorepo",
|
||||||
title: 'Frontend Arch',
|
title: 'Frontend Arch',
|
||||||
description: "Centralized documentation for the Enterprise Frontend Monorepo",
|
description: 'Centralized documentation for the Enterprise Frontend Monorepo',
|
||||||
head: [
|
head: [
|
||||||
['link', { rel: 'icon', href: '/favicon.svg' }] // Jika Anda menggunakan favicon.svg
|
['link', { rel: 'icon', href: '/favicon.svg' }], // Jika Anda menggunakan favicon.svg
|
||||||
],
|
],
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
search: {
|
search: {
|
||||||
provider: 'local',
|
provider: 'local',
|
||||||
options: {
|
options: {
|
||||||
detailedView: true
|
detailedView: true,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
logo: '/logo.svg',
|
logo: '/logo.svg',
|
||||||
nav: [
|
nav: [{ text: 'Docs', link: '/overview' }],
|
||||||
{ text: 'Docs', link: '/overview' },
|
|
||||||
],
|
|
||||||
|
|
||||||
sidebar: [
|
sidebar: [
|
||||||
{
|
{
|
||||||
@@ -33,13 +31,22 @@ const config = withMermaid(
|
|||||||
text: 'Core Architecture',
|
text: 'Core Architecture',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
{ text: 'API & Domain Logic', link: '/packages/core-api/' },
|
// { text: 'API & Domain Logic', link: '/packages/core-api/' },
|
||||||
|
{
|
||||||
|
text: 'API & Domain Logic',
|
||||||
|
collapsed: false,
|
||||||
|
items: [
|
||||||
|
{ text: 'API Engine', link: '/packages/core-api' },
|
||||||
|
{ text: 'Data Transformers', link: '/packages/core-api/transformers' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{ text: 'Event Bus System', link: '/packages/core-events/' },
|
{ text: 'Event Bus System', link: '/packages/core-events/' },
|
||||||
{ text: 'Storage & Persistence', link: '/packages/core-storage/' },
|
{ text: 'Storage & Persistence', link: '/packages/core-storage/' },
|
||||||
{ text: 'I18n & Localization', link: '/packages/core-i18n/' },
|
{ text: 'I18n & Localization', link: '/packages/core-i18n/' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'UI System',
|
text: 'UI System',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
@@ -52,23 +59,23 @@ const config = withMermaid(
|
|||||||
text: 'Desktop Ecosystem',
|
text: 'Desktop Ecosystem',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Overview', link: '/apps/desktop/' },
|
{ text: 'Overview', link: '/apps/desktop/' },
|
||||||
{ text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' },
|
{ text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' },
|
||||||
{ text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' },
|
{ text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' },
|
||||||
{ text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' },
|
{ text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
outline: { level: [2, 3] },
|
outline: { level: [2, 3] },
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{
|
{
|
||||||
icon: {
|
icon: {
|
||||||
svg: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><title>Gitea</title><path d="M4.209 4.603c-0.247 0 -0.525 0.02 -0.84 0.088 -0.333 0.07 -1.28 0.283 -2.054 1.027C-0.403 7.25 0.035 9.685 0.089 10.052c0.065 0.446 0.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s0.462 1.103 1.168 2.119c0.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212 -0.004 7.212 -0.004s0.458 0.004 1.08 -0.394c0.535 -0.324 1.013 -0.893 1.013 -0.893s0.492 -0.527 1.18 -1.73c0.21 -0.37 0.385 -0.729 0.538 -1.068 0 0 2.107 -4.471 2.107 -8.823 -0.042 -1.318 -0.367 -1.55 -0.443 -1.627 -0.156 -0.156 -0.366 -0.153 -0.366 -0.153s-4.475 0.252 -6.792 0.306c-0.508 0.011 -1.012 0.023 -1.512 0.027v4.474l-0.634 -0.301c0 -1.39 -0.004 -4.17 -0.004 -4.17 -1.107 0.016 -3.405 -0.084 -3.405 -0.084s-5.399 -0.27 -5.987 -0.324c-0.187 -0.011 -0.401 -0.032 -0.648 -0.032zm0.354 1.832h0.111s0.271 2.269 0.6 3.597C5.549 11.147 6.22 13 6.22 13s-0.996 -0.119 -1.641 -0.348c-0.99 -0.324 -1.409 -0.714 -1.409 -0.714s-0.73 -0.511 -1.096 -1.52C1.444 8.73 2.021 7.7 2.021 7.7s0.32 -0.859 1.47 -1.145c0.395 -0.106 0.863 -0.12 1.072 -0.12zm8.33 2.554c0.26 0.003 0.509 0.127 0.509 0.127l0.868 0.422 -0.529 1.075a0.686 0.686 0 0 0 -0.614 0.359 0.685 0.685 0 0 0 0.072 0.756l-0.939 1.924a0.69 0.69 0 0 0 -0.66 0.527 0.687 0.687 0 0 0 0.347 0.763 0.686 0.686 0 0 0 0.867 -0.206 0.688 0.688 0 0 0 -0.069 -0.882l0.916 -1.874a0.667 0.667 0 0 0 0.237 -0.02 0.657 0.657 0 0 0 0.271 -0.137 8.826 8.826 0 0 1 1.016 0.512 0.761 0.761 0 0 1 0.286 0.282c0.073 0.21 -0.073 0.569 -0.073 0.569 -0.087 0.29 -0.702 1.55 -0.702 1.55a0.692 0.692 0 0 0 -0.676 0.477 0.681 0.681 0 1 0 1.157 -0.252c0.073 -0.141 0.141 -0.282 0.214 -0.431 0.19 -0.397 0.515 -1.16 0.515 -1.16 0.035 -0.066 0.218 -0.394 0.103 -0.814 -0.095 -0.435 -0.48 -0.638 -0.48 -0.638 -0.467 -0.301 -1.116 -0.58 -1.116 -0.58s0 -0.156 -0.042 -0.27a0.688 0.688 0 0 0 -0.148 -0.241l0.516 -1.062 2.89 1.401s0.48 0.218 0.583 0.619c0.073 0.282 -0.019 0.534 -0.069 0.657 -0.24 0.587 -2.1 4.317 -2.1 4.317s-0.232 0.554 -0.748 0.588a1.065 1.065 0 0 1 -0.393 -0.045l-0.202 -0.08 -4.31 -2.1s-0.417 -0.218 -0.49 -0.596c-0.083 -0.31 0.104 -0.691 0.104 -0.691l2.073 -4.272s0.183 -0.37 0.466 -0.497a0.855 0.855 0 0 1 0.35 -0.077z" fill="currentColor" stroke-width="1"></path></svg>'
|
svg: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><title>Gitea</title><path d="M4.209 4.603c-0.247 0 -0.525 0.02 -0.84 0.088 -0.333 0.07 -1.28 0.283 -2.054 1.027C-0.403 7.25 0.035 9.685 0.089 10.052c0.065 0.446 0.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s0.462 1.103 1.168 2.119c0.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212 -0.004 7.212 -0.004s0.458 0.004 1.08 -0.394c0.535 -0.324 1.013 -0.893 1.013 -0.893s0.492 -0.527 1.18 -1.73c0.21 -0.37 0.385 -0.729 0.538 -1.068 0 0 2.107 -4.471 2.107 -8.823 -0.042 -1.318 -0.367 -1.55 -0.443 -1.627 -0.156 -0.156 -0.366 -0.153 -0.366 -0.153s-4.475 0.252 -6.792 0.306c-0.508 0.011 -1.012 0.023 -1.512 0.027v4.474l-0.634 -0.301c0 -1.39 -0.004 -4.17 -0.004 -4.17 -1.107 0.016 -3.405 -0.084 -3.405 -0.084s-5.399 -0.27 -5.987 -0.324c-0.187 -0.011 -0.401 -0.032 -0.648 -0.032zm0.354 1.832h0.111s0.271 2.269 0.6 3.597C5.549 11.147 6.22 13 6.22 13s-0.996 -0.119 -1.641 -0.348c-0.99 -0.324 -1.409 -0.714 -1.409 -0.714s-0.73 -0.511 -1.096 -1.52C1.444 8.73 2.021 7.7 2.021 7.7s0.32 -0.859 1.47 -1.145c0.395 -0.106 0.863 -0.12 1.072 -0.12zm8.33 2.554c0.26 0.003 0.509 0.127 0.509 0.127l0.868 0.422 -0.529 1.075a0.686 0.686 0 0 0 -0.614 0.359 0.685 0.685 0 0 0 0.072 0.756l-0.939 1.924a0.69 0.69 0 0 0 -0.66 0.527 0.687 0.687 0 0 0 0.347 0.763 0.686 0.686 0 0 0 0.867 -0.206 0.688 0.688 0 0 0 -0.069 -0.882l0.916 -1.874a0.667 0.667 0 0 0 0.237 -0.02 0.657 0.657 0 0 0 0.271 -0.137 8.826 8.826 0 0 1 1.016 0.512 0.761 0.761 0 0 1 0.286 0.282c0.073 0.21 -0.073 0.569 -0.073 0.569 -0.087 0.29 -0.702 1.55 -0.702 1.55a0.692 0.692 0 0 0 -0.676 0.477 0.681 0.681 0 1 0 1.157 -0.252c0.073 -0.141 0.141 -0.282 0.214 -0.431 0.19 -0.397 0.515 -1.16 0.515 -1.16 0.035 -0.066 0.218 -0.394 0.103 -0.814 -0.095 -0.435 -0.48 -0.638 -0.48 -0.638 -0.467 -0.301 -1.116 -0.58 -1.116 -0.58s0 -0.156 -0.042 -0.27a0.688 0.688 0 0 0 -0.148 -0.241l0.516 -1.062 2.89 1.401s0.48 0.218 0.583 0.619c0.073 0.282 -0.019 0.534 -0.069 0.657 -0.24 0.587 -2.1 4.317 -2.1 4.317s-0.232 0.554 -0.748 0.588a1.065 1.065 0 0 1 -0.393 -0.045l-0.202 -0.08 -4.31 -2.1s-0.417 -0.218 -0.49 -0.596c-0.083 -0.31 0.104 -0.691 0.104 -0.691l2.073 -4.272s0.183 -0.37 0.466 -0.497a0.855 0.855 0 0 1 0.35 -0.077z" fill="currentColor" stroke-width="1"></path></svg>',
|
||||||
|
},
|
||||||
|
link: 'https://git.eigen.co.id/eigen/fe-monorepo-template',
|
||||||
},
|
},
|
||||||
link: 'https://git.eigen.co.id/eigen/fe-monorepo-template'
|
],
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mermaid configuration
|
// Mermaid configuration
|
||||||
@@ -79,12 +86,10 @@ const config = withMermaid(
|
|||||||
// Fix cascading CJS/ESM SyntaxErrors caused by Vite dynamically discovering mermaid
|
// Fix cascading CJS/ESM SyntaxErrors caused by Vite dynamically discovering mermaid
|
||||||
vite: {
|
vite: {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: [
|
include: ['mermaid'],
|
||||||
'mermaid'
|
},
|
||||||
]
|
},
|
||||||
}
|
}),
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pnpm strict workspace workaround:
|
// Pnpm strict workspace workaround:
|
||||||
@@ -94,7 +99,7 @@ const config = withMermaid(
|
|||||||
// We strip them out so esbuild can naturally inline them into the 'mermaid' chunk instead.
|
// We strip them out so esbuild can naturally inline them into the 'mermaid' chunk instead.
|
||||||
if (config.vite?.optimizeDeps?.include) {
|
if (config.vite?.optimizeDeps?.include) {
|
||||||
config.vite.optimizeDeps.include = config.vite.optimizeDeps.include.filter(
|
config.vite.optimizeDeps.include = config.vite.optimizeDeps.include.filter(
|
||||||
(dep) => !['@braintree/sanitize-url', 'debug', 'cytoscape-cose-bilkent', 'cytoscape'].includes(dep)
|
(dep) => !['@braintree/sanitize-url', 'debug', 'cytoscape-cose-bilkent', 'cytoscape'].includes(dep),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.22.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { lazy, Suspense, useState } from 'react';
|
|||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { ThemeProvider, ColorSchemeType, DensityType } from '@repo/ui/provider';
|
import { ThemeProvider, ColorSchemeType, DensityType } from '@repo/ui/provider';
|
||||||
import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components';
|
import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components';
|
||||||
|
import { LoadingScreen } from '../core/components/loading-screen';
|
||||||
|
|
||||||
const AuthModule = lazy(() => import('./auth'));
|
const AuthModule = lazy(() => import('./auth'));
|
||||||
const AppModule = lazy(() => import('./modules'));
|
const AppModule = lazy(() => import('./modules'));
|
||||||
@@ -15,7 +16,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<ThemeProvider colorScheme={colorScheme} density={density}>
|
<ThemeProvider colorScheme={colorScheme} density={density}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth/*" element={<AuthModule />} />
|
<Route path="/auth/*" element={<AuthModule />} />
|
||||||
<Route path="/app/*" element={<AppModule />} />
|
<Route path="/app/*" element={<AppModule />} />
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function ExamplePage() {
|
|
||||||
return <div className="bg-amber-200">example</div>;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||||
|
import { FullPageDTO, FullPageEntity } from '../domain/entities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full Page Remote Data Services
|
||||||
|
*
|
||||||
|
* Provides core data services for the full-page module by extending the base remote data services.
|
||||||
|
* While this class automatically handles standard CRUD operations and data transformations out-of-the-box,
|
||||||
|
* implementers can freely extend it by adding custom methods to support domain-specific API endpoints
|
||||||
|
* or complex business logic as needed.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* export class FullPageRemoteDataServices extends BaseRemoteDataServices<FullPageEntity, FullPageDTO> {
|
||||||
|
* // Example of adding a custom method tailored to specific module needs
|
||||||
|
* public async getDashboardMetrics(status: string): Promise<MetricsPayload> {
|
||||||
|
* const response = await this.httpClient.get(`${this.apiUrl}/metrics`, {
|
||||||
|
* params: { status }
|
||||||
|
* });
|
||||||
|
* return response.data;
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class FullPageRemoteDataServices extends BaseRemoteDataServices<FullPageEntity, FullPageDTO> {}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core configuration and constants for the Full Page module.
|
||||||
|
* Used across Domain, Data, and Presentation layers.
|
||||||
|
*/
|
||||||
|
export const FullPageModuleConfig: ModuleConfigEntity = {
|
||||||
|
/** Unique identifier for permissions, caching, and i18n */
|
||||||
|
moduleKey: 'EXAMPLE_FULL_PAGE',
|
||||||
|
|
||||||
|
/** Translation namespace — must match the namespace used in registerModuleNamespace() */
|
||||||
|
translationNamespace: 'EXAMPLE_FULL_PAGE',
|
||||||
|
|
||||||
|
/** Base API endpoint for remote data services */
|
||||||
|
apiUrl: '/full-page',
|
||||||
|
|
||||||
|
/** Base Web Router URL for UI navigation */
|
||||||
|
webUrl: '/app/full-page',
|
||||||
|
|
||||||
|
/** Architectural category of the module, used for rendering and routing logic */
|
||||||
|
moduleCategory: 'FULL_PAGE',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './full-page.constants';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a full-page entity in the frontend domain model.
|
||||||
|
*
|
||||||
|
* This entity is derived from the API's `FullPageDTO` via the
|
||||||
|
* {@link FullPageTransformer}, which handles field name mapping
|
||||||
|
* and computed field derivation.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export interface FullPageEntity extends BaseEntity {
|
||||||
|
status?: string;
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the raw data structure returned by the API for a full-page resource.
|
||||||
|
*
|
||||||
|
* This DTO uses snake_case field names matching the backend's JSON serialization.
|
||||||
|
* It is transformed into a {@link FullPageEntity} by the {@link FullPageTransformer}.
|
||||||
|
*/
|
||||||
|
export interface FullPageDTO extends FullPageEntity {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './full-page.entity';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Full Page Factory
|
||||||
|
*
|
||||||
|
* This module acts as the dependency injection and configuration center for the full-page feature.
|
||||||
|
* It pre-configures and exports singleton instances of the data transformer and data service.
|
||||||
|
* By centralizing the instantiation here, it ensures that all UI components and hooks
|
||||||
|
* within the module share the same API client, configuration, and transformation logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from '../../../../../../core/lib/api-client';
|
||||||
|
import { FullPageRemoteDataServices } from '../../data/full-page.remote.service';
|
||||||
|
import { FullPageModuleConfig } from '../constants/full-page.constants';
|
||||||
|
import { FullPageRemoteDataTransformer } from '../transformers/full-page.remote.transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance of the FullPageRemoteDataTransformer.
|
||||||
|
* Exported for potential direct usage if manual data mapping is required outside the standard API flow.
|
||||||
|
*/
|
||||||
|
export const fullPageDataTransformer = new FullPageRemoteDataTransformer();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-configured singleton instance of the FullPageRemoteDataServices.
|
||||||
|
* Ready to be consumed by UI components, state managers, or module providers.
|
||||||
|
* It is fully wired with the HTTP client and automatically handles data mapping via the injected transformer.
|
||||||
|
*/
|
||||||
|
export const fullPageDataService = new FullPageRemoteDataServices(apiClient, {
|
||||||
|
apiUrl: FullPageModuleConfig.apiUrl,
|
||||||
|
moduleKey: FullPageModuleConfig.moduleKey,
|
||||||
|
transformer: fullPageDataTransformer,
|
||||||
|
});
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
|
import { FullPageEntity, FullPageDTO } from '../entities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full Page Remote Data Transformer
|
||||||
|
*
|
||||||
|
* Responsible for transforming data between the raw API data transfer objects (DTOs)
|
||||||
|
* and the frontend domain entities for the full-page module. By extending the base transformer,
|
||||||
|
* it ensures strict type safety and decouples data parsing logic from the API service layer.
|
||||||
|
*
|
||||||
|
* Implementers must define the core mapping rules (`transformToEntity`, `transformToDTO`)
|
||||||
|
* and can freely add custom transformation methods for specific API responses.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* export class FullPageRemoteDataTransformer extends BaseDataTransformer<FullPageEntity, FullPageDTO> {
|
||||||
|
* // Map snake_case API payload to camelCase frontend entity
|
||||||
|
* public transformToEntity(dto: FullPageDTO): FullPageEntity {
|
||||||
|
* return {
|
||||||
|
* id: dto.id,
|
||||||
|
* documentNumber: dto.document_number,
|
||||||
|
* createdAt: new Date(dto.created_at),
|
||||||
|
* // ... other property mappings
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Map camelCase frontend entity back to snake_case API payload
|
||||||
|
* public transformToDTO(entity: FullPageEntity): FullPageDTO {
|
||||||
|
* return {
|
||||||
|
* id: entity.id,
|
||||||
|
* document_number: entity.documentNumber,
|
||||||
|
* // ... other property mappings
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Example of adding a custom transformation method for a specific feature
|
||||||
|
* public transformMetrics(rawData: any): MetricsEntity {
|
||||||
|
* return {
|
||||||
|
* totalActive: rawData.total_active_count ?? 0,
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class FullPageRemoteDataTransformer extends BaseDataTransformer<FullPageEntity, FullPageDTO> {}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { compose, required, rangeLength } from '@repo/ui/validators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to generate the Zod validation schema for the Full Page module.
|
||||||
|
* It accepts a translation object `t` to maintain domain purity while supporting
|
||||||
|
* dynamic, localized error messages (i18n).
|
||||||
|
*
|
||||||
|
* @param t - The translation object (typically derived from `useTranslation()` in the UI layer).
|
||||||
|
* @returns The configured Zod object schema.
|
||||||
|
*/
|
||||||
|
export const createFullPageSchema = (t: any) => {
|
||||||
|
return z.object({
|
||||||
|
// Code: Required, length between 3 and 10 characters.
|
||||||
|
code: compose(z.string(), required(t.fields.code), rangeLength(3, 10, t.fields.code)),
|
||||||
|
|
||||||
|
// Name: Required, length between 3 and 50 characters.
|
||||||
|
name: compose(z.string(), required(t.fields.name), rangeLength(3, 50, t.fields.name)),
|
||||||
|
|
||||||
|
// Status: Required selection (typically from a dropdown/select).
|
||||||
|
status: compose(z.string(), required(t.fields.status)),
|
||||||
|
|
||||||
|
// Description: Optional text field.
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Transfer Object (DTO) for the Full Page form.
|
||||||
|
* This type is automatically inferred from the Zod schema factory.
|
||||||
|
* Use this type as a generic for form initialization, e.g., `useForm<FullPageFormDTO>()`.
|
||||||
|
*/
|
||||||
|
export type FullPageFormDTO = z.infer<ReturnType<typeof createFullPageSchema>>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||||
|
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||||
|
import { FullPageModuleConfig } from '../../domain/constants';
|
||||||
|
import { fullPageDataService } from '../../domain/factories';
|
||||||
|
import { FullPageEntity } from '../../domain/entities';
|
||||||
|
|
||||||
|
import fullPageId from '../locales/id/full-page.json';
|
||||||
|
import fullPageEn from '../locales/en/full-page.json';
|
||||||
|
|
||||||
|
const IndexPage = lazy(() => import('../pages/full-page.page.index'));
|
||||||
|
const FormPage = lazy(() => import('../pages/full-page.page.form'));
|
||||||
|
const DetailPage = lazy(() => import('../pages/full-page.page.detail'));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Namespace Registration (Module Scope)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Called once at import time — safe, idempotent, outside React render cycle.
|
||||||
|
// The namespace 'full-page' must match config.translationNamespace.
|
||||||
|
registerModuleNamespace(FullPageModuleConfig.translationNamespace, {
|
||||||
|
id: fullPageId,
|
||||||
|
en: fullPageEn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module Factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function FullPageModule() {
|
||||||
|
return (
|
||||||
|
<EnterpriseModuleProvider<FullPageEntity> config={FullPageModuleConfig} dataServices={fullPageDataService}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/index" element={<IndexPage />} />
|
||||||
|
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||||
|
<Route path="/edit/:dataId" element={<FormPage formPageType="edit" />} />
|
||||||
|
<Route path="/duplicate/:dataId" element={<FormPage formPageType="duplicate" />} />
|
||||||
|
<Route path="/create" element={<FormPage formPageType="create" />} />
|
||||||
|
<Route path="/" element={<Navigate to={`${FullPageModuleConfig.webUrl}/index`} replace={true} />} />
|
||||||
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
|
</Routes>
|
||||||
|
</EnterpriseModuleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "Full Page Management",
|
||||||
|
"fields": {
|
||||||
|
"status": "Status",
|
||||||
|
"name": "Name",
|
||||||
|
"code": "Code",
|
||||||
|
"description": "Description"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"title": "Manajemen Halaman Penuh",
|
||||||
|
"fields": {
|
||||||
|
"status": "Status",
|
||||||
|
"name": "Nama",
|
||||||
|
"code": "Kode",
|
||||||
|
"description": "Deskripsi"
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
export default function FullPagePageDetail() {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>full-page.page.detail</div>
|
||||||
|
<div>{t('fields.code')}</div>
|
||||||
|
<div>{t('fields.name')}</div>
|
||||||
|
<div>{t('fields.status')}</div>
|
||||||
|
<div>{t('fields.description')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
export default function FullPagePageForm({ formPageType }: { formPageType: 'edit' | 'create' | 'duplicate' }) {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>full-page.page.form</div>
|
||||||
|
<div>Form Type: {formPageType}</div>
|
||||||
|
<div>{t('fields.code')}</div>
|
||||||
|
<div>{t('fields.name')}</div>
|
||||||
|
<div>{t('fields.status')}</div>
|
||||||
|
<div>{t('fields.description')}</div>
|
||||||
|
<div>{t('validation:required')}</div>
|
||||||
|
<div>{t('common:save')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
import { Table, Box, Title, Paper } from '@repo/ui/components';
|
||||||
|
import { PageActions } from '@repo/ui/components';
|
||||||
|
import {
|
||||||
|
useEnterpriseModuleTranslationContext,
|
||||||
|
useEnterpriseModuleNavigationContext,
|
||||||
|
} from '@repo/ui/foundations';
|
||||||
|
import { FullPageEntity } from '../../domain/entities';
|
||||||
|
import { Edit2, Eye, Copy, Trash2 } from 'lucide-react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
// TODO: Replace this mock data with real API data loaded from DataService via useEnterpriseModuleDataServiceContext
|
||||||
|
const MOCK_DATA: FullPageEntity[] = [
|
||||||
|
{ id: '1', name: 'Dashboard Widget', code: 'WID-001', status: 'ACTIVE', description: 'Main dashboard widget' },
|
||||||
|
{ id: '2', name: 'Report Generator', code: 'REP-002', status: 'INACTIVE', description: 'Generates monthly reports' },
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'User Management',
|
||||||
|
code: 'USR-003',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
description: 'Manages user roles and permissions',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FullPagePageIndex() {
|
||||||
|
// Translation is scoped to ['full-page', 'common'] — no prefix needed for module keys
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
const { navigateToDetail, navigateToEdit, navigateToDuplicate } = useEnterpriseModuleNavigationContext();
|
||||||
|
|
||||||
|
const getRowActions = useCallback(
|
||||||
|
(row: FullPageEntity) => [
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
label: t('common:view'),
|
||||||
|
icon: <Eye size={14} />,
|
||||||
|
onClick: () => navigateToDetail(row.id as string),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: t('common:edit'),
|
||||||
|
icon: <Edit2 size={14} />,
|
||||||
|
onClick: () => navigateToEdit(row.id as string),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'duplicate',
|
||||||
|
label: t('common:duplicate'),
|
||||||
|
icon: <Copy size={14} />,
|
||||||
|
onClick: () => navigateToDuplicate(row.id as string),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider' as const,
|
||||||
|
key: 'div-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: t('common:delete'),
|
||||||
|
icon: <Trash2 size={14} />,
|
||||||
|
intent: 'destructive' as const,
|
||||||
|
onClick: () => {
|
||||||
|
// Mock delete action
|
||||||
|
alert(`Delete ${row.name}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, navigateToDetail, navigateToEdit, navigateToDuplicate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = MOCK_DATA.map((item) => (
|
||||||
|
<Table.Tr key={item.id}>
|
||||||
|
<Table.Td>{item.code}</Table.Td>
|
||||||
|
<Table.Td>{item.name}</Table.Td>
|
||||||
|
<Table.Td>{item.status}</Table.Td>
|
||||||
|
<Table.Td>{item.description}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<PageActions actions={getRowActions(item)} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Title order={2} mb="md">
|
||||||
|
{t('title')}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Paper withBorder shadow="sm" radius="md">
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t('fields.code')}</Table.Th>
|
||||||
|
<Table.Th>{t('fields.name')}</Table.Th>
|
||||||
|
<Table.Th>{t('fields.status')}</Table.Th>
|
||||||
|
<Table.Th>{t('fields.description')}</Table.Th>
|
||||||
|
<Table.Th w={200}>{t('common:edit')}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import ModuleLayout from './layouts/module.layout';
|
||||||
|
|
||||||
const ExamplePage = lazy(() => import('./example/example.page'));
|
const FullPageModule = lazy(() => import('./example/full-page/presentation/factory'));
|
||||||
|
|
||||||
export default function AppModule() {
|
export default function AppModule() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<ModuleLayout>
|
||||||
<Route path="/" element={<ExamplePage />} />
|
<Routes>
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="/full-page/*" element={<FullPageModule />} />
|
||||||
</Routes>
|
<Route path="/" element={<Navigate to="/app/full-page" replace={true} />} />
|
||||||
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
|
</Routes>
|
||||||
|
</ModuleLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
|
import { Burger, Group, Select, Text, useCoreAppShell } from '@repo/ui/components';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
import { AppStorageKey, secureStorage } from '../../../../core/storage/local';
|
||||||
|
|
||||||
|
export default function HeaderLayout() {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||||
|
return (
|
||||||
|
<Group h="100%" px="md" justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
Mock Header (bg="green.1")
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
w={180}
|
||||||
|
size="sm"
|
||||||
|
variant="filled"
|
||||||
|
leftSection={<Globe size={16} />}
|
||||||
|
data={[
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||||
|
]}
|
||||||
|
value={i18n.resolvedLanguage || i18n.language}
|
||||||
|
onChange={async (val) => {
|
||||||
|
if (!val) return;
|
||||||
|
await i18n.changeLanguage(val);
|
||||||
|
await secureStorage.setItem(AppStorageKey.LOCALE, val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components';
|
||||||
|
import HeaderLayout from './components/header.layout';
|
||||||
|
|
||||||
|
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const configAppShell: CoreAppShellConfig = {
|
||||||
|
variant: 'header-first',
|
||||||
|
features: {
|
||||||
|
desktopCollapseVariant: 'mini',
|
||||||
|
withUtilityBar: false,
|
||||||
|
withAside: false,
|
||||||
|
withFooter: false,
|
||||||
|
withDoubleSidebar: false,
|
||||||
|
persistState: false,
|
||||||
|
disabled: false,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CoreAppShell config={configAppShell} slots={{ header: <HeaderLayout /> }}>
|
||||||
|
{children}
|
||||||
|
</CoreAppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Card, Title, Text, Table, Stack, Badge } from '@repo/ui/components';
|
||||||
|
import { PageActions, RowActions, PageAction, RowAction } from '@repo/ui/components';
|
||||||
|
import { Save, Printer, Trash, MoreVertical, Edit, FileText, CheckCircle, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ActionToolsShowcase() {
|
||||||
|
const pageActions: PageAction[] = [
|
||||||
|
{
|
||||||
|
key: 'save',
|
||||||
|
label: 'Save Changes',
|
||||||
|
icon: <Save size={16} />,
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
key: 'print',
|
||||||
|
label: 'Print',
|
||||||
|
icon: <Printer size={16} />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'print-original',
|
||||||
|
label: 'Print Original',
|
||||||
|
icon: <FileText size={16} />,
|
||||||
|
onClick: (k) => console.log(k),
|
||||||
|
},
|
||||||
|
// { type: 'divider' },
|
||||||
|
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||||
|
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||||
|
{ key: 'print-copy', label: 'Print Copy', icon: <FileText size={16} />, onClick: (k) => console.log(k) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'confirm',
|
||||||
|
label: 'Confirm',
|
||||||
|
icon: <CheckCircle size={16} />,
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: 'Delete',
|
||||||
|
icon: <Trash size={16} />,
|
||||||
|
intent: 'destructive',
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'success',
|
||||||
|
label: 'Success',
|
||||||
|
icon: <Check size={16} />,
|
||||||
|
intent: 'success',
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'warning',
|
||||||
|
label: 'Warning',
|
||||||
|
icon: <Check size={16} />,
|
||||||
|
intent: 'warning',
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Primary',
|
||||||
|
label: 'Primary',
|
||||||
|
icon: <Check size={16} />,
|
||||||
|
intent: 'primary',
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rowActions: RowAction[] = [
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
tooltip: 'Edit Record',
|
||||||
|
label: 'Edit Record',
|
||||||
|
icon: <Edit size={16} />,
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'approve',
|
||||||
|
tooltip: 'Approve',
|
||||||
|
label: 'Approve',
|
||||||
|
icon: <CheckCircle size={16} />,
|
||||||
|
intent: 'success',
|
||||||
|
onClick: (key) => console.log('Clicked', key),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'more',
|
||||||
|
label: 'More Options',
|
||||||
|
icon: <MoreVertical size={16} />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: 'Delete Record',
|
||||||
|
icon: <Trash size={16} />,
|
||||||
|
intent: 'destructive',
|
||||||
|
onClick: (k) => console.log(k),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tableData = [
|
||||||
|
{ id: '1', name: 'Invoice #001', status: 'Pending' },
|
||||||
|
{ id: '2', name: 'Invoice #002', status: 'Approved' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<Card withBorder shadow="sm" radius="md" p="md">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Page Actions
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" mb="lg">
|
||||||
|
Used in toolbars and page headers.
|
||||||
|
</Text>
|
||||||
|
<PageActions actions={pageActions} onClose={() => {}} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder shadow="sm" radius="md" p="md">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Row Actions
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" mb="lg">
|
||||||
|
Used inside data grids or list items.
|
||||||
|
</Text>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>ID</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th style={{ width: 120 }}>Actions</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{tableData.map((row) => (
|
||||||
|
<Table.Tr key={row.id}>
|
||||||
|
<Table.Td>{row.id}</Table.Td>
|
||||||
|
<Table.Td>{row.name}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color={row.status === 'Approved' ? 'success' : 'warning'}>{row.status}</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<RowActions actions={rowActions} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<RowActions showLabels actions={rowActions} />
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
import { Layers } from 'lucide-react';
|
||||||
|
|
||||||
|
export const COMPONENTS_REGISTRY = {
|
||||||
|
actionTools: {
|
||||||
|
id: 'action-tools',
|
||||||
|
name: 'Action Tools',
|
||||||
|
description: 'Showcase for PageActions and RowActions components',
|
||||||
|
icon: Layers,
|
||||||
|
component: lazy(() => import('./components/ActionToolsShowcase')),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,36 +1,30 @@
|
|||||||
import { useState, useMemo, ReactNode } from 'react';
|
import { useState, useMemo, ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
import {
|
import { Box, Button, Group, SegmentedControl, Stack, Text, Paper, Switch, Select, Burger } from '@repo/ui/components';
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
SegmentedControl,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Paper,
|
|
||||||
Switch,
|
|
||||||
Select,
|
|
||||||
Burger
|
|
||||||
} from '@repo/ui/components';
|
|
||||||
import {
|
import {
|
||||||
CoreAppShell,
|
CoreAppShell,
|
||||||
CorePageContainer,
|
CorePageContainer,
|
||||||
CoreAppShellConfig,
|
CoreAppShellConfig,
|
||||||
LayoutVariant,
|
LayoutVariant,
|
||||||
DesktopCollapseVariant,
|
DesktopCollapseVariant,
|
||||||
useCoreAppShell
|
useCoreAppShell,
|
||||||
} from '@repo/ui/components';
|
} from '@repo/ui/components';
|
||||||
import { Home, BarChart2, Settings as SettingsIcon } from 'lucide-react';
|
import { Home, BarChart2, Settings as SettingsIcon } from 'lucide-react';
|
||||||
|
|
||||||
// Sub-component to test hook methods
|
// Sub-component to test hook methods
|
||||||
function LayoutControls() {
|
function LayoutControls() {
|
||||||
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } = useCoreAppShell();
|
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } =
|
||||||
|
useCoreAppShell();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<Button onClick={toggleDesktop} variant="default" size="xs">Toggle Desktop Sidebar</Button>
|
<Button onClick={toggleDesktop} variant="default" size="xs">
|
||||||
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">Toggle Mobile Sidebar</Button>
|
Toggle Desktop Sidebar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">
|
||||||
|
Toggle Mobile Sidebar
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
|
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -51,10 +45,19 @@ function LayoutControls() {
|
|||||||
function MockHeader() {
|
function MockHeader() {
|
||||||
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
const { mobileOpened, toggleMobile } = useCoreAppShell();
|
||||||
return (
|
return (
|
||||||
<Group h="100%" px="md" justify="space-between" bg="green.1" c="green.9" style={{ borderBottom: '1px solid var(--mantine-color-green-3)' }}>
|
<Group
|
||||||
|
h="100%"
|
||||||
|
px="md"
|
||||||
|
justify="space-between"
|
||||||
|
bg="green.1"
|
||||||
|
c="green.9"
|
||||||
|
style={{ borderBottom: '1px solid var(--mantine-color-green-3)' }}
|
||||||
|
>
|
||||||
<Group>
|
<Group>
|
||||||
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||||
<Text fw={700} size="lg">Mock Header (bg="green.1")</Text>
|
<Text fw={700} size="lg">
|
||||||
|
Mock Header (bg="green.1")
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -63,10 +66,16 @@ function MockHeader() {
|
|||||||
function MockMobileDrawer() {
|
function MockMobileDrawer() {
|
||||||
return (
|
return (
|
||||||
<Box p="md" h="100%" bg="yellow.1" c="yellow.9">
|
<Box p="md" h="100%" bg="yellow.1" c="yellow.9">
|
||||||
<Text fw={700} mb="sm">Mock Mobile Drawer (bg="yellow.1")</Text>
|
<Text fw={700} mb="sm">
|
||||||
|
Mock Mobile Drawer (bg="yellow.1")
|
||||||
|
</Text>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>Mobile Dashboard</Button>
|
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
||||||
<Button variant="light" color="yellow" justify="flex-start" fullWidth>Mobile Settings</Button>
|
Mobile Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
|
||||||
|
Mobile Settings
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -78,7 +87,9 @@ function SettingRow({ title, description, control }: { title: string; descriptio
|
|||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw={500}>{title}</Text>
|
<Text fw={500}>{title}</Text>
|
||||||
<Text c="dimmed" size="sm">{description}</Text>
|
<Text c="dimmed" size="sm">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box>{control}</Box>
|
<Box>{control}</Box>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -104,7 +115,7 @@ export default function ShellDemo() {
|
|||||||
withAside: withAside ? undefined : false,
|
withAside: withAside ? undefined : false,
|
||||||
withFooter: withFooter ? undefined : false,
|
withFooter: withFooter ? undefined : false,
|
||||||
withDoubleSidebar,
|
withDoubleSidebar,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}, [layoutVariant, collapseVariant, withUtilityBar, withAside, withFooter, withDoubleSidebar]);
|
}, [layoutVariant, collapseVariant, withUtilityBar, withAside, withFooter, withDoubleSidebar]);
|
||||||
|
|
||||||
@@ -123,184 +134,213 @@ export default function ShellDemo() {
|
|||||||
withFooter: withFooter ? undefined : false,
|
withFooter: withFooter ? undefined : false,
|
||||||
withDoubleSidebar,
|
withDoubleSidebar,
|
||||||
persistState: false, // Don't persist for the demo to avoid confusing other showcases
|
persistState: false, // Don't persist for the demo to avoid confusing other showcases
|
||||||
disabled: false, // Allow nested render overrides if needed
|
disabled: false, // Allow nested render overrides if needed
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CoreAppShell
|
<CoreAppShell
|
||||||
config={config}
|
config={config}
|
||||||
|
slots={{
|
||||||
slots={{
|
utilityBar: (
|
||||||
utilityBar: (
|
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
|
||||||
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
|
<Text size="xs" fw={600}>
|
||||||
<Text size="xs" fw={600}>Mock Utility Bar (bg="blue.1")</Text>
|
Mock Utility Bar (bg="blue.1")
|
||||||
</Group>
|
</Text>
|
||||||
),
|
</Group>
|
||||||
header: <MockHeader />,
|
),
|
||||||
sidebarMobile: <MockMobileDrawer />,
|
header: <MockHeader />,
|
||||||
sidebar: !withDoubleSidebar ? (
|
sidebarMobile: <MockMobileDrawer />,
|
||||||
<Box p="md" h="100%" bg="grape.1" c="grape.9" style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}>
|
sidebar: !withDoubleSidebar ? (
|
||||||
<Text fw={700} mb="sm">Mock Standard Navbar (bg="grape.1")</Text>
|
<Box
|
||||||
<Stack gap="xs">
|
p="md"
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Dashboard</Button>
|
h="100%"
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Users</Button>
|
bg="grape.1"
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Reports</Button>
|
c="grape.9"
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Settings</Button>
|
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
||||||
</Stack>
|
>
|
||||||
</Box>
|
<Text fw={700} mb="sm">
|
||||||
) : undefined,
|
Mock Standard Navbar (bg="grape.1")
|
||||||
sidebarRail: withDoubleSidebar ? (
|
</Text>
|
||||||
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
|
<Stack gap="xs">
|
||||||
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
Mock Rail (bg="orange.1")
|
Dashboard
|
||||||
</Text>
|
</Button>
|
||||||
<Home size={24} />
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
<BarChart2 size={24} />
|
Users
|
||||||
<SettingsIcon size={24} />
|
</Button>
|
||||||
</Stack>
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
) : undefined,
|
Reports
|
||||||
sidebarPanel: withDoubleSidebar ? (
|
</Button>
|
||||||
<Box p="md" h="100%" bg="grape.1" c="grape.9" style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}>
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
<Text fw={700} mb="sm">Mock Panel (bg="grape.1")</Text>
|
Settings
|
||||||
<Stack gap="xs">
|
</Button>
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Dashboard</Button>
|
</Stack>
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Users</Button>
|
</Box>
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Reports</Button>
|
) : undefined,
|
||||||
<Button variant="light" color="grape" justify="flex-start" fullWidth>Settings</Button>
|
sidebarRail: withDoubleSidebar ? (
|
||||||
</Stack>
|
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
|
||||||
</Box>
|
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
|
||||||
) : undefined,
|
Mock Rail (bg="orange.1")
|
||||||
aside: (
|
</Text>
|
||||||
<Box p="md" h="100%" bg="cyan.1" c="cyan.9">
|
<Home size={24} />
|
||||||
<Text fw={700} mb="md">Mock Aside</Text>
|
<BarChart2 size={24} />
|
||||||
<Text size="sm">This area could be used for notifications, help text, or contextual settings.</Text>
|
<SettingsIcon size={24} />
|
||||||
</Box>
|
|
||||||
),
|
|
||||||
footer: (
|
|
||||||
<Group h="100%" px="md" justify="space-between" bg="gray.1" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
|
||||||
<Text size="sm" fw={600}>Mock Footer (bg="gray.1")</Text>
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
|
|
||||||
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CorePageContainer
|
|
||||||
headerSlot={
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Text component="h1" size="xl" fw={700} m={0}>
|
|
||||||
Layout Engine Interactive Demo
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<Button component={Link} to="/showcase" leftSection={<ArrowLeft size={16} />} variant="default">
|
|
||||||
Back to Showcase
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
stickyHeader
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Paper withBorder p="md" bg="var(--mantine-color-gray-0)">
|
|
||||||
<LayoutControls />
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Layout Variant"
|
|
||||||
description="Switch between standard cloud or SaaS layout styles."
|
|
||||||
control={
|
|
||||||
<Select
|
|
||||||
value={layoutVariant}
|
|
||||||
onChange={(value) => setLayoutVariant((value as LayoutVariant) || 'sidebar-first')}
|
|
||||||
data={[
|
|
||||||
{ label: 'Sidebar First (Alt)', value: 'sidebar-first' },
|
|
||||||
{ label: 'Header First (Default)', value: 'header-first' },
|
|
||||||
{ label: 'Top Nav (Hidden Sidebar)', value: 'top-nav' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Desktop Collapse Strategy"
|
|
||||||
description="Determine if the sidebar shrinks to icons or slides out completely."
|
|
||||||
control={
|
|
||||||
<SegmentedControl
|
|
||||||
value={collapseVariant}
|
|
||||||
onChange={(value) => setCollapseVariant(value as DesktopCollapseVariant)}
|
|
||||||
data={[
|
|
||||||
{ label: 'Hide (Slide Out)', value: 'hide' },
|
|
||||||
{ label: 'Mini (Shrink)', value: 'mini' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Enable Double Sidebar"
|
|
||||||
description="Activate the Google-style rail and contextual panel navigation."
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={withDoubleSidebar}
|
|
||||||
onChange={(event) => setWithDoubleSidebar(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Render Utility Bar"
|
|
||||||
description="Show a system-level announcement bar above the main header."
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={withUtilityBar}
|
|
||||||
onChange={(event) => setWithUtilityBar(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Render Aside"
|
|
||||||
description="Toggle the right-hand properties or filter panel."
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={withAside}
|
|
||||||
onChange={(event) => setWithAside(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
title="Render Footer"
|
|
||||||
description="Toggle the bottom application footer."
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={withFooter}
|
|
||||||
onChange={(event) => setWithFooter(event.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box mt="xl">
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Text fw={700} size="lg">Configuration Preview</Text>
|
|
||||||
<Button
|
|
||||||
variant={copied ? 'filled' : 'light'}
|
|
||||||
color={copied ? 'teal' : 'blue'}
|
|
||||||
size="xs"
|
|
||||||
onClick={handleCopy}
|
|
||||||
>
|
|
||||||
{copied ? 'Copied to Clipboard!' : 'Copy JSON'}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Paper withBorder p="md" bg="dark.8" c="gray.0" style={{ fontFamily: 'monospace', overflowX: 'auto' }}>
|
|
||||||
<pre style={{ margin: 0 }}>{JSON.stringify(liveConfig, null, 2)}</pre>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</CorePageContainer>
|
) : undefined,
|
||||||
</CoreAppShell>
|
sidebarPanel: withDoubleSidebar ? (
|
||||||
|
<Box
|
||||||
|
p="md"
|
||||||
|
h="100%"
|
||||||
|
bg="grape.1"
|
||||||
|
c="grape.9"
|
||||||
|
style={{ borderRight: '1px solid var(--mantine-color-grape-3)' }}
|
||||||
|
>
|
||||||
|
<Text fw={700} mb="sm">
|
||||||
|
Mock Panel (bg="grape.1")
|
||||||
|
</Text>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
|
Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
|
Users
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
|
Reports
|
||||||
|
</Button>
|
||||||
|
<Button variant="light" color="grape" justify="flex-start" fullWidth>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : undefined,
|
||||||
|
aside: (
|
||||||
|
<Box p="md" h="100%" bg="cyan.1" c="cyan.9">
|
||||||
|
<Text fw={700} mb="md">
|
||||||
|
Mock Aside
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">This area could be used for notifications, help text, or contextual settings.</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
footer: (
|
||||||
|
<Group
|
||||||
|
h="100%"
|
||||||
|
px="md"
|
||||||
|
justify="space-between"
|
||||||
|
bg="gray.1"
|
||||||
|
style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Mock Footer (bg="gray.1")
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CorePageContainer
|
||||||
|
headerSlot={
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text component="h1" size="xl" fw={700} m={0}>
|
||||||
|
Layout Engine Interactive Demo
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button component={Link} to="/showcase" leftSection={<ArrowLeft size={16} />} variant="default">
|
||||||
|
Back to Showcase
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
stickyHeader
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper withBorder p="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<LayoutControls />
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Layout Variant"
|
||||||
|
description="Switch between standard cloud or SaaS layout styles."
|
||||||
|
control={
|
||||||
|
<Select
|
||||||
|
value={layoutVariant}
|
||||||
|
onChange={(value) => setLayoutVariant((value as LayoutVariant) || 'sidebar-first')}
|
||||||
|
data={[
|
||||||
|
{ label: 'Sidebar First (Alt)', value: 'sidebar-first' },
|
||||||
|
{ label: 'Header First (Default)', value: 'header-first' },
|
||||||
|
{ label: 'Top Nav (Hidden Sidebar)', value: 'top-nav' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Desktop Collapse Strategy"
|
||||||
|
description="Determine if the sidebar shrinks to icons or slides out completely."
|
||||||
|
control={
|
||||||
|
<SegmentedControl
|
||||||
|
value={collapseVariant}
|
||||||
|
onChange={(value) => setCollapseVariant(value as DesktopCollapseVariant)}
|
||||||
|
data={[
|
||||||
|
{ label: 'Hide (Slide Out)', value: 'hide' },
|
||||||
|
{ label: 'Mini (Shrink)', value: 'mini' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Enable Double Sidebar"
|
||||||
|
description="Activate the Google-style rail and contextual panel navigation."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={withDoubleSidebar}
|
||||||
|
onChange={(event) => setWithDoubleSidebar(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Render Utility Bar"
|
||||||
|
description="Show a system-level announcement bar above the main header."
|
||||||
|
control={
|
||||||
|
<Switch checked={withUtilityBar} onChange={(event) => setWithUtilityBar(event.currentTarget.checked)} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Render Aside"
|
||||||
|
description="Toggle the right-hand properties or filter panel."
|
||||||
|
control={<Switch checked={withAside} onChange={(event) => setWithAside(event.currentTarget.checked)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Render Footer"
|
||||||
|
description="Toggle the bottom application footer."
|
||||||
|
control={<Switch checked={withFooter} onChange={(event) => setWithFooter(event.currentTarget.checked)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box mt="xl">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
Configuration Preview
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant={copied ? 'filled' : 'light'}
|
||||||
|
color={copied ? 'teal' : 'blue'}
|
||||||
|
size="xs"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied to Clipboard!' : 'Copy JSON'}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Paper withBorder p="md" bg="dark.8" c="gray.0" style={{ fontFamily: 'monospace', overflowX: 'auto' }}>
|
||||||
|
<pre style={{ margin: 0 }}>{JSON.stringify(liveConfig, null, 2)}</pre>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CorePageContainer>
|
||||||
|
</CoreAppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, Suspense } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ColorSchemeType, DensityType } from '@repo/ui/provider';
|
import { ColorSchemeType, DensityType } from '@repo/ui/provider';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
} from '@repo/ui/components';
|
} from '@repo/ui/components';
|
||||||
import { ShieldCheck, Database, Lock, Layout, Activity, Printer, FileText } 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';
|
||||||
import { useTranslation } from '@repo/core-i18n';
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
import PrinterList from './printer-list';
|
import PrinterList from './printer-list';
|
||||||
@@ -31,6 +31,7 @@ import ExamplePage from './example/example.page';
|
|||||||
import EventsDemoPage from './events-demo';
|
import EventsDemoPage from './events-demo';
|
||||||
import PouchSample from './pouch-sample';
|
import PouchSample from './pouch-sample';
|
||||||
import FormDemoView from './example/features/form-demo';
|
import FormDemoView from './example/features/form-demo';
|
||||||
|
import { COMPONENTS_REGISTRY } from './registry';
|
||||||
|
|
||||||
interface ShowcaseViewProps {
|
interface ShowcaseViewProps {
|
||||||
colorScheme: ColorSchemeType;
|
colorScheme: ColorSchemeType;
|
||||||
@@ -52,6 +53,11 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
];
|
];
|
||||||
|
|
||||||
const getSubtitle = () => {
|
const getSubtitle = () => {
|
||||||
|
const registryItem = Object.values(COMPONENTS_REGISTRY).find((item) => item.id === activeTab);
|
||||||
|
if (registryItem) {
|
||||||
|
return registryItem.description;
|
||||||
|
}
|
||||||
|
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'rbac':
|
case 'rbac':
|
||||||
return 'Role-Based Access Control and Permissions';
|
return 'Role-Based Access Control and Permissions';
|
||||||
@@ -83,6 +89,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (val === 'layout-engine') {
|
if (val === 'layout-engine') {
|
||||||
navigate('/shell-demo');
|
navigate('/shell-demo');
|
||||||
|
} else if (val === 'apps') {
|
||||||
|
navigate('/app');
|
||||||
} else {
|
} else {
|
||||||
setActiveTab(val);
|
setActiveTab(val);
|
||||||
}
|
}
|
||||||
@@ -137,6 +145,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
<Tabs.Tab value="hardware" leftSection={<Printer size={18} />}>
|
<Tabs.Tab value="hardware" leftSection={<Printer size={18} />}>
|
||||||
Hardware
|
Hardware
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="apps" leftSection={<LayoutDashboard size={18} />}>
|
||||||
|
Apps
|
||||||
|
</Tabs.Tab>
|
||||||
|
|
||||||
|
{Object.values(COMPONENTS_REGISTRY).map((registryItem) => {
|
||||||
|
const Icon = registryItem.icon;
|
||||||
|
return (
|
||||||
|
<Tabs.Tab key={registryItem.id} value={registryItem.id} leftSection={<Icon size={18} />}>
|
||||||
|
{registryItem.name}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value={activeTab as string}>
|
<Tabs.Panel value={activeTab as string}>
|
||||||
@@ -374,6 +394,20 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* --- DYNAMIC COMPONENTS FROM REGISTRY --- */}
|
||||||
|
{Object.values(COMPONENTS_REGISTRY).map((registryItem) => {
|
||||||
|
const Component = registryItem.component;
|
||||||
|
return (
|
||||||
|
activeTab === registryItem.id && (
|
||||||
|
<Stack gap="xl" key={registryItem.id}>
|
||||||
|
<Suspense fallback={<Text>Loading {registryItem.name}...</Text>}>
|
||||||
|
<Component />
|
||||||
|
</Suspense>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Blok Utama (Brand Color) -->
|
||||||
|
<rect x="6" y="6" width="16" height="16" rx="4" fill="#652ed9"/>
|
||||||
|
<!-- Blok Sekunder (Memberikan kesan struktur & kedalaman) -->
|
||||||
|
<rect x="18" y="18" width="16" height="16" rx="4" fill="#652ed9" fill-opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,11 @@
|
|||||||
|
import { Loader, Stack } from '@repo/ui/components';
|
||||||
|
|
||||||
|
export const LoadingScreen = () => {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex h-screen w-screen items-center justify-center bg-white">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<Loader color="blue" size="lg" type="dots" />
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,8 +24,12 @@ async function bootstrap() {
|
|||||||
// Initialize i18next and load language from secureStorage
|
// Initialize i18next and load language from secureStorage
|
||||||
await setupI18n({
|
await setupI18n({
|
||||||
storageAdapter: {
|
storageAdapter: {
|
||||||
getLanguage: async () => await secureStorage.getItem<string>(AppStorageKey.LOCALE),
|
getLanguage: async () => {
|
||||||
setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng),
|
return secureStorage.getItem<string>(AppStorageKey.LOCALE);
|
||||||
|
},
|
||||||
|
setLanguage: async (lng: string) => {
|
||||||
|
await secureStorage.setItem(AppStorageKey.LOCALE, lng);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
-14
@@ -1,14 +0,0 @@
|
|||||||
import 'react-i18next';
|
|
||||||
// Import the core types so we don't break the common namespace
|
|
||||||
import type { resources as coreResources } from '@repo/core-i18n/src/setup';
|
|
||||||
import bookingEn from '../apps/modules/example/features/i18n/locales/en/booking.json';
|
|
||||||
|
|
||||||
// Combine core resources with app-specific decentralized resources
|
|
||||||
declare module 'react-i18next' {
|
|
||||||
interface CustomTypeOptions {
|
|
||||||
defaultNS: 'common';
|
|
||||||
resources: typeof coreResources['en'] & {
|
|
||||||
booking: typeof bookingEn;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,10 +49,7 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants';
|
|||||||
* const { data, status } = await services.getOne<BookingEntity>('42');
|
* const { data, status } = await services.getOne<BookingEntity>('42');
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export abstract class BaseRemoteDataServices<
|
export abstract class BaseRemoteDataServices<E extends BaseEntity = BaseEntity, TDTO = E> {
|
||||||
E extends BaseEntity = BaseEntity,
|
|
||||||
TDTO = E,
|
|
||||||
> {
|
|
||||||
/** The injected, isolated HTTP client instance. */
|
/** The injected, isolated HTTP client instance. */
|
||||||
protected readonly httpClient: AxiosInstance;
|
protected readonly httpClient: AxiosInstance;
|
||||||
|
|
||||||
@@ -118,7 +115,7 @@ export abstract class BaseRemoteDataServices<
|
|||||||
'ex-module-action': action,
|
'ex-module-action': action,
|
||||||
...(options?.config?.headers ?? {}),
|
...(options?.config?.headers ?? {}),
|
||||||
},
|
},
|
||||||
telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext // FIXME,
|
telemetryContext: options?.telemetryContext ?? options?.config?.telemetryContext, // FIXME,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: response.data, status: response.status };
|
return { data: response.data, status: response.status };
|
||||||
@@ -149,9 +146,7 @@ export abstract class BaseRemoteDataServices<
|
|||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async customRequest<T = unknown>(
|
async customRequest<T = unknown>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
config: AxiosRequestConfig,
|
|
||||||
): Promise<ApiResponse<T>> {
|
|
||||||
const response = await this.httpClient.request<T>({
|
const response = await this.httpClient.request<T>({
|
||||||
...config,
|
...config,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -297,93 +292,63 @@ export abstract class BaseRemoteDataServices<
|
|||||||
// ─── Data Processing Lifecycle ────────────────────────────────
|
// ─── Data Processing Lifecycle ────────────────────────────────
|
||||||
|
|
||||||
/** Confirm processing of a single data record. */
|
/** Confirm processing of a single data record. */
|
||||||
confirmProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
confirmData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.confirmProcessData, {
|
return this.execute<void>(DESCRIPTORS.confirmData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confirm processing of multiple data records. */
|
/** Confirm processing of multiple data records. */
|
||||||
batchConfirmProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
batchConfirmData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.batchConfirmProcessData, {
|
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. */
|
||||||
cancelProcessData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
cancelData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.cancelProcessData, {
|
return this.execute<void>(DESCRIPTORS.cancelData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel processing of multiple data records. */
|
/** Cancel processing of multiple data records. */
|
||||||
batchCancelProcessData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
batchCancelData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.batchCancelProcessData, {
|
return this.execute<void>(DESCRIPTORS.batchCancelData, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Transaction Lifecycle ────────────────────────────────────
|
// ─── Transaction Lifecycle ────────────────────────────────────
|
||||||
|
|
||||||
/** Confirm a transaction. */
|
|
||||||
confirmProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
|
||||||
return this.execute<void>(DESCRIPTORS.confirmProcessTransaction, {
|
|
||||||
variableURL: { id },
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Confirm multiple transactions. */
|
|
||||||
batchConfirmProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
|
||||||
return this.execute<void>(DESCRIPTORS.batchConfirmProcessTransaction, {
|
|
||||||
config: { ...config, data: { ids } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancel a transaction. */
|
|
||||||
cancelProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
|
||||||
return this.execute<void>(DESCRIPTORS.cancelProcessTransaction, {
|
|
||||||
variableURL: { id },
|
|
||||||
config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cancel multiple transactions. */
|
|
||||||
batchCancelProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
|
||||||
return this.execute<void>(DESCRIPTORS.batchCancelProcessTransaction, {
|
|
||||||
config: { ...config, data: { ids } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rollback a transaction. */
|
/** Rollback a transaction. */
|
||||||
rollbackProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
rollbackData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.rollbackProcessTransaction, {
|
return this.execute<void>(DESCRIPTORS.rollbackData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rollback multiple transactions. */
|
/** Rollback multiple transactions. */
|
||||||
batchRollbackProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
batchRollbackData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.batchRollbackProcessTransaction, {
|
return this.execute<void>(DESCRIPTORS.batchRollbackData, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hold a transaction. */
|
/** Hold a transaction. */
|
||||||
holdProcessTransaction(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
holdData(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.holdProcessTransaction, {
|
return this.execute<void>(DESCRIPTORS.holdData, {
|
||||||
variableURL: { id },
|
variableURL: { id },
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hold multiple transactions. */
|
/** Hold multiple transactions. */
|
||||||
batchHoldProcessTransaction(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
batchHoldData(ids: string[], config?: AxiosRequestConfig): Promise<ApiResponse<void>> {
|
||||||
return this.execute<void>(DESCRIPTORS.batchHoldProcessTransaction, {
|
return this.execute<void>(DESCRIPTORS.batchHoldData, {
|
||||||
config: { ...config, data: { ids } },
|
config: { ...config, data: { ids } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ export const REQUEST_ACTION = {
|
|||||||
CREATE: 'CREATE',
|
CREATE: 'CREATE',
|
||||||
EDIT: 'EDIT',
|
EDIT: 'EDIT',
|
||||||
DELETE: 'DELETE',
|
DELETE: 'DELETE',
|
||||||
CONFIRM_DATA: 'CONFIRM_DATA',
|
|
||||||
CANCEL_DATA: 'CANCEL_DATA',
|
ACTIVATE: 'ACTIVATE',
|
||||||
CONFIRM_PROCESS_TRANSACTION: 'CONFIRM_PROCESS_TRANSACTION',
|
DEACTIVATE: 'DEACTIVATE',
|
||||||
CANCEL_PROCESS_TRANSACTION: 'CANCEL_PROCESS_TRANSACTION',
|
|
||||||
|
CONFIRM: 'CONFIRM',
|
||||||
|
CANCEL: 'CANCEL',
|
||||||
|
ROLLBACK: 'ROLLBACK',
|
||||||
|
HOLD: 'HOLD',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ─── Default HTTP Methods ───────────────────────────────────────
|
// ─── Default HTTP Methods ───────────────────────────────────────
|
||||||
@@ -36,19 +40,15 @@ export const DEFAULT_METHODS: RequestMethodMap = {
|
|||||||
deactivateMethod: 'PATCH',
|
deactivateMethod: 'PATCH',
|
||||||
batchDeactivateMethod: 'POST',
|
batchDeactivateMethod: 'POST',
|
||||||
|
|
||||||
confirmProcessDataMethod: 'PATCH',
|
confirmDataMethod: 'PATCH',
|
||||||
batchConfirmProcessDataMethod: 'POST',
|
batchConfirmDataMethod: 'POST',
|
||||||
cancelProcessDataMethod: 'PATCH',
|
cancelDataMethod: 'PATCH',
|
||||||
batchCancelProcessDataMethod: 'POST',
|
batchCancelDataMethod: 'POST',
|
||||||
|
|
||||||
confirmProcessTransactionMethod: 'PATCH',
|
rollbackDataMethod: 'PATCH',
|
||||||
batchConfirmProcessTransactionMethod: 'POST',
|
batchRollbackDataMethod: 'POST',
|
||||||
cancelProcessTransactionMethod: 'PATCH',
|
holdDataMethod: 'PATCH',
|
||||||
batchCancelProcessTransactionMethod: 'POST',
|
batchHoldDataMethod: 'POST',
|
||||||
rollbackProcessTransactionMethod: 'PATCH',
|
|
||||||
batchRollbackProcessTransactionMethod: 'POST',
|
|
||||||
holdProcessTransactionMethod: 'PATCH',
|
|
||||||
batchHoldProcessTransactionMethod: 'POST',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Operation Descriptors ──────────────────────────────────────
|
// ─── Operation Descriptors ──────────────────────────────────────
|
||||||
@@ -59,31 +59,63 @@ export const DEFAULT_METHODS: RequestMethodMap = {
|
|||||||
* and action header — eliminating 22 boilerplate methods.
|
* and action header — eliminating 22 boilerplate methods.
|
||||||
*/
|
*/
|
||||||
export const DESCRIPTORS = {
|
export const DESCRIPTORS = {
|
||||||
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
|
getMany: { urlKey: 'getManyUrl', methodKey: 'getManyMethod', action: REQUEST_ACTION.VIEW },
|
||||||
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
|
getOne: { urlKey: 'getOneUrl', methodKey: 'getOneMethod', action: REQUEST_ACTION.VIEW },
|
||||||
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
|
create: { urlKey: 'createUrl', methodKey: 'createMethod', action: REQUEST_ACTION.CREATE },
|
||||||
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
|
edit: { urlKey: 'editUrl', methodKey: 'editMethod', action: REQUEST_ACTION.EDIT },
|
||||||
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
|
delete: { urlKey: 'deleteUrl', methodKey: 'deleteMethod', action: REQUEST_ACTION.DELETE },
|
||||||
batchDelete: { urlKey: 'batchDeleteUrl', methodKey: 'batchDeleteMethod', action: REQUEST_ACTION.DELETE },
|
batchDelete: { urlKey: 'batchDeleteUrl', methodKey: 'batchDeleteMethod', action: REQUEST_ACTION.DELETE },
|
||||||
|
|
||||||
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
activate: { urlKey: 'activateUrl', methodKey: 'activateMethod', action: REQUEST_ACTION.ACTIVATE },
|
||||||
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
batchActivate: { urlKey: 'batchActivateUrl', methodKey: 'batchActivateMethod', action: REQUEST_ACTION.ACTIVATE },
|
||||||
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
deactivate: { urlKey: 'deactivateUrl', methodKey: 'deactivateMethod', action: REQUEST_ACTION.DEACTIVATE },
|
||||||
batchDeactivate: { urlKey: 'batchDeactivateUrl', methodKey: 'batchDeactivateMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
batchDeactivate: {
|
||||||
|
urlKey: 'batchDeactivateUrl',
|
||||||
|
methodKey: 'batchDeactivateMethod',
|
||||||
|
action: REQUEST_ACTION.DEACTIVATE,
|
||||||
|
},
|
||||||
|
|
||||||
confirmProcessData: { urlKey: 'confirmProcessDataUrl', methodKey: 'confirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
confirmData: {
|
||||||
batchConfirmProcessData: { urlKey: 'batchConfirmProcessDataUrl', methodKey: 'batchConfirmProcessDataMethod', action: REQUEST_ACTION.CONFIRM_DATA },
|
urlKey: 'confirmDataUrl',
|
||||||
cancelProcessData: { urlKey: 'cancelProcessDataUrl', methodKey: 'cancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
|
methodKey: 'confirmDataMethod',
|
||||||
batchCancelProcessData: { urlKey: 'batchCancelProcessDataUrl', methodKey: 'batchCancelProcessDataMethod', action: REQUEST_ACTION.CANCEL_DATA },
|
action: REQUEST_ACTION.CONFIRM,
|
||||||
|
},
|
||||||
|
batchConfirmData: {
|
||||||
|
urlKey: 'batchConfirmDataUrl',
|
||||||
|
methodKey: 'batchConfirmDataMethod',
|
||||||
|
action: REQUEST_ACTION.CONFIRM,
|
||||||
|
},
|
||||||
|
cancelData: {
|
||||||
|
urlKey: 'cancelDataUrl',
|
||||||
|
methodKey: 'cancelDataMethod',
|
||||||
|
action: REQUEST_ACTION.CANCEL,
|
||||||
|
},
|
||||||
|
batchCancelData: {
|
||||||
|
urlKey: 'batchCancelDataUrl',
|
||||||
|
methodKey: 'batchCancelDataMethod',
|
||||||
|
action: REQUEST_ACTION.CANCEL,
|
||||||
|
},
|
||||||
|
|
||||||
confirmProcessTransaction: { urlKey: 'confirmProcessTransactionUrl', methodKey: 'confirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
rollbackData: {
|
||||||
batchConfirmProcessTransaction: { urlKey: 'batchConfirmProcessTransactionUrl', methodKey: 'batchConfirmProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
urlKey: 'rollbackDataUrl',
|
||||||
cancelProcessTransaction: { urlKey: 'cancelProcessTransactionUrl', methodKey: 'cancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
|
methodKey: 'rollbackDataMethod',
|
||||||
batchCancelProcessTransaction: { urlKey: 'batchCancelProcessTransactionUrl', methodKey: 'batchCancelProcessTransactionMethod', action: REQUEST_ACTION.CANCEL_PROCESS_TRANSACTION },
|
action: REQUEST_ACTION.ROLLBACK,
|
||||||
rollbackProcessTransaction: { urlKey: 'rollbackProcessTransactionUrl', methodKey: 'rollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
},
|
||||||
batchRollbackProcessTransaction: { urlKey: 'batchRollbackProcessTransactionUrl', methodKey: 'batchRollbackProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
batchRollbackData: {
|
||||||
holdProcessTransaction: { urlKey: 'holdProcessTransactionUrl', methodKey: 'holdProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
urlKey: 'batchRollbackDataUrl',
|
||||||
batchHoldProcessTransaction: { urlKey: 'batchHoldProcessTransactionUrl', methodKey: 'batchHoldProcessTransactionMethod', action: REQUEST_ACTION.CONFIRM_PROCESS_TRANSACTION },
|
methodKey: 'batchRollbackDataMethod',
|
||||||
|
action: REQUEST_ACTION.ROLLBACK,
|
||||||
|
},
|
||||||
|
holdData: {
|
||||||
|
urlKey: 'holdDataUrl',
|
||||||
|
methodKey: 'holdDataMethod',
|
||||||
|
action: REQUEST_ACTION.HOLD,
|
||||||
|
},
|
||||||
|
batchHoldData: {
|
||||||
|
urlKey: 'batchHoldDataUrl',
|
||||||
|
methodKey: 'batchHoldDataMethod',
|
||||||
|
action: REQUEST_ACTION.HOLD,
|
||||||
|
},
|
||||||
} as const satisfies Record<string, RequestDescriptor>;
|
} as const satisfies Record<string, RequestDescriptor>;
|
||||||
|
|
||||||
// ─── Default URL Factory ────────────────────────────────────────
|
// ─── Default URL Factory ────────────────────────────────────────
|
||||||
@@ -109,18 +141,14 @@ export function makeDefaultURLs(apiUrl: string): ApiURLMap {
|
|||||||
deactivateUrl: `${apiUrl}/:id/inactive`,
|
deactivateUrl: `${apiUrl}/:id/inactive`,
|
||||||
batchDeactivateUrl: `${apiUrl}/batch-inactive`,
|
batchDeactivateUrl: `${apiUrl}/batch-inactive`,
|
||||||
|
|
||||||
confirmProcessDataUrl: `${apiUrl}/:id/confirm`,
|
confirmDataUrl: `${apiUrl}/:id/confirm`,
|
||||||
batchConfirmProcessDataUrl: `${apiUrl}/batch-confirm`,
|
batchConfirmDataUrl: `${apiUrl}/batch-confirm`,
|
||||||
cancelProcessDataUrl: `${apiUrl}/:id/cancel`,
|
cancelDataUrl: `${apiUrl}/:id/cancel`,
|
||||||
batchCancelProcessDataUrl: `${apiUrl}/batch-cancel`,
|
batchCancelDataUrl: `${apiUrl}/batch-cancel`,
|
||||||
|
|
||||||
confirmProcessTransactionUrl: `${apiUrl}/:id/confirm-data`,
|
rollbackDataUrl: `${apiUrl}/:id/rollback`,
|
||||||
batchConfirmProcessTransactionUrl: `${apiUrl}/batch-confirm-data`,
|
batchRollbackDataUrl: `${apiUrl}/batch-rollback`,
|
||||||
cancelProcessTransactionUrl: `${apiUrl}/:id/cancel`,
|
holdDataUrl: `${apiUrl}/:id/hold`,
|
||||||
batchCancelProcessTransactionUrl: `${apiUrl}/batch-cancel`,
|
batchHoldDataUrl: `${apiUrl}/batch-hold`,
|
||||||
rollbackProcessTransactionUrl: `${apiUrl}/:id/confirm-rollback`,
|
|
||||||
batchRollbackProcessTransactionUrl: `${apiUrl}/batch-confirm-rollback`,
|
|
||||||
holdProcessTransactionUrl: `${apiUrl}/:id/confirm-hold`,
|
|
||||||
batchHoldProcessTransactionUrl: `${apiUrl}/batch-confirm-hold`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,15 @@ export interface ApiURLMap {
|
|||||||
deactivateUrl: string;
|
deactivateUrl: string;
|
||||||
batchDeactivateUrl: string;
|
batchDeactivateUrl: string;
|
||||||
|
|
||||||
confirmProcessDataUrl: string;
|
confirmDataUrl: string;
|
||||||
batchConfirmProcessDataUrl: string;
|
batchConfirmDataUrl: string;
|
||||||
cancelProcessDataUrl: string;
|
cancelDataUrl: string;
|
||||||
batchCancelProcessDataUrl: string;
|
batchCancelDataUrl: string;
|
||||||
|
|
||||||
confirmProcessTransactionUrl: string;
|
rollbackDataUrl: string;
|
||||||
batchConfirmProcessTransactionUrl: string;
|
batchRollbackDataUrl: string;
|
||||||
cancelProcessTransactionUrl: string;
|
holdDataUrl: string;
|
||||||
batchCancelProcessTransactionUrl: string;
|
batchHoldDataUrl: string;
|
||||||
rollbackProcessTransactionUrl: string;
|
|
||||||
batchRollbackProcessTransactionUrl: string;
|
|
||||||
holdProcessTransactionUrl: string;
|
|
||||||
batchHoldProcessTransactionUrl: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── HTTP Method Map ────────────────────────────────────────────
|
// ─── HTTP Method Map ────────────────────────────────────────────
|
||||||
@@ -65,19 +61,15 @@ export interface RequestMethodMap {
|
|||||||
deactivateMethod: string;
|
deactivateMethod: string;
|
||||||
batchDeactivateMethod: string;
|
batchDeactivateMethod: string;
|
||||||
|
|
||||||
confirmProcessDataMethod: string;
|
confirmDataMethod: string;
|
||||||
batchConfirmProcessDataMethod: string;
|
batchConfirmDataMethod: string;
|
||||||
cancelProcessDataMethod: string;
|
cancelDataMethod: string;
|
||||||
batchCancelProcessDataMethod: string;
|
batchCancelDataMethod: string;
|
||||||
|
|
||||||
confirmProcessTransactionMethod: string;
|
rollbackDataMethod: string;
|
||||||
batchConfirmProcessTransactionMethod: string;
|
batchRollbackDataMethod: string;
|
||||||
cancelProcessTransactionMethod: string;
|
holdDataMethod: string;
|
||||||
batchCancelProcessTransactionMethod: string;
|
batchHoldDataMethod: string;
|
||||||
rollbackProcessTransactionMethod: string;
|
|
||||||
batchRollbackProcessTransactionMethod: string;
|
|
||||||
holdProcessTransactionMethod: string;
|
|
||||||
batchHoldProcessTransactionMethod: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Request Descriptor ─────────────────────────────────────────
|
// ─── Request Descriptor ─────────────────────────────────────────
|
||||||
@@ -117,10 +109,7 @@ export interface ExecuteOptions {
|
|||||||
* @typeParam TEntity - The frontend domain entity type (defaults to BaseEntity)
|
* @typeParam TEntity - The frontend domain entity type (defaults to BaseEntity)
|
||||||
* @typeParam TDTO - The API DTO shape (defaults to TEntity for backward compatibility)
|
* @typeParam TDTO - The API DTO shape (defaults to TEntity for backward compatibility)
|
||||||
*/
|
*/
|
||||||
export interface DataServicesConfig<
|
export interface DataServicesConfig<TEntity extends BaseEntity = BaseEntity, TDTO = TEntity> {
|
||||||
TEntity extends BaseEntity = BaseEntity,
|
|
||||||
TDTO = TEntity,
|
|
||||||
> {
|
|
||||||
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
|
/** Base API path (e.g., '/bookings'). Used to generate all URL templates. */
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
|
/** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */
|
||||||
|
|||||||
@@ -17,10 +17,7 @@
|
|||||||
* // => '/orgs/a/users/b'
|
* // => '/orgs/a/users/b'
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function interpolateUrl(
|
export function interpolateUrl(template: string, variables?: Record<string, string>): string {
|
||||||
template: string,
|
|
||||||
variables?: Record<string, string>,
|
|
||||||
): string {
|
|
||||||
if (!variables || Object.keys(variables).length === 0) {
|
if (!variables || Object.keys(variables).length === 0) {
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
@@ -32,9 +29,7 @@ export function interpolateUrl(
|
|||||||
const key = segment.slice(1);
|
const key = segment.slice(1);
|
||||||
const value = variables[key];
|
const value = variables[key];
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
throw new Error(
|
throw new Error(`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`);
|
||||||
`[interpolateUrl] Missing value for URL parameter ":${key}" in template "${template}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return encodeURIComponent(value);
|
return encodeURIComponent(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { setupI18n, type SupportedLanguage } from './setup';
|
export { setupI18n, type SupportedLanguage } from './setup';
|
||||||
export { changeLanguage, applyTenantOverrides } from './manager';
|
export { changeLanguage, applyTenantOverrides } from './manager';
|
||||||
|
export { registerModuleNamespace, isNamespaceRegistered } from './registry';
|
||||||
export { useTranslation, Trans } from 'react-i18next';
|
export { useTranslation, Trans } from 'react-i18next';
|
||||||
export { default as i18n } from 'i18next';
|
export { default as i18n } from 'i18next';
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
"success": "Success",
|
"success": "Success",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"loading": "Loading...",
|
"loading": "Loading",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit"
|
"edit": "Edit",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"view": "View"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
"success": "Sukses",
|
"success": "Sukses",
|
||||||
"error": "Galat",
|
"error": "Galat",
|
||||||
"settings": "Pengaturan",
|
"settings": "Pengaturan",
|
||||||
"loading": "Memuat...",
|
"loading": "Memuat",
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"edit": "Ubah"
|
"edit": "Ubah",
|
||||||
|
"duplicate": "Duplikat",
|
||||||
|
"view": "Lihat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module Namespace Registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks which namespaces have already been registered to prevent
|
||||||
|
* double-registration and detect accidental namespace collisions.
|
||||||
|
*/
|
||||||
|
const registeredNamespaces = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resources shape expected by registerModuleNamespace.
|
||||||
|
* Keys are language codes, values are flat or nested translation objects.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const resources = {
|
||||||
|
* en: { title: 'Full Page', fields: { name: 'Name' } },
|
||||||
|
* id: { title: 'Halaman Penuh', fields: { name: 'Nama' } },
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type ModuleNamespaceResources = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a module's translation namespace into the global i18n instance.
|
||||||
|
*
|
||||||
|
* **Must be called at module scope** (outside any React component) to ensure
|
||||||
|
* resources are available before the first render. The function is idempotent —
|
||||||
|
* calling it multiple times with the same namespace is a safe no-op.
|
||||||
|
*
|
||||||
|
* @param namespace - Unique namespace identifier (should match the JSON filename, e.g. 'full-page').
|
||||||
|
* @param resources - An object keyed by language code containing the translation dictionaries.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import fullPageEn from '../locales/en/full-page.json';
|
||||||
|
* import fullPageId from '../locales/id/full-page.json';
|
||||||
|
*
|
||||||
|
* // ✅ Called at module scope — safe, idempotent, outside React render
|
||||||
|
* registerModuleNamespace('full-page', {
|
||||||
|
* en: fullPageEn,
|
||||||
|
* id: fullPageId,
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function registerModuleNamespace(namespace: string, resources: ModuleNamespaceResources): void {
|
||||||
|
if (registeredNamespaces.has(namespace)) {
|
||||||
|
return; // Already registered — no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredNamespaces.add(namespace);
|
||||||
|
|
||||||
|
for (const [lng, bundle] of Object.entries(resources)) {
|
||||||
|
// deep: true → merges with any existing keys under this namespace
|
||||||
|
// overwrite: false → does NOT replace keys that already exist (e.g. tenant overrides)
|
||||||
|
i18n.addResourceBundle(lng, namespace, bundle, true, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a namespace has been registered.
|
||||||
|
* Useful for debugging or conditional loading logic.
|
||||||
|
*/
|
||||||
|
export function isNamespaceRegistered(namespace: string): boolean {
|
||||||
|
return registeredNamespaces.has(namespace);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"./form": "./src/components/Form/index.ts",
|
"./form": "./src/components/Form/index.ts",
|
||||||
"./hooks": "./src/hooks/index.ts",
|
"./hooks": "./src/hooks/index.ts",
|
||||||
"./provider": "./src/provider/index.ts",
|
"./provider": "./src/provider/index.ts",
|
||||||
"./validators": "./src/validators/index.ts"
|
"./validators": "./src/validators/index.ts",
|
||||||
|
"./foundations": "./src/foundations/index.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -24,7 +25,9 @@
|
|||||||
"@mantine/core": "^8.3.15",
|
"@mantine/core": "^8.3.15",
|
||||||
"@mantine/hooks": "^8.3.15",
|
"@mantine/hooks": "^8.3.15",
|
||||||
"@mantine/tiptap": "^9.3.2",
|
"@mantine/tiptap": "^9.3.2",
|
||||||
|
"@repo/core-api": "workspace:^",
|
||||||
"@repo/core-i18n": "workspace:*",
|
"@repo/core-i18n": "workspace:*",
|
||||||
|
"@repo/core-storage": "workspace:^",
|
||||||
"@repo/utils": "workspace:*",
|
"@repo/utils": "workspace:*",
|
||||||
"@tiptap/extension-link": "^3.27.1",
|
"@tiptap/extension-link": "^3.27.1",
|
||||||
"@tiptap/extension-text-align": "^3.27.1",
|
"@tiptap/extension-text-align": "^3.27.1",
|
||||||
@@ -33,7 +36,9 @@
|
|||||||
"@tiptap/react": "^3.27.1",
|
"@tiptap/react": "^3.27.1",
|
||||||
"@tiptap/starter-kit": "^3.27.1",
|
"@tiptap/starter-kit": "^3.27.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"lucide-react": "^1.22.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
|
"react-router-dom": "^7.11.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "^3.2.2",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './utils';
|
||||||
|
export * from './page-actions';
|
||||||
|
export * from './row-actions';
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { memo, Fragment } from 'react';
|
||||||
|
import { Group, Button, Menu, Divider, ActionIcon, Box } from '@mantine/core';
|
||||||
|
import { ChevronDown, MoreVertical, X } from 'lucide-react';
|
||||||
|
import { PageAction } from './types';
|
||||||
|
import { getIntentColor } from './utils';
|
||||||
|
|
||||||
|
export interface PageActionsProps {
|
||||||
|
/** Array of configured page-level actions. */
|
||||||
|
actions: PageAction[];
|
||||||
|
/** Optional callback triggered when the close (X) button is clicked. */
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A responsive and flexible presentational component for page-level actions.
|
||||||
|
* Automatically adapts layout based on screen size:
|
||||||
|
* - Desktop: Renders a horizontal toolbar with buttons and dividers.
|
||||||
|
* - Mobile: Renders a single Menu dropdown containing all actions.
|
||||||
|
*
|
||||||
|
* @performance Wrapped in React.memo to prevent unnecessary re-renders.
|
||||||
|
*/
|
||||||
|
export const PageActions = memo(function PageActions({ actions }: PageActionsProps) {
|
||||||
|
return (
|
||||||
|
<Box style={{ display: 'inline-flex' }}>
|
||||||
|
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
|
||||||
|
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
if (action.type === 'divider') {
|
||||||
|
return <Divider key={`divider-${index}`} orientation="vertical" mr="sm" ml="sm" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Dropdown Menu for actions with children
|
||||||
|
if (action.children && action.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button
|
||||||
|
variant={action.variant || 'transparent'}
|
||||||
|
color={getIntentColor(action.intent)}
|
||||||
|
leftSection={action.icon}
|
||||||
|
rightSection={<ChevronDown size={14} />}
|
||||||
|
disabled={action.disabled}
|
||||||
|
size="xs"
|
||||||
|
pr="sm"
|
||||||
|
pl="sm"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{action.children.map((child, childIndex) => {
|
||||||
|
if (child.type === 'divider') {
|
||||||
|
return <Menu.Divider key={`child-divider-${childIndex}`} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={child.key}
|
||||||
|
leftSection={child.icon}
|
||||||
|
color={getIntentColor(child.intent)}
|
||||||
|
disabled={child.disabled}
|
||||||
|
onClick={() => child.onClick?.(child.key || '')}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.key}
|
||||||
|
variant={action.variant || 'transparent'}
|
||||||
|
color={getIntentColor(action.intent)}
|
||||||
|
leftSection={action.icon}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={() => action.onClick?.(action.key || '')}
|
||||||
|
size="xs"
|
||||||
|
pr="sm"
|
||||||
|
pl="sm"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* --- MOBILE VIEW (hidden on desktop devices) --- */}
|
||||||
|
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
||||||
|
<Menu position="bottom-end" withArrow withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="transparent" size="md">
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
if (action.type === 'divider') {
|
||||||
|
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.children && action.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<Fragment key={action.key}>
|
||||||
|
<Menu.Label>{action.label}</Menu.Label>
|
||||||
|
{action.children.map((child, childIndex) => {
|
||||||
|
if (child.type === 'divider') {
|
||||||
|
return <Menu.Divider key={`mobile-child-divider-${childIndex}`} mt="xs" mb="xs" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={child.key}
|
||||||
|
leftSection={child.icon}
|
||||||
|
color={getIntentColor(child.intent)}
|
||||||
|
disabled={child.disabled}
|
||||||
|
onClick={() => child.onClick?.(child.key || '')}
|
||||||
|
style={{ paddingLeft: '1.5rem' }} // Indent nested items
|
||||||
|
mt="sm"
|
||||||
|
mb="sm"
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={action.key}
|
||||||
|
leftSection={action.icon}
|
||||||
|
color={getIntentColor(action.intent)}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={() => action.onClick?.(action.key || '')}
|
||||||
|
mt="sm"
|
||||||
|
mb="sm"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Fragment, memo } from 'react';
|
||||||
|
import { Group, ActionIcon, Tooltip, Menu, Divider, Box, Button } from '@mantine/core';
|
||||||
|
import { RowAction } from './types';
|
||||||
|
import { getIntentColor } from './utils';
|
||||||
|
import { MoreVertical } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface RowActionsProps {
|
||||||
|
/** Array of configured row-level actions. */
|
||||||
|
actions: RowAction[];
|
||||||
|
showLabels?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lightweight presentational component optimized for rendering inside data grid rows.
|
||||||
|
* Automatically handles tooltip generation and constructs dropdown menus for nested actions.
|
||||||
|
*
|
||||||
|
* @performance Wrapped in React.memo to guarantee zero overhead inside large lists/grids.
|
||||||
|
*/
|
||||||
|
export const RowActions = memo(function RowActions({ actions = [], showLabels = false }: RowActionsProps) {
|
||||||
|
/**
|
||||||
|
* Helper function to render a standalone icon button.
|
||||||
|
* Wraps the icon in a Tooltip if the configuration provides one.
|
||||||
|
*/
|
||||||
|
const renderIcon = (action: RowAction, fallbackKey: string) => {
|
||||||
|
const actionKey = action.key || fallbackKey;
|
||||||
|
|
||||||
|
const iconBtn = (
|
||||||
|
<Button
|
||||||
|
key={action.key}
|
||||||
|
variant={'transparent'}
|
||||||
|
color={getIntentColor(action.intent)}
|
||||||
|
leftSection={action.icon}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={() => action.onClick?.(action.key || '')}
|
||||||
|
size="xs"
|
||||||
|
pr="xs"
|
||||||
|
pl="xs"
|
||||||
|
pt={0}
|
||||||
|
pb={0}
|
||||||
|
>
|
||||||
|
{showLabels && action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return action.tooltip ? (
|
||||||
|
<Tooltip key={`tooltip-${actionKey}`} label={action.tooltip} withArrow withinPortal>
|
||||||
|
{iconBtn}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
iconBtn
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ display: 'inline-flex' }}>
|
||||||
|
{/* --- DESKTOP VIEW (hidden on mobile devices) --- */}
|
||||||
|
<Group gap={0} wrap="nowrap" visibleFrom="sm">
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
if (action.type === 'divider') {
|
||||||
|
return <Divider key={`divider-${index}`} orientation="vertical" mr="xs" ml="xs" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Dropdown Menu for actions with children
|
||||||
|
if (action.children && action.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<Menu key={action.key} position="bottom-start" withArrow withinPortal trigger="hover">
|
||||||
|
<Menu.Target>{renderIcon(action, `action-${index}`)}</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{action.children.map((child, childIndex) => {
|
||||||
|
if (child.type === 'divider') {
|
||||||
|
return <Menu.Divider key={`child-divider-${childIndex}`} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={child.key}
|
||||||
|
leftSection={child.icon}
|
||||||
|
color={getIntentColor(child.intent)}
|
||||||
|
disabled={child.disabled}
|
||||||
|
onClick={() => child.onClick?.(child.key || '')}
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderIcon(action, `action-${index}`);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* --- MOBILE VIEW (hidden on desktop devices) --- */}
|
||||||
|
<Group gap={0} wrap="nowrap" hiddenFrom="sm">
|
||||||
|
<Menu position="bottom-end" withArrow withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="transparent" size="md">
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
if (action.type === 'divider') {
|
||||||
|
return <Menu.Divider key={`mobile-divider-${index}`} mt="sm" mb="sm" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.children && action.children.length > 0) {
|
||||||
|
return (
|
||||||
|
<Fragment key={action.key}>
|
||||||
|
<Menu.Label>{action.label}</Menu.Label>
|
||||||
|
{action.children.map((child, childIndex) => {
|
||||||
|
if (child.type === 'divider') {
|
||||||
|
return <Menu.Divider key={`mobile-child-divider-${childIndex}`} mt="xs" mb="xs" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={child.key}
|
||||||
|
leftSection={child.icon}
|
||||||
|
color={getIntentColor(child.intent)}
|
||||||
|
disabled={child.disabled}
|
||||||
|
onClick={() => child.onClick?.(child.key || '')}
|
||||||
|
style={{ paddingLeft: '1.5rem' }} // Indent nested items
|
||||||
|
mt="sm"
|
||||||
|
mb="sm"
|
||||||
|
>
|
||||||
|
{child.label}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={action.key}
|
||||||
|
leftSection={action.icon}
|
||||||
|
color={getIntentColor(action.intent)}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={() => action.onClick?.(action.key || '')}
|
||||||
|
mt="sm"
|
||||||
|
mb="sm"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the semantic intent of an action.
|
||||||
|
* The UI component will map these intents to specific theme colors
|
||||||
|
* (e.g., 'destructive' translates to red, 'success' translates to teal).
|
||||||
|
*/
|
||||||
|
export type ActionIntent = 'default' | 'success' | 'warning' | 'destructive' | 'primary';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base Action Entity.
|
||||||
|
* Contains fundamental properties shared across all action types within the system.
|
||||||
|
*/
|
||||||
|
export interface BaseAction {
|
||||||
|
/** Unique identifier for the action. Required for 'action', optional for 'divider'. */
|
||||||
|
key?: string;
|
||||||
|
/** Type of action. Use 'divider' to render a separator. Defaults to 'action'. */
|
||||||
|
type?: 'action' | 'divider';
|
||||||
|
/** Visual representation of the action. Optional for dividers. */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Disables interaction if set to true. */
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
/** Semantic context to determine visual emphasis (color mapping). */
|
||||||
|
intent?: ActionIntent;
|
||||||
|
/** Callback triggered upon action execution. */
|
||||||
|
onClick?: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-Level Action Entity.
|
||||||
|
* Specifically designed for toolbars, page headers, or detailed forms.
|
||||||
|
* Enforces the presence of a text label (unless type is divider) and supports button-specific visual variants.
|
||||||
|
*/
|
||||||
|
export interface PageAction extends BaseAction {
|
||||||
|
/** Text label displayed on the button. Required for 'action' type. */
|
||||||
|
label?: string;
|
||||||
|
/** Specifies the Mantine button variant. Defaults to 'subtle'. */
|
||||||
|
variant?: 'filled' | 'light' | 'outline' | 'default' | 'subtle' | 'transparent';
|
||||||
|
/** Nested actions rendered as a Dropdown Menu below the main button. */
|
||||||
|
children?: PageAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row-Level Action Entity.
|
||||||
|
* Specifically optimized for dense areas like data grids or list items.
|
||||||
|
* Labels are optional (utilized inside dropdowns), supports hover tooltips,
|
||||||
|
* and allows nested action hierarchies (e.g., Kebab menus).
|
||||||
|
*/
|
||||||
|
export interface RowAction extends BaseAction {
|
||||||
|
/** Optional text, primarily used when rendered inside a nested menu item. */
|
||||||
|
label?: string;
|
||||||
|
/** Optional text displayed on hover. */
|
||||||
|
tooltip?: string;
|
||||||
|
/** Nested actions that will be rendered inside a dropdown menu. */
|
||||||
|
children?: RowAction[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ActionIntent } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps semantic intents to corresponding Mantine theme colors.
|
||||||
|
* Ensures consistent color application across different action components.
|
||||||
|
* * @param intent The semantic intent of the action.
|
||||||
|
* @returns A valid Mantine color string, or undefined to fallback to theme defaults.
|
||||||
|
*/
|
||||||
|
export const getIntentColor = (intent?: ActionIntent): string | undefined => {
|
||||||
|
switch (intent) {
|
||||||
|
case 'destructive':
|
||||||
|
return 'red';
|
||||||
|
case 'warning':
|
||||||
|
return 'yellow';
|
||||||
|
case 'success':
|
||||||
|
return 'teal';
|
||||||
|
case 'primary':
|
||||||
|
return undefined; // Default primary color
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'default'; // Fallback to theme default color if no intent is specified
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,7 +3,6 @@ import { AppShell, Flex, Box, Button } from '@mantine/core';
|
|||||||
import { CoreAppShellProvider, useCoreAppShell } from './core-app-shell-context';
|
import { CoreAppShellProvider, useCoreAppShell } from './core-app-shell-context';
|
||||||
import { CoreAppShellConfig, CoreAppShellSlots, CoreAppShellDimensions } from './types';
|
import { CoreAppShellConfig, CoreAppShellSlots, CoreAppShellDimensions } from './types';
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
|
const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
|
||||||
utilityBarHeight: 32,
|
utilityBarHeight: 32,
|
||||||
headerHeight: 60,
|
headerHeight: 60,
|
||||||
@@ -14,12 +13,13 @@ const DEFAULT_DIMENSIONS: Required<CoreAppShellDimensions> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface CoreAppShellInnerProps {
|
interface CoreAppShellInnerProps {
|
||||||
slots: CoreAppShellSlots;
|
slots?: CoreAppShellSlots;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
function CoreAppShellInner({ slots = {}, children }: CoreAppShellInnerProps) {
|
||||||
const { mobileOpened, desktopOpened, sidebarVariant, asideOpened, navbarPanelOpened, config, toggleMobile } = useCoreAppShell();
|
const { mobileOpened, desktopOpened, sidebarVariant, asideOpened, navbarPanelOpened, config, toggleMobile } =
|
||||||
|
useCoreAppShell();
|
||||||
const { variant, dimensions, features } = config;
|
const { variant, dimensions, features } = config;
|
||||||
const dims = { ...DEFAULT_DIMENSIONS, ...dimensions };
|
const dims = { ...DEFAULT_DIMENSIONS, ...dimensions };
|
||||||
|
|
||||||
@@ -52,7 +52,6 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
const showAside = (features?.withAside ?? Boolean(slots.aside)) && Boolean(slots.aside);
|
const showAside = (features?.withAside ?? Boolean(slots.aside)) && Boolean(slots.aside);
|
||||||
const showFooter = (features?.withFooter ?? Boolean(slots.footer)) && Boolean(slots.footer);
|
const showFooter = (features?.withFooter ?? Boolean(slots.footer)) && Boolean(slots.footer);
|
||||||
|
|
||||||
|
|
||||||
// Header height needs to account for utility bar if present
|
// Header height needs to account for utility bar if present
|
||||||
const totalHeaderHeight = useMemo(() => {
|
const totalHeaderHeight = useMemo(() => {
|
||||||
if (!showUtilityBar) return dims.headerHeight;
|
if (!showUtilityBar) return dims.headerHeight;
|
||||||
@@ -63,6 +62,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
return `calc(${dims.headerHeight}${typeof dims.headerHeight === 'number' ? 'px' : ''} + ${dims.utilityBarHeight}${typeof dims.utilityBarHeight === 'number' ? 'px' : ''})`;
|
return `calc(${dims.headerHeight}${typeof dims.headerHeight === 'number' ? 'px' : ''} + ${dims.utilityBarHeight}${typeof dims.utilityBarHeight === 'number' ? 'px' : ''})`;
|
||||||
}, [dims.headerHeight, dims.utilityBarHeight, showUtilityBar]);
|
}, [dims.headerHeight, dims.utilityBarHeight, showUtilityBar]);
|
||||||
|
|
||||||
|
console.log({ totalHeaderHeight, dimensions });
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
layout={appShellLayout}
|
layout={appShellLayout}
|
||||||
@@ -74,7 +74,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
breakpoint: 'sm',
|
breakpoint: 'sm',
|
||||||
collapsed: {
|
collapsed: {
|
||||||
mobile: !mobileOpened,
|
mobile: !mobileOpened,
|
||||||
desktop: isTopNav ? true : (features?.desktopCollapseVariant === 'hide' ? !desktopOpened : false),
|
desktop: isTopNav ? true : features?.desktopCollapseVariant === 'hide' ? !desktopOpened : false,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
aside={
|
aside={
|
||||||
@@ -100,9 +100,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
{slots.utilityBar}
|
{slots.utilityBar}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box flex={1}>
|
<Box flex={1}>{slots.header}</Box>
|
||||||
{slots.header}
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
@@ -126,7 +124,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
h="100%"
|
h="100%"
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRight: '1px solid var(--mantine-color-default-border)'
|
borderRight: '1px solid var(--mantine-color-default-border)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{slots.sidebarRail}
|
{slots.sidebarRail}
|
||||||
@@ -143,15 +141,13 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box hiddenFrom="sm" h="100%">
|
<Box hiddenFrom="sm" h="100%">
|
||||||
<Flex direction="column" h="100%">
|
<Flex direction="column" h="100%">
|
||||||
{
|
{isSidebarFirst && (
|
||||||
isSidebarFirst && (
|
<Box p="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
<Box p="md" style={{ borderTop: '1px solid var(--mantine-color-default-border)' }}>
|
<Button fullWidth variant="default" onClick={toggleMobile}>
|
||||||
<Button fullWidth variant="default" onClick={toggleMobile}>
|
Close
|
||||||
Close
|
</Button>
|
||||||
</Button>
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
<Box flex={1} style={{ overflowY: 'auto' }}>
|
<Box flex={1} style={{ overflowY: 'auto' }}>
|
||||||
{slots.sidebarMobile || slots.sidebar}
|
{slots.sidebarMobile || slots.sidebar}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -175,9 +171,7 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main>{children}</AppShell.Main>
|
||||||
{children}
|
|
||||||
</AppShell.Main>
|
|
||||||
|
|
||||||
{showFooter && (
|
{showFooter && (
|
||||||
<AppShell.Footer
|
<AppShell.Footer
|
||||||
@@ -204,16 +198,14 @@ function CoreAppShellInner({ slots, children }: CoreAppShellInnerProps) {
|
|||||||
|
|
||||||
export interface CoreAppShellProps {
|
export interface CoreAppShellProps {
|
||||||
config: CoreAppShellConfig;
|
config: CoreAppShellConfig;
|
||||||
slots: CoreAppShellSlots;
|
slots?: CoreAppShellSlots;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CoreAppShell({ config, slots, children }: CoreAppShellProps) {
|
export function CoreAppShell({ config, slots, children }: CoreAppShellProps) {
|
||||||
return (
|
return (
|
||||||
<CoreAppShellProvider config={config}>
|
<CoreAppShellProvider config={config}>
|
||||||
<CoreAppShellInner slots={slots}>
|
<CoreAppShellInner slots={slots}>{children}</CoreAppShellInner>
|
||||||
{children}
|
|
||||||
</CoreAppShellInner>
|
|
||||||
</CoreAppShellProvider>
|
</CoreAppShellProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from './system-pages/forbidden';
|
|||||||
export * from './system-pages/maintenance';
|
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';
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module Constants & Base Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardized action constants across all enterprise modules.
|
||||||
|
* Used to define RBAC permissions, UI rendering logic, and event handling.
|
||||||
|
*/
|
||||||
|
export const ModuleAction = {
|
||||||
|
CREATE: 'CREATE',
|
||||||
|
EDIT: 'EDIT',
|
||||||
|
DELETE: 'DELETE',
|
||||||
|
DUPLICATE: 'DUPLICATE',
|
||||||
|
SAVE: 'SAVE',
|
||||||
|
|
||||||
|
PRINT: 'PRINT',
|
||||||
|
PRINT_COPY: 'PRINT_COPY',
|
||||||
|
|
||||||
|
APPROVAL: 'APPROVAL',
|
||||||
|
ACTIVATE: 'ACTIVATE',
|
||||||
|
DEACTIVATE: 'DEACTIVATE',
|
||||||
|
|
||||||
|
CONFIRM: 'CONFIRM',
|
||||||
|
CANCEL: 'CANCEL',
|
||||||
|
ROLLBACK: 'ROLLBACK',
|
||||||
|
HOLD: 'HOLD',
|
||||||
|
|
||||||
|
LOGS: 'LOGS',
|
||||||
|
NOTES: 'NOTES',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a valid module action.
|
||||||
|
* Utilizes the `(string & {})` pattern to preserve IDE autocomplete for standard actions
|
||||||
|
* while allowing implementers to extend it with custom string identifiers.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
export type ModuleActionType = (typeof ModuleAction)[keyof typeof ModuleAction] | (string & {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the architectural category of the module (e.g., routed vs. modal-driven).
|
||||||
|
*/
|
||||||
|
export type ModuleCategoryType = 'SINGLE_PAGE' | 'FULL_PAGE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the current operational mode of a form instance.
|
||||||
|
*/
|
||||||
|
export type FormPageType = 'CREATE' | 'EDIT' | 'DUPLICATE';
|
||||||
|
|
||||||
|
export interface SinglePageConfig {
|
||||||
|
type?: 'MODAL' | 'DRAWER';
|
||||||
|
size?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SinglePageModalState {
|
||||||
|
open: boolean;
|
||||||
|
dataId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SinglePageFormState extends SinglePageModalState {
|
||||||
|
formType: FormPageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftConfig {
|
||||||
|
enableDraft?: boolean;
|
||||||
|
/** @default 5000 */
|
||||||
|
autoSaveIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Root Provider Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foundational configuration injected into the root ModuleProvider.
|
||||||
|
* @template E The base database entity for the module.
|
||||||
|
* @property moduleKey Unique identifier for the module, used for permissions, caching, and i18n.
|
||||||
|
* @property webUrl Base URL for the module's web routes.
|
||||||
|
* @property apiUrl Base API endpoint for the module's remote data services.
|
||||||
|
* @property tabTitle Optional title for browser tabs. When omitted, the Provider will auto-set it from the module's translation key `title`.
|
||||||
|
* @property moduleCategory Architectural category of the module, used for rendering and routing logic.
|
||||||
|
* @property translationNamespace Translation namespace for i18n resource bundles. Must match the namespace string used in `registerModuleNamespace()`.
|
||||||
|
* @property singlePageFormConfig Optional configuration for single-page form modals or drawers.
|
||||||
|
* @property singlePageDetailConfig Optional configuration for single-page detail modals or drawers.
|
||||||
|
*/
|
||||||
|
export interface ModuleConfigEntity<E extends BaseEntity = BaseEntity> {
|
||||||
|
moduleKey: string;
|
||||||
|
webUrl: string;
|
||||||
|
apiUrl: string;
|
||||||
|
tabTitle?: string;
|
||||||
|
moduleCategory: ModuleCategoryType;
|
||||||
|
translationNamespace: string;
|
||||||
|
|
||||||
|
singlePageFormConfig?: SinglePageConfig;
|
||||||
|
singlePageDetailConfig?: SinglePageConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context Slices (Anti-Rerender Strategy)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ConfigSlice<E extends BaseEntity = BaseEntity> {
|
||||||
|
config: ModuleConfigEntity<E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataServiceSlice<
|
||||||
|
E extends BaseEntity,
|
||||||
|
S extends BaseRemoteDataServices<E> = BaseRemoteDataServices<E>,
|
||||||
|
> {
|
||||||
|
dataServices: S;
|
||||||
|
/**
|
||||||
|
* User privileges payload for the current module.
|
||||||
|
* Typed as `unknown` to enforce strict type checking before consumption via RBAC utilities.
|
||||||
|
*/
|
||||||
|
privilege: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State management slice for grid/table selections and data filtering.
|
||||||
|
* Utilizes generic parameters to ensure type safety for arbitrary filter and metadata payloads.
|
||||||
|
*/
|
||||||
|
export interface SelectionSlice<
|
||||||
|
E extends BaseEntity = BaseEntity,
|
||||||
|
TFilter = Record<string, unknown>,
|
||||||
|
TMeta = Record<string, unknown>,
|
||||||
|
> {
|
||||||
|
selectedRows: E[];
|
||||||
|
setSelectedRows: (rows: E[]) => void;
|
||||||
|
metaData: TMeta;
|
||||||
|
setMetaData: (data: TMeta) => void;
|
||||||
|
filterData: TFilter;
|
||||||
|
setFilterData: (data: TFilter) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationSlice {
|
||||||
|
navigateToIndex: () => void;
|
||||||
|
navigateToCreate: () => void;
|
||||||
|
navigateToEdit: (id: string) => void;
|
||||||
|
navigateToDetail: (id: string) => void;
|
||||||
|
navigateToDuplicate: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalSlice {
|
||||||
|
formState: SinglePageFormState;
|
||||||
|
setFormState: (state: SinglePageFormState) => void;
|
||||||
|
detailState: SinglePageModalState;
|
||||||
|
setDetailState: (state: SinglePageModalState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translation slice for enterprise modules, providing a standardized interface for i18n operations.
|
||||||
|
*
|
||||||
|
* The `t` function is scoped to `[moduleNamespace, 'common']`, meaning:
|
||||||
|
* - Keys are first resolved in the module's own namespace.
|
||||||
|
* - If not found, they fall back to the `common` namespace.
|
||||||
|
* - Explicit namespace prefix (e.g. `common:save`) is still supported.
|
||||||
|
*
|
||||||
|
* @property t - Translation function scoped to the module's namespace with common fallback.
|
||||||
|
*/
|
||||||
|
export interface TranslationSlice {
|
||||||
|
/** Translation function scoped to [moduleNamespace, 'common']. No prefix needed for module keys. */
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page-Level Configurations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle interceptors for form processing.
|
||||||
|
* @template E The base database entity.
|
||||||
|
* @template TFormData The payload structure (defaults to Partial<E> for DTOs).
|
||||||
|
*/
|
||||||
|
export interface EnterpriseFormLifecycleHooks<E extends BaseEntity, TFormData = Partial<E>> {
|
||||||
|
onValidate?: (data: TFormData) => Promise<boolean>;
|
||||||
|
beforeSave?: (data: TFormData) => Promise<TFormData>;
|
||||||
|
/** Overrides the default repository save implementation. */
|
||||||
|
save?: (data: TFormData) => Promise<E>;
|
||||||
|
afterSave?: (result: E) => Promise<void>;
|
||||||
|
afterGetData?: (data: E) => Promise<TFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnterpriseIndexPageConfig<E extends BaseEntity = BaseEntity> {
|
||||||
|
children?: ReactNode;
|
||||||
|
showPageHeader?: boolean;
|
||||||
|
useDefaultPadding?: boolean;
|
||||||
|
customHiddenActions?: (selected: E[], defaultHidden: string[]) => string[];
|
||||||
|
/** Strongly typed event handler to prevent arbitrary string usage for actions. */
|
||||||
|
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
|
||||||
|
filterDrawerContent?: ReactNode;
|
||||||
|
|
||||||
|
registerRefreshCallback?: (callback: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends EnterpriseFormLifecycleHooks<E> {
|
||||||
|
children?: ReactNode;
|
||||||
|
showPageHeader?: boolean;
|
||||||
|
useDefaultPadding?: boolean;
|
||||||
|
customHiddenActions?: (data: Partial<E>, defaultHidden: string[]) => string[];
|
||||||
|
/** Strongly typed event handler for custom form interactions. */
|
||||||
|
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
|
||||||
|
|
||||||
|
draftConfig?: DraftConfig;
|
||||||
|
/** Type-safe array of entity keys to exclude during an update operation. */
|
||||||
|
ignoreKeyUpdate?: (keyof E)[];
|
||||||
|
/** Type-safe array of entity keys to exclude when duplicating a record. */
|
||||||
|
ignoreKeyDuplicate?: (keyof E)[];
|
||||||
|
initialValue?: Partial<E>;
|
||||||
|
|
||||||
|
presetDuplicate?: (data: E) => Promise<Partial<E>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> {
|
||||||
|
children?: ReactNode;
|
||||||
|
showPageHeader?: boolean;
|
||||||
|
useDefaultPadding?: boolean;
|
||||||
|
customHiddenActions?: (data: E | null, defaultHidden: string[]) => string[];
|
||||||
|
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
|
||||||
|
|
||||||
|
editMode?: 'FULL' | 'PARTIAL';
|
||||||
|
afterGetData?: (data: E) => Promise<unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
|
||||||
|
export interface DetailPageContextValue<E extends BaseEntity = BaseEntity> {
|
||||||
|
detailData: E | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
isPartialEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailPageContext = createContext<DetailPageContextValue<any> | null>(null);
|
||||||
|
|
||||||
|
export function useDetailPageContext<E extends BaseEntity = BaseEntity>(): DetailPageContextValue<E> {
|
||||||
|
const context = useContext(DetailPageContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDetailPageContext must be used within an EnterpriseDetailPageProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createLocalStorage } from '@repo/core-storage';
|
||||||
|
import { useEnterpriseModuleConfigContext } from './use-module.context';
|
||||||
|
import { DraftConfig, FormPageType } from '../entities/entity';
|
||||||
|
|
||||||
|
interface FormDraftContextProps {
|
||||||
|
formType: FormPageType;
|
||||||
|
dataId?: string;
|
||||||
|
config?: DraftConfig;
|
||||||
|
userID: string; // Optional user ID for multi-user scenarios
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftStorage = createLocalStorage<string>({
|
||||||
|
// No encryption needed for general drafts usually, but could be added
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useFormDraftContext({ userID, formType, dataId, config }: FormDraftContextProps) {
|
||||||
|
const { config: moduleConfig } = useEnterpriseModuleConfigContext();
|
||||||
|
const [hasDraft, setHasDraft] = useState(false);
|
||||||
|
const [draftData, setDraftData] = useState<any>(null);
|
||||||
|
|
||||||
|
const isEnabled = config?.enableDraft === true;
|
||||||
|
const draftKey = `${userID || 'UNRESOLVED_PRINCIPAL'}:draft:${moduleConfig.moduleKey}:${formType}:${dataId || 'new'}`;
|
||||||
|
|
||||||
|
// Check for existing draft on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
|
||||||
|
draftStorage.getItem(draftKey).then((data: any) => {
|
||||||
|
if (data) {
|
||||||
|
setDraftData(data);
|
||||||
|
setHasDraft(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [draftKey, isEnabled]);
|
||||||
|
|
||||||
|
const saveDraft = useCallback(
|
||||||
|
(data: any) => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
draftStorage.setItem(draftKey, {
|
||||||
|
...data,
|
||||||
|
_draftSavedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[draftKey, isEnabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDraft = useCallback(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
draftStorage.removeItem(draftKey);
|
||||||
|
setHasDraft(false);
|
||||||
|
setDraftData(null);
|
||||||
|
}, [draftKey, isEnabled]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEnabled,
|
||||||
|
hasDraft,
|
||||||
|
draftData,
|
||||||
|
saveDraft,
|
||||||
|
clearDraft,
|
||||||
|
setHasDraft, // To close the recovery dialog
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
import { FormPageType } from '../entities/entity';
|
||||||
|
|
||||||
|
export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
|
||||||
|
formType: FormPageType;
|
||||||
|
isCreate: boolean;
|
||||||
|
isEdit: boolean;
|
||||||
|
isDuplicate: boolean;
|
||||||
|
dataId?: string;
|
||||||
|
initialData: Partial<E> | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
save: (data: any) => Promise<void>;
|
||||||
|
cancel: () => void;
|
||||||
|
// Draft specific
|
||||||
|
hasDraft: boolean;
|
||||||
|
applyDraft: () => void;
|
||||||
|
discardDraft: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormPageContext = createContext<FormPageContextValue<any> | null>(null);
|
||||||
|
|
||||||
|
export function useFormPageContext<E extends BaseEntity = BaseEntity>(): FormPageContextValue<E> {
|
||||||
|
const context = useContext(FormPageContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useFormPageContext must be used within an EnterpriseFormPageProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
|
||||||
|
export interface IndexPageContextValue<E extends BaseEntity = BaseEntity> {
|
||||||
|
renderRowActions: (row: E) => React.ReactNode;
|
||||||
|
refreshGrid: () => void;
|
||||||
|
// State for batch modals
|
||||||
|
isConfirmModalOpen: boolean;
|
||||||
|
setIsConfirmModalOpen: (open: boolean) => void;
|
||||||
|
isDeleteModalOpen: boolean;
|
||||||
|
setIsDeleteModalOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IndexPageContext = createContext<IndexPageContextValue<any> | null>(null);
|
||||||
|
|
||||||
|
export function useIndexPageContext<E extends BaseEntity = BaseEntity>(): IndexPageContextValue<E> {
|
||||||
|
const context = useContext(IndexPageContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useIndexPageContext must be used within an IndexPageProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||||
|
import type {
|
||||||
|
ConfigSlice,
|
||||||
|
DataServiceSlice,
|
||||||
|
SelectionSlice,
|
||||||
|
NavigationSlice,
|
||||||
|
ModalSlice,
|
||||||
|
TranslationSlice,
|
||||||
|
} from '../entities/entity';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context Definitions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const EnterpriseConfigContext = createContext<ConfigSlice<any> | null>(null);
|
||||||
|
export const EnterpriseDataServiceContext = createContext<DataServiceSlice<any, any> | null>(null);
|
||||||
|
export const EnterpriseSelectionContext = createContext<SelectionSlice<any> | null>(null);
|
||||||
|
export const EnterpriseNavigationContext = createContext<NavigationSlice | null>(null);
|
||||||
|
export const EnterpriseModalContext = createContext<ModalSlice | null>(null);
|
||||||
|
export const EnterpriseTranslationContext = createContext<TranslationSlice | null>(null);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Granular Hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useEnterpriseModuleConfigContext<E extends BaseEntity = BaseEntity>(): ConfigSlice<E> {
|
||||||
|
const context = useContext(EnterpriseConfigContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useEnterpriseModuleConfigContext must be used within an EnterpriseModuleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnterpriseModuleDataServiceContext<
|
||||||
|
E extends BaseEntity = BaseEntity,
|
||||||
|
S extends BaseRemoteDataServices<E> = BaseRemoteDataServices<E>,
|
||||||
|
>(): DataServiceSlice<E, S> {
|
||||||
|
const context = useContext(EnterpriseDataServiceContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useEnterpriseModuleDataServiceContext must be used within an EnterpriseModuleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnterpriseModuleSelectionContext<E extends BaseEntity = BaseEntity>(): SelectionSlice<E> {
|
||||||
|
const context = useContext(EnterpriseSelectionContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useEnterpriseModuleSelectionContext must be used within an EnterpriseModuleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnterpriseModuleNavigationContext(): NavigationSlice {
|
||||||
|
const context = useContext(EnterpriseNavigationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useEnterpriseModuleNavigationContext must be used within an EnterpriseModuleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnterpriseModuleModalContext(): ModalSlice {
|
||||||
|
const context = useContext(EnterpriseModalContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useEnterpriseModuleModalContext must be used within an EnterpriseModuleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnterpriseModuleTranslationContext(): TranslationSlice {
|
||||||
|
const context = useContext(EnterpriseTranslationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useEnterpriseModuleTranslationContext must be used within an EnterpriseModuleProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Composed Hook (Use Sparingly)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useEnterpriseModuleContext<E extends BaseEntity = BaseEntity>() {
|
||||||
|
return {
|
||||||
|
...useEnterpriseModuleConfigContext<E>(),
|
||||||
|
...useEnterpriseModuleDataServiceContext<E>(),
|
||||||
|
...useEnterpriseModuleSelectionContext<E>(),
|
||||||
|
...useEnterpriseModuleNavigationContext(),
|
||||||
|
...useEnterpriseModuleModalContext(),
|
||||||
|
...useEnterpriseModuleTranslationContext(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './entities/entity';
|
||||||
|
|
||||||
|
export * from './hooks/use-module.context';
|
||||||
|
|
||||||
|
export * from './providers/module.provider';
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useMemo, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
|
import { BaseEntity, BaseRemoteDataServices } from '@repo/core-api/data-services';
|
||||||
|
|
||||||
|
import { ModuleConfigEntity, SinglePageFormState, SinglePageModalState } from '../entities/entity';
|
||||||
|
import {
|
||||||
|
EnterpriseConfigContext,
|
||||||
|
EnterpriseDataServiceContext,
|
||||||
|
EnterpriseSelectionContext,
|
||||||
|
EnterpriseNavigationContext,
|
||||||
|
EnterpriseModalContext,
|
||||||
|
EnterpriseTranslationContext,
|
||||||
|
} from '../hooks/use-module.context';
|
||||||
|
|
||||||
|
export interface EnterpriseModuleProviderProps<E extends BaseEntity> {
|
||||||
|
children: ReactNode;
|
||||||
|
config: ModuleConfigEntity<E>;
|
||||||
|
dataServices: BaseRemoteDataServices<E>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnterpriseModuleProvider<E extends BaseEntity>(props: EnterpriseModuleProviderProps<E>) {
|
||||||
|
const { children, config, dataServices } = props;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1. Config Slice (Static)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const configSlice = useMemo(() => ({ config }), [config]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1b. Translation Slice (Dedicated context — decoupled from config)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const namespaces = useMemo(
|
||||||
|
() => [config.translationNamespace, 'common'],
|
||||||
|
[config.translationNamespace],
|
||||||
|
);
|
||||||
|
const { t } = useTranslation(namespaces);
|
||||||
|
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
|
||||||
|
|
||||||
|
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
|
||||||
|
useEffect(() => {
|
||||||
|
const title = config.tabTitle || t('title');
|
||||||
|
if (title) {
|
||||||
|
document.title = title;
|
||||||
|
}
|
||||||
|
}, [config.tabTitle, t]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2. Data Service Slice (Stable refs)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const dataServiceSlice = useMemo(() => {
|
||||||
|
return { dataServices, privilege: undefined };
|
||||||
|
}, [dataServices]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 3. Selection Slice (Dynamic state)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const [selectedRows, setSelectedRows] = useState<E[]>([]);
|
||||||
|
const [metaData, setMetaData] = useState<any>(null);
|
||||||
|
const [filterData, setFilterData] = useState<any>(null);
|
||||||
|
|
||||||
|
const selectionSlice = useMemo(
|
||||||
|
() => ({
|
||||||
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
|
metaData,
|
||||||
|
setMetaData,
|
||||||
|
filterData,
|
||||||
|
setFilterData,
|
||||||
|
}),
|
||||||
|
[selectedRows, metaData, filterData],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4. Modal Slice (For Single Page mode)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const [formState, setFormState] = useState<SinglePageFormState>({ open: false, formType: 'CREATE' });
|
||||||
|
const [detailState, setDetailState] = useState<SinglePageModalState>({ open: false });
|
||||||
|
|
||||||
|
const modalSlice = useMemo(
|
||||||
|
() => ({ formState, setFormState, detailState, setDetailState }),
|
||||||
|
[formState, detailState],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. Navigation Slice
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const navigationSlice = useMemo(() => {
|
||||||
|
const isSingle = config.moduleCategory === 'SINGLE_PAGE';
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigateToIndex: () => {
|
||||||
|
if (isSingle) {
|
||||||
|
setFormState({ open: false, formType: 'CREATE' });
|
||||||
|
setDetailState({ open: false });
|
||||||
|
} else {
|
||||||
|
navigate(`${config.webUrl}/index`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigateToCreate: () => {
|
||||||
|
if (isSingle) {
|
||||||
|
setFormState({ open: true, formType: 'DUPLICATE' });
|
||||||
|
} else {
|
||||||
|
navigate(`${config.webUrl}/create`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigateToEdit: (id: string) => {
|
||||||
|
if (isSingle) {
|
||||||
|
setFormState({ open: true, formType: 'EDIT', dataId: id });
|
||||||
|
} else {
|
||||||
|
navigate(`${config.webUrl}/edit/${id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigateToDetail: (id: string) => {
|
||||||
|
if (isSingle) {
|
||||||
|
setDetailState({ open: true, dataId: id });
|
||||||
|
} else {
|
||||||
|
navigate(`${config.webUrl}/detail/${id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigateToDuplicate: (id: string) => {
|
||||||
|
if (isSingle) {
|
||||||
|
setFormState({ open: true, formType: 'DUPLICATE', dataId: id });
|
||||||
|
} else {
|
||||||
|
navigate(`${config.webUrl}/duplicate/${id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [config.moduleCategory, config.webUrl, navigate]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<EnterpriseConfigContext.Provider value={configSlice}>
|
||||||
|
<EnterpriseTranslationContext.Provider value={translationSlice}>
|
||||||
|
<EnterpriseDataServiceContext.Provider value={dataServiceSlice}>
|
||||||
|
<EnterpriseNavigationContext.Provider value={navigationSlice}>
|
||||||
|
<EnterpriseSelectionContext.Provider value={selectionSlice}>
|
||||||
|
<EnterpriseModalContext.Provider value={modalSlice}>{children}</EnterpriseModalContext.Provider>
|
||||||
|
</EnterpriseSelectionContext.Provider>
|
||||||
|
</EnterpriseNavigationContext.Provider>
|
||||||
|
</EnterpriseDataServiceContext.Provider>
|
||||||
|
</EnterpriseTranslationContext.Provider>
|
||||||
|
</EnterpriseConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './enterprise-module';
|
||||||
Generated
+17
-4
@@ -183,8 +183,8 @@ importers:
|
|||||||
specifier: ^24.2.2
|
specifier: ^24.2.2
|
||||||
version: 24.2.3(typescript@5.5.4)
|
version: 24.2.3(typescript@5.5.4)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.17.0
|
specifier: ^1.22.0
|
||||||
version: 1.17.0(react@19.2.3)
|
version: 1.22.0(react@19.2.3)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.3
|
version: 19.2.3
|
||||||
@@ -446,9 +446,15 @@ importers:
|
|||||||
'@mantine/tiptap':
|
'@mantine/tiptap':
|
||||||
specifier: ^9.3.2
|
specifier: ^9.3.2
|
||||||
version: 9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3)
|
version: 9.3.2(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(@tiptap/extension-link@3.27.1)(@tiptap/react@3.27.1)(react-dom@19.2.3)(react@19.2.3)
|
||||||
|
'@repo/core-api':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../core-api
|
||||||
'@repo/core-i18n':
|
'@repo/core-i18n':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core-i18n
|
version: link:../core-i18n
|
||||||
|
'@repo/core-storage':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../core-storage
|
||||||
'@repo/utils':
|
'@repo/utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../utils
|
version: link:../utils
|
||||||
@@ -473,9 +479,15 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.22.0
|
||||||
|
version: 1.22.0(react@19.2.3)
|
||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.56.4
|
specifier: ^7.56.4
|
||||||
version: 7.79.0(react@19.2.3)
|
version: 7.79.0(react@19.2.3)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^7.11.0
|
||||||
|
version: 7.11.0(react-dom@19.2.3)(react@19.2.3)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@@ -1673,6 +1685,7 @@ packages:
|
|||||||
|
|
||||||
/@floating-ui/core@1.7.4:
|
/@floating-ui/core@1.7.4:
|
||||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||||
|
requiresBuild: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.10
|
'@floating-ui/utils': 0.2.10
|
||||||
dev: false
|
dev: false
|
||||||
@@ -8627,8 +8640,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==}
|
resolution: {integrity: sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/lucide-react@1.17.0(react@19.2.3):
|
/lucide-react@1.22.0(react@19.2.3):
|
||||||
resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==}
|
resolution: {integrity: sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user