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:
Vendored
+1
-1
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,7 +31,16 @@ 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/' },
|
||||
@@ -45,6 +52,7 @@ const config = withMermaid(
|
||||
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' },
|
||||
],
|
||||
},
|
||||
@@ -64,11 +72,11 @@ const config = withMermaid(
|
||||
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>'
|
||||
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()` |
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function LoginPage() {
|
||||
return <div>Login Page</div>;
|
||||
}
|
||||
+16
-17
@@ -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,
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { FullPageEntity, FullPageDTO } from '../entities';
|
||||
|
||||
/**
|
||||
* Full Page Remote Data Transformer
|
||||
*
|
||||
* Responsible for transforming data between the raw API data transfer objects (DTOs)
|
||||
* and the frontend domain entities for the full-page module. By extending the base transformer,
|
||||
* it ensures strict type safety and decouples data parsing logic from the API service layer.
|
||||
*
|
||||
* Implementers must define the core mapping rules (`transformToEntity`, `transformToDTO`)
|
||||
* and can freely add custom transformation methods for specific API responses.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* export class FullPageRemoteDataTransformer extends BaseDataTransformer<FullPageEntity, FullPageDTO> {
|
||||
* // Map snake_case API payload to camelCase frontend entity
|
||||
* public transformToEntity(dto: FullPageDTO): FullPageEntity {
|
||||
* return {
|
||||
* id: dto.id,
|
||||
* documentNumber: dto.document_number,
|
||||
* createdAt: new Date(dto.created_at),
|
||||
* // ... other property mappings
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* // Map camelCase frontend entity back to snake_case API payload
|
||||
* public transformToDTO(entity: FullPageEntity): FullPageDTO {
|
||||
* return {
|
||||
* id: entity.id,
|
||||
* document_number: entity.documentNumber,
|
||||
* // ... other property mappings
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* // Example of adding a custom transformation method for a specific feature
|
||||
* public transformMetrics(rawData: any): MetricsEntity {
|
||||
* return {
|
||||
* totalActive: rawData.total_active_count ?? 0,
|
||||
* };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FullPageRemoteDataTransformer extends BaseDataTransformer<FullPageEntity, FullPageDTO> {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
import { compose, required, rangeLength } from '@repo/ui/validators';
|
||||
|
||||
/**
|
||||
* Factory function to generate the Zod validation schema for the Full Page module.
|
||||
* It accepts a translation object `t` to maintain domain purity while supporting
|
||||
* dynamic, localized error messages (i18n).
|
||||
*
|
||||
* @param t - The translation object (typically derived from `useTranslation()` in the UI layer).
|
||||
* @returns The configured Zod object schema.
|
||||
*/
|
||||
export const createFullPageSchema = (t: any) => {
|
||||
return z.object({
|
||||
// Code: Required, length between 3 and 10 characters.
|
||||
code: compose(z.string(), required(t.fields.code), rangeLength(3, 10, t.fields.code)),
|
||||
|
||||
// Name: Required, length between 3 and 50 characters.
|
||||
name: compose(z.string(), required(t.fields.name), rangeLength(3, 50, t.fields.name)),
|
||||
|
||||
// Status: Required selection (typically from a dropdown/select).
|
||||
status: compose(z.string(), required(t.fields.status)),
|
||||
|
||||
// Description: Optional text field.
|
||||
description: z.string().optional(),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Data Transfer Object (DTO) for the Full Page form.
|
||||
* This type is automatically inferred from the Zod schema factory.
|
||||
* Use this type as a generic for form initialization, e.g., `useForm<FullPageFormDTO>()`.
|
||||
*/
|
||||
export type FullPageFormDTO = z.infer<ReturnType<typeof createFullPageSchema>>;
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
+131
@@ -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>
|
||||
);
|
||||
}
|
||||
+21
@@ -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>
|
||||
);
|
||||
}
|
||||
+146
@@ -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>
|
||||
);
|
||||
}
|
||||
+46
@@ -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';
|
||||
+25
@@ -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,
|
||||
});
|
||||
+45
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Single Page Management",
|
||||
"fields": {
|
||||
"status": "Status",
|
||||
"name": "Name",
|
||||
"code": "Code",
|
||||
"description": "Description"
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Manajemen Halaman Tunggal",
|
||||
"fields": {
|
||||
"status": "Status",
|
||||
"name": "Nama",
|
||||
"code": "Kode",
|
||||
"description": "Deskripsi"
|
||||
}
|
||||
}
|
||||
+99
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ModuleLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<ExamplePage />} />
|
||||
<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
|
||||
|
||||
+118
@@ -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();
|
||||
+159
@@ -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 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { lazy } from 'react';
|
||||
import { Layers } from 'lucide-react';
|
||||
|
||||
export const COMPONENTS_REGISTRY = {
|
||||
actionTools: {
|
||||
id: 'action-tools',
|
||||
name: 'Action Tools',
|
||||
description: 'Showcase for PageActions and RowActions components',
|
||||
icon: Layers,
|
||||
component: lazy(() => import('./components/ActionToolsShowcase')),
|
||||
},
|
||||
};
|
||||
@@ -1,36 +1,30 @@
|
||||
import { useState, useMemo, ReactNode } from 'react';
|
||||
import { 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]);
|
||||
|
||||
@@ -125,29 +136,46 @@ export default function ShellDemo() {
|
||||
persistState: false, // Don't persist for the demo to avoid confusing other showcases
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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,
|
||||
@@ -162,29 +190,53 @@ export default function ShellDemo() {
|
||||
</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>
|
||||
<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>
|
||||
<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 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
|
||||
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
|
||||
@@ -253,38 +305,27 @@ export default function ShellDemo() {
|
||||
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)}
|
||||
/>
|
||||
<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)}
|
||||
/>
|
||||
}
|
||||
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)}
|
||||
/>
|
||||
}
|
||||
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>
|
||||
<Text fw={700} size="lg">
|
||||
Configuration Preview
|
||||
</Text>
|
||||
<Button
|
||||
variant={copied ? 'filled' : 'light'}
|
||||
color={copied ? 'teal' : 'blue'}
|
||||
@@ -298,7 +339,6 @@ export default function ShellDemo() {
|
||||
<pre style={{ margin: 0 }}>{JSON.stringify(liveConfig, null, 2)}</pre>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
</Stack>
|
||||
</CorePageContainer>
|
||||
</CoreAppShell>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user