diff --git a/.vscode/settings.json b/.vscode/settings.json index 964af63..1b53be8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,5 @@ "mode": "auto" } ], - "cSpell.words": ["mantine", "Menlo", "Millis", "Pandang", "Segoe", "Ujung", "WITA"] + "cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "VITE", "WITA"] } diff --git a/apps/docs-dev/src/.vitepress/config.mts b/apps/docs-dev/src/.vitepress/config.mts index 1783a76..e114bac 100644 --- a/apps/docs-dev/src/.vitepress/config.mts +++ b/apps/docs-dev/src/.vitepress/config.mts @@ -1,25 +1,23 @@ -import { defineConfig } from 'vitepress' -import { withMermaid } from 'vitepress-plugin-mermaid' +import { defineConfig } from 'vitepress'; +import { withMermaid } from 'vitepress-plugin-mermaid'; const config = withMermaid( defineConfig({ // title: "Frontend Monorepo", title: 'Frontend Arch', - description: "Centralized documentation for the Enterprise Frontend Monorepo", + description: 'Centralized documentation for the Enterprise Frontend Monorepo', head: [ - ['link', { rel: 'icon', href: '/favicon.svg' }] // Jika Anda menggunakan favicon.svg - ], + ['link', { rel: 'icon', href: '/favicon.svg' }], // Jika Anda menggunakan favicon.svg + ], themeConfig: { search: { provider: 'local', options: { - detailedView: true - } + detailedView: true, + }, }, logo: '/logo.svg', - nav: [ - { text: 'Docs', link: '/overview' }, - ], + nav: [{ text: 'Docs', link: '/overview' }], sidebar: [ { @@ -33,18 +31,28 @@ const config = withMermaid( text: 'Core Architecture', collapsed: false, 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: 'Storage & Persistence', link: '/packages/core-storage/' }, { text: 'I18n & Localization', link: '/packages/core-i18n/' }, ], }, - { + { text: 'UI System', collapsed: false, items: [ { text: 'Overview', link: '/packages/ui/' }, { text: 'App Layout', link: '/packages/ui/CORE-APP-SHELL' }, + { text: 'Action Tools', link: '/packages/ui/ACTION-TOOLS' }, { text: 'Form Primitives', link: '/packages/ui/FORM-COMPONENTS' }, ], }, @@ -52,23 +60,23 @@ const config = withMermaid( text: 'Desktop Ecosystem', collapsed: false, items: [ - { text: 'Overview', link: '/apps/desktop/' }, - { text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' }, - { text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' }, - { text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' }, + { text: 'Overview', link: '/apps/desktop/' }, + { text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' }, + { text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' }, + { text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' }, ], }, ], outline: { level: [2, 3] }, socialLinks: [ - { - icon: { - svg: 'Gitea' + { + icon: { + svg: 'Gitea', + }, + link: 'https://git.eigen.co.id/eigen/fe-monorepo-template', }, - link: 'https://git.eigen.co.id/eigen/fe-monorepo-template' - } - ] + ], }, // Mermaid configuration @@ -79,22 +87,20 @@ const config = withMermaid( // Fix cascading CJS/ESM SyntaxErrors caused by Vite dynamically discovering mermaid vite: { optimizeDeps: { - include: [ - 'mermaid' - ] - } - } - }) + include: ['mermaid'], + }, + }, + }), ); -// Pnpm strict workspace workaround: +// Pnpm strict workspace workaround: // vitepress-plugin-mermaid aggressively injects sub-dependencies into optimizeDeps.include. // Because pnpm uses strict symlinks, Vite fails to resolve these sub-dependencies from the project root, // causing pre-bundling to fail and cascading CJS/ESM SyntaxErrors in the browser. // We strip them out so esbuild can naturally inline them into the 'mermaid' chunk instead. if (config.vite?.optimizeDeps?.include) { 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), ); } diff --git a/apps/docs-dev/src/packages/core-api/transformers.md b/apps/docs-dev/src/packages/core-api/transformers.md new file mode 100644 index 0000000..614eb39 --- /dev/null +++ b/apps/docs-dev/src/packages/core-api/transformers.md @@ -0,0 +1,323 @@ +# Data Transformers + +> **Purpose:** Separate data transformation logic from API call logic, enabling clean DTO ↔ Entity mapping with type safety. +> +> **Location:** `packages/core-api/src/data-services/base-data.transformer.ts` + +--- + +## Why Data Transformers? + +In enterprise applications, the shape of data returned by the API (DTOs) often differs from the shape used in the frontend (Domain Entities). Common differences include: + +| API (DTO) | Frontend (Entity) | +| ------------------------------ | ---------------------------- | +| `snake_case` field names | `camelCase` field names | +| Deeply nested structures | Flattened/normalized shapes | +| Raw ISO date strings | Parsed `Date` objects | +| No computed fields | Derived/computed properties | +| Backend-specific enums | Frontend-friendly enums | + +Without transformers, this mapping logic leaks into components, hooks, and services — violating the **Single Responsibility Principle** and making the codebase harder to test and maintain. + +### Benefits + +- **Separation of Concerns** — Transformation logic lives in one place, not scattered across components +- **Testability** — Transformers are pure functions, trivially unit-testable +- **Reusability** — Same transformer can be used across multiple services or contexts +- **Type Safety** — Two generic parameters (`TEntity`, `TDTO`) enforce correct mapping at compile time +- **Backward Compatible** — Transformers are optional; existing services work unchanged + +--- + +## Architecture + +```mermaid +graph LR + classDef api fill:#f59e0b,stroke:#b45309,color:#fff + classDef transformer fill:#6366f1,stroke:#4338ca,color:#fff + classDef entity fill:#10b981,stroke:#047857,color:#fff + classDef service fill:#3b82f6,stroke:#2563eb,color:#fff + + API["🌐 REST API
(snake_case DTOs)"]:::api + SVC["BaseRemoteDataServices
(execute, getOne, getMany, ...)"]:::service + TFM["Data Transformer
(transformToEntity / transformToDTO)"]:::transformer + ENT["Domain Entity
(camelCase, computed fields)"]:::entity + + API -->|"Response (DTO)"| SVC + SVC -->|"dto"| TFM + TFM -->|"entity"| ENT + + ENT -->|"entity"| TFM + TFM -->|"dto"| SVC + SVC -->|"Request (DTO)"| API +``` + +**Data flows:** +- **API → Frontend:** Response DTO → `transformToEntity()` → Domain Entity +- **Frontend → API:** Domain Entity → `transformToDTO()` → Request DTO + +--- + +## Quick Start + +### 1. Define Your Types + +```typescript +// Domain Entity (what your UI uses) +interface BookingEntity extends BaseEntity { + bookingCode: string; + customerName: string; + checkInDate: string; +} + +// API DTO (what the backend returns) +interface BookingDTO { + id?: string; + booking_code: string; + customer_name: string; + check_in_date: string; +} +``` + +### 2. Create a Transformer + +```typescript +import { BaseDataTransformer } from '@repo/core-api/data-services'; + +class BookingTransformer extends BaseDataTransformer { + transformToEntity(dto: BookingDTO): BookingEntity { + return { + id: dto.id, + bookingCode: dto.booking_code, + customerName: dto.customer_name, + checkInDate: dto.check_in_date, + }; + } + + transformToDTO(entity: BookingEntity): BookingDTO { + return { + id: entity.id, + booking_code: entity.bookingCode, + customer_name: entity.customerName, + check_in_date: entity.checkInDate, + }; + } +} +``` + +### 3. Inject into Data Services + +```typescript +import { CommonRemoteDataServices } from '@repo/core-api/data-services'; + +const bookingServices = new CommonRemoteDataServices( + apiClient, + { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer: new BookingTransformer(), + }, +); + +// Now all CRUD methods automatically transform: +const { data } = await bookingServices.getOne('42'); +// data is BookingEntity (camelCase) ✓ + +await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... }); +// Payload is sent as { booking_code: 'BK001', customer_name: 'Alice', ... } ✓ +``` + +--- + +## Interface Reference + +### `IDataTransformer` + +The minimal contract for bidirectional data transformation. + +```typescript +interface IDataTransformer { + transformToEntity(dto: TDTO): TEntity; + transformToDTO(entity: TEntity): TDTO; + + // Optional operation-specific hooks + transformGetOneResponse?(dto: TDTO): TEntity; + transformGetManyResponse?(dtos: TDTO[]): TEntity[]; + transformCreatePayload?(entity: Partial): Partial; + transformEditPayload?(entity: Partial): Partial; +} +``` + +### `BaseDataTransformer` + +Abstract class implementing `IDataTransformer` with sensible defaults. + +| Method | Default Behavior | Override When | +| ------------------------- | ---------------------------------------- | ------------------------------------------ | +| `transformToEntity` | Identity cast (passthrough) | Always — this is the core mapping | +| `transformToDTO` | Identity cast (passthrough) | Always — this is the core mapping | +| `transformGetOneResponse` | Delegates to `transformToEntity` | `getOne` needs computed/derived fields | +| `transformGetManyResponse`| Maps each item via `transformToEntity` | List responses need bulk transformations | +| `transformCreatePayload` | Delegates to `transformToDTO` | Create payloads need special handling (e.g., strip IDs) | +| `transformEditPayload` | Delegates to `transformToDTO` | Edit payloads differ from create | + +--- + +## Integration with `BaseRemoteDataServices` + +When a transformer is injected via `DataServicesConfig.transformer`, the base service methods automatically apply transformations: + +| Service Method | Transformer Hook Used | Direction | +| -------------- | -------------------------------- | --------------- | +| `getOne()` | `transformGetOneResponse()` | Response → Entity | +| `getMany()` | `transformGetManyResponse()` | Response → Entity | +| `create()` | `transformCreatePayload()` | Entity → DTO | +| `edit()` | `transformEditPayload()` | Entity → DTO | +| `delete()` | None (no data transformation) | — | +| `customRequest()` | None (manual transformation) | — | + +> **Important:** If no transformer is injected, all methods behave exactly as before — data passes through unchanged. This ensures 100% backward compatibility. + +--- + +## Advanced: Extending Transformers + +For domain-specific features that go beyond standard CRUD, you can extend both the transformer and the data service. + +### Extended Transformer + +```typescript +// advanced-booking.transformer.ts +import { BookingTransformer } from './booking.transformer'; + +interface AvailabilityChartRawData { + dates: Array<{ + date_iso: string; + available_rooms: number; + occupancy_rate: number; + }>; +} + +interface AvailabilityChartData { + dataPoints: Array<{ + label: string; + availableRooms: number; + isHighDemand: boolean; + }>; +} + +class AdvancedBookingTransformer extends BookingTransformer { + // All standard CRUD mappings are inherited ✓ + + // Add custom transformation for non-CRUD data + transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData { + return { + dataPoints: rawData.dates.map((item) => ({ + label: new Date(item.date_iso).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }), + availableRooms: item.available_rooms, + isHighDemand: item.occupancy_rate > 80, + })), + }; + } +} +``` + +### Extended Data Service + +```typescript +// advanced-booking.data-services.ts +import { BaseRemoteDataServices } from '@repo/core-api/data-services'; + +class AdvancedBookingDataServices extends BaseRemoteDataServices { + private readonly advancedTransformer: AdvancedBookingTransformer; + + constructor() { + const transformer = new AdvancedBookingTransformer(); + super(apiClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer, + }); + this.advancedTransformer = transformer; + } + + // Custom method using the extended transformer + async getAvailabilityChart(params: { + startDate: string; + endDate: string; + }): Promise> { + const response = await this.customRequest({ + url: '/bookings/availability-chart', + method: 'GET', + params: { start_date: params.startDate, end_date: params.endDate }, + }); + + return { + data: this.advancedTransformer.transformAvailabilityChart(response.data), + status: response.status, + }; + } +} + +export const advancedBookingServices = new AdvancedBookingDataServices(); +``` + +--- + +## Migration Guide + +Adding transformers to existing services requires **zero breaking changes**: + +### Step 1: Create a Transformer + +```typescript +class MyTransformer extends BaseDataTransformer { + transformToEntity(dto: MyDTO): MyEntity { /* ... */ } + transformToDTO(entity: MyEntity): MyDTO { /* ... */ } +} +``` + +### Step 2: Add a Second Generic Parameter + +```diff +- const services = new CommonRemoteDataServices(apiClient, { ++ const services = new CommonRemoteDataServices(apiClient, { + apiUrl: '/my-endpoint', ++ transformer: new MyTransformer(), + }); +``` + +### Step 3: (Optional) Override Operation-Specific Hooks + +```typescript +class MyTransformer extends BaseDataTransformer { + transformToEntity(dto: MyDTO): MyEntity { /* ... */ } + transformToDTO(entity: MyEntity): MyDTO { /* ... */ } + + // Only override if getOne needs special handling + override transformGetOneResponse(dto: MyDTO): MyEntity { + const entity = this.transformToEntity(dto); + return { ...entity, computedField: derive(dto) }; + } +} +``` + +> **Existing services without transformers are completely unaffected.** The `TDTO` generic defaults to `TEntity`, and the `transformer` config property defaults to `undefined`. + +--- + +## Sample Implementation + +A full working example is available in the showcase booking feature: + +| File | Description | +| ---- | ----------- | +| `apps/web/.../booking/data/booking.transformer.ts` | Basic transformer with snake_case ↔ camelCase mapping | +| `apps/web/.../booking/data/booking.data-services.ts` | Data service with injected transformer | +| `apps/web/.../booking/data/advanced-booking.transformer.ts` | Extended transformer with custom chart method | +| `apps/web/.../booking/data/advanced-booking.data-services.ts` | Extended service with custom `getAvailabilityChart()` | diff --git a/apps/docs-dev/src/packages/core-i18n/index.md b/apps/docs-dev/src/packages/core-i18n/index.md index 26e72ad..2e49973 100644 --- a/apps/docs-dev/src/packages/core-i18n/index.md +++ b/apps/docs-dev/src/packages/core-i18n/index.md @@ -4,9 +4,10 @@ > > **Description:** Hybrid namespace internationalization engine built on i18next, providing centralized common vocabularies with lazy-loaded feature dictionaries, tenant overrides, and backend sync with automatic rollback. -A highly decoupled, type-safe internationalization engine for the monorepo. +A highly decoupled, type-safe internationalization engine for the monorepo. It uses a **Hybrid Namespace Strategy**: + 1. **Centralized Engine**: Setup, local persistence orchestration, and global words (`common`). 2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded. @@ -46,10 +47,10 @@ graph TD DICT -.->|lazy loads| I18N COMMON --->|preloads| I18N I18N <===>|reads / persists| STORE - + I18N --->|changeLanguage sync| SYNC SYNC -.->|fails? rollback| I18N - + TENANT -.->|applyTenantOverrides| I18N %% ─── Apply Styles ─── @@ -68,7 +69,7 @@ graph TD ## 1. App-Level Setup (Bootstrap) -Initialize the engine *before* your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation. +Initialize the engine _before_ your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation. ```tsx // apps/web/src/main.tsx @@ -83,17 +84,19 @@ async function bootstrap() { await setupI18n({ storageAdapter: { getLanguage: async () => { - const stored = await secureStorage.getItem(AppStorageKey.LOCALE); + const stored = await secureStorage.getItem(AppStorageKey.LANGUAGE); return typeof stored === 'string' ? stored : null; }, setLanguage: async (lng: string) => { - await secureStorage.setItem(AppStorageKey.LOCALE, lng); + await secureStorage.setItem(AppStorageKey.LANGUAGE, lng); }, }, }); createRoot(document.getElementById('app')!).render( - , + + + , ); } bootstrap(); @@ -106,6 +109,7 @@ bootstrap(); Dictionaries live right next to the UI components that use them. ### Folder Structure + ```text apps/web/src/apps/modules/booking/ ├── presentation/BookingTable.tsx @@ -114,10 +118,38 @@ apps/web/src/apps/modules/booking/ └── en/booking.json ``` -### Lazy Loading & Type Safety -Register the namespace when the component mounts. To get native TypeScript autocomplete for nested keys (e.g., `header.title`), augment the global `react-i18next` types. +### Namespace Registration & Context + +To prevent unnecessary React re-renders and ensure dictionaries are loaded before the first paint, translations are registered at the module scope using `registerModuleNamespace`. + +**1. Register the Namespace in the Module Factory:** + +Call `registerModuleNamespace` at the top level of your module factory (e.g., `index.tsx`). This function is idempotent and safe to call outside the React render cycle. The namespace provided must match the `translationNamespace` defined in your module configuration. + +```tsx +// apps/web/src/apps/modules/booking/presentation/factory/index.tsx +import { EnterpriseModuleProvider } from '@repo/ui/foundations'; +import { registerModuleNamespace } from '@repo/core-i18n'; +import { BookingModuleConfig } from '../../domain/constants'; + +import bookingId from '../locales/id/booking.json'; +import bookingEn from '../locales/en/booking.json'; + +// Called once at import time — safe, idempotent, outside React render cycle. +registerModuleNamespace(BookingModuleConfig.translationNamespace, { + id: bookingId, + en: bookingEn, +}); + +export default function BookingModule() { + return {/* Routes and Pages */}; +} +``` + +**2. Augment Types for Autocomplete:** + +To get native TypeScript autocomplete for nested keys (e.g., `header.title`), augment the global `react-i18next` types. -**1. Augment Types:** ```ts // apps/web/src/types/i18next.d.ts import 'react-i18next'; @@ -127,31 +159,36 @@ import bookingEn from '../apps/modules/booking/locales/en/booking.json'; declare module 'react-i18next' { interface CustomTypeOptions { defaultNS: 'common'; - resources: typeof coreResources['en'] & { booking: typeof bookingEn }; + resources: (typeof coreResources)['en'] & { booking: typeof bookingEn }; } } ``` -**2. Use in Component:** +**3. Consume in Components:** + +Inside your pages and components, use `useEnterpriseModuleTranslationContext()` instead of the raw `useTranslation` hook. The context automatically scopes the `t` function to your module's namespace and the global `common` namespace. + +> [!WARNING] > **Best Practice:** Do not hardcode the module namespace prefix (e.g., `t('booking:header.title')`) when using `useEnterpriseModuleTranslationContext`. The provider already scopes it. Simply use `t('header.title')`. You can still access global keys using the common prefix: `t('common:edit')`. + ```tsx -import { useEffect } from 'react'; -import { i18n, useTranslation } from '@repo/core-i18n'; -import bookingId from '../locales/id/booking.json'; -import bookingEn from '../locales/en/booking.json'; +// apps/web/src/apps/modules/booking/presentation/pages/booking.page.index.tsx +import { Title } from '@repo/ui/components'; +import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; -export default function BookingFeature() { - const { t } = useTranslation(['common', 'booking']); +export default function BookingPageIndex() { + const { t } = useEnterpriseModuleTranslationContext(); - useEffect(() => { - i18n.addResourceBundle('id', 'booking', bookingId, true, false); - i18n.addResourceBundle('en', 'booking', bookingEn, true, false); - }, []); - - return

