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"]
}
+35 -29
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,12 +87,10 @@ const config = withMermaid(
// Fix cascading CJS/ESM SyntaxErrors caused by Vite dynamically discovering mermaid
vite: {
optimizeDeps: {
include: [
'mermaid'
]
}
}
})
include: ['mermaid'],
},
},
}),
);
// Pnpm strict workspace workaround:
@@ -94,7 +100,7 @@ const config = withMermaid(
// 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()` |
+68 -29
View File
@@ -7,6 +7,7 @@
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.
@@ -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>
```
---
@@ -205,12 +243,9 @@ export function AuthProvider({ children }) {
// 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);
}
}
@@ -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**.
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": {
@@ -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')),
},
};
+235 -195
View File
@@ -1,36 +1,30 @@
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 { 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={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"
@@ -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