Merge pull request 'core/page-provider' (#24) from core/page-provider into main

Reviewed-on: eigen/fe-monorepo-template#24
This commit is contained in:
2026-07-20 06:28:17 +00:00
160 changed files with 9880 additions and 685 deletions
+1 -1
View File
@@ -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"]
}
+36 -30
View File
@@ -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: '<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>'
{
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>',
},
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),
);
}
@@ -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<br/>(snake_case DTOs)"]:::api
SVC["BaseRemoteDataServices<br/>(execute, getOne, getMany, ...)"]:::service
TFM["Data Transformer<br/>(transformToEntity / transformToDTO)"]:::transformer
ENT["Domain Entity<br/>(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<BookingEntity, BookingDTO> {
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<BookingEntity, BookingDTO>(
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<TEntity, TDTO>`
The minimal contract for bidirectional data transformation.
```typescript
interface IDataTransformer<TEntity, TDTO> {
transformToEntity(dto: TDTO): TEntity;
transformToDTO(entity: TEntity): TDTO;
// Optional operation-specific hooks
transformGetOneResponse?(dto: TDTO): TEntity;
transformGetManyResponse?(dtos: TDTO[]): TEntity[];
transformCreatePayload?(entity: Partial<TEntity>): Partial<TDTO>;
transformEditPayload?(entity: Partial<TEntity>): Partial<TDTO>;
}
```
### `BaseDataTransformer<TEntity, TDTO>`
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<BookingEntity, BookingDTO> {
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<ApiResponse<AvailabilityChartData>> {
const response = await this.customRequest<AvailabilityChartRawData>({
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<MyEntity, MyDTO> {
transformToEntity(dto: MyDTO): MyEntity { /* ... */ }
transformToDTO(entity: MyEntity): MyDTO { /* ... */ }
}
```
### Step 2: Add a Second Generic Parameter
```diff
- const services = new CommonRemoteDataServices<MyEntity>(apiClient, {
+ const services = new CommonRemoteDataServices<MyEntity, MyDTO>(apiClient, {
apiUrl: '/my-endpoint',
+ transformer: new MyTransformer(),
});
```
### Step 3: (Optional) Override Operation-Specific Hooks
```typescript
class MyTransformer extends BaseDataTransformer<MyEntity, MyDTO> {
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()` |
+78 -39
View File
@@ -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(
<StrictMode><App /></StrictMode>,
<StrictMode>
<App />
</StrictMode>,
);
}
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 <EnterpriseModuleProvider config={BookingModuleConfig}>{/* Routes and Pages */}</EnterpriseModuleProvider>;
}
```
**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 <h1>{t('booking:header.title')}</h1>; // Autocomplete works!
return (
<div>
<Title>{t('title')}</Title> {/* Automatically resolves to booking:title */}
<button>{t('common:edit')}</button> {/* Fallback to global common vocabulary */}
</div>
);
}
```
**3. Dynamic Variables (Interpolation):**
**4. Dynamic Variables (Interpolation):**
```json
// booking.json
{
@@ -160,9 +197,10 @@ export default function BookingFeature() {
}
}
```
```tsx
// Inside component
<h1>{t('booking:messages.welcome', { name: 'Firman', count: 5 })}</h1>
<h1>{t('messages.welcome', { name: 'Firman', count: 5 })}</h1>
```
---
@@ -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
}
}
}
```
```
@@ -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: <Save size={16} />,
intent: 'primary',
onClick: (key) => console.log('Clicked', key),
},
{ type: 'divider' },
{
key: 'print',
label: 'Print',
icon: <Printer size={16} />,
// Nested actions render as a dropdown menu below the main button
children: [
{
key: 'print-original',
label: 'Print Original',
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: 'delete',
label: 'Delete',
icon: <Trash size={16} />,
intent: 'destructive',
onClick: (key) => console.log('Clicked', key),
},
];
return <PageActions actions={actions} onClose={() => 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: <Edit size={16} />,
onClick: (key) => console.log('Clicked', key),
},
{
key: 'approve',
tooltip: '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),
},
],
},
];
return (
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td>Invoice #001</Table.Td>
<Table.Td>
<RowActions actions={rowActions} />
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
);
}
```
## 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.
+6
View File
@@ -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 |
+3 -5
View File
@@ -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<AppStorageKeyValue>([
AppStorageKey.LOCALE,
]);
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([AppStorageKey.LANGUAGE]);
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
plainTextKeys: PLAIN_KEYS
plainTextKeys: PLAIN_KEYS,
});
+2 -2
View File
@@ -22,8 +22,8 @@ import App from './app';
async function bootstrap() {
await setupI18n({
storageAdapter: {
getLanguage: async () => await secureStorage.getItem<string>(AppStorageKey.LOCALE),
setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng),
getLanguage: async () => await secureStorage.getItem<string>(AppStorageKey.LANGUAGE),
setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LANGUAGE, lng),
},
});
+9 -1
View File
@@ -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
+3 -2
View File
@@ -1,10 +1,11 @@
<!doctype html>
<html lang="en">
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
<meta name="google" content="notranslate" />
<title>Applications</title>
</head>
<body>
<div id="app"></div>
+3 -2
View File
@@ -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:*",
+12 -1
View File
@@ -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 <div>auth module</div>;
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Navigate to="/auth/login" replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
}
+3
View File
@@ -0,0 +1,3 @@
export default function LoginPage() {
return <div>Login Page</div>;
}
+16 -17
View File
@@ -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<ColorSchemeType>('light');
const colorScheme = useThemeStore((s) => s.colorScheme);
const [density, setDensity] = useState<DensityType>('compact');
useEffect(() => {
// Execution runs purely in the background (fire and forget)
// Will not block the initial UI rendering process
initializeAndPurgeHistoryBackground();
}, []);
return (
<ThemeProvider colorScheme={colorScheme} density={density}>
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/auth/*" element={<AuthModule />} />
<Route path="/app/*" element={<AppModule />} />
<Route
path="/showcase"
element={
<ShowcaseView
colorScheme={colorScheme}
setColorScheme={setColorScheme}
density={density}
setDensity={setDensity}
/>
}
/>
<Route path="/showcase" element={<ShowcaseView density={density} setDensity={setDensity} />} />
<Route path="/shell-demo" element={<ShellDemo />} />
<Route path="/404" element={<NotFound />} />
<Route path="/403" element={<Forbidden />} />
<Route path="/404" element={<NotFound homeUrl="/app" />} />
<Route path="/403" element={<Forbidden homeUrl="/app" />} />
<Route path="/maintenance" element={<Maintenance />} />
<Route path="/coming-soon" element={<ComingSoon />} />
<Route path="/" element={<Navigate to="/showcase" />} />
@@ -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,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;
@@ -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,
});
@@ -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,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: <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 = paginatedData.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>
<Text
size="sm"
fw={600}
style={{ color: 'light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0))' }}
>
{item.code}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>
{item.name}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant={item.status === 'ACTIVE' ? 'light' : item.status === 'MAINTENANCE' ? 'outline' : 'dot'}
color={item.status === 'ACTIVE' ? 'teal' : item.status === 'MAINTENANCE' ? 'orange' : 'gray'}
size="sm"
radius="sm"
fw={600}
>
{item.status}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{item.description}
</Text>
</Table.Td>
<Table.Td ta="right">
<PageActions actions={getRowActions(item)} />
</Table.Td>
</Table.Tr>
));
return (
<Paper withBorder shadow="sm" radius="md" style={{ overflow: 'hidden' }}>
{/* --- TABLE TOOLBAR --- */}
<Box
p="md"
style={{ borderBottom: '1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-7))' }}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<TextInput
placeholder="Search items..."
leftSection={<Search size={16} strokeWidth={2} style={{ color: 'var(--mantine-color-dimmed)' }} />}
size="sm"
radius="md"
w={280}
/>
<Button
variant="default"
size="sm"
radius="md"
leftSection={<Filter size={16} strokeWidth={2} style={{ color: 'var(--mantine-color-dimmed)' }} />}
>
Filters
</Button>
</Group>
<Group gap="sm" wrap="nowrap">
<Group gap="xs">
<Button
variant="light"
color="teal"
size="sm"
radius="md"
leftSection={<CheckCircle2 size={14} strokeWidth={2.5} />}
>
Activate
</Button>
<Button
variant="light"
color="gray"
size="sm"
radius="md"
leftSection={<Ban size={14} strokeWidth={2.5} />}
>
Deactivate
</Button>
<Button
variant="light"
color="red"
size="sm"
radius="md"
leftSection={<Trash2 size={14} strokeWidth={2.5} />}
>
Delete
</Button>
</Group>
</Group>
</Group>
</Box>
{/* --- END TABLE TOOLBAR --- */}
<ScrollArea.Autosize mah="calc(100vh - 350px)" offsetScrollbars>
<Table
stickyHeader
stickyHeaderOffset={-1}
striped
highlightOnHover
verticalSpacing="sm"
horizontalSpacing="md"
>
<Table.Thead bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
<Table.Tr>
<Table.Th fw={700} style={{ letterSpacing: '0.5px', textTransform: 'uppercase' }} fz="xs" c="dimmed">
{t('fields.code')}
</Table.Th>
<Table.Th fw={700} style={{ letterSpacing: '0.5px', textTransform: 'uppercase' }} fz="xs" c="dimmed">
{t('fields.name')}
</Table.Th>
<Table.Th fw={700} style={{ letterSpacing: '0.5px', textTransform: 'uppercase' }} fz="xs" c="dimmed">
{t('fields.status')}
</Table.Th>
<Table.Th fw={700} style={{ letterSpacing: '0.5px', textTransform: 'uppercase' }} fz="xs" c="dimmed">
{t('fields.description')}
</Table.Th>
<Table.Th
fw={700}
style={{ letterSpacing: '0.5px', textTransform: 'uppercase' }}
fz="xs"
c="dimmed"
w={80}
ta="right"
>
Actions
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</ScrollArea.Autosize>
{/* --- PAGINATION FOOTER --- */}
<Box
p="md"
style={{ borderTop: '1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-7))' }}
>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed" fw={500}>
Showing{' '}
<Text span fw={600} c="var(--mantine-color-text)">
{(page - 1) * pageSize + 1}
</Text>{' '}
to{' '}
<Text span fw={600} c="var(--mantine-color-text)">
{Math.min(page * pageSize, totalRecords)}
</Text>{' '}
of{' '}
<Text span fw={600} c="var(--mantine-color-text)">
{totalRecords}
</Text>{' '}
entries
</Text>
<Group gap="md">
<Group gap="xs">
<Text size="sm" c="dimmed" fw={500}>
Rows per page:
</Text>
<Select
size="sm"
radius="md"
data={['10', '20', '50']}
value={String(pageSize)}
onChange={(val) => {
setPageSize(Number(val));
setPage(1);
}}
w={75}
/>
</Group>
<Pagination total={totalPages} value={page} onChange={setPage} size="sm" radius="md" color="indigo" />
</Group>
</Group>
</Box>
{/* --- END PAGINATION FOOTER --- */}
</Paper>
);
}
@@ -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 '../languages/id/full-page.json';
import fullPageEn from '../languages/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,16 @@
{
"title": "Full Page",
"detail_page_title": "Detail Full Page",
"create_page_title": "New Full Page",
"duplicate_page_title": "Duplicate Full Page",
"description": "An example module of a <1>full page layout</1> for detailed forms.",
"detail_page_description": "View and manage detailed information for this record.",
"create_page_description": "Fill out the form below to add a new record to the system.",
"duplicate_page_description": "Copy and modify information from an existing record to quickly create a new one.",
"fields": {
"status": "Status",
"name": "Name",
"code": "Code",
"description": "Description"
}
}
@@ -0,0 +1,16 @@
{
"title": "Halaman Penuh",
"detail_page_title": "Detail Halaman Penuh",
"create_page_title": "Buat Halaman Penuh Baru",
"duplicate_page_title": "Duplikat Halaman Penuh",
"description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.",
"detail_page_description": "Lihat dan kelola informasi terperinci terkait data ini.",
"create_page_description": "Lengkapi formulir di bawah ini untuk menambahkan data baru ke dalam sistem.",
"duplicate_page_description": "Salin dan sesuaikan informasi dari data yang sudah ada untuk mempercepat pembuatan data baru.",
"fields": {
"status": "Status",
"name": "Nama",
"code": "Kode",
"description": "Deskripsi"
}
}
@@ -0,0 +1,131 @@
import { Paper, SimpleGrid, Box, Text, Group, Badge, Divider, Breadcrumbs, Anchor, Flex, Title, ActionIcon, Button } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { ChevronRight, CheckCircle2, Trash2, Edit2, Play } from 'lucide-react';
export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<Box pb="xl">
{/* --- INLINED PAGE HEADER --- */}
<Box mb="xl" mt="xs">
<Breadcrumbs
mb="md"
separator={<ChevronRight size={12} strokeWidth={3} style={{ color: 'var(--mantine-color-gray-5)' }} />}
>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Acme Corp
</Anchor>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Infrastructure
</Anchor>
<Anchor href="#" c="dimmed" size="xs" fw={500} style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}>
Database Clusters
</Anchor>
<Text size="xs" fw={600} style={{ color: 'light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0))', letterSpacing: '0.2px' }}>
prod-db-01
</Text>
</Breadcrumbs>
<Group justify="space-between" align="center" wrap="nowrap">
<Flex gap="md" align="center">
<Box>
<Group gap="xs" align="center" mb={4}>
<Title
order={2}
fw={600}
style={{
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
letterSpacing: '-0.3px',
}}
>
prod-db-01
</Title>
<Badge
variant="light"
color="teal"
size="sm"
radius="sm"
fw={600}
leftSection={<CheckCircle2 size={10} strokeWidth={3} />}
style={{ textTransform: 'capitalize' }}
>
Running
</Badge>
</Group>
<Text size="sm" c="dimmed" fw={400}>
PostgreSQL v15.4 · <Text span fw={500} c="var(--mantine-color-text)">us-east-1</Text> · Created Oct 12, 2023
</Text>
</Box>
</Flex>
<Group gap="sm">
<ActionIcon variant="light" color="red" size="lg" radius="md" aria-label="Delete">
<Trash2 size={18} strokeWidth={2} />
</ActionIcon>
<Button
variant="default"
radius="md"
leftSection={<Edit2 size={16} strokeWidth={2} style={{ color: 'var(--mantine-color-dimmed)' }} />}
>
Edit
</Button>
<Button
variant="filled"
color="indigo"
radius="md"
leftSection={<Play size={16} strokeWidth={2.5} />}
style={{ boxShadow: '0 4px 14px 0 rgba(76, 110, 245, 0.39)' }}
>
Restart
</Button>
</Group>
</Group>
<Divider mt={28} mb={0} color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
</Box>
{/* --- END INLINED PAGE HEADER --- */}
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text size="lg" fw={600} mb="xs" style={{ color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))', letterSpacing: '-0.3px' }}>
General Information
</Text>
<Text size="sm" c="dimmed" mb="xl">
View the complete details and configuration for this entity.
</Text>
<Divider mb="xl" color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xl" verticalSpacing="xl">
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.code')}
</Text>
<Text size="sm" fw={500}>WID-001</Text>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.name')}
</Text>
<Text size="sm" fw={500}>Dashboard Widget</Text>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.status')}
</Text>
<Badge variant="light" color="teal" size="sm" radius="sm" fw={600}>
ACTIVE
</Badge>
</Box>
<Box>
<Text size="xs" tt="uppercase" fw={700} c="dimmed" mb={4} style={{ letterSpacing: '0.5px' }}>
{t('fields.description')}
</Text>
<Text size="sm" fw={500}>Main dashboard widget</Text>
</Box>
</SimpleGrid>
</Paper>
</Box>
);
}
@@ -0,0 +1,21 @@
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { FullPageModuleConfig } from '../../domain/constants';
export default function FullPagePageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<EnterpriseDetailPageProvider
highlightDataKey="issuer_name"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
// showBadges: false,
breadcrumbs: [
{ label: t('nav:example-module'), type: 'text' },
{ label: t('nav:example-full-page'), type: 'link', href: `${FullPageModuleConfig.webUrl}/index` },
],
}}
></EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,146 @@
import {
Paper,
Box,
Text,
Divider,
TextInput,
Select,
Textarea,
Stack,
Breadcrumbs,
Anchor,
Flex,
Title,
Button,
Group,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { ChevronRight, Database } from 'lucide-react';
export default function FullPagePageForm({ formPageType }: { formPageType: 'edit' | 'create' | 'duplicate' }) {
const { t } = useEnterpriseModuleTranslationContext();
return (
<Box pb="xl">
{/* --- INLINED PAGE HEADER --- */}
<Box mb="xl" mt="xs">
<Breadcrumbs
mb="md"
separator={<ChevronRight size={12} strokeWidth={3} style={{ color: 'var(--mantine-color-gray-5)' }} />}
>
<Anchor
href="#"
c="dimmed"
size="xs"
fw={500}
style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}
>
Database Clusters
</Anchor>
<Text
size="xs"
fw={600}
style={{
color: 'light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0))',
letterSpacing: '0.2px',
}}
>
{formPageType === 'create' ? 'Create Cluster' : 'Edit Cluster'}
</Text>
</Breadcrumbs>
<Group justify="space-between" align="center" wrap="nowrap">
<Flex gap="md" align="center">
<Box>
<Title
order={2}
fw={600}
mb={4}
style={{
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
letterSpacing: '-0.3px',
}}
>
{formPageType === 'create' ? 'Create New Cluster' : 'Edit Cluster Configuration'}
</Title>
<Text size="sm" c="dimmed" fw={400}>
Configure and provision a new highly available database cluster.
</Text>
</Box>
</Flex>
<Group gap="sm">
<Button variant="default" radius="md">
Cancel
</Button>
<Button
variant="filled"
color="indigo"
radius="md"
leftSection={<Database size={16} strokeWidth={2.5} />}
style={{ boxShadow: '0 4px 14px 0 rgba(76, 110, 245, 0.39)' }}
>
{formPageType === 'create' ? 'Deploy Cluster' : 'Save Changes'}
</Button>
</Group>
</Group>
<Divider mt={28} mb={0} color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
</Box>
{/* --- END INLINED PAGE HEADER --- */}
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text
size="lg"
fw={600}
mb="xs"
style={{
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
letterSpacing: '-0.3px',
}}
>
{formPageType === 'create' ? 'Create Entity' : 'Edit Entity'}
</Text>
<Text size="sm" c="dimmed" mb="xl">
Fill in the required information to configure your entity properly. Fields marked with * are required.
</Text>
<Divider mb="xl" color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
<Box maw={600}>
<Stack gap="lg">
<TextInput
label={t('fields.code')}
placeholder="e.g. WID-001"
required
defaultValue={formPageType === 'edit' ? 'WID-001' : ''}
radius="md"
/>
<TextInput
label={t('fields.name')}
placeholder="e.g. Dashboard Widget"
required
defaultValue={formPageType === 'edit' ? 'Dashboard Widget' : ''}
radius="md"
/>
<Select
label={t('fields.status')}
placeholder="Select status"
data={['ACTIVE', 'INACTIVE']}
defaultValue="ACTIVE"
required
radius="md"
/>
<Textarea
label={t('fields.description')}
placeholder="Enter a detailed description..."
minRows={4}
defaultValue={formPageType === 'edit' ? 'Main dashboard widget' : ''}
radius="md"
/>
</Stack>
</Box>
</Paper>
</Box>
);
}
@@ -0,0 +1,46 @@
import { EnterpriseIndexPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { Trans } from '@repo/core-i18n';
import { Badge, Text } from '@repo/ui/components';
import { Activity, LayoutDashboard } from 'lucide-react';
import { DataTable } from '../components/data-table';
export default function FullPagePageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans
t={t}
i18nKey="description"
components={{
1: <Text span fw={500} c="var(--mantine-color-text)" />,
}}
/>
),
badges: (
<Badge
variant="light"
color="teal"
size="sm"
radius="sm"
fw={600}
leftSection={<Activity size={10} strokeWidth={3} />}
style={{ textTransform: 'capitalize' }}
>
Healthy
</Badge>
),
icon: LayoutDashboard,
breadcrumbs: [
{ label: t('nav:example-module'), type: 'text' },
{ label: t('nav:example-full-page'), type: 'text' },
],
}}
>
<DataTable />
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,15 @@
import { lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
const FullPageModule = lazy(() => import('./full-page/presentation/factory'));
const SinglePageModule = lazy(() => import('./single-page/presentation/factory'));
export default function ExampleModule() {
return (
<Routes>
<Route path="/full-page/*" element={<FullPageModule />} />
<Route path="/single-page/*" element={<SinglePageModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
}
@@ -0,0 +1,25 @@
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
import { SinglePageDTO, SinglePageEntity } from '../domain/entities';
/**
* Full Page Remote Data Services
*
* Provides core data services for the single-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 SinglePageRemoteDataServices extends BaseRemoteDataServices<SinglePageEntity, SinglePageDTO> {
* // 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 SinglePageRemoteDataServices extends BaseRemoteDataServices<SinglePageEntity, SinglePageDTO> {}
@@ -0,0 +1 @@
export * from './single-page.constants';
@@ -0,0 +1,25 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
/**
* Core configuration and constants for the Full Page module.
* Used across Domain, Data, and Presentation layers.
*/
export const SinglePageModuleConfig: ModuleConfigEntity = {
/** Unique identifier for permissions, caching, and i18n */
moduleKey: 'EXAMPLE_SINGLE_PAGE',
/** Translation namespace — must match the namespace used in registerModuleNamespace() */
translationNamespace: 'single-page',
/** Base API endpoint for remote data services */
apiUrl: '/single-page',
/** Base Web Router URL for UI navigation */
webUrl: '/app/example/single-page',
/** Architectural category of the module, used for rendering and routing logic */
moduleCategory: 'SINGLE_PAGE',
/** */
moduleType: 'MASTER_DATA',
} as const;
@@ -0,0 +1 @@
export * from './single-page.entity';
@@ -0,0 +1,26 @@
import { BaseEntity } from '@repo/core-api/data-services';
/**
* Represents a single-page entity in the frontend domain model.
*
* This entity is derived from the API's `SinglePageDTO` via the
* {@link SinglePageTransformer}, which handles field name mapping
* and computed field derivation.
*
*/
export interface SinglePageEntity extends BaseEntity {
status?: string;
name?: string;
code?: string;
description?: string;
}
/**
* Represents the raw data structure returned by the API for a single-page resource.
*
* This DTO uses snake_case field names matching the backend's JSON serialization.
* It is transformed into a {@link SinglePageEntity} by the {@link SinglePageTransformer}.
*/
export interface SinglePageDTO extends SinglePageEntity {
[key: string]: any;
}
@@ -0,0 +1,30 @@
/**
* Full Page Factory
*
* This module acts as the dependency injection and configuration center for the single-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 { SinglePageRemoteDataServices } from '../../data/single-page.remote.service';
import { SinglePageModuleConfig } from '../constants/single-page.constants';
import { SinglePageRemoteDataTransformer } from '../transformers/single-page.remote.transformer';
/**
* Singleton instance of the SinglePageRemoteDataTransformer.
* Exported for potential direct usage if manual data mapping is required outside the standard API flow.
*/
export const singlePageDataTransformer = new SinglePageRemoteDataTransformer();
/**
* Pre-configured singleton instance of the SinglePageRemoteDataServices.
* 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 singlePageDataService = new SinglePageRemoteDataServices(apiClient, {
apiUrl: SinglePageModuleConfig.apiUrl,
moduleKey: SinglePageModuleConfig.moduleKey,
transformer: singlePageDataTransformer,
});
@@ -0,0 +1,45 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { SinglePageEntity, SinglePageDTO } 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 single-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 SinglePageRemoteDataTransformer extends BaseDataTransformer<SinglePageEntity, SinglePageDTO> {
* // Map snake_case API payload to camelCase frontend entity
* public transformToEntity(dto: SinglePageDTO): SinglePageEntity {
* 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: SinglePageEntity): SinglePageDTO {
* 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 SinglePageRemoteDataTransformer extends BaseDataTransformer<SinglePageEntity, SinglePageDTO> {}
@@ -0,0 +1,37 @@
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 { SinglePageModuleConfig } from '../../domain/constants';
import { singlePageDataService } from '../../domain/factories';
import { SinglePageEntity } from '../../domain/entities';
import singlePageId from '../languages/id/single-page.json';
import singlePageEn from '../languages/en/single-page.json';
const IndexPage = lazy(() => import('../pages/single-page.page.index'));
// ---------------------------------------------------------------------------
// Namespace Registration (Module Scope)
// ---------------------------------------------------------------------------
// Called once at import time — safe, idempotent, outside React render cycle.
// The namespace 'single-page' must match config.translationNamespace.
registerModuleNamespace(SinglePageModuleConfig.translationNamespace, {
id: singlePageId,
en: singlePageEn,
});
// ---------------------------------------------------------------------------
// Module Factory
// ---------------------------------------------------------------------------
export default function SinglePageModule() {
return (
<EnterpriseModuleProvider<SinglePageEntity> config={SinglePageModuleConfig} dataServices={singlePageDataService}>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${SinglePageModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,9 @@
{
"title": "Single Page Management",
"fields": {
"status": "Status",
"name": "Name",
"code": "Code",
"description": "Description"
}
}
@@ -0,0 +1,9 @@
{
"title": "Manajemen Halaman Tunggal",
"fields": {
"status": "Status",
"name": "Nama",
"code": "Kode",
"description": "Deskripsi"
}
}
@@ -0,0 +1,99 @@
import { Table, Box, Title, Paper } from '@repo/ui/components';
import { PageActions } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useEnterpriseModuleNavigationContext } from '@repo/ui/foundations';
import { SinglePageEntity } 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: SinglePageEntity[] = [
{ 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 SinglePagePageIndex() {
// Translation is scoped to ['SINGLE_PAGE', 'common'] — no prefix needed for module keys
const { t } = useEnterpriseModuleTranslationContext();
const { navigateToDetail, navigateToEdit, navigateToDuplicate } = useEnterpriseModuleNavigationContext();
const getRowActions = useCallback(
(row: SinglePageEntity) => [
{
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>
);
}
+15 -5
View File
@@ -1,13 +1,23 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import ModuleLayout from './layouts/module.layout';
const ExamplePage = lazy(() => import('./example/example.page'));
const ExampleModule = lazy(() => import('./example'));
const SystemSetting = lazy(() => import('./system/setting'));
const SystemInformation = lazy(() => import('./system/information'));
const SystemNotification = lazy(() => import('./system/notification'));
export default function AppModule() {
return (
<Routes>
<Route path="/" element={<ExamplePage />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
<ModuleLayout>
<Routes>
<Route path="/example/*" element={<ExampleModule />} />
<Route path="/system/setting" element={<SystemSetting />} />
<Route path="/system/information" element={<SystemInformation />} />
<Route path="/system/notifications" element={<SystemNotification />} />
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</ModuleLayout>
);
}
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { Drawer, Text, Stack, NavLink, ThemeIcon, ActionIcon, ScrollArea, Group } from '@repo/ui/components';
import { useAppEvent } from '@repo/core-events';
import { useTranslation } from '@repo/core-i18n';
import { Bookmark, Trash2 } from 'lucide-react';
import { LAYOUT_EVENTS } from '../../../../../core/constants/events';
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { SavedPageItem } from '../../types/saved-page.types';
// Setup relative time formatting
dayjs.extend(relativeTime);
export function BookmarkDrawer() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [items, setItems] = useState<SavedPageItem[]>([]);
// Listen to the global event bus to toggle the drawer
useAppEvent(LAYOUT_EVENTS.TOGGLE_BOOKMARK_DRAWER, () => {
setOpened((prev) => !prev);
});
const loadData = async () => {
try {
const data = await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.BOOKMARK_PAGE);
setItems(data || []);
} catch (e) {
console.error('Failed to load bookmark items', e);
setItems([]);
}
};
useEffect(() => {
if (opened) {
loadData();
}
}, [opened]);
const handleRemoveItem = async (id: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
const newItems = items.filter((item) => item.id !== id);
await secureIndexedDB.setItem(AppStorageKey.BOOKMARK_PAGE, newItems);
setItems(newItems);
} catch (err) {
console.error('Failed to remove bookmark item', err);
}
};
const handleClearAll = async () => {
try {
await secureIndexedDB.removeItem(AppStorageKey.BOOKMARK_PAGE);
setItems([]);
} catch (err) {
console.error('Failed to clear bookmarks', err);
}
};
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size="sm"
title={
<Group gap="sm">
<Bookmark size={18} style={{ color: 'var(--mantine-color-text)' }} />
<Text fw={600} size="lg">
{t('bookmark:title')}
</Text>
</Group>
}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 60px)' } }}
>
{items.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" mt="xl" p="md">
{t('bookmark:empty')}
</Text>
) : (
<>
<Group justify="flex-end" px="md" py="xs">
<Text
size="xs"
c="red"
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '4px' }}
onClick={handleClearAll}
>
<Trash2 size={12} /> {t('bookmark:clearAll')}
</Text>
</Group>
<ScrollArea flex={1} type="scroll" px="xs" py="xs" pb={'xl'}>
<Stack gap={2}>
{items.map((item) => (
<NavLink
key={item.id}
component={Link as any}
to={item.path}
onClick={() => setOpened(false)}
label={
<Text size="sm" truncate="end" fw={500} lineClamp={1}>
{item.title}
</Text>
}
description={
<Stack gap={2}>
<Text size="xs" c="dimmed" truncate="end" mt={2}>
{item.path}
</Text>
</Stack>
}
leftSection={
<ThemeIcon variant="transparent" c="dimmed" size="sm">
<Bookmark size={16} />
</ThemeIcon>
}
rightSection={
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={(e) => {
e.preventDefault();
handleRemoveItem(item.id, e);
}}
aria-label={t('bookmark:removeItem')}
>
<Trash2 size={14} />
</ActionIcon>
}
styles={{
root: {
padding: '8px 12px',
borderBottom: '1px solid var(--mantine-color-default-border)',
},
}}
/>
))}
</Stack>
</ScrollArea>
</>
)}
</Drawer>
);
}
@@ -0,0 +1,195 @@
import { useTranslation } from '@repo/core-i18n';
import {
Burger,
Group,
Select,
Switch,
Text,
useCoreAppShell,
ActionIcon,
Menu,
Avatar,
UnstyledButton,
Box,
} from '@repo/ui/components';
import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown, Sun, Moon } from 'lucide-react';
import { AppStorageKey, secureStorage } from '../../../../core/storage/local';
import { useThemeStore } from '../../../../core/stores/theme.store';
import { NotificationDropdown } from './notifications/notification-dropdown';
import { Link } from 'react-router-dom';
// Dummy user data for preview
const USER = {
name: 'Jane Doe',
email: 'jane.doe@enterprise.com',
role: 'System Administrator',
// avatar: null as string | null, // Simulated null for testing fallback
avatar: 'https://i.pravatar.cc/150?u=jane.doe',
};
const getInitials = (name: string): string => {
const parts = name.trim().split(/\s+/);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0].substring(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
export default function HeaderLayout() {
const { i18n, t } = useTranslation();
const { mobileOpened, toggleMobile } = useCoreAppShell();
const { colorScheme, setColorScheme } = useThemeStore();
return (
<Group h="100%" px="md" justify="space-between" bg="var(--mantine-color-body)" wrap="nowrap">
{/* 1. LEFT SECTION (Navigation & Context) */}
<Group wrap="nowrap" gap="sm">
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
{/* Contextual Title / Breadcrumb Placeholder */}
<Text fw={600} size="sm" c="dimmed" visibleFrom="sm" style={{ letterSpacing: '0.5px' }}>
LOGO
</Text>
</Group>
{/* 3. RIGHT SECTION (Actions & Profile) */}
<Group wrap="nowrap" gap="sm">
<ActionIcon
component={Link}
to="/app/system/information"
variant="subtle"
color="gray"
size="lg"
aria-label="Help"
>
<HelpCircle size={18} />
</ActionIcon>
<NotificationDropdown>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label="Notifications">
<Bell size={18} />
</ActionIcon>
</NotificationDropdown>
<Menu shadow="md" width={260} position="bottom-end" radius="md" withArrow>
<Menu.Target>
<UnstyledButton
style={{
padding: '4px',
borderRadius: 'var(--mantine-radius-xl)',
transition: 'background-color 150ms ease',
}}
>
<Group gap="sm" wrap="nowrap" pl="sm">
<Avatar src={USER.avatar} size={28} radius="xl" color="blue">
{getInitials(USER.name)}
</Avatar>
<Box visibleFrom="sm" style={{ textAlign: 'left' }}>
<Text size="sm" fw={600} lh={1.1} maw={140} truncate="end">
{USER.name}
</Text>
<Text size="xs" c="dimmed" maw={140} truncate="end">
{USER.role}
</Text>
</Box>
<ChevronDown size={14} style={{ opacity: 0.5 }} className="mantine-visible-from-sm" />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown p="md">
{/* Mobile User Info */}
<Box px="sm" py="xs" hiddenFrom="sm">
<Text size="sm" fw={600} truncate="end">
{USER.name}
</Text>
<Text size="xs" c="dimmed" truncate="end">
{USER.email}
</Text>
</Box>
<Menu.Divider hiddenFrom="sm" />
{/* Preferences Section */}
<Menu.Label>{t('common:preferences')}</Menu.Label>
{/* Custom Language Item */}
<Group justify="space-between" wrap="nowrap" px="sm" py="xs">
<Group gap="sm" wrap="nowrap">
<Globe size={14} />
<Text size="sm">{t('common:language')}</Text>
</Group>
<Select
size="xs"
variant="unstyled"
w={110}
data={[
{ value: 'en', label: t('common:english') },
{ value: 'id', label: t('common:indonesian') },
]}
value={i18n.resolvedLanguage || i18n.language}
onChange={async (val) => {
if (!val) return;
await i18n.changeLanguage(val);
await secureStorage.setItem(AppStorageKey.LANGUAGE, val);
}}
styles={{
input: {
textAlign: 'right',
cursor: 'pointer',
height: '22px',
minHeight: '22px',
fontSize: 'var(--mantine-font-size-sm)',
},
root: {
marginTop: '-4px',
marginBottom: '-4px',
},
}}
/>
</Group>
{/* Custom Theme Item */}
<Group justify="space-between" wrap="nowrap" px="sm" py="xs">
<Group gap="sm" wrap="nowrap">
{colorScheme === 'dark' ? <Moon size={14} /> : <Sun size={14} />}
<Text size="sm">{t('common:theme')}</Text>
</Group>
<Switch
checked={colorScheme === 'dark'}
onChange={(event) => setColorScheme(event.currentTarget.checked ? 'dark' : 'light')}
size="sm"
color="brand"
onLabel={<Moon size={12} strokeWidth={2.5} color="var(--mantine-color-white)" />}
offLabel={<Sun size={12} strokeWidth={2.5} color="var(--mantine-color-brand-6)" />}
styles={{ track: { cursor: 'pointer' } }}
/>
</Group>
<Menu.Divider />
{/* Application Section */}
<Menu.Label>{t('common:application')}</Menu.Label>
<Menu.Item component={Link} to="/app/system/setting" leftSection={<Settings size={14} />}>
{t('common:settings')}
</Menu.Item>
<Menu.Item leftSection={<User size={14} />}>{t('common:myProfile')}</Menu.Item>
<Menu.Divider />
{/* Workspace Section */}
<Menu.Label>{t('common:workspace')}</Menu.Label>
<Menu.Item leftSection={<Briefcase size={14} />}>{t('common:switchWorkspace')}</Menu.Item>
<Menu.Divider />
{/* Sign Out Section */}
<Menu.Item color="red" leftSection={<LogOut size={14} />}>
{t('common:signOut')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
);
}
@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import {
Drawer,
Text,
Stack,
Group,
NavLink,
ThemeIcon,
ActionIcon,
ScrollArea,
Menu,
UnstyledButton,
} from '@repo/ui/components';
import { useAppEvent } from '@repo/core-events';
import { useTranslation } from '@repo/core-i18n';
import { History, Clock, Trash2 } from 'lucide-react';
import { LAYOUT_EVENTS } from '../../../../../core/constants/events';
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { SavedPageItem } from '../../types/saved-page.types';
import type { SystemSettings } from '../../../system/setting/types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } from '../../../system/setting/types/setting.types';
// Setup relative time formatting
dayjs.extend(relativeTime);
export function HistoryDrawer() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [items, setItems] = useState<SavedPageItem[]>([]);
// Listen to the global event bus to toggle the drawer
useAppEvent(LAYOUT_EVENTS.TOGGLE_HISTORY_DRAWER, () => {
setOpened((prev) => !prev);
});
const loadData = async () => {
try {
const data = await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.HISTORY_PAGE);
const settings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const now = Date.now();
const unitMs = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
};
const retentionMs = settings.historyRetentionValue * unitMs[settings.historyRetentionUnit];
const validItems = (data || []).filter((item) => now - item.timestamp < retentionMs);
// Save back if we purged some items
if (data && validItems.length !== data.length) {
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems);
}
setItems(validItems);
} catch (e) {
console.error('Failed to load history items', e);
setItems([]);
}
};
useEffect(() => {
if (opened) {
loadData();
}
}, [opened]);
const handleClearRange = async (range: 'lastHour' | 'today' | 'allTime') => {
try {
if (range === 'allTime') {
await secureIndexedDB.removeItem(AppStorageKey.HISTORY_PAGE);
setItems([]);
return;
}
const now = Date.now();
const cutoff = range === 'lastHour' ? now - 60 * 60 * 1000 : new Date().setHours(0, 0, 0, 0); // start of today
const newItems = items.filter((item) => item.timestamp < cutoff);
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems);
setItems(newItems);
} catch (e) {
console.error('Failed to clear history items', e);
}
};
const handleRemoveItem = async (id: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
try {
const newItems = items.filter((item) => item.id !== id);
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, newItems);
setItems(newItems);
} catch (err) {
console.error('Failed to remove history item', err);
}
};
return (
<Drawer
opened={opened}
onClose={() => setOpened(false)}
position="right"
size="sm"
title={
<Group gap="sm">
<History size={18} style={{ color: 'var(--mantine-color-text)' }} />
<Text fw={600} size="lg">
{t('history:title')}
</Text>
</Group>
}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 60px)' } }}
>
{items.length === 0 ? (
<Text c="dimmed" size="sm" ta="center" mt="xl" p="md">
{t('history:empty')}
</Text>
) : (
<>
<Group justify="flex-end" px="md" py="xs">
<Menu shadow="md" width={200} position="bottom-end">
<Menu.Target>
<UnstyledButton>
<Group gap={6} c="dimmed" style={{ transition: 'color 0.2s' }}>
<Trash2 size={14} />
<Text size="xs" fw={500} style={{ cursor: 'pointer' }}>
{t('history:clearRange')}
</Text>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('history:clearRange')}</Menu.Label>
<Menu.Item onClick={() => handleClearRange('lastHour')}>{t('history:ranges.lastHour')}</Menu.Item>
<Menu.Item onClick={() => handleClearRange('today')}>{t('history:ranges.today')}</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => handleClearRange('allTime')}>
{t('history:ranges.allTime')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
<ScrollArea flex={1} type="scroll" px="xs" py="xs" pb={'xl'}>
<Stack gap={2}>
{items.map((item) => (
<NavLink
key={item.id}
component={Link as any}
to={item.path}
onClick={() => setOpened(false)}
label={
<Text size="sm" truncate="end" fw={500} lineClamp={1}>
{item.title}
</Text>
}
description={
<Stack gap={2}>
<Text size="xs" c="dimmed" truncate="end" mt={2}>
{item.path}
</Text>
</Stack>
}
leftSection={
<ThemeIcon variant="transparent" c="dimmed" size="sm">
<Clock size={16} />
</ThemeIcon>
}
rightSection={
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={(e) => {
e.preventDefault();
handleRemoveItem(item.id, e);
}}
aria-label={t('history:removeItem')}
>
<Trash2 size={14} />
</ActionIcon>
}
styles={{
root: {
padding: '8px 12px',
borderBottom: '1px solid var(--mantine-color-default-border)',
},
}}
/>
))}
</Stack>
</ScrollArea>
</>
)}
</Drawer>
);
}
@@ -0,0 +1,157 @@
import { useState } from 'react';
import { useTranslation } from '@repo/core-i18n';
import {
Popover,
Group,
Text,
UnstyledButton,
ScrollArea,
Box,
Indicator,
Button,
Center,
Stack,
NavLink,
} from '@repo/ui/components';
import { BellOff } from 'lucide-react';
import { Link } from 'react-router-dom';
interface NotificationItem {
id: string;
title: string;
description: string;
timestamp: string;
isRead: boolean;
}
interface NotificationDropdownProps {
children: React.ReactNode;
}
export function NotificationDropdown({ children }: NotificationDropdownProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [notifications, setNotifications] = useState<NotificationItem[]>([
{
id: '1',
title: 'New User Registration',
description: 'John Doe has registered a new account in the system.',
timestamp: t('common:justNow'),
isRead: false,
},
{
id: '2',
title: 'System Update Completed',
description: 'The ERP database has been successfully updated to v2.4.',
timestamp: `2 ${t('common:hoursAgo')}`,
isRead: false,
},
{
id: '3',
title: 'Weekly Report Ready',
description: 'Your weekly financial summary is ready to be downloaded.',
timestamp: `5 ${t('common:hoursAgo')}`,
isRead: true,
},
]);
const unreadCount = notifications.filter((n) => !n.isRead).length;
const handleMarkAllAsRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
};
return (
<Popover opened={opened} onChange={setOpened} width={340} position="bottom-end" withArrow shadow="md" radius="md">
<Popover.Target>
{/* Trigger wrapped around the Bell icon */}
<Box onClick={() => setOpened((o) => !o)} style={{ cursor: 'pointer' }}>
<Indicator inline size={9} offset={4} color="red" withBorder disabled={unreadCount === 0}>
{children}
</Indicator>
</Box>
</Popover.Target>
<Popover.Dropdown p={0}>
<Group
justify="space-between"
px="md"
py="xs"
style={{ borderBottom: '1px solid var(--app-shell-border-color)' }}
>
<Text fw={600} size="sm">
{t('common:notificationsTitle')}
</Text>
{unreadCount > 0 && (
<UnstyledButton onClick={handleMarkAllAsRead}>
<Text size="xs" c="blue" fw={500} style={{ '&:hover': { textDecoration: 'underline' } }}>
{t('common:markAllAsRead')}
</Text>
</UnstyledButton>
)}
</Group>
<ScrollArea.Autosize mah={300}>
{notifications.length === 0 ? (
<Center p="xl" style={{ minHeight: 150 }}>
<Stack align="center" gap="xs">
<BellOff size={32} style={{ opacity: 0.2 }} />
<Text size="sm" c="dimmed">
{t('common:emptyState')}
</Text>
</Stack>
</Center>
) : (
notifications.map((item) => (
<NavLink
key={item.id}
label={
<Box>
<Text size="sm" fw={item.isRead ? 500 : 600} lh={1.2} mb={4}>
{item.title}
</Text>
<Text size="xs" c="dimmed" lh={1.3} mb={6} style={{ whiteSpace: 'normal' }}>
{item.description}
</Text>
<Text size="xs" c="dimmed" style={{ opacity: 0.7 }}>
{item.timestamp}
</Text>
</Box>
}
leftSection={
<Box style={{ alignSelf: 'flex-start', marginTop: '4px' }}>
<Indicator size={8} color="blue" disabled={item.isRead}>
<Box w={8} h={8} />
</Indicator>
</Box>
}
active={!item.isRead}
variant="light"
style={{
borderBottom: '1px solid var(--app-shell-border-color)',
}}
/>
))
)}
</ScrollArea.Autosize>
{notifications.length > 0 && (
<Box p="xs">
<Button
component={Link}
to="/app/system/notifications"
variant="light"
color="gray"
fullWidth
size="xs"
radius="md"
>
{t('common:viewAll')}
</Button>
</Box>
)}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,611 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Box, NavLink, Stack, Tooltip, ActionIcon, Divider, Menu, TextInput, Button, Group } from '@repo/ui/components';
import { useCoreAppShell } from '@repo/ui/components';
import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X, History, Bookmark } from 'lucide-react';
import { publish } from '@repo/core-events';
import type { MenuItemType } from '../types/menu.types';
import type { SidebarVariant } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { shortcutsData } from '@repo/ui/constants';
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface SidebarMenuProps {
/** Menu items to render */
items: MenuItemType[];
/**
* Override the sidebar variant. When omitted, reads from CoreAppShell context.
* Use `'expanded'` when rendering in the mobile slot (always show labels).
*/
variantOverride?: SidebarVariant;
/** Whether to show the collapse/expand toggle button (desktop only) */
withToggle?: boolean;
/** Whether to show the sticky menu filter input (default: true) */
showMenuFilter?: boolean;
/** Whether to show the History button (default: true) */
showHistory?: boolean;
/** Whether to show the Bookmark button (default: true) */
showBookmark?: boolean;
}
// ---------------------------------------------------------------------------
// Individual Menu Item (Expanded)
// ---------------------------------------------------------------------------
interface MenuItemExpandedProps {
item: MenuItemType;
activeKeys: Set<string>;
isSearching?: boolean;
expandVersion?: number;
collapseVersion?: number;
}
const MenuItemExpanded = memo(function MenuItemExpanded({
item,
activeKeys,
isSearching,
expandVersion = 0,
collapseVersion = 0,
}: MenuItemExpandedProps) {
const { t } = useTranslation();
const Icon = item.icon;
const isActive = activeKeys.has(item.key);
const hasChildren = item.children && item.children.length > 0;
const isExactActive = isActive && !hasChildren;
const isParentActive = isActive && hasChildren;
const [opened, setOpened] = useState(isActive);
const isOpened = isSearching || opened;
useEffect(() => {
if (expandVersion > 0) setOpened(true);
}, [expandVersion]);
useEffect(() => {
if (collapseVersion > 0) setOpened(false);
}, [collapseVersion]);
return (
<NavLink
component={hasChildren ? 'button' : (Link as any)}
to={hasChildren ? undefined : item.path}
label={t(item.label)}
leftSection={<Icon size={18} />}
active={isExactActive}
opened={isOpened}
onChange={setOpened}
variant="light"
styles={{
root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
},
label: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}}
>
{hasChildren &&
item.children!.map((child) => (
<MenuItemExpanded
key={child.key}
item={child}
activeKeys={activeKeys}
isSearching={isSearching}
expandVersion={expandVersion}
collapseVersion={collapseVersion}
/>
))}
</NavLink>
);
});
// ---------------------------------------------------------------------------
// Individual Menu Item (Mini / Collapsed / Flyout)
// ---------------------------------------------------------------------------
interface MenuItemFlyoutProps {
item: MenuItemType;
activeKeys: Set<string>;
isRoot?: boolean;
}
const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot = true }: MenuItemFlyoutProps) {
const { t } = useTranslation();
const Icon = item.icon;
const isActive = activeKeys.has(item.key);
const hasChildren = item.children && item.children.length > 0;
// -- Leaf Item (No children) --
if (!hasChildren) {
if (isRoot) {
return (
<Tooltip label={t(item.label)} position="right" withArrow transitionProps={{ transition: 'fade-right' }}>
<ActionIcon
component={Link as any}
to={item.path}
variant={isActive ? 'light' : 'subtle'}
color={isActive ? undefined : 'var(--mantine-color-text)'}
size="lg"
aria-label={t(item.label)}
>
<Icon size={20} />
</ActionIcon>
</Tooltip>
);
} else {
return (
<Menu.Item
component={Link as any}
to={item.path}
leftSection={<Icon size={14} />}
color={isActive ? 'brand' : undefined}
styles={{
itemLabel: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 240,
},
}}
>
{t(item.label)}
</Menu.Item>
);
}
}
// -- Parent Item (Has children) --
const Target = isRoot ? (
<ActionIcon
variant={isActive ? 'light' : 'subtle'}
color={isActive ? undefined : 'var(--mantine-color-text)'}
size="lg"
aria-label={t(item.label)}
>
<Icon size={20} />
</ActionIcon>
) : (
<Menu.Item
leftSection={<Icon size={14} />}
rightSection={<ChevronsRight size={14} />}
color={isActive ? 'brand' : undefined}
closeMenuOnClick={false} // Mencegah parent tertutup saat memunculkan Level 3
styles={{
itemLabel: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 240,
},
}}
>
{t(item.label)}
</Menu.Item>
);
return (
<Menu
trigger="hover"
position={isRoot ? 'right-start' : 'right-start'}
offset={isRoot ? 12 : 4}
withArrow={isRoot}
loop={false}
withinPortal
openDelay={100}
closeDelay={300}
>
<Menu.Target>{Target}</Menu.Target>
<Menu.Dropdown>
{isRoot && (
<>
<Menu.Label>{t(item.label)}</Menu.Label>
<Menu.Divider />
</>
)}
{item.children!.map((child) => (
<MenuItemFlyout key={child.key} item={child} activeKeys={activeKeys} isRoot={false} />
))}
</Menu.Dropdown>
</Menu>
);
});
// ---------------------------------------------------------------------------
// SidebarMenu Component
// ---------------------------------------------------------------------------
export const SidebarMenu = memo(function SidebarMenu({
items,
variantOverride,
withToggle = false,
showMenuFilter = true,
showHistory = true,
showBookmark = true,
}: SidebarMenuProps) {
const { t } = useTranslation();
const { pathname } = useLocation();
const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell();
const variant = variantOverride ?? contextVariant;
const isMini = variant === 'mini';
const [searchQuery, setSearchQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isSearching = searchQuery.trim().length > 0;
const [expandVersion, setExpandVersion] = useState(0);
const [collapseVersion, setCollapseVersion] = useState(0);
const [isAllExpanded, setIsAllExpanded] = useState(false);
const handleToggleExpandAll = useCallback(() => {
if (isAllExpanded) {
setCollapseVersion((v) => v + 1);
setIsAllExpanded(false);
} else {
setExpandVersion((v) => v + 1);
setIsAllExpanded(true);
}
}, [isAllExpanded]);
// Recursive active state calculation
const activeKeys = useMemo(() => {
const keys = new Set<string>();
const checkActive = (item: MenuItemType): boolean => {
let isActive = false;
// 1. Exact match logic (handles trailing /index)
const basePath = item.path.replace(/\/index$/, '');
if (pathname === item.path || pathname === basePath) {
isActive = true;
}
// 2. Prefix match logic (for nested module routes like /detail/:id)
// We protect root paths like '/' or '/app' from matching everything.
if (!isActive && basePath !== '/' && basePath !== '/app' && basePath !== '') {
if (pathname.startsWith(basePath + '/')) {
isActive = true;
}
}
// 3. Recursive children match
if (item.children) {
for (const child of item.children) {
if (checkActive(child)) {
isActive = true;
}
}
}
if (isActive) {
keys.add(item.key);
}
return isActive;
};
for (const item of items) {
checkActive(item);
}
return keys;
}, [pathname, items]);
// Client-side recursive filtering logic
const filteredItems = useMemo(() => {
if (!isSearching) return items;
const query = searchQuery.toLowerCase();
const filterItem = (item: MenuItemType): MenuItemType | null => {
const isMatch = t(item.label).toLowerCase().includes(query);
if (item.children) {
const filteredChildren = item.children.map(filterItem).filter((child): child is MenuItemType => child !== null);
// If parent matches, keep all its original children visible for standard UX
if (isMatch) {
return item;
}
// If child matches, keep parent visible and show only matching children branches
if (filteredChildren.length > 0) {
return { ...item, children: filteredChildren };
}
return null;
}
return isMatch ? item : null;
};
return items.map(filterItem).filter((item): item is MenuItemType => item !== null);
}, [items, isSearching, searchQuery]);
const handleToggle = useCallback(() => {
setSidebarVariant(isMini ? 'expanded' : 'mini');
}, [isMini, setSidebarVariant]);
const handleExpandAndSearch = useCallback(() => {
if (isMini) {
setSidebarVariant('expanded');
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [isMini, setSidebarVariant]);
const [isMac, setIsMac] = useState(false);
useEffect(() => {
setIsMac(typeof window !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent));
}, []);
const filterMenuShortcutText = useMemo(() => {
const shortcutData = shortcutsData.find((s) => s.key === 'search_menu');
return isMac ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
}, [isMac]);
const toggleShortcutText = useMemo(() => {
const shortcutData = shortcutsData.find((s) => s.key === 'collapse_sidebar');
return isMac ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
}, [isMac]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Shortcut 1: Cmd/Ctrl + Shift + M for SEARCH MENU
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'm') {
e.preventDefault();
if (isMini) {
handleExpandAndSearch();
} else {
inputRef.current?.focus();
}
}
// Shortcut 2: Cmd/Ctrl + Shift + B for TOGGLE SIDEBAR
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'b') {
e.preventDefault();
handleToggle();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isMini, handleExpandAndSearch]);
// -- Mini (Collapsed) Mode ------------------------------------------------
if (isMini) {
const hasTopSection = showMenuFilter || showHistory || showBookmark;
return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
{hasTopSection && (
<Box
p="md"
style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'color-mix(in srgb, var(--mantine-color-body) 80%, transparent)',
backdropFilter: 'blur(8px)',
borderBottom: '1px solid var(--app-shell-border-color)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
}}
>
{showMenuFilter && (
<ActionIcon
variant="subtle"
size="md"
onClick={handleExpandAndSearch}
aria-label="Search menu"
color="var(--mantine-color-text)"
>
<Search size={20} />
</ActionIcon>
)}
{(showHistory || showBookmark) && (
<Stack gap="xs" align="center">
{showHistory && (
<Tooltip label={t('common:system_menu.history')} position="right" withArrow>
<ActionIcon
variant="subtle"
size="md"
color="var(--mantine-color-text)"
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_HISTORY_DRAWER, undefined)}
aria-label="History"
>
<History size={18} />
</ActionIcon>
</Tooltip>
)}
{showBookmark && (
<Tooltip label={t('common:system_menu.bookmark')} position="right" withArrow>
<ActionIcon
variant="subtle"
size="md"
color="var(--mantine-color-text)"
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_BOOKMARK_DRAWER, undefined)}
aria-label="Bookmark"
>
<Bookmark size={18} />
</ActionIcon>
</Tooltip>
)}
</Stack>
)}
</Box>
)}
<Stack align="center" gap="xs" p="xs" pt="md" style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
{filteredItems.map((item) => (
<MenuItemFlyout key={item.key} item={item} activeKeys={activeKeys} />
))}
</Stack>
{withToggle && (
<>
<Divider />
<Stack align="center" p="xs">
<Tooltip label={`${t('common:expandSidebar')} (${toggleShortcutText})`} position="right" withArrow>
<ActionIcon variant="subtle" color="gray" size="lg" onClick={handleToggle} aria-label="Expand sidebar">
<ChevronsRight size={18} />
</ActionIcon>
</Tooltip>
</Stack>
</>
)}
</Box>
);
}
// -- Expanded Mode --------------------------------------------------------
const hasTopSection = showMenuFilter || showHistory || showBookmark;
return (
<Box h="100%" style={{ display: 'flex', flexDirection: 'column' }}>
{hasTopSection && (
<Box
p="md"
style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'color-mix(in srgb, var(--mantine-color-body) 80%, transparent)',
backdropFilter: 'blur(8px)',
borderBottom: '1px solid var(--app-shell-border-color)',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
{showMenuFilter && (
<Box style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<TextInput
ref={inputRef}
placeholder={`${t('common:searchMenu')}...`}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.currentTarget.value)}
leftSection={<Search size={14} />}
variant="default"
radius="md"
size="xs"
style={{ flex: 1 }}
rightSectionWidth={searchQuery ? 30 : 52}
rightSection={
searchQuery ? (
<ActionIcon variant="subtle" onClick={() => setSearchQuery('')} size="sm">
<X size={14} />
</ActionIcon>
) : (
<span
style={{
fontSize: '10px',
fontWeight: 600,
pointerEvents: 'none',
padding: '2px 4px',
color: 'var(--mantine-color-dimmed)',
border: '1px solid var(--app-shell-border-color)',
borderRadius: 'var(--mantine-radius-sm)',
}}
>
{filterMenuShortcutText}
</span>
)
}
/>
<Tooltip
label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
position="bottom"
withArrow
>
<ActionIcon
variant="default"
onClick={handleToggleExpandAll}
size={30}
radius="md"
fw={400}
aria-label={isAllExpanded ? t('common:collapseAll') : t('common:expandAll')}
>
{isAllExpanded ? <PanelTopOpen size={16} /> : <PanelTopClose size={16} />}
</ActionIcon>
</Tooltip>
</Box>
)}
{(showHistory || showBookmark) && (
<Group wrap="nowrap" gap="xs" grow>
{showHistory && (
<Button
variant="default"
radius="md"
fw={400}
size="xs"
leftSection={<History size={14} />}
fullWidth
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_HISTORY_DRAWER, undefined)}
styles={{ section: { marginRight: 6 }, inner: { color: 'var(--mantine-color-text)' } }}
>
{t('common:system_menu.history')}
</Button>
)}
{showBookmark && (
<Button
variant="default"
radius="md"
fw={400}
size="xs"
leftSection={<Bookmark size={14} />}
fullWidth
onClick={() => publish(LAYOUT_EVENTS.TOGGLE_BOOKMARK_DRAWER, undefined)}
styles={{ inner: { color: 'var(--mantine-color-text)' } }}
>
{t('common:system_menu.bookmark')}
</Button>
)}
</Group>
)}
</Box>
)}
<Box p="lg" pt="md" style={{ flex: 1, overflowY: 'auto' }}>
{filteredItems.map((item) => (
<MenuItemExpanded
key={item.key}
item={item}
activeKeys={activeKeys}
isSearching={isSearching}
expandVersion={expandVersion}
collapseVersion={collapseVersion}
/>
))}
</Box>
{withToggle && (
<>
<Divider />
<Box p="xs">
<NavLink
label={`${t('common:collapse')} (${toggleShortcutText})`}
leftSection={<ChevronsLeft size={18} />}
onClick={handleToggle}
variant="subtle"
styles={{
root: { borderRadius: 'var(--mantine-radius-md)', color: 'var(--mantine-color-dimmed)' },
}}
/>
</Box>
</>
)}
</Box>
);
});
@@ -0,0 +1,258 @@
import {
FileText,
LayoutDashboard,
Database,
Settings,
CreditCard,
Users,
Box,
Layers,
Warehouse,
Activity,
Globe,
Briefcase,
Phone,
ShoppingCart,
Truck,
HardHat,
Factory,
Calculator,
Receipt,
PiggyBank,
Calendar,
Clock,
Shield,
FileSearch,
} from 'lucide-react';
import type { MenuItemType } from '../types/menu.types';
/**
* Application sidebar menu items.
*
* Paths are absolute and must align with the router hierarchy:
* BrowserRouter → /app/* → /example/* → /single-page/* | /full-page/*
*/
export const MENU_ITEMS: MenuItemType[] = [
{
key: 'dashboard',
label: 'nav:dashboard',
icon: LayoutDashboard,
path: '/app/dashboard',
},
{
key: 'crm',
label: 'nav:crm',
icon: Users,
path: '/app/crm',
children: [
{
key: 'crm-leads',
label: 'nav:crm-leads',
icon: Briefcase,
path: '/app/crm/leads',
},
{
key: 'crm-pipelines',
label: 'nav:crm-pipelines',
icon: Activity,
path: '/app/crm/pipelines',
},
{
key: 'crm-contacts',
label: 'nav:crm-contacts',
icon: Phone,
path: '/app/crm/contacts',
},
],
},
{
key: 'sales',
label: 'nav:sales',
icon: ShoppingCart,
path: '/app/sales',
children: [
{
key: 'sales-quotations',
label: 'nav:sales-quotations',
icon: FileText,
path: '/app/sales/quotations',
},
{
key: 'sales-orders',
label: 'nav:sales-orders',
icon: Box,
path: '/app/sales/orders',
},
{
key: 'sales-invoices',
label: 'nav:sales-invoices',
icon: Receipt,
path: '/app/sales/invoices',
},
],
},
{
key: 'supply-chain',
label: 'nav:supply-chain',
icon: Truck,
path: '/app/supply-chain',
children: [
{
key: 'sc-inventory',
label: 'nav:sc-inventory',
icon: Box,
path: '/app/supply-chain/inventory',
children: [
{
key: 'sc-inventory-products',
label: 'nav:sc-inventory-products',
icon: Layers,
path: '/app/supply-chain/inventory/products',
},
{
key: 'sc-inventory-categories',
label: 'nav:sc-inventory-categories',
icon: Globe,
path: '/app/supply-chain/inventory/categories',
},
{
key: 'sc-inventory-adjustments',
label: 'nav:sc-inventory-adjustments',
icon: FileSearch,
path: '/app/supply-chain/inventory/adjustments',
},
],
},
{
key: 'sc-warehouses',
label: 'nav:sc-warehouses',
icon: Warehouse,
path: '/app/supply-chain/warehouses',
},
{
key: 'sc-logistics',
label: 'nav:sc-logistics',
icon: Globe,
path: '/app/supply-chain/logistics',
},
],
},
{
key: 'manufacturing',
label: 'nav:manufacturing',
icon: Factory,
path: '/app/manufacturing',
children: [
{
key: 'mfg-bom',
label: 'nav:mfg-bom',
icon: Layers,
path: '/app/manufacturing/bom',
},
{
key: 'mfg-work-orders',
label: 'nav:mfg-work-orders',
icon: HardHat,
path: '/app/manufacturing/work-orders',
},
],
},
{
key: 'hris',
label: 'nav:hris',
icon: Briefcase,
path: '/app/hris',
children: [
{
key: 'hris-employees',
label: 'nav:hris-employees',
icon: Users,
path: '/app/hris/employees',
},
{
key: 'hris-attendance',
label: 'nav:hris-attendance',
icon: Clock,
path: '/app/hris/attendance',
},
{
key: 'hris-payroll',
label: 'nav:hris-payroll',
icon: CreditCard,
path: '/app/hris/payroll',
},
{
key: 'hris-calendar',
label: 'nav:hris-calendar',
icon: Calendar,
path: '/app/hris/calendar',
},
],
},
{
key: 'accounting',
label: 'nav:accounting',
icon: Calculator,
path: '/app/accounting',
children: [
{
key: 'acc-gl',
label: 'nav:acc-gl',
icon: Database,
path: '/app/accounting/general-ledger',
},
{
key: 'acc-taxes',
label: 'nav:acc-taxes',
icon: PiggyBank,
path: '/app/accounting/taxes',
},
],
},
{
key: 'settings',
label: 'nav:settings',
icon: Settings,
path: '/app/settings',
children: [
{
key: 'settings-general',
label: 'nav:settings-general',
icon: Settings,
path: '/app/settings/general',
},
{
key: 'settings-security',
label: 'nav:settings-security',
icon: Shield,
path: '/app/settings/security',
},
{
key: 'long-text-2',
label: 'nav:long-text-2',
icon: FileText,
path: '/app/settings/long-menu-test',
},
],
},
{
key: 'example-module',
label: 'nav:example-module',
icon: Database,
path: '/app/example-module',
children: [
{
key: 'example-full-page',
label: 'nav:example-full-page',
icon: LayoutDashboard,
path: '/app/example/full-page/index',
},
{
key: 'example-single-page',
label: 'nav:example-single-page',
icon: FileText,
path: '/app/example/single-page/index',
},
],
},
];
@@ -0,0 +1,114 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local';
import type { SavedPageItem } from '../types/saved-page.types';
import type { SystemSettings } from '../../system/setting/types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } from '../../system/setting/types/setting.types';
// ---------------------------------------------------------------------------
// 1. In-Memory Cache & Broadcast Channel
// ---------------------------------------------------------------------------
export let memoryHistoryCache: SavedPageItem[] = [];
export let isHistoryReady = false;
// Create a secret communication path between tabs
const historyChannel = typeof window !== 'undefined' ? new BroadcastChannel('app_history_sync') : null;
// Listen for updates from other tabs passively in the background
if (historyChannel) {
historyChannel.onmessage = (event: MessageEvent<SavedPageItem>) => {
const incomingItem = event.data;
// Remove duplicates if another tab visits a page that is already in history
const filteredData = memoryHistoryCache.filter((item) => item.path !== incomingItem.path);
// Update this tab's local memory (Without needing to query IndexedDB)
memoryHistoryCache = [incomingItem, ...filteredData].slice(0, 500);
};
}
// ---------------------------------------------------------------------------
// 2. Background Task: Initialization & Purge (Remain the same)
// ---------------------------------------------------------------------------
export async function initializeAndPurgeHistoryBackground() {
if (isHistoryReady) return;
try {
const data = (await secureIndexedDB.getItem<SavedPageItem[]>(AppStorageKey.HISTORY_PAGE)) || [];
if (data.length === 0) {
isHistoryReady = true;
return;
}
const settings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const now = Date.now();
const unitMs: Record<string, number> = {
seconds: 1000,
minutes: 60 * 1000,
hours: 60 * 60 * 1000,
days: 24 * 60 * 60 * 1000,
};
const retentionMs = settings.historyRetentionValue * (unitMs[settings.historyRetentionUnit] || unitMs.days);
const validItems = data.filter((item: SavedPageItem) => now - item.timestamp < retentionMs);
memoryHistoryCache = validItems;
isHistoryReady = true;
if (validItems.length !== data.length) {
await secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, validItems);
}
} catch (err) {
console.error('[HistoryManager] Failed to init history', err);
memoryHistoryCache = [];
isHistoryReady = true;
}
}
// ---------------------------------------------------------------------------
// 3. The Hook: Track & Broadcast
// ---------------------------------------------------------------------------
export function useHistoryTracker() {
const location = useLocation();
useEffect(() => {
const trackPage = () => {
if (!isHistoryReady) return;
const currentPath = location.pathname;
if (currentPath === '/' || currentPath === '/app' || currentPath.includes('/auth')) return;
const filteredData = memoryHistoryCache.filter((item: SavedPageItem) => item.path !== currentPath);
const pageTitle =
document.title && document.title.length > 0
? document.title
: currentPath.split('/').pop()?.replace(/-/g, ' ') || currentPath;
const newItem: SavedPageItem = {
id: `hist_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
path: currentPath,
title: pageTitle,
timestamp: Date.now(),
};
// 1. Update In-Memory Cache in this Tab (Instant)
memoryHistoryCache = [newItem, ...filteredData].slice(0, 500);
// 2. Notify all other tabs about this new item (Instant)
historyChannel?.postMessage(newItem);
// 3. Save to IndexedDB in the background (Asynchronous)
secureIndexedDB.setItem(AppStorageKey.HISTORY_PAGE, memoryHistoryCache).catch((err) => {
console.error('[HistoryManager] Failed to track history', err);
});
};
const timer = setTimeout(() => {
trackPage();
}, 500);
return () => clearTimeout(timer);
}, [location.pathname, location.search]);
}
@@ -0,0 +1,8 @@
{
"title": "Bookmarks",
"empty": "No saved bookmarks found.",
"clearAll": "Clear All",
"removeItem": "Remove Bookmark",
"savedAt": "Saved {{time}}",
"clearSuccess": "Bookmarks cleared successfully."
}
@@ -0,0 +1,13 @@
{
"title": "History",
"empty": "No recent history found.",
"clearAll": "Clear History",
"clearRange": "Clear by Range",
"ranges": {
"lastHour": "Last Hour",
"today": "Today",
"allTime": "All Time"
},
"removeItem": "Remove History",
"clearSuccess": "History cleared successfully."
}
@@ -0,0 +1,37 @@
{
"dashboard": "Dashboard",
"crm": "CRM",
"crm-leads": "Leads",
"crm-pipelines": "Pipelines",
"crm-contacts": "Contacts",
"sales": "Sales",
"sales-quotations": "Quotations",
"sales-orders": "Sales Orders",
"sales-invoices": "Invoices",
"supply-chain": "Supply Chain",
"sc-inventory": "Inventory Management",
"sc-inventory-products": "Products",
"sc-inventory-categories": "Categories",
"sc-inventory-adjustments": "Stock Adjustments",
"sc-warehouses": "Warehouses",
"sc-logistics": "Logistics",
"manufacturing": "Manufacturing",
"mfg-bom": "Bill of Materials",
"mfg-work-orders": "Work Orders",
"hris": "HRIS",
"hris-employees": "Employees",
"hris-attendance": "Attendance",
"hris-payroll": "Payroll",
"hris-calendar": "Company Calendar",
"accounting": "Accounting",
"acc-gl": "General Ledger",
"acc-taxes": "Taxes",
"settings": "Settings & Configuration",
"settings-general": "General Settings",
"settings-security": "Security",
"long-text-2": "Extremely Long Menu Name To Test Text Truncation Handling Properly",
"example-module": "Example Module",
"example-full-page": "Example Full Page",
"example-single-page": "Example Single Page",
"system": "System"
}
@@ -0,0 +1,8 @@
{
"title": "Bookmarks",
"empty": "Belum ada halaman yang ditandai.",
"clearAll": "Hapus Semua",
"removeItem": "Hapus Markah",
"savedAt": "Disimpan {{time}}",
"clearSuccess": "Semua markah berhasil dihapus."
}
@@ -0,0 +1,13 @@
{
"title": "Riwayat",
"empty": "Belum ada riwayat halaman terbaru.",
"clearAll": "Hapus Riwayat",
"clearRange": "Hapus Rentang",
"ranges": {
"lastHour": "1 Jam Terakhir",
"today": "Hari Ini",
"allTime": "Semua Waktu"
},
"removeItem": "Hapus Item",
"clearSuccess": "Riwayat berhasil dihapus."
}
@@ -0,0 +1,37 @@
{
"dashboard": "Dasbor",
"crm": "CRM",
"crm-leads": "Prospek",
"crm-pipelines": "Alur Penjualan",
"crm-contacts": "Kontak",
"sales": "Penjualan",
"sales-quotations": "Penawaran",
"sales-orders": "Pesanan Penjualan",
"sales-invoices": "Faktur",
"supply-chain": "Rantai Pasok",
"sc-inventory": "Manajemen Inventaris",
"sc-inventory-products": "Produk",
"sc-inventory-categories": "Kategori",
"sc-inventory-adjustments": "Penyesuaian Stok",
"sc-warehouses": "Gudang",
"sc-logistics": "Logistik",
"manufacturing": "Manufaktur",
"mfg-bom": "Daftar Material",
"mfg-work-orders": "Perintah Kerja",
"hris": "HRIS",
"hris-employees": "Karyawan",
"hris-attendance": "Kehadiran",
"hris-payroll": "Penggajian",
"hris-calendar": "Kalender Perusahaan",
"accounting": "Akuntansi",
"acc-gl": "Buku Besar",
"acc-taxes": "Pajak",
"settings": "Pengaturan & Konfigurasi",
"settings-general": "Pengaturan Umum",
"settings-security": "Keamanan",
"long-text-2": "Nama Menu Sangat Panjang Untuk Menguji Penanganan Pemotongan Teks Dengan Baik",
"example-module": "Modul Contoh",
"example-full-page": "Contoh Halaman Penuh",
"example-single-page": "Contoh Halaman Tunggal",
"system": "Sistem"
}
@@ -0,0 +1,56 @@
import { CoreAppShell, CoreAppShellConfig } from '@repo/ui/components';
import { registerModuleNamespace } from '@repo/core-i18n';
import HeaderLayout from './components/header.layout';
import { SidebarMenu } from './components/sidebar';
import { HistoryDrawer } from './components/history';
import { BookmarkDrawer } from './components/bookmark';
import { useHistoryTracker } from './hooks/useHistoryTracker';
import { MENU_ITEMS } from './data/menu.data';
import navEn from './languages/en/nav.json';
import navId from './languages/id/nav.json';
import historyEn from './languages/en/history.json';
import historyId from './languages/id/history.json';
import bookmarkEn from './languages/en/bookmark.json';
import bookmarkId from './languages/id/bookmark.json';
// ---------------------------------------------------------------------------
// Namespace Registration (Module Scope)
// ---------------------------------------------------------------------------
// Called once at import time — safe, idempotent, outside React render cycle.
registerModuleNamespace('nav', { en: navEn, id: navId });
registerModuleNamespace('history', { en: historyEn, id: historyId });
registerModuleNamespace('bookmark', { en: bookmarkEn, id: bookmarkId });
export default function ModuleLayout({ children }: { children: React.ReactNode }) {
useHistoryTracker();
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 />,
sidebar: <SidebarMenu items={MENU_ITEMS} withToggle />,
sidebarMobile: <SidebarMenu items={MENU_ITEMS} variantOverride="expanded" />,
}}
>
{children}
<HistoryDrawer />
<BookmarkDrawer />
</CoreAppShell>
);
}
@@ -0,0 +1,17 @@
import type { LucideIcon } from 'lucide-react';
/**
* Represents a single navigation menu item in the sidebar.
*/
export interface MenuItemType {
/** Unique identifier for the menu item */
key: string;
/** i18n translation key (e.g. 'nav:dashboard'). Resolved via t() at render time. */
label: string;
/** Lucide icon component */
icon: LucideIcon;
/** Absolute route path for navigation */
path: string;
/** Optional nested child menu items */
children?: MenuItemType[];
}
@@ -0,0 +1,6 @@
export interface SavedPageItem {
id: string;
title: string;
path: string;
timestamp: number;
}
@@ -0,0 +1,109 @@
import { useTranslation } from '@repo/core-i18n';
import { Group, Kbd, Table, Text, TextInput, Box, Center, ScrollArea } from '@repo/ui/components';
import { shortcutsData } from '@repo/ui/constants';
import { Search, Keyboard } from 'lucide-react';
import { useEffect, useState, useMemo, Fragment } from 'react';
export function Shortcut() {
const { t } = useTranslation();
const [isMac, setIsMac] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// OS Detection for rendering appropriate keyboard shortcuts
useEffect(() => {
setIsMac(typeof window !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent));
}, []);
const filteredShortcuts = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
if (!query) return shortcutsData;
return shortcutsData.filter((item) => {
const labelMatch = t(item.label).toLowerCase().includes(query);
const descMatch = t(item.desc).toLowerCase().includes(query);
return labelMatch || descMatch;
});
}, [searchQuery, t]);
const rows = filteredShortcuts.map((element, index) => {
const keys = isMac ? element.macKeys : element.winKeys;
return (
<Table.Tr key={index}>
<Table.Td>
<Text size="sm" fw={600} style={{ color: 'var(--mantine-color-text)' }}>
{t(element.label)}
</Text>
<Text size="xs" c="dimmed" mt={4} style={{ lineHeight: 1.4 }}>
{t(element.desc)}
</Text>
</Table.Td>
<Table.Td w={250}>
<Group gap={6} wrap="nowrap" align="center">
{keys.map((key, i) => (
<Fragment key={i}>
<Kbd size="md" style={{ fontSize: '12px' }}>
{key}
</Kbd>
{i < keys.length - 1 && (
<Text size="sm" c="dimmed" fw={500}>
+
</Text>
)}
</Fragment>
))}
</Group>
</Table.Td>
</Table.Tr>
);
});
return (
<Box>
<Box p="md" style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<TextInput
placeholder={t('information:info.searchPlaceholder')}
leftSection={<Search size={16} style={{ opacity: 0.5 }} />}
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
w={{ base: '100%', sm: 350 }}
radius="md"
size="xs"
/>
</Box>
<ScrollArea.Autosize mah="calc(100vh - 350px)" offsetScrollbars>
<Table
stickyHeader
stickyHeaderOffset={-1}
striped
highlightOnHover
verticalSpacing="sm"
horizontalSpacing="md"
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('information:info.table.action').toUpperCase()}</Table.Th>
<Table.Th>{t('information:info.table.shortcut').toUpperCase()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.length > 0 ? (
rows
) : (
<Table.Tr>
<Table.Td colSpan={2}>
<Center py={40} style={{ flexDirection: 'column', gap: '8px' }}>
<Keyboard size={32} style={{ opacity: 0.2 }} />
<Text c="dimmed" size="sm" fw={500}>
{t('information:info.emptySearch')}
</Text>
</Center>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</ScrollArea.Autosize>
</Box>
);
}
@@ -0,0 +1,19 @@
import { useTranslation } from '@repo/core-i18n';
import { Group, Text } from '@repo/ui/components';
import { Info } from 'lucide-react';
export function System() {
const { t } = useTranslation();
return (
<Group justify="center" align="center" style={{ minHeight: 200, flexDirection: 'column' }}>
<Info size={48} color="var(--mantine-color-dimmed)" strokeWidth={1.5} />
<Text size="lg" fw={500} mt="md">
{t('information:info.placeholder.title')}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={400}>
{t('information:info.placeholder.description')}
</Text>
</Group>
);
}
@@ -0,0 +1,63 @@
import { Tabs, Card, CorePageContainer } from '@repo/ui/components';
import { Keyboard, Info } from 'lucide-react';
import { ModulePageHeader } from '@repo/ui/foundations';
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { Shortcut } from './components/shortcut';
import { System } from './components/system';
import informationId from './languages/id/information.json';
import informationEn from './languages/en/information.json';
import { useEffect } from 'react';
registerModuleNamespace('information', {
id: informationId,
en: informationEn,
});
export default function InformationPage() {
const { t } = useTranslation();
useEffect(() => {
document.title = t('information:info.pageTitle');
}, [t]);
return (
<CorePageContainer>
<ModulePageHeader
icon={Info}
title={t('information:info.pageTitle')}
description={t('information:info.pageDescription')}
disableMinimize={true}
moduleKey="SYSTEM_INFORMATION"
breadcrumbs={[
{ label: t('nav:system'), type: 'text' },
{ label: t('information:info.pageTitle'), type: 'link', href: '/app/system/information' },
]}
/>
<Tabs defaultValue="shortcuts" variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="shortcuts" leftSection={<Keyboard size={16} />}>
{t('information:info.tabs.shortcuts')}
</Tabs.Tab>
<Tabs.Tab value="system" leftSection={<Info size={16} />}>
{t('information:info.tabs.information')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="shortcuts">
<Card shadow="sm" radius="md" withBorder padding={'xl'}>
<Shortcut />
</Card>
</Tabs.Panel>
<Tabs.Panel value="system">
<Card shadow="sm" radius="md" withBorder padding="xl">
<System />
</Card>
</Tabs.Panel>
</Tabs>
</CorePageContainer>
);
}
@@ -0,0 +1,34 @@
{
"info": {
"pageTitle": "Information",
"pageDescription": "Central hub for system details, configurations, and user references.",
"tabs": {
"shortcuts": "Keyboard Shortcuts",
"information": "System Information"
},
"searchPlaceholder": "Search shortcuts...",
"emptySearch": "No shortcuts found",
"table": {
"action": "Action",
"shortcut": "Shortcut"
},
"placeholder": {
"title": "System Details Unavailable",
"description": "Detailed information regarding package versions, build environments, and core dependencies will be displayed here in a future update."
}
},
"shortcuts": {
"create": {
"label": "Create / Add Data",
"desc": "Works on index or detail pages containing an add data action button, provided you have the required access permissions."
},
"searchMenu": {
"label": "Search Navigation Menu",
"desc": "Works globally across all pages to quickly search for menus."
},
"toggleSidebar": {
"label": "Toggle Sidebar",
"desc": "Works globally to collapse or expand the main navigation panel to maximize workspace area."
}
}
}
@@ -0,0 +1,34 @@
{
"info": {
"pageTitle": "Informasi",
"pageDescription": "Pusat informasi untuk detail sistem, konfigurasi, dan referensi pengguna.",
"tabs": {
"shortcuts": "Pintasan Keyboard",
"information": "Informasi Sistem"
},
"searchPlaceholder": "Cari pintasan...",
"emptySearch": "Pintasan tidak ditemukan",
"table": {
"action": "Aksi",
"shortcut": "Pintasan"
},
"placeholder": {
"title": "Detail Sistem Belum Tersedia",
"description": "Informasi detail mengenai versi paket, environment build, dan dependensi inti akan ditampilkan di sini pada pembaruan mendatang."
}
},
"shortcuts": {
"create": {
"label": "Buat / Tambah Data",
"desc": "Berfungsi di halaman indeks atau detail yang memiliki tombol aksi tambah data, serta memerlukan hak akses (permission) yang sah."
},
"searchMenu": {
"label": "Cari Menu Navigasi",
"desc": "Berfungsi secara global di seluruh halaman untuk mencari menu dengan cepat."
},
"toggleSidebar": {
"label": "Buka/Tutup Sidebar",
"desc": "Berfungsi secara global untuk melipat atau melebarkan panel navigasi utama agar ruang kerja menjadi lebih luas."
}
}
}
@@ -0,0 +1,17 @@
import { Container, Paper, Title, Text } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
export default function NotificationPage() {
const { t } = useTranslation();
return (
<Container fluid p="md">
<Paper shadow="sm" p="md" radius="md">
<Title order={2} mb="xs">
{t('common:systemNotifications')}
</Title>
<Text c="dimmed">{t('common:systemNotificationsDesc')}</Text>
</Paper>
</Container>
);
}
@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react';
import { Stack, Button, Group, Text, Box } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FieldNumberInput, FieldSelect } from '@repo/ui/form';
import { secureIndexedDB, AppStorageKey } from '../../../../../core/storage/local';
import type { SystemSettings, RetentionUnit } from '../types/setting.types';
import { DEFAULT_SYSTEM_SETTINGS } from '../types/setting.types';
const historySchema = z.object({
historyRetentionValue: z.coerce.number().min(1, { message: 'Must be at least 1' }),
historyRetentionUnit: z.enum(['seconds', 'minutes', 'hours', 'days']),
});
type HistorySettingsFormValues = z.infer<typeof historySchema>;
export function HistorySetting({ onDirtyChange }: { onDirtyChange: (isDirty: boolean) => void }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const {
control,
handleSubmit,
reset,
formState: { isDirty },
} = useForm<HistorySettingsFormValues>({
resolver: zodResolver(historySchema),
defaultValues: {
historyRetentionValue: DEFAULT_SYSTEM_SETTINGS.historyRetentionValue,
historyRetentionUnit: DEFAULT_SYSTEM_SETTINGS.historyRetentionUnit,
},
});
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => {
const loadSettings = async () => {
try {
const settings = await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS);
if (settings) {
reset({
historyRetentionValue: settings.historyRetentionValue,
historyRetentionUnit: settings.historyRetentionUnit,
});
} else {
reset({
historyRetentionValue: DEFAULT_SYSTEM_SETTINGS.historyRetentionValue,
historyRetentionUnit: DEFAULT_SYSTEM_SETTINGS.historyRetentionUnit,
});
}
} catch (err) {
console.error('Failed to load system settings', err);
}
};
loadSettings();
}, [reset]);
const handleReset = async () => {
try {
const settings = await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS);
if (settings) {
reset({
historyRetentionValue: settings.historyRetentionValue,
historyRetentionUnit: settings.historyRetentionUnit,
});
} else {
reset({
historyRetentionValue: DEFAULT_SYSTEM_SETTINGS.historyRetentionValue,
historyRetentionUnit: DEFAULT_SYSTEM_SETTINGS.historyRetentionUnit,
});
}
} catch (err) {
console.error('Failed to reset system settings', err);
}
};
const onSubmit = async (data: HistorySettingsFormValues) => {
setLoading(true);
try {
const currentSettings =
(await secureIndexedDB.getItem<SystemSettings>(AppStorageKey.SYSTEM_SETTINGS)) || DEFAULT_SYSTEM_SETTINGS;
const newSettings = {
...currentSettings,
historyRetentionValue: data.historyRetentionValue,
historyRetentionUnit: data.historyRetentionUnit as RetentionUnit,
};
await secureIndexedDB.setItem(AppStorageKey.SYSTEM_SETTINGS, newSettings);
reset(data); // Reset form to clear dirty state
} catch (err) {
console.error('Failed to save settings', err);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<Text fw={500} size="md" mb="xs">
{t('setting:history.retentionTitle')}
</Text>
<Text c="dimmed" size="sm" mb="md">
{t('setting:history.retentionDesc')}
</Text>
<Group align="flex-top">
<Box w={120}>
<FieldNumberInput
control={control}
name="historyRetentionValue"
min={1}
allowNegative={false}
allowDecimal={false}
/>
</Box>
<Box w={150}>
<FieldSelect
control={control}
name="historyRetentionUnit"
data={[
{ value: 'seconds', label: t('setting:history.units.seconds') },
{ value: 'minutes', label: t('setting:history.units.minutes') },
{ value: 'hours', label: t('setting:history.units.hours') },
{ value: 'days', label: t('setting:history.units.days') },
]}
clearable={false}
/>
</Box>
</Group>
</Box>
<Group justify="flex-start" mt="md">
<Button type="submit" loading={loading} disabled={!isDirty}>
{t('setting:history.save')}
</Button>
{isDirty && (
<Button variant="default" onClick={handleReset}>
{t('setting:history.reset')}
</Button>
)}
</Group>
</Stack>
</form>
);
}
@@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
import { Stack, Button, Group, Text, Box } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FieldSwitch } from '@repo/ui/form';
const notificationSchema = z.object({
email: z.boolean(),
push: z.boolean(),
});
type NotificationSettings = z.infer<typeof notificationSchema>;
export function NotificationSetting({ onDirtyChange }: { onDirtyChange: (isDirty: boolean) => void }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const { control, handleSubmit, reset, formState: { isDirty } } = useForm<NotificationSettings>({
resolver: zodResolver(notificationSchema),
defaultValues: {
email: false,
push: false,
},
});
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
const handleReset = () => {
reset({
email: false,
push: false,
});
};
const onSubmit = async (data: NotificationSettings) => {
setLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
// Reset form to clear dirty state
reset(data);
} catch (err) {
console.error('Failed to save notification settings', err);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<Box>
<Text fw={500} size="md" mb="xs">{t('setting:notification.title')}</Text>
<Text c="dimmed" size="sm" mb="md">{t('setting:notification.desc')}</Text>
<Stack gap="sm">
<FieldSwitch
control={control}
name="email"
label={t('setting:notification.email')}
description={t('setting:notification.emailDesc')}
/>
<FieldSwitch
control={control}
name="push"
label={t('setting:notification.push')}
description={t('setting:notification.pushDesc')}
/>
</Stack>
</Box>
<Group justify="flex-start" mt="md">
<Button type="submit" loading={loading} disabled={!isDirty}>
{t('setting:notification.save')}
</Button>
{isDirty && (
<Button variant="default" onClick={handleReset}>
{t('setting:notification.reset')}
</Button>
)}
</Group>
</Stack>
</form>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { Tabs, Card, CorePageContainer, Modal, Button, Text, Group } from '@repo/ui/components';
import { Settings, History, Bell } from 'lucide-react';
import { ModulePageHeader } from '@repo/ui/foundations';
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { HistorySetting } from './components/history-setting';
import { NotificationSetting } from './components/notification-setting';
import settingId from './languages/id/setting.json';
import settingEn from './languages/en/setting.json';
registerModuleNamespace('setting', {
id: settingId,
en: settingEn,
});
export default function SettingPage() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<string>('history');
const [isDirty, setIsDirty] = useState(false);
const [pendingTab, setPendingTab] = useState<string | null>(null);
useEffect(() => {
document.title = t('setting:pageTitle');
}, [t]);
const handleTabChange = (value: string | null) => {
if (!value) return;
if (value === activeTab) return;
if (isDirty) {
setPendingTab(value);
} else {
setActiveTab(value);
}
};
const handleDiscardChanges = () => {
if (pendingTab) {
setActiveTab(pendingTab);
setIsDirty(false);
setPendingTab(null);
}
};
const handleCancelSwitch = () => {
setPendingTab(null);
};
return (
<CorePageContainer>
<ModulePageHeader
icon={Settings}
title={t('setting:pageTitle')}
description={t('setting:pageDescription')}
disableMinimize={true}
moduleKey="SYSTEM_SETTING"
breadcrumbs={[
{ label: t('nav:system'), type: 'text' },
{ label: t('setting:pageTitle'), type: 'link', href: '/app/system/setting' },
]}
/>
<Tabs value={activeTab} onChange={handleTabChange} variant="outline" radius="md">
<Tabs.List mb="md">
<Tabs.Tab value="history" leftSection={<History size={16} />}>
{t('setting:tabs.history')}
</Tabs.Tab>
<Tabs.Tab value="notification" leftSection={<Bell size={16} />}>
{t('setting:tabs.notification')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="history">
<Card shadow="sm" radius="md" withBorder padding="xl">
{activeTab === 'history' && <HistorySetting onDirtyChange={setIsDirty} />}
</Card>
</Tabs.Panel>
<Tabs.Panel value="notification">
<Card shadow="sm" radius="md" withBorder padding="xl">
{activeTab === 'notification' && <NotificationSetting onDirtyChange={setIsDirty} />}
</Card>
</Tabs.Panel>
</Tabs>
<Modal
opened={!!pendingTab}
onClose={handleCancelSwitch}
title={<Text fw={600}>{t('setting:unsaved.title')}</Text>}
yOffset={50}
>
<Text size="sm" mb="xl">
{t('setting:unsaved.message')}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={handleCancelSwitch}>
{t('setting:unsaved.cancel')}
</Button>
<Button color="red" onClick={handleDiscardChanges}>
{t('setting:unsaved.discard')}
</Button>
</Group>
</Modal>
</CorePageContainer>
);
}
@@ -0,0 +1,37 @@
{
"pageTitle": "System Settings",
"pageDescription": "Manage global application preferences and configurations.",
"tabs": {
"history": "History & Storage",
"notification": "Notifications"
},
"history": {
"retentionTitle": "History Retention",
"retentionDesc": "Automatically clear history items older than this duration.",
"save": "Save Changes",
"reset": "Discard Changes",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days"
}
},
"notification": {
"title": "Notification Settings",
"desc": "Manage how and when you receive notifications.",
"email": "Email Notifications",
"emailDesc": "Receive updates and alerts via email.",
"push": "Push Notifications",
"pushDesc": "Receive desktop push notifications.",
"save": "Save Changes",
"reset": "Discard Changes",
"saveSuccess": "Notification settings saved successfully."
},
"unsaved": {
"title": "Unsaved Changes",
"message": "You have unsaved changes in the current tab. Are you sure you want to leave without saving?",
"discard": "Continue (Discard Changes)",
"cancel": "Cancel"
}
}
@@ -0,0 +1,37 @@
{
"pageTitle": "Pengaturan Sistem",
"pageDescription": "Kelola preferensi dan konfigurasi aplikasi secara global.",
"tabs": {
"history": "Riwayat & Penyimpanan",
"notification": "Notifikasi"
},
"history": {
"retentionTitle": "Durasi Penyimpanan Riwayat",
"retentionDesc": "Hapus otomatis item riwayat yang usianya lebih lama dari batas waktu ini.",
"save": "Simpan Perubahan",
"reset": "Buang Perubahan",
"units": {
"seconds": "Detik",
"minutes": "Menit",
"hours": "Jam",
"days": "Hari"
}
},
"notification": {
"title": "Pengaturan Notifikasi",
"desc": "Kelola bagaimana dan kapan Anda menerima notifikasi.",
"email": "Notifikasi Email",
"emailDesc": "Terima pembaruan dan peringatan melalui email.",
"push": "Notifikasi Push",
"pushDesc": "Terima notifikasi push di desktop Anda.",
"save": "Simpan Perubahan",
"reset": "Buang Perubahan",
"saveSuccess": "Pengaturan notifikasi berhasil disimpan."
},
"unsaved": {
"title": "Perubahan Belum Disimpan",
"message": "Anda memiliki perubahan yang belum disimpan pada tab ini. Apakah Anda yakin ingin berpindah tab tanpa menyimpan?",
"discard": "Lanjutkan (Buang Perubahan)",
"cancel": "Batal"
}
}
@@ -0,0 +1,11 @@
export type RetentionUnit = 'seconds' | 'minutes' | 'hours' | 'days';
export interface SystemSettings {
historyRetentionValue: number;
historyRetentionUnit: RetentionUnit;
}
export const DEFAULT_SYSTEM_SETTINGS: SystemSettings = {
historyRetentionValue: 7,
historyRetentionUnit: 'days',
};
@@ -0,0 +1,161 @@
import { Card, Title, Text, Table, Stack, Badge } from '@repo/ui/components';
import { PageActions, RowActions, PageActionProps, RowActionProps } from '@repo/ui/components';
import { Save, Printer, Trash, MoreVertical, Edit, FileText, CheckCircle, Check } from 'lucide-react';
export default function ActionToolsShowcase() {
const pageActions: PageActionProps[] = [
{
key: 'save',
label: 'Save Changes',
icon: <Save size={16} />,
variant: 'filled',
onClick: (key) => console.log('Clicked', key),
},
{
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: RowActionProps[] = [
{
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} />
</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>
);
}
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { usePublishEvent } from '@repo/core-events';
import { Button, Group, Stack, TextInput, Badge } from '@repo/ui/components';
import { AUTH_EVENTS } from '../../../../core/constants/events';
// ─── ProfileSettingsUI ──────────────────────────────────────────
@@ -29,7 +30,7 @@ export function ProfileSettingsUI() {
const [saveCount, setSaveCount] = useState(0);
const handleSave = () => {
publish('AUTH:PROFILE_UPDATED', {
publish(AUTH_EVENTS.PROFILE_UPDATED, {
id: 'user-1',
name,
email,
@@ -1,4 +1,5 @@
import { useAppEvent } from '@repo/core-events';
import { AUTH_EVENTS } from '../../../../core/constants/events';
import type { ProfileUpdatedPayload } from '@repo/core-events';
import { secureIndexedDB, AppStorageKey } from '../../../../core/storage/local';
@@ -16,7 +17,7 @@ interface StorageSyncListenerProps {
* and persists the profile data to IndexedDB via `@repo/core-storage`.
*/
export function StorageSyncListener({ onLog }: StorageSyncListenerProps) {
useAppEvent('AUTH:PROFILE_UPDATED', (payload: ProfileUpdatedPayload) => {
useAppEvent(AUTH_EVENTS.PROFILE_UPDATED, (payload: ProfileUpdatedPayload) => {
onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`);
// Persist to IndexedDB via local AppStorageKey.
@@ -14,6 +14,7 @@ import { PrinterListener } from './printer/printer.listener';
import { LiveStockGrid } from './stock-grid/live-stock-grid.ui';
import { ProfileSettingsUI } from './auth-sync/profile-settings.ui';
import { StorageSyncListener } from './auth-sync/storage-sync.listener';
import { DEVICE_EVENTS, AUTH_EVENTS } from '../../../core/constants/events';
// ─── Events Demo Page ───────────────────────────────────────────
@@ -64,8 +65,8 @@ export default function EventsDemoPage() {
🖨 Showcase 1: Cross-Platform Printer Abstraction
</Title>
<Text size="sm" c="dimmed" mb="md">
The CashierUI publishes a <code>DEVICE:PRINT_RECEIPT</code> event.
A headless PrinterListener decides whether to use Electron IPC or browser print.
The CashierUI publishes a <code>{DEVICE_EVENTS.PRINT_RECEIPT}</code> event.
The PrinterListener listens for it and simulates interacting with a physical printer.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -125,8 +126,8 @@ export default function EventsDemoPage() {
💾 Showcase 3: Auth/Profile Sync with <code>@repo/core-storage</code>
</Title>
<Text size="sm" c="dimmed" mb="md">
ProfileSettingsUI publishes <code>AUTH:PROFILE_UPDATED</code>.
A headless StorageSyncListener persists it to IndexedDB via <code>secureIndexedDB</code>.
ProfileSettingsUI publishes <code>{AUTH_EVENTS.PROFILE_UPDATED}</code>.
StorageSyncListener silently catches it in the background and saves to IndexedDB via <code>secureIndexedDB</code>.
</Text>
{/* Headless listener — renders nothing visible */}
@@ -10,6 +10,7 @@ import {
Table,
Badge,
} from '@repo/ui/components';
import { DEVICE_EVENTS } from '../../../../core/constants/events';
// ─── Mock Receipt Data ──────────────────────────────────────────
@@ -42,7 +43,7 @@ export function CashierUI() {
const total = items.reduce((sum, item) => sum + item.qty * item.price, 0);
const handlePrint = () => {
publish('DEVICE:PRINT_RECEIPT', {
publish(DEVICE_EVENTS.PRINT_RECEIPT, {
receiptId: `RCP-${Date.now().toString(36).toUpperCase()}`,
items,
total,
@@ -1,4 +1,5 @@
import { useAppEvent } from '@repo/core-events';
import { DEVICE_EVENTS } from '../../../../core/constants/events';
import type { PrintReceiptPayload } from '@repo/core-events';
// ─── Props ──────────────────────────────────────────────────────
@@ -29,7 +30,7 @@ interface PrinterListenerProps {
* The event bus supports unlimited subscribers per event.
*/
export function PrinterListener({ onLog }: PrinterListenerProps) {
useAppEvent('DEVICE:PRINT_RECEIPT', (payload: PrintReceiptPayload) => {
useAppEvent(DEVICE_EVENTS.PRINT_RECEIPT, (payload: PrintReceiptPayload) => {
const isElectron = typeof window !== 'undefined' && !!window.electronAPI;
if (isElectron) {
@@ -87,12 +87,7 @@ export function LiveStockGrid() {
Stop Feed
</Button>
)}
<Button
variant="light"
color="info"
onClick={() => setShowAll((s) => !s)}
size="sm"
>
<Button variant="light" color="info" onClick={() => setShowAll((s) => !s)} size="sm">
{showAll ? `Show ${VISIBLE_ROWS} rows` : `Show all ${STOCK_COUNT} rows`}
</Button>
</Group>
@@ -118,7 +113,7 @@ export function LiveStockGrid() {
</Text>
{/* ── Data Grid ─────────────────────────────────────────── */}
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid var(--mantine-color-default-border)' }}>
<div style={{ maxHeight: 500, overflow: 'auto', border: '1px solid var(--app-shell-border-color)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr
@@ -133,11 +128,51 @@ export function LiveStockGrid() {
zIndex: 1,
}}
>
<th style={{ padding: '6px 8px', textAlign: 'left', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Ticker</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Price</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Change</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Volume</th>
<th style={{ padding: '6px 8px', textAlign: 'right', borderBottom: '2px solid var(--mantine-color-default-border)' }}>Renders</th>
<th
style={{
padding: '6px 8px',
textAlign: 'left',
borderBottom: '2px solid var(--app-shell-border-color)',
}}
>
Ticker
</th>
<th
style={{
padding: '6px 8px',
textAlign: 'right',
borderBottom: '2px solid var(--app-shell-border-color)',
}}
>
Price
</th>
<th
style={{
padding: '6px 8px',
textAlign: 'right',
borderBottom: '2px solid var(--app-shell-border-color)',
}}
>
Change
</th>
<th
style={{
padding: '6px 8px',
textAlign: 'right',
borderBottom: '2px solid var(--app-shell-border-color)',
}}
>
Volume
</th>
<th
style={{
padding: '6px 8px',
textAlign: 'right',
borderBottom: '2px solid var(--app-shell-border-color)',
}}
>
Renders
</th>
</tr>
</thead>
<tbody>
@@ -1,4 +1,5 @@
import { publish } from '@repo/core-events';
import { WS_EVENTS } from '../../../../core/constants/events';
// ─── Stock Tickers ──────────────────────────────────────────────
@@ -80,7 +81,7 @@ export function startMockWebSocket(config: MockWebSocketConfig): MockWebSocketHa
prices.set(id, newPrice);
// Publish to the event bus
publish('WS:STOCK_UPDATE', {
publish(WS_EVENTS.STOCK_UPDATE, {
id,
price: newPrice,
change,
@@ -1,5 +1,6 @@
import { memo, useState, useRef } from 'react';
import { useAppEvent } from '@repo/core-events';
import { WS_EVENTS } from '../../../../core/constants/events';
// ─── Props ──────────────────────────────────────────────────────
@@ -36,7 +37,7 @@ export const StockRow = memo(function StockRow({ stockId }: StockRowProps) {
const renderCountRef = useRef(0);
renderCountRef.current += 1;
useAppEvent('WS:STOCK_UPDATE', (payload) => {
useAppEvent(WS_EVENTS.STOCK_UPDATE, (payload) => {
// ── Critical filter ──────────────────────────────────────────
// This is the key performance optimization. Only the row whose
// ID matches the event payload will call setState. All other
@@ -0,0 +1,118 @@
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { apiClient } from '../../../../../../core/lib/api-client';
import type { BookingEntity } from './booking.data-services';
import type { BookingDTO } from './booking.transformer';
import {
AdvancedBookingTransformer,
type AvailabilityChartRawData,
type AvailabilityChartData,
} from './advanced-booking.transformer';
// ─── Advanced Booking Data Services ─────────────────────────────
/**
* Extended booking data services with custom methods for
* advanced booking features beyond standard CRUD.
*
* Extends {@link BaseRemoteDataServices} directly (instead of using
* `CommonRemoteDataServices`) to add domain-specific methods like
* `getAvailabilityChart()`.
*
* Uses {@link AdvancedBookingTransformer} which provides:
* - All standard DTO ↔ Entity mappings (inherited from BookingTransformer)
* - Custom `transformAvailabilityChart()` for chart data
* - Enhanced `transformGetManyResponse()` with status normalization
*
* @example
* ```ts
* // Standard CRUD (inherited, with transformer)
* const { data: bookings } = await advancedBookingServices.getMany();
* const { data: booking } = await advancedBookingServices.getOne('42');
*
* // Custom method for chart data
* const { data: chartData } = await advancedBookingServices.getAvailabilityChart({
* startDate: '2026-07-01',
* endDate: '2026-07-31',
* });
* ```
*/
class AdvancedBookingDataServices extends BaseRemoteDataServices<BookingEntity, BookingDTO> {
/**
* The concrete advanced transformer instance.
*
* Stored separately from the base `transformer` property
* to access custom methods (like `transformAvailabilityChart`)
* that aren't part of the `IDataTransformer` interface.
*/
private readonly advancedTransformer: AdvancedBookingTransformer;
constructor() {
const advancedTransformer = new AdvancedBookingTransformer();
super(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer: advancedTransformer,
});
this.advancedTransformer = advancedTransformer;
}
// ─── Custom Methods ─────────────────────────────────────────
/**
* Fetch the availability chart data for a given date range.
*
* Calls the `/bookings/availability-chart` endpoint and transforms
* the raw API response into a UI-friendly chart format using
* {@link AdvancedBookingTransformer.transformAvailabilityChart}.
*
* @param params - Date range parameters for the chart query
* @param params.startDate - Start date (ISO format, e.g., '2026-07-01')
* @param params.endDate - End date (ISO format, e.g., '2026-07-31')
* @returns Transformed chart data ready for UI rendering
*
* @example
* ```ts
* const { data } = await advancedBookingServices.getAvailabilityChart({
* startDate: '2026-07-01',
* endDate: '2026-07-31',
* });
*
* // data.dataPoints → Array of chart-ready data points
* // data.summary → Aggregated metrics for the period
* ```
*/
async getAvailabilityChart(params: {
startDate: string;
endDate: string;
}): Promise<ApiResponse<AvailabilityChartData>> {
const response = await this.customRequest<AvailabilityChartRawData>({
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,
};
}
}
// ─── Singleton Export ────────────────────────────────────────────
/**
* Pre-configured advanced booking data services instance.
*
* Use this when you need both standard CRUD operations and
* custom methods like `getAvailabilityChart()`.
*
* For standard CRUD-only usage, prefer `bookingServices` from
* `booking.data-services.ts` instead.
*/
export const advancedBookingServices = new AdvancedBookingDataServices();
@@ -0,0 +1,159 @@
import { BookingTransformer } from './booking.transformer';
import type { BookingDTO } from './booking.transformer';
import type { BookingEntity } from './booking.data-services';
// ─── Advanced Types ─────────────────────────────────────────────
/**
* Raw availability chart data as returned by the API.
*
* The backend returns a flat structure with snake_case keys
* and ISO date strings. This needs to be transformed into
* a more UI-friendly shape for chart rendering.
*/
export interface AvailabilityChartRawData {
dates: Array<{
date_iso: string;
available_rooms: number;
total_rooms: number;
occupancy_rate: number;
revenue_per_room: number;
}>;
summary: {
avg_occupancy_rate: number;
total_revenue: number;
period_start: string;
period_end: string;
};
}
/**
* UI-friendly availability chart data.
*
* Pre-computed for direct rendering in chart components
* with camelCase fields, formatted labels, and derived metrics.
*/
export interface AvailabilityChartData {
/** Data points ready for chart rendering. */
dataPoints: Array<{
/** Formatted date label (e.g., 'Mon, Jul 1'). */
label: string;
/** ISO date string for programmatic use. */
dateISO: string;
/** Number of rooms available. */
availableRooms: number;
/** Total room capacity. */
totalRooms: number;
/** Occupancy rate as a percentage (0-100). */
occupancyRate: number;
/** Revenue per available room. */
revenuePerRoom: number;
/** Whether the day is a high-demand day (>80% occupancy). */
isHighDemand: boolean;
}>;
/** Aggregated summary metrics for the period. */
summary: {
averageOccupancy: number;
totalRevenue: number;
periodStart: string;
periodEnd: string;
/** Number of high-demand days in the period. */
highDemandDays: number;
};
}
// ─── Advanced Booking Transformer ───────────────────────────────
/**
* Extended booking transformer with additional custom methods
* for non-CRUD data transformations.
*
* Inherits all standard DTO ↔ Entity mapping from
* {@link BookingTransformer} and adds domain-specific
* transformations for advanced features like availability charts.
*
* **When to extend vs. create new:**
* - Extend when the new transformer shares the same entity/DTO pair
* and you need additional transformation methods
* - Create a new transformer when the entity/DTO types are different
*
* @example
* ```ts
* const transformer = new AdvancedBookingTransformer();
*
* // Standard CRUD mapping (inherited)
* const entity = transformer.transformToEntity(bookingDTO);
*
* // Custom chart transformation (new)
* const chartData = transformer.transformAvailabilityChart(rawChartData);
* ```
*/
export class AdvancedBookingTransformer extends BookingTransformer {
/**
* Transform raw availability chart data from the API into a
* UI-friendly format for chart rendering.
*
* Performs the following transformations:
* 1. Maps snake_case fields to camelCase
* 2. Formats date strings into human-readable labels
* 3. Computes derived `isHighDemand` flag (>80% occupancy)
* 4. Aggregates `highDemandDays` count in the summary
*
* @param rawData - Raw chart data from the `/bookings/availability-chart` endpoint
* @returns Transformed chart data ready for UI rendering
*/
transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData {
const HIGH_DEMAND_THRESHOLD = 80;
const dataPoints = rawData.dates.map((item) => {
const date = new Date(item.date_iso);
const isHighDemand = item.occupancy_rate > HIGH_DEMAND_THRESHOLD;
return {
label: date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
}),
dateISO: item.date_iso,
availableRooms: item.available_rooms,
totalRooms: item.total_rooms,
occupancyRate: item.occupancy_rate,
revenuePerRoom: item.revenue_per_room,
isHighDemand,
};
});
const highDemandDays = dataPoints.filter((dp) => dp.isHighDemand).length;
return {
dataPoints,
summary: {
averageOccupancy: rawData.summary.avg_occupancy_rate,
totalRevenue: rawData.summary.total_revenue,
periodStart: rawData.summary.period_start,
periodEnd: rawData.summary.period_end,
highDemandDays,
},
};
}
/**
* Enhanced getMany response that also normalizes status values.
*
* Demonstrates overriding an inherited hook to add
* additional processing on top of the base transformation.
*
* @param dtos - Array of booking DTOs from the API
* @returns Transformed entities with normalized status
*/
override transformGetManyResponse(dtos: BookingDTO[]): BookingEntity[] {
return super.transformGetManyResponse(dtos).map((entity) => ({
...entity,
// Normalize 'cancelled' vs 'canceled' from different API versions
status: entity.status === ('canceled' as BookingEntity['status'])
? 'cancelled'
: entity.status,
}));
}
}
@@ -1,6 +1,8 @@
import { CommonRemoteDataServices } from '@repo/core-api/data-services';
import type { BaseEntity } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../core/lib/api-client';
import { BookingTransformer } from './booking.transformer';
import type { BookingDTO } from './booking.transformer';
// ─── Domain Entity ──────────────────────────────────────────────
@@ -22,19 +24,35 @@ export interface BookingEntity extends BaseEntity {
// ─── Data Services Instance ─────────────────────────────────────
/**
* Booking data services — wired to the enterprise `apiClient`.
* Booking data services — wired to the enterprise `apiClient`
* with automatic DTO ↔ Entity transformation.
*
* All requests flow through the full interceptor chain:
* Faro tracing → Bearer token injection → ApiError normalization.
*
* The injected {@link BookingTransformer} automatically:
* - Maps snake_case API responses to camelCase entities on `getOne`/`getMany`
* - Maps camelCase entity payloads to snake_case DTOs on `create`/`edit`
* - Strips `id` from create payloads
* - Computes `durationNights` on `getOne` responses
*
* @example
* ```ts
* const { data } = await bookingServices.getMany({ params: { page: 1 } });
* // data is BookingEntity[] with camelCase fields
*
* const { data: booking } = await bookingServices.getOne('42');
* // booking is BookingEntity with computed durationNights
*
* await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... });
* // Payload is automatically transformed to { booking_code: 'BK001', customer_name: 'Alice', ... }
*
* await bookingServices.confirmProcessTransaction('42');
* ```
*/
export const bookingServices = new CommonRemoteDataServices<BookingEntity>(apiClient, {
export const bookingServices = new CommonRemoteDataServices<BookingEntity, BookingDTO>(apiClient, {
apiUrl: '/bookings',
moduleKey: 'BOOKING',
transformer: new BookingTransformer(),
});
@@ -0,0 +1,123 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import type { BookingEntity } from './booking.data-services';
// ─── Booking DTO (API Response Shape) ───────────────────────────
/**
* Raw booking data as returned by the API.
*
* Uses snake_case field names matching the backend's JSON serialization.
* This DTO is never used directly in UI components — it is transformed
* into a {@link BookingEntity} by the {@link BookingTransformer}.
*/
export interface BookingDTO {
id?: string;
booking_code: string;
customer_name: string;
check_in_date: string;
check_out_date: string;
status: 'pending' | 'confirmed' | 'cancelled';
total_amount: number;
}
// ─── Booking Transformer ────────────────────────────────────────
/**
* Transforms between the API's `BookingDTO` (snake_case) and
* the frontend's `BookingEntity` (camelCase).
*
* Handles:
* - Field name mapping (snake_case ↔ camelCase)
* - Computed field derivation (e.g., `durationNights` on `getOne`)
* - Payload sanitization (e.g., stripping `id` on create)
*
* @example
* ```ts
* const transformer = new BookingTransformer();
*
* // API response → Domain entity
* const entity = transformer.transformToEntity({
* id: '42',
* booking_code: 'BK042',
* customer_name: 'Alice',
* check_in_date: '2026-07-01',
* check_out_date: '2026-07-03',
* status: 'confirmed',
* total_amount: 500000,
* });
* // → { id: '42', bookingCode: 'BK042', customerName: 'Alice', ... }
* ```
*/
export class BookingTransformer extends BaseDataTransformer<BookingEntity, BookingDTO> {
/**
* Map an API booking DTO to a frontend booking entity.
*
* @param dto - Raw booking data from the API
* @returns Mapped booking entity with camelCase fields
*/
override transformToEntity(dto: BookingDTO): BookingEntity {
return {
id: dto.id,
bookingCode: dto.booking_code,
customerName: dto.customer_name,
checkInDate: dto.check_in_date,
checkOutDate: dto.check_out_date,
status: dto.status,
totalAmount: dto.total_amount,
};
}
/**
* Map a frontend booking entity to an API booking DTO.
*
* @param entity - Booking entity from the frontend
* @returns Mapped booking DTO with snake_case fields
*/
override transformToDTO(entity: BookingEntity): BookingDTO {
return {
id: entity.id as string,
booking_code: entity.bookingCode,
customer_name: entity.customerName,
check_in_date: entity.checkInDate,
check_out_date: entity.checkOutDate,
status: entity.status,
total_amount: entity.totalAmount,
};
}
/**
* Transform a single booking response with computed fields.
*
* Adds `durationNights` as a derived convenience field
* that is only relevant when viewing a single booking detail.
*
* @param dto - Raw booking DTO from the API
* @returns Booking entity with computed fields
*/
override transformGetOneResponse(dto: BookingDTO): BookingEntity {
const entity = this.transformToEntity(dto);
const checkIn = new Date(dto.check_in_date);
const checkOut = new Date(dto.check_out_date);
const durationMs = checkOut.getTime() - checkIn.getTime();
const durationNights = Math.max(0, Math.ceil(durationMs / (1000 * 60 * 60 * 24)));
return {
...entity,
// Attach computed field via type assertion since
// durationNights is a view-layer convenience
...(durationNights > 0 ? { durationNights } : {}),
};
}
/**
* Strip `id` from create payloads since the backend generates IDs.
*
* @param entity - Partial booking entity from the create form
* @returns Sanitized DTO payload without `id`
*/
override transformCreatePayload(entity: Partial<BookingEntity>): Partial<BookingDTO> {
const dto = this.transformToDTO(entity as BookingEntity);
const { id: _, ...rest } = dto;
return rest;
}
}
@@ -2,9 +2,9 @@ import { useState, useEffect, useCallback } from 'react';
import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n';
import { secureIndexedDB, AppStorageKey } from '../../../../../../core/storage/local';
// Decentralized locale imports
import bookingId from '../locales/id/booking.json';
import bookingEn from '../locales/en/booking.json';
// Decentralized languages imports
import bookingId from '../languages/id/booking.json';
import bookingEn from '../languages/en/booking.json';
// ─── Shared Styles ──────────────────────────────────────────────
+12
View File
@@ -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')),
},
};
+242 -202
View File
@@ -1,38 +1,32 @@
import { useState, useMemo, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import {
Box,
Button,
Group,
SegmentedControl,
Stack,
Text,
Paper,
Switch,
Select,
Burger
} from '@repo/ui/components';
import {
CoreAppShell,
CorePageContainer,
CoreAppShellConfig,
LayoutVariant,
import { Box, Button, Group, SegmentedControl, Stack, Text, Paper, Switch, Select, Burger } from '@repo/ui/components';
import {
CoreAppShell,
CorePageContainer,
CoreAppShellConfig,
LayoutVariant,
DesktopCollapseVariant,
useCoreAppShell
useCoreAppShell,
} from '@repo/ui/components';
import { Home, BarChart2, Settings as SettingsIcon } from 'lucide-react';
// Sub-component to test hook methods
function LayoutControls() {
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } = useCoreAppShell();
const { toggleDesktop, toggleMobile, sidebarVariant, setSidebarVariant, toggleAside, toggleNavbarPanel } =
useCoreAppShell();
return (
<Group mb="md">
<Button onClick={toggleDesktop} variant="default" size="xs">Toggle Desktop Sidebar</Button>
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">Toggle Mobile Sidebar</Button>
<Button
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
<Button onClick={toggleDesktop} variant="default" size="xs">
Toggle Desktop Sidebar
</Button>
<Button onClick={toggleMobile} variant="default" size="xs" hiddenFrom="sm">
Toggle Mobile Sidebar
</Button>
<Button
onClick={() => setSidebarVariant(sidebarVariant === 'expanded' ? 'mini' : 'expanded')}
variant="default"
size="xs"
>
@@ -51,10 +45,19 @@ function LayoutControls() {
function MockHeader() {
const { mobileOpened, toggleMobile } = useCoreAppShell();
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>
<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>
);
@@ -63,10 +66,16 @@ function MockHeader() {
function MockMobileDrawer() {
return (
<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">
<Button variant="light" color="yellow" justify="flex-start" fullWidth>Mobile Dashboard</Button>
<Button variant="light" color="yellow" justify="flex-start" fullWidth>Mobile Settings</Button>
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
Mobile Dashboard
</Button>
<Button variant="light" color="yellow" justify="flex-start" fullWidth>
Mobile Settings
</Button>
</Stack>
</Box>
);
@@ -78,7 +87,9 @@ function SettingRow({ title, description, control }: { title: string; descriptio
<Group justify="space-between" wrap="nowrap">
<Stack gap={0}>
<Text fw={500}>{title}</Text>
<Text c="dimmed" size="sm">{description}</Text>
<Text c="dimmed" size="sm">
{description}
</Text>
</Stack>
<Box>{control}</Box>
</Group>
@@ -104,7 +115,7 @@ export default function ShellDemo() {
withAside: withAside ? undefined : false,
withFooter: withFooter ? undefined : false,
withDoubleSidebar,
}
},
};
}, [layoutVariant, collapseVariant, withUtilityBar, withAside, withFooter, withDoubleSidebar]);
@@ -123,184 +134,213 @@ export default function ShellDemo() {
withFooter: withFooter ? undefined : false,
withDoubleSidebar,
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,
}
},
};
return (
<CoreAppShell
config={config}
slots={{
utilityBar: (
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
<Text size="xs" fw={600}>Mock Utility Bar (bg="blue.1")</Text>
</Group>
),
header: <MockHeader />,
sidebarMobile: <MockMobileDrawer />,
sidebar: !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 Standard Navbar (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,
sidebarRail: withDoubleSidebar ? (
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
Mock Rail (bg="orange.1")
</Text>
<Home size={24} />
<BarChart2 size={24} />
<SettingsIcon size={24} />
</Stack>
) : undefined,
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>
slots={{
utilityBar: (
<Group h="100%" px="md" justify="flex-end" bg="blue.1" c="blue.9">
<Text size="xs" fw={600}>
Mock Utility Bar (bg="blue.1")
</Text>
</Group>
),
header: <MockHeader />,
sidebarMobile: <MockMobileDrawer />,
sidebar: !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 Standard Navbar (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,
sidebarRail: withDoubleSidebar ? (
<Stack align="center" gap="lg" pt="md" h="100%" bg="orange.1" c="orange.9">
<Text size="xs" fw={700} style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}>
Mock Rail (bg="orange.1")
</Text>
<Home size={24} />
<BarChart2 size={24} />
<SettingsIcon size={24} />
</Stack>
</CorePageContainer>
</CoreAppShell>
) : undefined,
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(--app-shell-border-color)' }}
>
<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>
);
}
+60 -7
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useState, Suspense } from 'react';
import { useNavigate } from 'react-router-dom';
import { ColorSchemeType, DensityType } from '@repo/ui/provider';
import { useThemeStore } from '../../core/stores/theme.store';
import {
Button,
Card,
@@ -22,8 +23,10 @@ import {
Tabs,
Box,
Paper,
StatusBadge,
STATUS_DATA,
} 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 { useTranslation } from '@repo/core-i18n';
import PrinterList from './printer-list';
@@ -31,15 +34,15 @@ import ExamplePage from './example/example.page';
import EventsDemoPage from './events-demo';
import PouchSample from './pouch-sample';
import FormDemoView from './example/features/form-demo';
import { COMPONENTS_REGISTRY } from './registry';
interface ShowcaseViewProps {
colorScheme: ColorSchemeType;
setColorScheme: (val: ColorSchemeType) => void;
density: DensityType;
setDensity: (val: DensityType) => void;
}
export default function ShowcaseView({ colorScheme, setColorScheme, density, setDensity }: ShowcaseViewProps) {
export default function ShowcaseView({ density, setDensity }: ShowcaseViewProps) {
const { colorScheme, setColorScheme } = useThemeStore();
const [activeTab, setActiveTab] = useState<string | null>('ui-components');
const { i18n } = useTranslation();
const navigate = useNavigate();
@@ -52,6 +55,11 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
];
const getSubtitle = () => {
const registryItem = Object.values(COMPONENTS_REGISTRY).find((item) => item.id === activeTab);
if (registryItem) {
return registryItem.description;
}
switch (activeTab) {
case 'rbac':
return 'Role-Based Access Control and Permissions';
@@ -83,6 +91,8 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
onChange={(val) => {
if (val === 'layout-engine') {
navigate('/shell-demo');
} else if (val === 'apps') {
navigate('/app');
} else {
setActiveTab(val);
}
@@ -95,7 +105,7 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
list: {
minWidth: 260,
padding: '1rem',
borderRight: '1px solid var(--mantine-color-default-border)',
borderRight: '1px solid var(--app-shell-border-color)',
backgroundColor: 'var(--mantine-color-default-element-bg)',
},
panel: { flex: 1, display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' },
@@ -137,6 +147,18 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
<Tabs.Tab value="hardware" leftSection={<Printer size={18} />}>
Hardware
</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.Panel value={activeTab as string}>
@@ -229,6 +251,25 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
</Group>
</div>
<Divider />
<div>
<Title order={4} mb="xs">
Enterprise Status Badges
</Title>
<Text size="sm" c="dimmed" mb="md">
Pre-configured status badges for transaction and master data.
</Text>
<Group>
<StatusBadge status={STATUS_DATA.DRAFT} />
<StatusBadge status={STATUS_DATA.PENDING} />
<StatusBadge status={STATUS_DATA.PROCESS} />
<StatusBadge status={STATUS_DATA.APPROVED} />
<StatusBadge status={STATUS_DATA.CANCELLED} />
<StatusBadge status={STATUS_DATA.WAITING} />
<StatusBadge status={STATUS_DATA.ON_HOLD} />
<StatusBadge status={STATUS_DATA.REFUNDED} />
</Group>
</div>
<Divider />
<div>
<Title order={4} mb="md">
Buttons
@@ -374,7 +415,19 @@ export default function ShowcaseView({ colorScheme, setColorScheme, density, set
</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>
</Box>
</Tabs.Panel>
+6
View File
@@ -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="brand" size="lg" type="dots" />
</Stack>
</div>
);
};
+21
View File
@@ -0,0 +1,21 @@
export const LAYOUT_EVENTS = {
TOGGLE_HISTORY_DRAWER: 'LAYOUT:TOGGLE_HISTORY_DRAWER',
TOGGLE_BOOKMARK_DRAWER: 'LAYOUT:TOGGLE_BOOKMARK_DRAWER',
} as const;
export const DEVICE_EVENTS = {
PRINT_RECEIPT: 'DEVICE:PRINT_RECEIPT',
} as const;
export const WS_EVENTS = {
STOCK_UPDATE: 'WS:STOCK_UPDATE',
} as const;
export const AUTH_EVENTS = {
PROFILE_UPDATED: 'AUTH:PROFILE_UPDATED',
} as const;
export const APP_EVENTS = {
INITIALIZED: 'APP:INITIALIZED',
ERROR: 'APP:ERROR',
} as const;
+12 -2
View File
@@ -3,9 +3,19 @@
* DO NOT use `import.meta.env` directly in components. Import this `ENV` object instead.
*/
export const ENV = {
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api',
APP_ENV: (import.meta.env.VITE_APP_ENV || 'development') as 'development' | 'staging' | 'production',
DEFAULT_LANGUAGE: 'en',
IS_PROD: import.meta.env.VITE_APP_ENV === 'production',
APP_ENV: (import.meta.env.VITE_APP_ENV || 'development') as 'development' | 'staging' | 'production',
APP_NAME: import.meta.env.VITE_APP_NAME || 'fe-monorepo-web',
APP_VERSION: import.meta.env.VITE_APP_VERSION || '0.0.0',
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api',
// Observability
FARO_URL: import.meta.env.VITE_FARO_URL || 'https://telemetry.eigen.co.id/collect',
OTLP_TRACE_URL: import.meta.env.VITE_OTLP_TRACE_URL || 'https://telemetry.eigen.co.id/v1/traces',
// CouchDB Connection
COUCHDB_BASE_URL: import.meta.env.VITE_COUCHDB_BASE_URL || 'http://localhost:5984',
+13 -4
View File
@@ -1,5 +1,7 @@
import { createHttpClient } from '@repo/core-api/http-client';
import { faroAdapter } from '@repo/core-api/observability';
import { ENV } from '../environment';
import { AppStorageKey, secureStorage } from '../storage/local';
/**
* Enterprise HTTP client for `apps/web`.
@@ -15,17 +17,24 @@ import { faroAdapter } from '@repo/core-api/observability';
*/
export const apiClient = createHttpClient(
{
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:8080/api/v1',
baseURL: ENV.API_BASE_URL,
timeout: 15000,
observability: faroAdapter,
},
{
// ── Auth Interceptor ──────────────────────────────────────────
onRequest: async (config) => {
config.headers['ex-app-name'] = ENV.APP_NAME;
config.headers['ex-app-version'] = ENV.APP_VERSION;
config.headers['ex-timezone-offset-minutes'] = new Date().getTimezoneOffset();
config.headers['ex-timezone-offset-hours'] = Math.floor(new Date().getTimezoneOffset() / 60);
const language = await secureStorage.getItem<string>(AppStorageKey.LANGUAGE);
config.headers['ex-language'] = language ?? ENV.DEFAULT_LANGUAGE;
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
+12 -3
View File
@@ -2,11 +2,15 @@ import { createLocalStorage, createIndexedDB } from '@repo/core-storage';
export const AppStorageKey = {
USER_PROFILE: 'user_profile',
LOCALE: 'app_locale',
LANGUAGE: 'app_language',
THEME: 'app_theme',
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
MOCK_DB_COMPANY_A: 'mock_db_company_a',
OFFLINE_DRAFT: 'offline_draft',
SYSTEM_SETTINGS: 'system_settings',
HISTORY_PAGE: 'history_page',
BOOKMARK_PAGE: 'bookmark_page',
} as const;
export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey];
@@ -18,17 +22,22 @@ export const ENCRYPTED_KEYS = new Set<AppStorageKeyValue>([
]);
export const PLAIN_KEYS = new Set<AppStorageKeyValue>([
AppStorageKey.LOCALE,
AppStorageKey.LANGUAGE,
AppStorageKey.THEME,
AppStorageKey.MOCK_DB_COMPANY_A,
AppStorageKey.OFFLINE_DRAFT,
AppStorageKey.SYSTEM_SETTINGS,
AppStorageKey.HISTORY_PAGE,
AppStorageKey.BOOKMARK_PAGE,
]);
export const secureStorage = createLocalStorage<AppStorageKeyValue>({
encryptedKeys: ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS,
});
export const secureIndexedDB = createIndexedDB<AppStorageKeyValue>({
dbName: 'eigen_erp_db',
dbName: 'e_apps_db',
storeName: 'web_store',
encryptedKeys: ENCRYPTED_KEYS,
plainTextKeys: PLAIN_KEYS,
+30
View File
@@ -0,0 +1,30 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { ColorSchemeType } from '@repo/ui/provider';
import { AppStorageKey } from '../storage/local';
interface ThemeState {
colorScheme: ColorSchemeType;
setColorScheme: (scheme: ColorSchemeType) => void;
}
/**
* Global theme store with localStorage persistence.
*
* Uses the Zustand `persist` middleware so the chosen color scheme
* survives page refreshes. The storage key (`app_theme`) is
* intentionally kept in sync with `AppStorageKey.THEME` — both
* write to the same localStorage entry.
*/
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
colorScheme: 'light',
setColorScheme: (scheme) => set({ colorScheme: scheme }),
}),
{
name: AppStorageKey.THEME, // matches AppStorageKey.THEME
storage: createJSONStorage(() => localStorage),
},
),
);

Some files were not shown because too many files have changed in this diff Show More