{t('booking:header.title')}

; // Autocomplete works! + return ( +
+ {t('title')} {/* Automatically resolves to booking:title */} + {/* Fallback to global common vocabulary */} +
+ ); } ``` -**3. Dynamic Variables (Interpolation):** +**4. Dynamic Variables (Interpolation):** + ```json // booking.json { @@ -160,9 +197,10 @@ export default function BookingFeature() { } } ``` + ```tsx // Inside component -

{t('booking:messages.welcome', { name: 'Firman', count: 5 })}

+

{t('messages.welcome', { name: 'Firman', count: 5 })}

``` --- @@ -184,7 +222,7 @@ export const getErrorMessage = (code: string) => { ## 4. Real-World Implementation Flow -The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization. +The engine supports robust flows for authenticated apps, including Tenant Vocabulary Overrides and Backend Synchronization. ### A. The Tenant Override Flow (After Login) @@ -202,18 +240,15 @@ export function AuthProvider({ children }) { try { // 1. Fetch tenant-specific overrides from the API const response = await api.get('/v1/tenant/i18n-config'); - - // 2. Inject into the engine. + + // 2. Inject into the engine. // `deep: true` ensures only provided keys are overridden. - applyTenantOverrides( - response.data.namespace, - response.data.overrides - ); + applyTenantOverrides(response.data.namespace, response.data.overrides); } catch (err) { - console.error("Failed to fetch tenant configuration", err); + console.error('Failed to fetch tenant configuration', err); } } - + fetchTenantConfig(); }, []); @@ -232,7 +267,7 @@ import { api } from '@/api'; const handleSwitch = async (newLng: string) => { try { await changeLanguage(newLng, async (lng) => { - // The core engine waits for this Promise. + // The core engine waits for this Promise. // If it throws, the UI reverts to the previous language automatically. await api.patch('/v1/user/profile', { language: lng }); }); @@ -242,6 +277,7 @@ const handleSwitch = async (newLng: string) => { } }; ``` + > [!NOTE] > For public pages (like `apps/landing`), simply call `changeLanguage('en')` without the callback function. It will update the UI and local storage instantly without hitting the network. @@ -252,18 +288,21 @@ const handleSwitch = async (newLng: string) => { To support Dynamic Tenant Overrides, the backend must expose an endpoint (e.g., `GET /v1/tenant/i18n-config`). ### Identification + The backend **MUST identify the tenant via the `Authorization` (JWT) header**. The frontend will not send `tenantId` in the query payload to prevent spoofing. ### Expected JSON Response Format -The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**. + +The response must match the structural shape of the frontend dictionary. Because the frontend uses a **Deep Merge** strategy, the backend **only needs to return the specific keys the tenant wishes to override**. If the frontend dictionary has `header.title` and `header.subtitle`, and the backend only sends `header.title`, the `subtitle` will safely remain intact. **Example Request:** `GET /v1/tenant/i18n-config` -*(Authorization: Bearer eyJhbG...)* +_(Authorization: Bearer eyJhbG...)_ **Expected Response (200 OK):** + ```json { "data": { @@ -276,4 +315,4 @@ If the frontend dictionary has `header.title` and `header.subtitle`, and the bac } } } -``` \ No newline at end of file +``` diff --git a/apps/docs-dev/src/packages/ui/ACTION-TOOLS.md b/apps/docs-dev/src/packages/ui/ACTION-TOOLS.md new file mode 100644 index 0000000..df3c367 --- /dev/null +++ b/apps/docs-dev/src/packages/ui/ACTION-TOOLS.md @@ -0,0 +1,183 @@ +# ActionTools Component + +The `ActionTools` suite provides flexible, responsive, and semantic action menus and toolbars for standardizing interactions across the application. It consists of two main presentational components: `PageActions` and `RowActions`. + +These components automatically adapt to screen sizes, handle tooltip generation, and construct dropdown menus for nested actions. + +## Overview + +- **`PageActions`**: A responsive toolbar for page-level actions. On desktop, it renders a horizontal button group. On mobile, it collapses into a single "More" dropdown menu. Best used in page headers, toolbars, or detailed forms. +- **`RowActions`**: A lightweight component optimized for dense areas like data grid rows or list items. It renders standalone icon buttons or kebab menus for nested actions. Uses `React.memo` for zero overhead inside large lists. + +## Import Statement + +```tsx +import { + PageActions, + RowActions, + type PageAction, + type RowAction +} from '@repo/ui/components'; +``` + +## Usage Examples + +### 1. Page Actions (Toolbars & Headers) + +`PageActions` requires a text `label` (unless the type is `divider`). You can customize the look using Mantine's button variants and map specific intents for semantic colors. + +```tsx +import { PageActions, type PageAction } from '@repo/ui/components'; +import { Save, Printer, Trash, FileText, CheckCircle } from 'lucide-react'; + +function PageHeader() { + const actions: PageAction[] = [ + { + key: 'save', + label: 'Save Changes', + icon: , + intent: 'primary', + onClick: (key) => console.log('Clicked', key), + }, + { type: 'divider' }, + { + key: 'print', + label: 'Print', + icon: , + // Nested actions render as a dropdown menu below the main button + children: [ + { + key: 'print-original', + label: 'Print Original', + icon: , + onClick: (k) => console.log(k), + }, + { + key: 'print-copy', + label: 'Print Copy', + icon: , + onClick: (k) => console.log(k) + }, + ], + }, + { + key: 'delete', + label: 'Delete', + icon: , + intent: 'destructive', + onClick: (key) => console.log('Clicked', key), + }, + ]; + + return console.log('closed')} />; +} +``` + +### 2. Row Actions (Data Grids & Lists) + +`RowActions` are optimized for density. Text labels are optional and primarily shown inside nested dropdowns. Hover tooltips are supported. + +```tsx +import { RowActions, type RowAction } from '@repo/ui/components'; +import { Edit, CheckCircle, MoreVertical, Trash } from 'lucide-react'; +import { Table } from '@repo/ui/components'; + +function DataTable() { + const rowActions: RowAction[] = [ + { + key: 'edit', + tooltip: 'Edit Record', + icon: , + onClick: (key) => console.log('Clicked', key), + }, + { + key: 'approve', + tooltip: 'Approve', + icon: , + intent: 'success', + onClick: (key) => console.log('Clicked', key), + }, + { + key: 'more', + label: 'More Options', + icon: , + children: [ + { + key: 'delete', + label: 'Delete Record', + icon: , + intent: 'destructive', + onClick: (k) => console.log(k), + }, + ], + }, + ]; + + return ( + + + + Invoice #001 + + + + + +
+ ); +} +``` + +## Props API Reference + +### PageActions Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `actions` | `PageAction[]` | Required | Array of configured page-level actions. | +| `onClose` | `() => void` | `undefined` | Optional callback triggered when the close (X) button is clicked. | + +### RowActions Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `actions` | `RowAction[]` | `[]` | Array of configured row-level actions. | +| `showLabels` | `boolean` | `false` | If true, renders the text label alongside the icon for top-level buttons. | + +### Action Definitions + +Both `PageAction` and `RowAction` share a common base interface. + +**Base Action Properties (`BaseAction`)** + +| Property | Type | Description | +|---|---|---| +| `key` | `string` | Unique identifier. Required for 'action', optional for 'divider'. | +| `type` | `'action'` \| `'divider'` | Type of action. Defaults to 'action'. | +| `icon` | `ReactNode` | Visual representation of the action. | +| `disabled` | `boolean` | Disables interaction if set to true. | +| `intent` | `'default'` \| `'success'` \| `'warning'` \| `'destructive'` \| `'primary'` | Semantic context to determine visual emphasis (color mapping). | +| `onClick` | `(key: string) => void` | Callback triggered upon execution. | + +**`PageAction` Specific Properties** + +| Property | Type | Description | +|---|---|---| +| `label` | `string` | Text label displayed on the button. Required for 'action' type. | +| `variant` | `'filled'` \| `'light'` \| `'outline'` \| `'default'` \| `'subtle'` \| `'transparent'` | Specifies the Mantine button variant. Defaults to 'transparent' internally. | +| `children` | `PageAction[]` | Nested actions rendered as a dropdown menu below the main button. | + +**`RowAction` Specific Properties** + +| Property | Type | Description | +|---|---|---| +| `label` | `string` | Text primarily used when rendered inside a nested menu item. | +| `tooltip` | `string` | Optional text displayed on hover over the standalone icon. | +| `children` | `RowAction[]` | Nested actions that will be rendered inside a dropdown menu. | + +## Best Practices + +- **Semantic Intents:** Always map your actions to a specific `intent` (e.g., `intent: 'destructive'` for deletions). The components will automatically map these to the appropriate theme colors. +- **Nested Actions (Dropdowns):** For actions that trigger sub-actions (like multiple print options), use the `children` array property. The component automatically manages the dropdown positioning and presentation. +- **Density in Rows:** Inside lists and tables, favor `RowActions` over `PageActions` and provide a `tooltip` instead of forcing a full `label`. This keeps the UI clean and performs efficiently, especially over many rows. Keep `showLabels` as `false` (default) for a tighter grid layout unless specifically required. +- **Dividers:** Use `{ type: 'divider' }` within your actions array to visually group related buttons together. The component handles both horizontal and vertical divider logic depending on the screen size. diff --git a/apps/docs-dev/src/packages/ui/index.md b/apps/docs-dev/src/packages/ui/index.md index dfc2034..4e87df0 100644 --- a/apps/docs-dev/src/packages/ui/index.md +++ b/apps/docs-dev/src/packages/ui/index.md @@ -68,6 +68,12 @@ function UserForm() { } ``` +## 🛠️ ActionTools Component + +> **Full Documentation**: [ACTION-TOOLS.md](./ACTION-TOOLS.md) + +The `ActionTools` suite provides flexible, responsive, and semantic action menus and toolbars (`PageActions` and `RowActions`). + ## Scripts | Command | Description | diff --git a/apps/landing/src/core/storage/index.ts b/apps/landing/src/core/storage/index.ts index 0cae5eb..d720d0b 100644 --- a/apps/landing/src/core/storage/index.ts +++ b/apps/landing/src/core/storage/index.ts @@ -1,15 +1,13 @@ import { createLocalStorage } from '@repo/core-storage'; export const AppStorageKey = { - LOCALE: 'app_locale', + LANGUAGE: 'app_language', } as const; export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; -export const PLAIN_KEYS = new Set([ - AppStorageKey.LOCALE, -]); +export const PLAIN_KEYS = new Set([AppStorageKey.LANGUAGE]); export const secureStorage = createLocalStorage({ - plainTextKeys: PLAIN_KEYS + plainTextKeys: PLAIN_KEYS, }); diff --git a/apps/landing/src/main.tsx b/apps/landing/src/main.tsx index 7bc17af..cae603d 100644 --- a/apps/landing/src/main.tsx +++ b/apps/landing/src/main.tsx @@ -22,8 +22,8 @@ import App from './app'; async function bootstrap() { await setupI18n({ storageAdapter: { - getLanguage: async () => await secureStorage.getItem(AppStorageKey.LOCALE), - setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng), + getLanguage: async () => await secureStorage.getItem(AppStorageKey.LANGUAGE), + setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LANGUAGE, lng), }, }); diff --git a/apps/web/.env.example b/apps/web/.env.example index dcd4573..ac9ac5b 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,5 +1,13 @@ -VITE_API_BASE_URL=http://localhost:8000/api VITE_APP_ENV=development +VITE_APP_NAME=development_fe-monorepo-web +VITE_APP_VERSION=0.0.1 + +VITE_API_BASE_URL=http://localhost:8000/api VITE_COUCHDB_BASE_URL=http://202.146.229.134:7700 + + +VITE_FARO_URL=https://telemetry.eigen.co.id/collect +VITE_OTLP_TRACE_URL=https://telemetry.eigen.co.id/v1/traces + VITE_COUCHDB_USERNAME=root VITE_COUCHDB_PASSWORD=password diff --git a/apps/web/index.html b/apps/web/index.html index dec270e..eed3f7d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,10 +1,11 @@ - + - Vite + React + + Applications
diff --git a/apps/web/package.json b/apps/web/package.json index 90c80ec..fc8add5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,14 +24,15 @@ "dayjs": "^1.11.19", "events": "^3.3.0", "i18next": "^24.2.2", - "lucide-react": "^1.17.0", + "lucide-react": "^1.22.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hook-form": "^7.56.4", "react-i18next": "^15.4.0", "react-router-dom": "^7.11.0", "tailwindcss": "^4.1.18", - "zod": "^3.25.36" + "zod": "^3.25.36", + "zustand": "^5.0.14" }, "devDependencies": { "@repo/eslint-config": "workspace:*", diff --git a/apps/web/src/apps/auth/index.tsx b/apps/web/src/apps/auth/index.tsx index c86aca0..4eb4320 100644 --- a/apps/web/src/apps/auth/index.tsx +++ b/apps/web/src/apps/auth/index.tsx @@ -1,3 +1,14 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; + +const LoginPage = lazy(() => import('./login')); + export default function AuthModule() { - return
auth module
; + return ( + + } /> + } /> + } /> + + ); } diff --git a/apps/web/src/apps/auth/login/index.tsx b/apps/web/src/apps/auth/login/index.tsx new file mode 100644 index 0000000..fb9b00c --- /dev/null +++ b/apps/web/src/apps/auth/login/index.tsx @@ -0,0 +1,3 @@ +export default function LoginPage() { + return
Login Page
; +} diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index 1f5a777..b132d29 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -1,7 +1,10 @@ -import { lazy, Suspense, useState } from 'react'; +import { lazy, Suspense, useEffect, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { ThemeProvider, ColorSchemeType, DensityType } from '@repo/ui/provider'; +import { ThemeProvider, DensityType } from '@repo/ui/provider'; import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components'; +import { LoadingScreen } from '../core/components/loading-screen'; +import { useThemeStore } from '../core/stores/theme.store'; +import { initializeAndPurgeHistoryBackground } from './modules/layouts/hooks/useHistoryTracker'; const AuthModule = lazy(() => import('./auth')); const AppModule = lazy(() => import('./modules')); @@ -9,30 +12,26 @@ const ShowcaseView = lazy(() => import('./showcase/showcase-view')); const ShellDemo = lazy(() => import('./showcase/shell-demo')); export default function App() { - const [colorScheme, setColorScheme] = useState('light'); + const colorScheme = useThemeStore((s) => s.colorScheme); const [density, setDensity] = useState('compact'); + useEffect(() => { + // Execution runs purely in the background (fire and forget) + // Will not block the initial UI rendering process + initializeAndPurgeHistoryBackground(); + }, []); + return ( - Loading...}> + }> } /> } /> - - } - /> + } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/apps/web/src/apps/modules/example/example.page.tsx b/apps/web/src/apps/modules/example/example.page.tsx deleted file mode 100644 index e6c1363..0000000 --- a/apps/web/src/apps/modules/example/example.page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ExamplePage() { - return
example
; -} diff --git a/apps/web/src/apps/modules/example/full-page/data/full-page.remote.service.ts b/apps/web/src/apps/modules/example/full-page/data/full-page.remote.service.ts new file mode 100644 index 0000000..2d63b7c --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/data/full-page.remote.service.ts @@ -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 { + * // Example of adding a custom method tailored to specific module needs + * public async getDashboardMetrics(status: string): Promise { + * const response = await this.httpClient.get(`${this.apiUrl}/metrics`, { + * params: { status } + * }); + * return response.data; + * } + * } + * ``` + */ +export class FullPageRemoteDataServices extends BaseRemoteDataServices {} diff --git a/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts new file mode 100644 index 0000000..9c03319 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts @@ -0,0 +1,26 @@ +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/example/full-page', + + /** Architectural category of the module, used for rendering and routing logic */ + moduleCategory: 'FULL_PAGE', + + /** */ + // moduleType: 'MASTER_DATA', + moduleType: 'TRANSACTION', +} as const; diff --git a/apps/web/src/apps/modules/example/full-page/domain/constants/index.ts b/apps/web/src/apps/modules/example/full-page/domain/constants/index.ts new file mode 100644 index 0000000..1f3aff8 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './full-page.constants'; diff --git a/apps/web/src/apps/modules/example/full-page/domain/entities/full-page.entity.ts b/apps/web/src/apps/modules/example/full-page/domain/entities/full-page.entity.ts new file mode 100644 index 0000000..046e669 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/entities/full-page.entity.ts @@ -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; +} diff --git a/apps/web/src/apps/modules/example/full-page/domain/entities/index.ts b/apps/web/src/apps/modules/example/full-page/domain/entities/index.ts new file mode 100644 index 0000000..de64115 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './full-page.entity'; diff --git a/apps/web/src/apps/modules/example/full-page/domain/factories/index.ts b/apps/web/src/apps/modules/example/full-page/domain/factories/index.ts new file mode 100644 index 0000000..45bf752 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/factories/index.ts @@ -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, +}); diff --git a/apps/web/src/apps/modules/example/full-page/domain/transformers/full-page.remote.transformer.ts b/apps/web/src/apps/modules/example/full-page/domain/transformers/full-page.remote.transformer.ts new file mode 100644 index 0000000..67a2d6a --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/transformers/full-page.remote.transformer.ts @@ -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 { + * // 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 {} diff --git a/apps/web/src/apps/modules/example/full-page/domain/validators/full-page.validator.ts b/apps/web/src/apps/modules/example/full-page/domain/validators/full-page.validator.ts new file mode 100644 index 0000000..a32d8e6 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/validators/full-page.validator.ts @@ -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()`. + */ +export type FullPageFormDTO = z.infer>; diff --git a/apps/web/src/apps/modules/example/full-page/presentation/components/data-table.tsx b/apps/web/src/apps/modules/example/full-page/presentation/components/data-table.tsx new file mode 100644 index 0000000..4f88c99 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/components/data-table.tsx @@ -0,0 +1,261 @@ +import { useState, useCallback } from 'react'; +import { + Table, + Box, + Paper, + Badge, + Text, + Group, + Button, + TextInput, + Pagination, + Select, + ScrollArea, +} 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, Search, Filter, CheckCircle2, Ban } from 'lucide-react'; + +// TODO: Replace this mock data with real API data loaded from DataService via useEnterpriseModuleDataServiceContext +const MOCK_DATA: FullPageEntity[] = Array.from({ length: 50 }).map((_, i) => ({ + id: String(i + 1), + name: `Database Cluster ${i + 1}`, + code: `DB-PROD-${String(i + 1).padStart(3, '0')}`, + status: i % 7 === 0 ? 'MAINTENANCE' : i % 4 === 0 ? 'INACTIVE' : 'ACTIVE', + description: `Managed PostgreSQL cluster instance in region us-east-${(i % 3) + 1}`, +})); + +export function DataTable() { + const { t } = useEnterpriseModuleTranslationContext(); + const { navigateToDetail, navigateToEdit, navigateToDuplicate } = useEnterpriseModuleNavigationContext(); + + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const totalRecords = MOCK_DATA.length; + const totalPages = Math.ceil(totalRecords / pageSize); + const paginatedData = MOCK_DATA.slice((page - 1) * pageSize, page * pageSize); + + const getRowActions = useCallback( + (row: FullPageEntity) => [ + { + key: 'view', + label: t('common:view'), + icon: , + onClick: () => navigateToDetail(row.id as string), + }, + { + key: 'edit', + label: t('common:edit'), + icon: , + onClick: () => navigateToEdit(row.id as string), + }, + { + key: 'duplicate', + label: t('common:duplicate'), + icon: , + onClick: () => navigateToDuplicate(row.id as string), + }, + { + type: 'divider' as const, + key: 'div-1', + }, + { + key: 'delete', + label: t('common:delete'), + icon: , + intent: 'destructive' as const, + onClick: () => { + // Mock delete action + alert(`Delete ${row.name}`); + }, + }, + ], + [t, navigateToDetail, navigateToEdit, navigateToDuplicate], + ); + + const rows = paginatedData.map((item) => ( + + + + {item.code} + + + + + {item.name} + + + + + {item.status} + + + + + {item.description} + + + + + + + )); + + return ( + + {/* --- TABLE TOOLBAR --- */} + + + + } + size="sm" + radius="md" + w={280} + /> + + + + + + + + + + + + + {/* --- END TABLE TOOLBAR --- */} + + + + + + + {t('fields.code')} + + + {t('fields.name')} + + + {t('fields.status')} + + + {t('fields.description')} + + + Actions + + + + {rows} +
+
+ + {/* --- PAGINATION FOOTER --- */} + + + + Showing{' '} + + {(page - 1) * pageSize + 1} + {' '} + to{' '} + + {Math.min(page * pageSize, totalRecords)} + {' '} + of{' '} + + {totalRecords} + {' '} + entries + + + + + Rows per page: + + +