From 1c2090f4fb6a5e5a9e576e3d36c58aeec32f2072 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:13:09 +0700 Subject: [PATCH 01/35] feat(core-api): implement abstract data transformer architecture - **Core API**: Introduced `IDataTransformer` interface and `BaseDataTransformer` abstract class. - **Data Services**: Updated `BaseRemoteDataServices` and `DataServicesConfig` to support optional transformer injection. CRUD methods now auto-transform data mapping (DTO <-> Entity) when a transformer is provided. - **Generics**: Updated `CommonRemoteDataServices` to forward the `TDTO` generic for strict type safety. Fixed `ApiResponse` casting and excess property type checks. - **Showcase/Samples**: Created `BookingTransformer` (snake_case to camelCase mapping) and injected it into `booking.data-services.ts`. - **Advanced Showcase**: Added `AdvancedBookingTransformer` and `AdvancedBookingService` to demonstrate extending base transformers with custom methods (e.g., `getAvailabilityChart`). - **Documentation**: Added comprehensive developer guide at `apps/docs-dev/src/packages/core-api/transformers.md`. - **Testing**: Added 16 new unit tests for transformer integrations (identity and mock scenarios). Note: The implementation is 100% backward compatible. All 49/49 tests pass and the TypeScript typecheck is entirely clean. --- .../src/packages/core-api/transformers.md | 323 ++++++++++++++ .../data/advanced-booking.data-services.ts | 118 ++++++ .../data/advanced-booking.transformer.ts | 159 +++++++ .../booking/data/booking.data-services.ts | 22 +- .../booking/data/booking.transformer.ts | 123 ++++++ .../data-services/base-data.transformer.ts | 221 ++++++++++ .../base-remote.data-services.test.ts | 397 ++++++++++++++++++ .../base-remote.data-services.ts | 102 ++++- .../common-remote.data-services.ts | 22 +- packages/core-api/src/data-services/index.ts | 3 + packages/core-api/src/data-services/types.ts | 23 +- 11 files changed, 1494 insertions(+), 19 deletions(-) create mode 100644 apps/docs-dev/src/packages/core-api/transformers.md create mode 100644 apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts create mode 100644 apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts create mode 100644 apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts create mode 100644 packages/core-api/src/data-services/base-data.transformer.ts diff --git a/apps/docs-dev/src/packages/core-api/transformers.md b/apps/docs-dev/src/packages/core-api/transformers.md new file mode 100644 index 0000000..614eb39 --- /dev/null +++ b/apps/docs-dev/src/packages/core-api/transformers.md @@ -0,0 +1,323 @@ +# Data Transformers + +> **Purpose:** Separate data transformation logic from API call logic, enabling clean DTO ↔ Entity mapping with type safety. +> +> **Location:** `packages/core-api/src/data-services/base-data.transformer.ts` + +--- + +## Why Data Transformers? + +In enterprise applications, the shape of data returned by the API (DTOs) often differs from the shape used in the frontend (Domain Entities). Common differences include: + +| API (DTO) | Frontend (Entity) | +| ------------------------------ | ---------------------------- | +| `snake_case` field names | `camelCase` field names | +| Deeply nested structures | Flattened/normalized shapes | +| Raw ISO date strings | Parsed `Date` objects | +| No computed fields | Derived/computed properties | +| Backend-specific enums | Frontend-friendly enums | + +Without transformers, this mapping logic leaks into components, hooks, and services — violating the **Single Responsibility Principle** and making the codebase harder to test and maintain. + +### Benefits + +- **Separation of Concerns** — Transformation logic lives in one place, not scattered across components +- **Testability** — Transformers are pure functions, trivially unit-testable +- **Reusability** — Same transformer can be used across multiple services or contexts +- **Type Safety** — Two generic parameters (`TEntity`, `TDTO`) enforce correct mapping at compile time +- **Backward Compatible** — Transformers are optional; existing services work unchanged + +--- + +## Architecture + +```mermaid +graph LR + classDef api fill:#f59e0b,stroke:#b45309,color:#fff + classDef transformer fill:#6366f1,stroke:#4338ca,color:#fff + classDef entity fill:#10b981,stroke:#047857,color:#fff + classDef service fill:#3b82f6,stroke:#2563eb,color:#fff + + API["🌐 REST API
(snake_case DTOs)"]:::api + SVC["BaseRemoteDataServices
(execute, getOne, getMany, ...)"]:::service + TFM["Data Transformer
(transformToEntity / transformToDTO)"]:::transformer + ENT["Domain Entity
(camelCase, computed fields)"]:::entity + + API -->|"Response (DTO)"| SVC + SVC -->|"dto"| TFM + TFM -->|"entity"| ENT + + ENT -->|"entity"| TFM + TFM -->|"dto"| SVC + SVC -->|"Request (DTO)"| API +``` + +**Data flows:** +- **API → Frontend:** Response DTO → `transformToEntity()` → Domain Entity +- **Frontend → API:** Domain Entity → `transformToDTO()` → Request DTO + +--- + +## Quick Start + +### 1. Define Your Types + +```typescript +// Domain Entity (what your UI uses) +interface BookingEntity extends BaseEntity { + bookingCode: string; + customerName: string; + checkInDate: string; +} + +// API DTO (what the backend returns) +interface BookingDTO { + id?: string; + booking_code: string; + customer_name: string; + check_in_date: string; +} +``` + +### 2. Create a Transformer + +```typescript +import { BaseDataTransformer } from '@repo/core-api/data-services'; + +class BookingTransformer extends BaseDataTransformer { + transformToEntity(dto: BookingDTO): BookingEntity { + return { + id: dto.id, + bookingCode: dto.booking_code, + customerName: dto.customer_name, + checkInDate: dto.check_in_date, + }; + } + + transformToDTO(entity: BookingEntity): BookingDTO { + return { + id: entity.id, + booking_code: entity.bookingCode, + customer_name: entity.customerName, + check_in_date: entity.checkInDate, + }; + } +} +``` + +### 3. Inject into Data Services + +```typescript +import { CommonRemoteDataServices } from '@repo/core-api/data-services'; + +const bookingServices = new CommonRemoteDataServices( + apiClient, + { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer: new BookingTransformer(), + }, +); + +// Now all CRUD methods automatically transform: +const { data } = await bookingServices.getOne('42'); +// data is BookingEntity (camelCase) ✓ + +await bookingServices.create({ bookingCode: 'BK001', customerName: 'Alice', ... }); +// Payload is sent as { booking_code: 'BK001', customer_name: 'Alice', ... } ✓ +``` + +--- + +## Interface Reference + +### `IDataTransformer` + +The minimal contract for bidirectional data transformation. + +```typescript +interface IDataTransformer { + transformToEntity(dto: TDTO): TEntity; + transformToDTO(entity: TEntity): TDTO; + + // Optional operation-specific hooks + transformGetOneResponse?(dto: TDTO): TEntity; + transformGetManyResponse?(dtos: TDTO[]): TEntity[]; + transformCreatePayload?(entity: Partial): Partial; + transformEditPayload?(entity: Partial): Partial; +} +``` + +### `BaseDataTransformer` + +Abstract class implementing `IDataTransformer` with sensible defaults. + +| Method | Default Behavior | Override When | +| ------------------------- | ---------------------------------------- | ------------------------------------------ | +| `transformToEntity` | Identity cast (passthrough) | Always — this is the core mapping | +| `transformToDTO` | Identity cast (passthrough) | Always — this is the core mapping | +| `transformGetOneResponse` | Delegates to `transformToEntity` | `getOne` needs computed/derived fields | +| `transformGetManyResponse`| Maps each item via `transformToEntity` | List responses need bulk transformations | +| `transformCreatePayload` | Delegates to `transformToDTO` | Create payloads need special handling (e.g., strip IDs) | +| `transformEditPayload` | Delegates to `transformToDTO` | Edit payloads differ from create | + +--- + +## Integration with `BaseRemoteDataServices` + +When a transformer is injected via `DataServicesConfig.transformer`, the base service methods automatically apply transformations: + +| Service Method | Transformer Hook Used | Direction | +| -------------- | -------------------------------- | --------------- | +| `getOne()` | `transformGetOneResponse()` | Response → Entity | +| `getMany()` | `transformGetManyResponse()` | Response → Entity | +| `create()` | `transformCreatePayload()` | Entity → DTO | +| `edit()` | `transformEditPayload()` | Entity → DTO | +| `delete()` | None (no data transformation) | — | +| `customRequest()` | None (manual transformation) | — | + +> **Important:** If no transformer is injected, all methods behave exactly as before — data passes through unchanged. This ensures 100% backward compatibility. + +--- + +## Advanced: Extending Transformers + +For domain-specific features that go beyond standard CRUD, you can extend both the transformer and the data service. + +### Extended Transformer + +```typescript +// advanced-booking.transformer.ts +import { BookingTransformer } from './booking.transformer'; + +interface AvailabilityChartRawData { + dates: Array<{ + date_iso: string; + available_rooms: number; + occupancy_rate: number; + }>; +} + +interface AvailabilityChartData { + dataPoints: Array<{ + label: string; + availableRooms: number; + isHighDemand: boolean; + }>; +} + +class AdvancedBookingTransformer extends BookingTransformer { + // All standard CRUD mappings are inherited ✓ + + // Add custom transformation for non-CRUD data + transformAvailabilityChart(rawData: AvailabilityChartRawData): AvailabilityChartData { + return { + dataPoints: rawData.dates.map((item) => ({ + label: new Date(item.date_iso).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }), + availableRooms: item.available_rooms, + isHighDemand: item.occupancy_rate > 80, + })), + }; + } +} +``` + +### Extended Data Service + +```typescript +// advanced-booking.data-services.ts +import { BaseRemoteDataServices } from '@repo/core-api/data-services'; + +class AdvancedBookingDataServices extends BaseRemoteDataServices { + private readonly advancedTransformer: AdvancedBookingTransformer; + + constructor() { + const transformer = new AdvancedBookingTransformer(); + super(apiClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer, + }); + this.advancedTransformer = transformer; + } + + // Custom method using the extended transformer + async getAvailabilityChart(params: { + startDate: string; + endDate: string; + }): Promise> { + const response = await this.customRequest({ + url: '/bookings/availability-chart', + method: 'GET', + params: { start_date: params.startDate, end_date: params.endDate }, + }); + + return { + data: this.advancedTransformer.transformAvailabilityChart(response.data), + status: response.status, + }; + } +} + +export const advancedBookingServices = new AdvancedBookingDataServices(); +``` + +--- + +## Migration Guide + +Adding transformers to existing services requires **zero breaking changes**: + +### Step 1: Create a Transformer + +```typescript +class MyTransformer extends BaseDataTransformer { + transformToEntity(dto: MyDTO): MyEntity { /* ... */ } + transformToDTO(entity: MyEntity): MyDTO { /* ... */ } +} +``` + +### Step 2: Add a Second Generic Parameter + +```diff +- const services = new CommonRemoteDataServices(apiClient, { ++ const services = new CommonRemoteDataServices(apiClient, { + apiUrl: '/my-endpoint', ++ transformer: new MyTransformer(), + }); +``` + +### Step 3: (Optional) Override Operation-Specific Hooks + +```typescript +class MyTransformer extends BaseDataTransformer { + transformToEntity(dto: MyDTO): MyEntity { /* ... */ } + transformToDTO(entity: MyEntity): MyDTO { /* ... */ } + + // Only override if getOne needs special handling + override transformGetOneResponse(dto: MyDTO): MyEntity { + const entity = this.transformToEntity(dto); + return { ...entity, computedField: derive(dto) }; + } +} +``` + +> **Existing services without transformers are completely unaffected.** The `TDTO` generic defaults to `TEntity`, and the `transformer` config property defaults to `undefined`. + +--- + +## Sample Implementation + +A full working example is available in the showcase booking feature: + +| File | Description | +| ---- | ----------- | +| `apps/web/.../booking/data/booking.transformer.ts` | Basic transformer with snake_case ↔ camelCase mapping | +| `apps/web/.../booking/data/booking.data-services.ts` | Data service with injected transformer | +| `apps/web/.../booking/data/advanced-booking.transformer.ts` | Extended transformer with custom chart method | +| `apps/web/.../booking/data/advanced-booking.data-services.ts` | Extended service with custom `getAvailabilityChart()` | diff --git a/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts new file mode 100644 index 0000000..929c940 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.data-services.ts @@ -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 { + /** + * 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> { + const response = await this.customRequest({ + url: '/bookings/availability-chart', + method: 'GET', + params: { + start_date: params.startDate, + end_date: params.endDate, + }, + }); + + return { + data: this.advancedTransformer.transformAvailabilityChart(response.data), + status: response.status, + }; + } +} + +// ─── 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(); diff --git a/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts new file mode 100644 index 0000000..657d803 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/booking/data/advanced-booking.transformer.ts @@ -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, + })); + } +} diff --git a/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts b/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts index 2c2d8a0..b65ce28 100644 --- a/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts +++ b/apps/web/src/apps/showcase/example/features/booking/data/booking.data-services.ts @@ -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(apiClient, { +export const bookingServices = new CommonRemoteDataServices(apiClient, { apiUrl: '/bookings', moduleKey: 'BOOKING', + transformer: new BookingTransformer(), }); + diff --git a/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts new file mode 100644 index 0000000..b37bc63 --- /dev/null +++ b/apps/web/src/apps/showcase/example/features/booking/data/booking.transformer.ts @@ -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 { + /** + * 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, + 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): Partial { + const dto = this.transformToDTO(entity as BookingEntity); + const { id: _, ...rest } = dto; + return rest; + } +} diff --git a/packages/core-api/src/data-services/base-data.transformer.ts b/packages/core-api/src/data-services/base-data.transformer.ts new file mode 100644 index 0000000..58dfc53 --- /dev/null +++ b/packages/core-api/src/data-services/base-data.transformer.ts @@ -0,0 +1,221 @@ +import type { BaseEntity } from './types'; + +// ─── Core Transformer Interface ───────────────────────────────── + +/** + * Minimal contract for bidirectional data transformation + * between API Data Transfer Objects (DTOs) and domain entities. + * + * Implement this interface when you only need the two core + * mapping methods without operation-specific hooks. + * + * @typeParam TEntity - The frontend domain entity type + * @typeParam TDTO - The API response/request DTO shape + * + * @example + * ```ts + * class UserTransformer implements IDataTransformer { + * transformToEntity(dto: UserDTO): UserEntity { + * return { id: dto.id, fullName: `${dto.first_name} ${dto.last_name}` }; + * } + * transformToDTO(entity: UserEntity): UserDTO { + * const [first, ...rest] = entity.fullName.split(' '); + * return { id: entity.id, first_name: first, last_name: rest.join(' ') }; + * } + * } + * ``` + */ +export interface IDataTransformer< + TEntity extends BaseEntity = BaseEntity, + TDTO = TEntity, +> { + /** + * Transform an API DTO into a domain entity. + * + * Called after receiving data from the API. Use this to map + * snake_case fields to camelCase, flatten nested structures, + * compute derived fields, or apply any normalization. + * + * @param dto - Raw data from the API response + * @returns The mapped domain entity + */ + transformToEntity(dto: TDTO): TEntity; + + /** + * Transform a domain entity into an API DTO. + * + * Called before sending data to the API. Use this to map + * camelCase fields to snake_case, restructure nested objects, + * or strip frontend-only computed fields. + * + * @param entity - Domain entity from the frontend + * @returns The mapped DTO for the API request + */ + transformToDTO(entity: TEntity): TDTO; + + // ─── Optional Operation-Specific Hooks ────────────────────── + + /** + * Transform the response of a `getOne()` call. + * If not provided, falls back to `transformToEntity`. + */ + transformGetOneResponse?(dto: TDTO): TEntity; + + /** + * Transform the response of a `getMany()` call. + * If not provided, falls back to mapping each item via `transformToEntity`. + */ + transformGetManyResponse?(dtos: TDTO[]): TEntity[]; + + /** + * Transform the payload before a `create()` call. + * If not provided, falls back to `transformToDTO`. + */ + transformCreatePayload?(entity: Partial): Partial; + + /** + * Transform the payload before an `edit()` call. + * If not provided, falls back to `transformToDTO`. + */ + transformEditPayload?(entity: Partial): Partial; +} + +// ─── Abstract Base Transformer ────────────────────────────────── + +/** + * Abstract base class providing operation-specific transformation + * hooks with sensible passthrough defaults. + * + * Extends the core `IDataTransformer` contract with granular hooks + * for each CRUD operation. Override only the hooks you need — + * unoverridden hooks delegate to the core `transformToEntity` / + * `transformToDTO` methods. + * + * **Design rationale (from legacy analysis):** + * The legacy `BaseTransformer` used operation-specific methods + * (`transformerGetOne`, `transformerCreate`, etc.) because different + * operations often need different transformations. For example, + * `getOne` might need to compute derived fields, while `create` + * might need to strip IDs. This pattern is preserved here with + * proper typing. + * + * @typeParam TEntity - The frontend domain entity type + * @typeParam TDTO - The API response/request DTO shape + * + * @example + * ```ts + * class BookingTransformer extends BaseDataTransformer { + * transformToEntity(dto: BookingDTO): BookingEntity { + * return { + * id: dto.id, + * bookingCode: dto.booking_code, + * customerName: dto.customer_name, + * }; + * } + * + * transformToDTO(entity: BookingEntity): BookingDTO { + * return { + * id: entity.id, + * booking_code: entity.bookingCode, + * customer_name: entity.customerName, + * }; + * } + * + * // Override only when getOne needs extra computed fields + * transformGetOneResponse(dto: BookingDTO): BookingEntity { + * const entity = this.transformToEntity(dto); + * return { ...entity, durationNights: computeNights(dto) }; + * } + * } + * ``` + */ +export abstract class BaseDataTransformer< + TEntity extends BaseEntity = BaseEntity, + TDTO = TEntity, +> implements IDataTransformer +{ + /** + * Core DTO → Entity transformation. + * + * Default implementation performs an identity cast. + * Override this in concrete transformers to provide + * the actual mapping logic. + * + * @param dto - Raw data from the API response + * @returns The mapped domain entity + */ + transformToEntity(dto: TDTO): TEntity { + return dto as unknown as TEntity; + } + + /** + * Core Entity → DTO transformation. + * + * Default implementation performs an identity cast. + * Override this in concrete transformers to provide + * the actual mapping logic. + * + * @param entity - Domain entity from the frontend + * @returns The mapped DTO for the API request + */ + transformToDTO(entity: TEntity): TDTO { + return entity as unknown as TDTO; + } + + // ─── Operation-Specific Hooks ─────────────────────────────── + + /** + * Transform the response of a `getOne()` call. + * + * Override this when `getOne` needs additional computed fields + * or different mapping than the default `transformToEntity`. + * + * @param dto - Single DTO from the API response + * @returns The mapped domain entity + */ + transformGetOneResponse(dto: TDTO): TEntity { + return this.transformToEntity(dto); + } + + /** + * Transform the response of a `getMany()` call. + * + * Override this when list responses need bulk transformations + * (e.g., sorting, filtering, or status normalization) beyond + * per-item mapping. + * + * @param dtos - Array of DTOs from the API response + * @returns Array of mapped domain entities + */ + transformGetManyResponse(dtos: TDTO[]): TEntity[] { + return dtos.map((dto) => this.transformToEntity(dto)); + } + + /** + * Transform the payload before a `create()` call. + * + * Override this when create payloads need special handling + * (e.g., stripping IDs, formatting dates, converting nested + * structures). + * + * @param entity - Partial entity data from the frontend + * @returns The mapped partial DTO for the API request + */ + transformCreatePayload(entity: Partial): Partial { + return this.transformToDTO(entity as TEntity) as Partial; + } + + /** + * Transform the payload before an `edit()` call. + * + * Override this when edit payloads need special handling + * that differs from create (e.g., preserving certain + * read-only fields, handling delta updates). + * + * @param entity - Partial entity data from the frontend + * @returns The mapped partial DTO for the API request + */ + transformEditPayload(entity: Partial): Partial { + return this.transformToDTO(entity as TEntity) as Partial; + } +} diff --git a/packages/core-api/src/data-services/base-remote.data-services.test.ts b/packages/core-api/src/data-services/base-remote.data-services.test.ts index b19104b..abed529 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.test.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { BaseEntity } from './types'; +import type { IDataTransformer } from './base-data.transformer'; import { CommonRemoteDataServices } from './common-remote.data-services'; +import { BaseDataTransformer } from './base-data.transformer'; // ─── Mock AxiosInstance ───────────────────────────────────────── @@ -261,3 +263,398 @@ describe('BaseRemoteDataServices (via CommonRemoteDataServices)', () => { }); }); }); + +// ═══════════════════════════════════════════════════════════════════ +// Data Transformer Integration Tests +// ═══════════════════════════════════════════════════════════════════ + +// ─── Test DTO (snake_case API shape) ──────────────────────────── + +interface TestDTO { + id?: string; + booking_code: string; + customer_name: string; +} + +interface TestEntity2 extends BaseEntity { + bookingCode: string; + customerName: string; +} + +// ─── Concrete Transformer for Testing ─────────────────────────── + +class TestTransformer extends BaseDataTransformer { + transformToEntity(dto: TestDTO): TestEntity2 { + return { + id: dto.id, + bookingCode: dto.booking_code, + customerName: dto.customer_name, + }; + } + + transformToDTO(entity: TestEntity2): TestDTO { + return { + id: entity.id, + booking_code: entity.bookingCode, + customer_name: entity.customerName, + }; + } +} + +// ─── Transformer with Custom Hooks ────────────────────────────── + +class CustomHookTransformer extends TestTransformer { + override transformGetOneResponse(dto: TestDTO): TestEntity2 { + const entity = this.transformToEntity(dto); + return { ...entity, customerName: entity.customerName.toUpperCase() }; + } + + override transformGetManyResponse(dtos: TestDTO[]): TestEntity2[] { + return dtos + .map((dto) => this.transformToEntity(dto)) + .map((entity) => ({ ...entity, bookingCode: `LIST-${entity.bookingCode}` })); + } + + override transformCreatePayload(entity: Partial): Partial { + const dto = super.transformCreatePayload(entity); + return { ...dto, id: undefined }; + } + + override transformEditPayload(entity: Partial): Partial { + const dto = super.transformEditPayload(entity); + return { ...dto, booking_code: `EDIT-${dto.booking_code}` }; + } +} + +// ─── Transformer Integration Tests ────────────────────────────── + +describe('BaseRemoteDataServices — Data Transformer Integration', () => { + let mockClient: AxiosInstance; + + beforeEach(() => { + mockClient = createMockHttpClient(); + }); + + // ── Without Transformer (backward compatibility) ────────────── + + describe('without transformer (backward compatibility)', () => { + it('getOne() returns raw API response unchanged', async () => { + const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' }; + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: rawDTO, + status: 200, + }); + + const services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + }); + + const result = await services.getOne('42'); + expect(result.data).toEqual(rawDTO); + }); + + it('getMany() returns raw API response unchanged', async () => { + const rawDTOs = [ + { id: '1', booking_code: 'BK001', customer_name: 'Alice' }, + { id: '2', booking_code: 'BK002', customer_name: 'Bob' }, + ]; + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: rawDTOs, + status: 200, + }); + + const services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + }); + + const result = await services.getMany(); + expect(result.data).toEqual(rawDTOs); + }); + + it('create() sends entity data as-is without transformation', async () => { + const services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + }); + + const entityData = { id: 'temp-1' }; + await services.create(entityData); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.data).toEqual(entityData); + }); + }); + + // ── With Base Transformer (core methods) ────────────────────── + + describe('with transformer (core transformToEntity / transformToDTO)', () => { + let services: CommonRemoteDataServices; + const transformer = new TestTransformer(); + + beforeEach(() => { + services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer, + }); + }); + + it('getOne() transforms API DTO to domain entity', async () => { + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: { id: '42', booking_code: 'BK042', customer_name: 'Alice' }, + status: 200, + }); + + const result = await services.getOne('42'); + + expect(result.data).toEqual({ + id: '42', + bookingCode: 'BK042', + customerName: 'Alice', + }); + expect(result.status).toBe(200); + }); + + it('getMany() transforms each DTO in the array to entities', async () => { + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: [ + { id: '1', booking_code: 'BK001', customer_name: 'Alice' }, + { id: '2', booking_code: 'BK002', customer_name: 'Bob' }, + ], + status: 200, + }); + + const result = await services.getMany(); + + expect(result.data).toEqual([ + { id: '1', bookingCode: 'BK001', customerName: 'Alice' }, + { id: '2', bookingCode: 'BK002', customerName: 'Bob' }, + ]); + }); + + it('getMany() handles empty array response', async () => { + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: [], + status: 200, + }); + + const result = await services.getMany(); + + expect(result.data).toEqual([]); + }); + + it('create() transforms entity payload to DTO before sending', async () => { + await services.create({ + bookingCode: 'BK001', + customerName: 'Alice', + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.data).toEqual({ + id: undefined, + booking_code: 'BK001', + customer_name: 'Alice', + }); + }); + + it('edit() transforms entity payload to DTO before sending', async () => { + await services.edit('42', { + bookingCode: 'BK042-UPDATED', + customerName: 'Bob', + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.data).toEqual({ + id: undefined, + booking_code: 'BK042-UPDATED', + customer_name: 'Bob', + }); + expect(requestArg.url).toBe('/bookings/42'); + }); + + it('delete() is unaffected by transformer (no data transformation needed)', async () => { + await services.delete('42'); + + expect(mockClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/bookings/42', + method: 'DELETE', + }), + ); + }); + + it('customRequest() is unaffected by transformer', async () => { + await services.customRequest({ + url: '/bookings/42/calculate-tax', + method: 'POST', + data: { items: [] }, + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.data).toEqual({ items: [] }); + }); + }); + + // ── With Custom Hook Transformer ────────────────────────────── + + describe('with custom operation-specific hooks', () => { + let services: CommonRemoteDataServices; + const transformer = new CustomHookTransformer(); + + beforeEach(() => { + services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + moduleKey: 'BOOKING', + transformer, + }); + }); + + it('getOne() uses transformGetOneResponse hook (uppercases customer name)', async () => { + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: { id: '42', booking_code: 'BK042', customer_name: 'alice' }, + status: 200, + }); + + const result = await services.getOne('42'); + + expect(result.data).toEqual({ + id: '42', + bookingCode: 'BK042', + customerName: 'ALICE', // uppercased by custom hook + }); + }); + + it('getMany() uses transformGetManyResponse hook (prefixes booking code)', async () => { + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: [ + { id: '1', booking_code: 'BK001', customer_name: 'Alice' }, + ], + status: 200, + }); + + const result = await services.getMany(); + + expect(result.data).toEqual([ + { id: '1', bookingCode: 'LIST-BK001', customerName: 'Alice' }, + ]); + }); + + it('create() uses transformCreatePayload hook (strips id)', async () => { + await services.create({ + id: 'should-be-removed', + bookingCode: 'BK001', + customerName: 'Alice', + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.data.id).toBeUndefined(); + expect(requestArg.data.booking_code).toBe('BK001'); + }); + + it('edit() uses transformEditPayload hook (prefixes booking code)', async () => { + await services.edit('42', { + bookingCode: 'BK042', + customerName: 'Alice', + }); + + const requestArg = (mockClient.request as ReturnType).mock.calls[0][0]; + expect(requestArg.data.booking_code).toBe('EDIT-BK042'); + }); + }); + + // ── Identity Transformer ────────────────────────────────────── + + describe('with identity transformer (default passthrough)', () => { + it('produces same results as no transformer', async () => { + const rawData = { id: '1', name: 'Test' }; + (mockClient.request as ReturnType).mockResolvedValue({ + data: rawData, + status: 200, + }); + + // Service without transformer + const servicesNoTransformer = new CommonRemoteDataServices(mockClient, { + apiUrl: '/items', + }); + + // Service with identity transformer (no method overrides) + class IdentityTransformer extends BaseDataTransformer {} + const servicesWithIdentity = new CommonRemoteDataServices(mockClient, { + apiUrl: '/items', + transformer: new IdentityTransformer(), + }); + + const resultWithout = await servicesNoTransformer.getOne('1'); + const resultWith = await servicesWithIdentity.getOne('1'); + + expect(resultWithout.data).toEqual(resultWith.data); + }); + }); + + // ── Transformer receives correct arguments ──────────────────── + + describe('transformer method invocation', () => { + it('transformGetOneResponse receives the raw DTO from API', async () => { + const rawDTO = { id: '42', booking_code: 'BK042', customer_name: 'Alice' }; + (mockClient.request as ReturnType).mockResolvedValueOnce({ + data: rawDTO, + status: 200, + }); + + const mockTransformer: IDataTransformer = { + transformToEntity: vi.fn((dto: TestDTO) => ({ + id: dto.id, + bookingCode: dto.booking_code, + customerName: dto.customer_name, + })), + transformToDTO: vi.fn(), + transformGetOneResponse: vi.fn((dto: TestDTO) => ({ + id: dto.id, + bookingCode: dto.booking_code, + customerName: dto.customer_name, + })), + transformGetManyResponse: vi.fn(), + transformCreatePayload: vi.fn(), + transformEditPayload: vi.fn(), + }; + + const services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + transformer: mockTransformer, + }); + + await services.getOne('42'); + + expect(mockTransformer.transformGetOneResponse).toHaveBeenCalledWith(rawDTO); + expect(mockTransformer.transformToEntity).not.toHaveBeenCalled(); + }); + + it('transformCreatePayload receives the entity data from caller', async () => { + const entityData: Partial = { + bookingCode: 'BK001', + customerName: 'Alice', + }; + + const mockTransformer: IDataTransformer = { + transformToEntity: vi.fn(), + transformToDTO: vi.fn(), + transformGetOneResponse: vi.fn(), + transformGetManyResponse: vi.fn(), + transformCreatePayload: vi.fn((entity) => ({ + booking_code: entity.bookingCode!, + customer_name: entity.customerName!, + })), + transformEditPayload: vi.fn(), + }; + + const services = new CommonRemoteDataServices(mockClient, { + apiUrl: '/bookings', + transformer: mockTransformer, + }); + + await services.create(entityData); + + expect(mockTransformer.transformCreatePayload).toHaveBeenCalledWith(entityData); + expect(mockTransformer.transformToDTO).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core-api/src/data-services/base-remote.data-services.ts b/packages/core-api/src/data-services/base-remote.data-services.ts index 51e875f..1efac40 100644 --- a/packages/core-api/src/data-services/base-remote.data-services.ts +++ b/packages/core-api/src/data-services/base-remote.data-services.ts @@ -7,6 +7,7 @@ import type { ExecuteOptions, DataServicesConfig, } from './types'; +import type { IDataTransformer } from './base-data.transformer'; import type { ApiResponse } from '../http-client/types'; import { interpolateUrl } from './url-builder'; import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants'; @@ -25,10 +26,12 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants'; * - All operations are fully typed end-to-end * - Includes `customRequest()` as an escape hatch for non-standard endpoints * - * @typeParam E - The domain entity type (must extend BaseEntity) + * @typeParam E - The domain entity type (must extend BaseEntity) + * @typeParam TDTO - The API DTO shape (defaults to E for backward compatibility) * * @example * ```ts + * // Without transformer (backward compatible) * class BookingDataServices extends BaseRemoteDataServices {} * * const services = new BookingDataServices(apiClient, { @@ -36,10 +39,20 @@ import { DEFAULT_METHODS, DESCRIPTORS, makeDefaultURLs } from './constants'; * moduleKey: 'BOOKING', * }); * + * // With transformer (DTO ↔ Entity mapping) + * const services = new BookingDataServices(apiClient, { + * apiUrl: '/bookings', + * moduleKey: 'BOOKING', + * transformer: new BookingTransformer(), + * }); + * * const { data, status } = await services.getOne('42'); * ``` */ -export abstract class BaseRemoteDataServices { +export abstract class BaseRemoteDataServices< + E extends BaseEntity = BaseEntity, + TDTO = E, +> { /** The injected, isolated HTTP client instance. */ protected readonly httpClient: AxiosInstance; @@ -52,9 +65,18 @@ export abstract class BaseRemoteDataServices /** Module key for the 'ex-module-key' audit header. */ protected readonly moduleKey: string | undefined; - constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + /** + * Optional data transformer for DTO ↔ Entity mapping. + * + * When present, CRUD methods automatically transform responses + * and payloads. When absent, data passes through unchanged. + */ + protected readonly transformer: IDataTransformer | undefined; + + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { this.httpClient = httpClient; this.moduleKey = config.moduleKey; + this.transformer = config.transformer; this.urls = { ...makeDefaultURLs(config.apiUrl ?? ''), @@ -143,31 +165,85 @@ export abstract class BaseRemoteDataServices // ─── CRUD Operations ─────────────────────────────────────────── - /** Fetch a paginated list of entities. */ - getMany(config?: AxiosRequestConfig): Promise> { - return this.execute(DESCRIPTORS.getMany, { config }); + /** + * Fetch a paginated list of entities. + * + * When a transformer is injected, the raw API response is passed + * through `transformGetManyResponse()` before being returned. + */ + async getMany(config?: AxiosRequestConfig): Promise> { + const result = await this.execute(DESCRIPTORS.getMany, { config }); + + if (this.transformer && Array.isArray(result.data)) { + return { + ...result, + data: this.transformer.transformGetManyResponse + ? this.transformer.transformGetManyResponse(result.data as unknown as TDTO[]) + : result.data.map((item: unknown) => this.transformer!.transformToEntity(item as TDTO)), + } as unknown as ApiResponse; + } + + return result; } - /** Fetch a single entity by ID. */ - getOne(id: string, config?: AxiosRequestConfig): Promise> { - return this.execute(DESCRIPTORS.getOne, { + /** + * Fetch a single entity by ID. + * + * When a transformer is injected, the raw API response is passed + * through `transformGetOneResponse()` before being returned. + */ + async getOne(id: string, config?: AxiosRequestConfig): Promise> { + const result = await this.execute(DESCRIPTORS.getOne, { variableURL: { id }, config, }); + + if (this.transformer && result.data != null) { + return { + ...result, + data: this.transformer.transformGetOneResponse + ? this.transformer.transformGetOneResponse(result.data as unknown as TDTO) + : this.transformer.transformToEntity(result.data as unknown as TDTO), + } as unknown as ApiResponse; + } + + return result; } - /** Create a new entity. */ + /** + * Create a new entity. + * + * When a transformer is injected, the entity payload is passed + * through `transformCreatePayload()` before being sent to the API. + */ create(data: Partial, config?: AxiosRequestConfig): Promise> { + const transformedData = this.transformer?.transformCreatePayload + ? this.transformer.transformCreatePayload(data) + : this.transformer + ? this.transformer.transformToDTO(data as E) + : data; + return this.execute(DESCRIPTORS.create, { - config: { ...config, data }, + config: { ...config, data: transformedData }, }); } - /** Update an existing entity by ID. */ + /** + * Update an existing entity by ID. + * + * When a transformer is injected, the entity payload is passed + * through `transformEditPayload()` before being sent to the API. + */ edit(id: string, data: Partial, config?: AxiosRequestConfig): Promise> { + const transformedData = this.transformer?.transformEditPayload + ? this.transformer.transformEditPayload(data) + : this.transformer + ? this.transformer.transformToDTO(data as E) + : data; + return this.execute(DESCRIPTORS.edit, { variableURL: { id }, - config: { ...config, data }, + config: { ...config, data: transformedData }, }); } diff --git a/packages/core-api/src/data-services/common-remote.data-services.ts b/packages/core-api/src/data-services/common-remote.data-services.ts index bdcae39..a9d6d43 100644 --- a/packages/core-api/src/data-services/common-remote.data-services.ts +++ b/packages/core-api/src/data-services/common-remote.data-services.ts @@ -12,11 +12,12 @@ import { BaseRemoteDataServices } from './base-remote.data-services'; * CRUD + lifecycle, extend BaseRemoteDataServices instead and add * custom methods using `this.execute()` or `this.customRequest()`. * - * @typeParam E - The domain entity type + * @typeParam E - The domain entity type + * @typeParam TDTO - The API DTO shape (defaults to E for backward compatibility) * * @example * ```ts - * // Direct instantiation for standard modules + * // Direct instantiation for standard modules (no transformer) * const bookingServices = new CommonRemoteDataServices( * apiClient, * { apiUrl: '/bookings', moduleKey: 'BOOKING' }, @@ -27,6 +28,19 @@ import { BaseRemoteDataServices } from './base-remote.data-services'; * * @example * ```ts + * // With transformer for DTO ↔ Entity mapping + * const bookingServices = new CommonRemoteDataServices( + * apiClient, + * { + * apiUrl: '/bookings', + * moduleKey: 'BOOKING', + * transformer: new BookingTransformer(), + * }, + * ); + * ``` + * + * @example + * ```ts * // For modules needing custom operations, extend the base: * class InvoiceDataServices extends BaseRemoteDataServices { * async calculateTax(invoiceId: string) { @@ -40,4 +54,6 @@ import { BaseRemoteDataServices } from './base-remote.data-services'; */ export class CommonRemoteDataServices< E extends BaseEntity = BaseEntity, -> extends BaseRemoteDataServices {} + TDTO = E, +> extends BaseRemoteDataServices {} + diff --git a/packages/core-api/src/data-services/index.ts b/packages/core-api/src/data-services/index.ts index edaf7ef..ff7ca20 100644 --- a/packages/core-api/src/data-services/index.ts +++ b/packages/core-api/src/data-services/index.ts @@ -1,6 +1,7 @@ // ─── Classes ──────────────────────────────────────────────────── export { BaseRemoteDataServices } from './base-remote.data-services'; export { CommonRemoteDataServices } from './common-remote.data-services'; +export { BaseDataTransformer } from './base-data.transformer'; // ─── Utilities ────────────────────────────────────────────────── export { interpolateUrl } from './url-builder'; @@ -17,3 +18,5 @@ export type { ExecuteOptions, DataServicesConfig, } from './types'; + +export type { IDataTransformer } from './base-data.transformer'; diff --git a/packages/core-api/src/data-services/types.ts b/packages/core-api/src/data-services/types.ts index 76ec63c..5c45e82 100644 --- a/packages/core-api/src/data-services/types.ts +++ b/packages/core-api/src/data-services/types.ts @@ -1,5 +1,6 @@ import type { AxiosRequestConfig } from 'axios'; import type { TelemetryContext } from '../http-client/types'; +import type { IDataTransformer } from './base-data.transformer'; // ─── Base Entity ──────────────────────────────────────────────── @@ -112,8 +113,14 @@ export interface ExecuteOptions { /** * Configuration for constructing a BaseRemoteDataServices instance. + * + * @typeParam TEntity - The frontend domain entity type (defaults to BaseEntity) + * @typeParam TDTO - The API DTO shape (defaults to TEntity for backward compatibility) */ -export interface DataServicesConfig { +export interface DataServicesConfig< + TEntity extends BaseEntity = BaseEntity, + TDTO = TEntity, +> { /** Base API path (e.g., '/bookings'). Used to generate all URL templates. */ apiUrl?: string; /** Module key for the 'ex-module-key' header (e.g., 'BOOKING'). */ @@ -122,4 +129,18 @@ export interface DataServicesConfig { urls?: Partial; /** Override specific HTTP methods. */ methods?: Partial; + + /** + * Optional data transformer for DTO ↔ Entity mapping. + * + * When provided, CRUD methods automatically transform: + * - **Responses** (`getOne`, `getMany`): DTO → Entity via transformer + * - **Payloads** (`create`, `edit`): Entity → DTO via transformer + * + * When omitted, data passes through unchanged (backward compatible). + * + * @see {@link IDataTransformer} for the transformer contract + * @see {@link BaseDataTransformer} for the abstract base class + */ + transformer?: IDataTransformer; } From 8f14c4bc7bf55822d1c520be2416093aaacad73a Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:04:20 +0700 Subject: [PATCH 02/35] feat: add RowActions component for enhanced row-level actions in data grids - Introduced RowActions component to manage row-level actions with tooltips and dropdown menus. - Created types for row actions and page actions to standardize action properties. - Implemented utility function to map action intents to Mantine theme colors. - Updated CoreAppShell component to support optional slots for better flexibility. - Added enterprise module structure with context hooks for managing module state and actions. - Implemented draft management for forms to enhance user experience during data entry. - Established context providers for detail, form, and index pages to streamline data handling. - Updated dependencies to ensure compatibility with the latest versions. --- .vscode/settings.json | 2 +- apps/docs-dev/src/.vitepress/config.mts | 65 +-- apps/web/package.json | 2 +- apps/web/src/apps/index.tsx | 3 +- .../src/apps/modules/example/example.page.tsx | 3 - .../data/full-page.remote.service.ts | 25 + .../domain/constants/full-page.constants.ts | 22 + .../full-page/domain/constants/index.ts | 1 + .../domain/entities/full-page.entity.ts | 26 + .../full-page/domain/entities/index.ts | 1 + .../full-page/domain/factories/index.ts | 30 ++ .../full-page.remote.transformer.ts | 45 ++ .../domain/validators/full-page.validator.ts | 33 ++ .../full-page/presentation/factory/index.tsx | 43 ++ .../presentation/locales/en/full-page.json | 9 + .../presentation/locales/id/full-page.json | 9 + .../pages/full-page.page.detail.tsx | 15 + .../pages/full-page.page.form.tsx | 18 + .../pages/full-page.page.index.tsx | 102 ++++ apps/web/src/apps/modules/index.tsx | 14 +- .../layouts/components/header.layout.tsx | 35 ++ .../apps/modules/layouts/module.layout.tsx | 24 + .../components/ActionToolsShowcase.tsx | 154 ++++++ apps/web/src/apps/showcase/registry.ts | 12 + .../src/apps/showcase/shell-demo/index.tsx | 444 ++++++++++-------- apps/web/src/apps/showcase/showcase-view.tsx | 38 +- apps/web/src/core/assets/logo.svg | 6 + .../src/core/components/loading-screen.tsx | 11 + apps/web/src/main.tsx | 8 +- apps/web/src/types/i18next.d.ts | 14 - .../base-remote.data-services.ts | 73 +-- .../core-api/src/data-services/constants.ts | 126 +++-- packages/core-api/src/data-services/types.ts | 45 +- .../core-api/src/data-services/url-builder.ts | 9 +- packages/core-i18n/src/index.ts | 1 + packages/core-i18n/src/locales/en/common.json | 8 +- packages/core-i18n/src/locales/id/common.json | 8 +- packages/core-i18n/src/registry.ts | 69 +++ packages/ui/package.json | 7 +- .../ui/src/components/actions-tools/index.ts | 4 + .../components/actions-tools/page-actions.tsx | 150 ++++++ .../components/actions-tools/row-actions.tsx | 154 ++++++ .../ui/src/components/actions-tools/types.ts | 57 +++ .../ui/src/components/actions-tools/utils.ts | 23 + .../core-app-shell/core-app-shell.tsx | 56 +-- packages/ui/src/components/index.ts | 1 + .../enterprise-module/entities/entity.ts | 225 +++++++++ .../hooks/use-detail-page.context.ts | 19 + .../hooks/use-form-draft.context.ts | 63 +++ .../hooks/use-form-page.context.ts | 30 ++ .../hooks/use-index-page.context.ts | 22 + .../hooks/use-module.context.ts | 91 ++++ .../foundations/enterprise-module/index.ts | 5 + .../providers/detail-page.provider.tsx | 0 .../providers/form-page.provider.tsx | 0 .../providers/index-page.provider.tsx | 0 .../providers/module.provider.tsx | 148 ++++++ packages/ui/src/foundations/index.ts | 1 + packages/ui/src/foundations/landing/.gitkeep | 0 pnpm-lock.yaml | 21 +- 60 files changed, 2188 insertions(+), 442 deletions(-) delete mode 100644 apps/web/src/apps/modules/example/example.page.tsx create mode 100644 apps/web/src/apps/modules/example/full-page/data/full-page.remote.service.ts create mode 100644 apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts create mode 100644 apps/web/src/apps/modules/example/full-page/domain/constants/index.ts create mode 100644 apps/web/src/apps/modules/example/full-page/domain/entities/full-page.entity.ts create mode 100644 apps/web/src/apps/modules/example/full-page/domain/entities/index.ts create mode 100644 apps/web/src/apps/modules/example/full-page/domain/factories/index.ts create mode 100644 apps/web/src/apps/modules/example/full-page/domain/transformers/full-page.remote.transformer.ts create mode 100644 apps/web/src/apps/modules/example/full-page/domain/validators/full-page.validator.ts create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.form.tsx create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx create mode 100644 apps/web/src/apps/modules/layouts/components/header.layout.tsx create mode 100644 apps/web/src/apps/modules/layouts/module.layout.tsx create mode 100644 apps/web/src/apps/showcase/components/ActionToolsShowcase.tsx create mode 100644 apps/web/src/apps/showcase/registry.ts create mode 100644 apps/web/src/core/assets/logo.svg create mode 100644 apps/web/src/core/components/loading-screen.tsx delete mode 100644 apps/web/src/types/i18next.d.ts create mode 100644 packages/core-i18n/src/registry.ts create mode 100644 packages/ui/src/components/actions-tools/index.ts create mode 100644 packages/ui/src/components/actions-tools/page-actions.tsx create mode 100644 packages/ui/src/components/actions-tools/row-actions.tsx create mode 100644 packages/ui/src/components/actions-tools/types.ts create mode 100644 packages/ui/src/components/actions-tools/utils.ts create mode 100644 packages/ui/src/foundations/enterprise-module/entities/entity.ts create mode 100644 packages/ui/src/foundations/enterprise-module/hooks/use-detail-page.context.ts create mode 100644 packages/ui/src/foundations/enterprise-module/hooks/use-form-draft.context.ts create mode 100644 packages/ui/src/foundations/enterprise-module/hooks/use-form-page.context.ts create mode 100644 packages/ui/src/foundations/enterprise-module/hooks/use-index-page.context.ts create mode 100644 packages/ui/src/foundations/enterprise-module/hooks/use-module.context.ts create mode 100644 packages/ui/src/foundations/enterprise-module/index.ts create mode 100644 packages/ui/src/foundations/enterprise-module/providers/detail-page.provider.tsx create mode 100644 packages/ui/src/foundations/enterprise-module/providers/form-page.provider.tsx create mode 100644 packages/ui/src/foundations/enterprise-module/providers/index-page.provider.tsx create mode 100644 packages/ui/src/foundations/enterprise-module/providers/module.provider.tsx create mode 100644 packages/ui/src/foundations/index.ts create mode 100644 packages/ui/src/foundations/landing/.gitkeep diff --git a/.vscode/settings.json b/.vscode/settings.json index 964af63..9f3ba47 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,5 @@ "mode": "auto" } ], - "cSpell.words": ["mantine", "Menlo", "Millis", "Pandang", "Segoe", "Ujung", "WITA"] + "cSpell.words": ["mantine", "Menlo", "mgmt", "Millis", "Pandang", "Segoe", "Ujung", "WITA"] } diff --git a/apps/docs-dev/src/.vitepress/config.mts b/apps/docs-dev/src/.vitepress/config.mts index 1783a76..3e884b5 100644 --- a/apps/docs-dev/src/.vitepress/config.mts +++ b/apps/docs-dev/src/.vitepress/config.mts @@ -1,25 +1,23 @@ -import { defineConfig } from 'vitepress' -import { withMermaid } from 'vitepress-plugin-mermaid' +import { defineConfig } from 'vitepress'; +import { withMermaid } from 'vitepress-plugin-mermaid'; const config = withMermaid( defineConfig({ // title: "Frontend Monorepo", title: 'Frontend Arch', - description: "Centralized documentation for the Enterprise Frontend Monorepo", + description: 'Centralized documentation for the Enterprise Frontend Monorepo', head: [ - ['link', { rel: 'icon', href: '/favicon.svg' }] // Jika Anda menggunakan favicon.svg - ], + ['link', { rel: 'icon', href: '/favicon.svg' }], // Jika Anda menggunakan favicon.svg + ], themeConfig: { search: { provider: 'local', options: { - detailedView: true - } + detailedView: true, + }, }, logo: '/logo.svg', - nav: [ - { text: 'Docs', link: '/overview' }, - ], + nav: [{ text: 'Docs', link: '/overview' }], sidebar: [ { @@ -33,13 +31,22 @@ const config = withMermaid( text: 'Core Architecture', collapsed: false, items: [ - { text: 'API & Domain Logic', link: '/packages/core-api/' }, + // { text: 'API & Domain Logic', link: '/packages/core-api/' }, + { + text: 'API & Domain Logic', + collapsed: false, + items: [ + { text: 'API Engine', link: '/packages/core-api' }, + { text: 'Data Transformers', link: '/packages/core-api/transformers' }, + ], + }, + { text: 'Event Bus System', link: '/packages/core-events/' }, { text: 'Storage & Persistence', link: '/packages/core-storage/' }, { text: 'I18n & Localization', link: '/packages/core-i18n/' }, ], }, - { + { text: 'UI System', collapsed: false, items: [ @@ -52,23 +59,23 @@ const config = withMermaid( text: 'Desktop Ecosystem', collapsed: false, items: [ - { text: 'Overview', link: '/apps/desktop/' }, - { text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' }, - { text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' }, - { text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' }, + { text: 'Overview', link: '/apps/desktop/' }, + { text: 'Lifecycle & Configuration', link: '/apps/desktop/CONFIGURATION' }, + { text: 'IPC & Bridge Architecture', link: '/apps/desktop/IPC_ARCHITECTURE' }, + { text: 'Distribution & Auto-Update', link: '/apps/desktop/AUTO_UPDATER' }, ], }, ], outline: { level: [2, 3] }, socialLinks: [ - { - icon: { - svg: 'Gitea' + { + icon: { + svg: 'Gitea', + }, + link: 'https://git.eigen.co.id/eigen/fe-monorepo-template', }, - link: 'https://git.eigen.co.id/eigen/fe-monorepo-template' - } - ] + ], }, // Mermaid configuration @@ -79,22 +86,20 @@ const config = withMermaid( // Fix cascading CJS/ESM SyntaxErrors caused by Vite dynamically discovering mermaid vite: { optimizeDeps: { - include: [ - 'mermaid' - ] - } - } - }) + include: ['mermaid'], + }, + }, + }), ); -// Pnpm strict workspace workaround: +// Pnpm strict workspace workaround: // vitepress-plugin-mermaid aggressively injects sub-dependencies into optimizeDeps.include. // Because pnpm uses strict symlinks, Vite fails to resolve these sub-dependencies from the project root, // causing pre-bundling to fail and cascading CJS/ESM SyntaxErrors in the browser. // We strip them out so esbuild can naturally inline them into the 'mermaid' chunk instead. if (config.vite?.optimizeDeps?.include) { config.vite.optimizeDeps.include = config.vite.optimizeDeps.include.filter( - (dep) => !['@braintree/sanitize-url', 'debug', 'cytoscape-cose-bilkent', 'cytoscape'].includes(dep) + (dep) => !['@braintree/sanitize-url', 'debug', 'cytoscape-cose-bilkent', 'cytoscape'].includes(dep), ); } diff --git a/apps/web/package.json b/apps/web/package.json index 90c80ec..bb1468b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,7 +24,7 @@ "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", diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index 1f5a777..5d6914d 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { ThemeProvider, ColorSchemeType, DensityType } from '@repo/ui/provider'; import { NotFound, Forbidden, Maintenance, ComingSoon } from '@repo/ui/components'; +import { LoadingScreen } from '../core/components/loading-screen'; const AuthModule = lazy(() => import('./auth')); const AppModule = lazy(() => import('./modules')); @@ -15,7 +16,7 @@ export default function App() { return ( - Loading...}> + }> } /> } /> diff --git a/apps/web/src/apps/modules/example/example.page.tsx b/apps/web/src/apps/modules/example/example.page.tsx deleted file mode 100644 index e6c1363..0000000 --- a/apps/web/src/apps/modules/example/example.page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ExamplePage() { - return
example
; -} diff --git a/apps/web/src/apps/modules/example/full-page/data/full-page.remote.service.ts b/apps/web/src/apps/modules/example/full-page/data/full-page.remote.service.ts new file mode 100644 index 0000000..2d63b7c --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/data/full-page.remote.service.ts @@ -0,0 +1,25 @@ +import { BaseRemoteDataServices } from '@repo/core-api/data-services'; +import { FullPageDTO, FullPageEntity } from '../domain/entities'; + +/** + * Full Page Remote Data Services + * + * Provides core data services for the full-page module by extending the base remote data services. + * While this class automatically handles standard CRUD operations and data transformations out-of-the-box, + * implementers can freely extend it by adding custom methods to support domain-specific API endpoints + * or complex business logic as needed. + * + * @example + * ```ts + * export class FullPageRemoteDataServices extends BaseRemoteDataServices { + * // Example of adding a custom method tailored to specific module needs + * public async getDashboardMetrics(status: string): Promise { + * const response = await this.httpClient.get(`${this.apiUrl}/metrics`, { + * params: { status } + * }); + * return response.data; + * } + * } + * ``` + */ +export class FullPageRemoteDataServices extends BaseRemoteDataServices {} diff --git a/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts new file mode 100644 index 0000000..3823bf4 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/constants/full-page.constants.ts @@ -0,0 +1,22 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; + +/** + * Core configuration and constants for the Full Page module. + * Used across Domain, Data, and Presentation layers. + */ +export const FullPageModuleConfig: ModuleConfigEntity = { + /** Unique identifier for permissions, caching, and i18n */ + moduleKey: 'EXAMPLE_FULL_PAGE', + + /** Translation namespace — must match the namespace used in registerModuleNamespace() */ + translationNamespace: 'EXAMPLE_FULL_PAGE', + + /** Base API endpoint for remote data services */ + apiUrl: '/full-page', + + /** Base Web Router URL for UI navigation */ + webUrl: '/app/full-page', + + /** Architectural category of the module, used for rendering and routing logic */ + moduleCategory: 'FULL_PAGE', +} as const; diff --git a/apps/web/src/apps/modules/example/full-page/domain/constants/index.ts b/apps/web/src/apps/modules/example/full-page/domain/constants/index.ts new file mode 100644 index 0000000..1f3aff8 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './full-page.constants'; diff --git a/apps/web/src/apps/modules/example/full-page/domain/entities/full-page.entity.ts b/apps/web/src/apps/modules/example/full-page/domain/entities/full-page.entity.ts new file mode 100644 index 0000000..046e669 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/entities/full-page.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@repo/core-api/data-services'; + +/** + * Represents a full-page entity in the frontend domain model. + * + * This entity is derived from the API's `FullPageDTO` via the + * {@link FullPageTransformer}, which handles field name mapping + * and computed field derivation. + * + */ +export interface FullPageEntity extends BaseEntity { + status?: string; + name?: string; + code?: string; + description?: string; +} + +/** + * Represents the raw data structure returned by the API for a full-page resource. + * + * This DTO uses snake_case field names matching the backend's JSON serialization. + * It is transformed into a {@link FullPageEntity} by the {@link FullPageTransformer}. + */ +export interface FullPageDTO extends FullPageEntity { + [key: string]: any; +} diff --git a/apps/web/src/apps/modules/example/full-page/domain/entities/index.ts b/apps/web/src/apps/modules/example/full-page/domain/entities/index.ts new file mode 100644 index 0000000..de64115 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './full-page.entity'; diff --git a/apps/web/src/apps/modules/example/full-page/domain/factories/index.ts b/apps/web/src/apps/modules/example/full-page/domain/factories/index.ts new file mode 100644 index 0000000..45bf752 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/factories/index.ts @@ -0,0 +1,30 @@ +/** + * Full Page Factory + * + * This module acts as the dependency injection and configuration center for the full-page feature. + * It pre-configures and exports singleton instances of the data transformer and data service. + * By centralizing the instantiation here, it ensures that all UI components and hooks + * within the module share the same API client, configuration, and transformation logic. + */ + +import { apiClient } from '../../../../../../core/lib/api-client'; +import { FullPageRemoteDataServices } from '../../data/full-page.remote.service'; +import { FullPageModuleConfig } from '../constants/full-page.constants'; +import { FullPageRemoteDataTransformer } from '../transformers/full-page.remote.transformer'; + +/** + * Singleton instance of the FullPageRemoteDataTransformer. + * Exported for potential direct usage if manual data mapping is required outside the standard API flow. + */ +export const fullPageDataTransformer = new FullPageRemoteDataTransformer(); + +/** + * Pre-configured singleton instance of the FullPageRemoteDataServices. + * Ready to be consumed by UI components, state managers, or module providers. + * It is fully wired with the HTTP client and automatically handles data mapping via the injected transformer. + */ +export const fullPageDataService = new FullPageRemoteDataServices(apiClient, { + apiUrl: FullPageModuleConfig.apiUrl, + moduleKey: FullPageModuleConfig.moduleKey, + transformer: fullPageDataTransformer, +}); diff --git a/apps/web/src/apps/modules/example/full-page/domain/transformers/full-page.remote.transformer.ts b/apps/web/src/apps/modules/example/full-page/domain/transformers/full-page.remote.transformer.ts new file mode 100644 index 0000000..67a2d6a --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/transformers/full-page.remote.transformer.ts @@ -0,0 +1,45 @@ +import { BaseDataTransformer } from '@repo/core-api/data-services'; +import { FullPageEntity, FullPageDTO } from '../entities'; + +/** + * Full Page Remote Data Transformer + * + * Responsible for transforming data between the raw API data transfer objects (DTOs) + * and the frontend domain entities for the full-page module. By extending the base transformer, + * it ensures strict type safety and decouples data parsing logic from the API service layer. + * + * Implementers must define the core mapping rules (`transformToEntity`, `transformToDTO`) + * and can freely add custom transformation methods for specific API responses. + * + * @example + * ```ts + * export class FullPageRemoteDataTransformer extends BaseDataTransformer { + * // Map snake_case API payload to camelCase frontend entity + * public transformToEntity(dto: FullPageDTO): FullPageEntity { + * return { + * id: dto.id, + * documentNumber: dto.document_number, + * createdAt: new Date(dto.created_at), + * // ... other property mappings + * }; + * } + * + * // Map camelCase frontend entity back to snake_case API payload + * public transformToDTO(entity: FullPageEntity): FullPageDTO { + * return { + * id: entity.id, + * document_number: entity.documentNumber, + * // ... other property mappings + * }; + * } + * + * // Example of adding a custom transformation method for a specific feature + * public transformMetrics(rawData: any): MetricsEntity { + * return { + * totalActive: rawData.total_active_count ?? 0, + * }; + * } + * } + * ``` + */ +export class FullPageRemoteDataTransformer extends BaseDataTransformer {} diff --git a/apps/web/src/apps/modules/example/full-page/domain/validators/full-page.validator.ts b/apps/web/src/apps/modules/example/full-page/domain/validators/full-page.validator.ts new file mode 100644 index 0000000..a32d8e6 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/domain/validators/full-page.validator.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { compose, required, rangeLength } from '@repo/ui/validators'; + +/** + * Factory function to generate the Zod validation schema for the Full Page module. + * It accepts a translation object `t` to maintain domain purity while supporting + * dynamic, localized error messages (i18n). + * + * @param t - The translation object (typically derived from `useTranslation()` in the UI layer). + * @returns The configured Zod object schema. + */ +export const createFullPageSchema = (t: any) => { + return z.object({ + // Code: Required, length between 3 and 10 characters. + code: compose(z.string(), required(t.fields.code), rangeLength(3, 10, t.fields.code)), + + // Name: Required, length between 3 and 50 characters. + name: compose(z.string(), required(t.fields.name), rangeLength(3, 50, t.fields.name)), + + // Status: Required selection (typically from a dropdown/select). + status: compose(z.string(), required(t.fields.status)), + + // Description: Optional text field. + description: z.string().optional(), + }); +}; + +/** + * Data Transfer Object (DTO) for the Full Page form. + * This type is automatically inferred from the Zod schema factory. + * Use this type as a generic for form initialization, e.g., `useForm()`. + */ +export type FullPageFormDTO = z.infer>; diff --git a/apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx b/apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx new file mode 100644 index 0000000..d3fdc07 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/factory/index.tsx @@ -0,0 +1,43 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { EnterpriseModuleProvider } from '@repo/ui/foundations'; +import { registerModuleNamespace } from '@repo/core-i18n'; +import { FullPageModuleConfig } from '../../domain/constants'; +import { fullPageDataService } from '../../domain/factories'; +import { FullPageEntity } from '../../domain/entities'; + +import fullPageId from '../locales/id/full-page.json'; +import fullPageEn from '../locales/en/full-page.json'; + +const IndexPage = lazy(() => import('../pages/full-page.page.index')); +const FormPage = lazy(() => import('../pages/full-page.page.form')); +const DetailPage = lazy(() => import('../pages/full-page.page.detail')); + +// --------------------------------------------------------------------------- +// Namespace Registration (Module Scope) +// --------------------------------------------------------------------------- +// Called once at import time — safe, idempotent, outside React render cycle. +// The namespace 'full-page' must match config.translationNamespace. +registerModuleNamespace(FullPageModuleConfig.translationNamespace, { + id: fullPageId, + en: fullPageEn, +}); + +// --------------------------------------------------------------------------- +// Module Factory +// --------------------------------------------------------------------------- +export default function FullPageModule() { + return ( + config={FullPageModuleConfig} dataServices={fullPageDataService}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json new file mode 100644 index 0000000..fe973f1 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json @@ -0,0 +1,9 @@ +{ + "title": "Full Page Management", + "fields": { + "status": "Status", + "name": "Name", + "code": "Code", + "description": "Description" + } +} \ No newline at end of file diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json new file mode 100644 index 0000000..0d7d6ac --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json @@ -0,0 +1,9 @@ +{ + "title": "Manajemen Halaman Penuh", + "fields": { + "status": "Status", + "name": "Nama", + "code": "Kode", + "description": "Deskripsi" + } +} \ No newline at end of file diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx new file mode 100644 index 0000000..d68059d --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.detail.tsx @@ -0,0 +1,15 @@ +import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; + +export default function FullPagePageDetail() { + const { t } = useEnterpriseModuleTranslationContext(); + + return ( +
+
full-page.page.detail
+
{t('fields.code')}
+
{t('fields.name')}
+
{t('fields.status')}
+
{t('fields.description')}
+
+ ); +} diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.form.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.form.tsx new file mode 100644 index 0000000..c489d62 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.form.tsx @@ -0,0 +1,18 @@ +import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; + +export default function FullPagePageForm({ formPageType }: { formPageType: 'edit' | 'create' | 'duplicate' }) { + const { t } = useEnterpriseModuleTranslationContext(); + + return ( +
+
full-page.page.form
+
Form Type: {formPageType}
+
{t('fields.code')}
+
{t('fields.name')}
+
{t('fields.status')}
+
{t('fields.description')}
+
{t('validation:required')}
+
{t('common:save')}
+
+ ); +} diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx new file mode 100644 index 0000000..480e528 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages/full-page.page.index.tsx @@ -0,0 +1,102 @@ +import { Table, Box, Title, Paper } from '@repo/ui/components'; +import { PageActions } from '@repo/ui/components'; +import { + useEnterpriseModuleTranslationContext, + useEnterpriseModuleNavigationContext, +} from '@repo/ui/foundations'; +import { FullPageEntity } from '../../domain/entities'; +import { Edit2, Eye, Copy, Trash2 } from 'lucide-react'; +import { useCallback } from 'react'; + +// TODO: Replace this mock data with real API data loaded from DataService via useEnterpriseModuleDataServiceContext +const MOCK_DATA: FullPageEntity[] = [ + { id: '1', name: 'Dashboard Widget', code: 'WID-001', status: 'ACTIVE', description: 'Main dashboard widget' }, + { id: '2', name: 'Report Generator', code: 'REP-002', status: 'INACTIVE', description: 'Generates monthly reports' }, + { + id: '3', + name: 'User Management', + code: 'USR-003', + status: 'ACTIVE', + description: 'Manages user roles and permissions', + }, +]; + +export default function FullPagePageIndex() { + // Translation is scoped to ['full-page', 'common'] — no prefix needed for module keys + const { t } = useEnterpriseModuleTranslationContext(); + + const { navigateToDetail, navigateToEdit, navigateToDuplicate } = useEnterpriseModuleNavigationContext(); + + const getRowActions = useCallback( + (row: FullPageEntity) => [ + { + key: 'view', + label: t('common:view'), + icon: , + onClick: () => navigateToDetail(row.id as string), + }, + { + key: 'edit', + label: t('common:edit'), + icon: , + onClick: () => navigateToEdit(row.id as string), + }, + { + key: 'duplicate', + label: t('common:duplicate'), + icon: , + onClick: () => navigateToDuplicate(row.id as string), + }, + { + type: 'divider' as const, + key: 'div-1', + }, + { + key: 'delete', + label: t('common:delete'), + icon: , + intent: 'destructive' as const, + onClick: () => { + // Mock delete action + alert(`Delete ${row.name}`); + }, + }, + ], + [t, navigateToDetail, navigateToEdit, navigateToDuplicate], + ); + + const rows = MOCK_DATA.map((item) => ( + + {item.code} + {item.name} + {item.status} + {item.description} + + + + + )); + + return ( + + + {t('title')} + + + + + + + {t('fields.code')} + {t('fields.name')} + {t('fields.status')} + {t('fields.description')} + {t('common:edit')} + + + {rows} +
+
+
+ ); +} diff --git a/apps/web/src/apps/modules/index.tsx b/apps/web/src/apps/modules/index.tsx index f36fdfb..dba5012 100644 --- a/apps/web/src/apps/modules/index.tsx +++ b/apps/web/src/apps/modules/index.tsx @@ -1,13 +1,17 @@ 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 FullPageModule = lazy(() => import('./example/full-page/presentation/factory')); export default function AppModule() { return ( - - } /> - } /> - + + + } /> + } /> + } /> + + ); } diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx new file mode 100644 index 0000000..a240641 --- /dev/null +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from '@repo/core-i18n'; +import { Burger, Group, Select, Text, useCoreAppShell } from '@repo/ui/components'; +import { Globe } from 'lucide-react'; +import { AppStorageKey, secureStorage } from '../../../../core/storage/local'; + +export default function HeaderLayout() { + const { i18n } = useTranslation(); + const { mobileOpened, toggleMobile } = useCoreAppShell(); + return ( + + + + + Mock Header (bg="green.1") + + } data={[ diff --git a/apps/web/src/apps/modules/layouts/module.layout.tsx b/apps/web/src/apps/modules/layouts/module.layout.tsx index 6ec734c..fa7c64d 100644 --- a/apps/web/src/apps/modules/layouts/module.layout.tsx +++ b/apps/web/src/apps/modules/layouts/module.layout.tsx @@ -5,8 +5,7 @@ import { MENU_ITEMS } from './data/menu.data'; export default function ModuleLayout({ children }: { children: React.ReactNode }) { const configAppShell: CoreAppShellConfig = { - // variant: 'header-first', - variant: 'sidebar-first', + variant: 'header-first', features: { desktopCollapseVariant: 'mini', withUtilityBar: false, From f9df63a27d0959e57ad02c830aa10fed2149ae51 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:51:00 +0700 Subject: [PATCH 11/35] feat: enhance HeaderLayout with user profile menu and notifications, update translations for common terms --- .../layouts/components/header.layout.tsx | 173 +++++++++++++++--- packages/core-i18n/src/locales/en/common.json | 12 +- packages/core-i18n/src/locales/id/common.json | 12 +- 3 files changed, 174 insertions(+), 23 deletions(-) diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx index 69e822a..66ad44a 100644 --- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -1,31 +1,162 @@ import { useTranslation } from '@repo/core-i18n'; -import { Burger, Group, Select, Text, useCoreAppShell } from '@repo/ui/components'; -import { Globe } from 'lucide-react'; +import { + Burger, + Group, + Select, + Text, + useCoreAppShell, + ActionIcon, + Indicator, + Menu, + Avatar, + UnstyledButton, + Box, +} from '@repo/ui/components'; +import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown } from 'lucide-react'; import { AppStorageKey, secureStorage } from '../../../../core/storage/local'; +// 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 } = useTranslation(); + const { i18n, t } = useTranslation(); const { mobileOpened, toggleMobile } = useCoreAppShell(); + return ( - - + + {/* 1. LEFT SECTION (Navigation & Context) */} + - LOGO - { + if (!val) return; + await i18n.changeLanguage(val); + await secureStorage.setItem(AppStorageKey.LOCALE, val); + }} + styles={{ input: { textAlign: 'right' } }} + /> + + + + + + + {t('common:application', { ns: 'common' })} + }> + {t('common:accountSettings', { ns: 'common' })} + + }>{t('common:myProfile', { ns: 'common' })} + + + + {t('common:workspace', { ns: 'common' })} + }> + {t('common:switchWorkspace', { ns: 'common' })} + + + + + + + }> + {t('common:signOut', { ns: 'common' })} + + + + ); diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index 8087839..498ac39 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -9,6 +9,16 @@ "delete": "Delete", "edit": "Edit", "duplicate": "Duplicate", - "view": "View" + "view": "View", + "preferences": "Preferences", + "language": "Language", + "application": "Application", + "accountSettings": "Account Settings", + "myProfile": "My Profile", + "workspace": "Workspace", + "switchWorkspace": "Switch Workspace", + "signOut": "Sign Out", + "english": "English", + "indonesian": "Indonesia" } } \ No newline at end of file diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index ff018cc..0fe528e 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -9,6 +9,16 @@ "delete": "Hapus", "edit": "Ubah", "duplicate": "Duplikat", - "view": "Lihat" + "view": "Lihat", + "preferences": "Preferensi", + "language": "Bahasa", + "application": "Aplikasi", + "accountSettings": "Pengaturan Akun", + "myProfile": "Profil Saya", + "workspace": "Ruang Kerja", + "switchWorkspace": "Ganti Ruang Kerja", + "signOut": "Keluar", + "english": "Inggris", + "indonesian": "Indonesia" } } \ No newline at end of file From 6df6880ba3f25e5f24ee3b79662e18a5e1f8b64e Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:59:14 +0700 Subject: [PATCH 12/35] refactor: improve layout spacing and enhance preferences menu in HeaderLayout --- .../layouts/components/header.layout.tsx | 100 ++++++++++-------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx index 66ad44a..8920a2f 100644 --- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -55,7 +55,7 @@ export default function HeaderLayout() { {/* 3. RIGHT SECTION (Actions & Profile) */} - + @@ -75,7 +75,7 @@ export default function HeaderLayout() { transition: 'background-color 150ms ease', }} > - + {getInitials(USER.name)} @@ -94,8 +94,9 @@ export default function HeaderLayout() { - - + + {/* Mobile User Info */} + {USER.name} @@ -105,56 +106,63 @@ export default function HeaderLayout() { - - {t('common:preferences', { ns: 'common' })} - - - - {t('common:language', { ns: 'common' })} - - { + if (!val) return; + await i18n.changeLanguage(val); + await secureStorage.setItem(AppStorageKey.LOCALE, val); + }} + styles={{ + input: { + textAlign: 'right', + cursor: 'pointer', + height: '22px', + minHeight: '22px', + }, + root: { + marginTop: '-4px', + marginBottom: '-4px', + }, + }} + /> + - - {t('common:application', { ns: 'common' })} - }> - {t('common:accountSettings', { ns: 'common' })} - - }>{t('common:myProfile', { ns: 'common' })} + {/* Application Section */} + {t('common:application')} + }>{t('common:accountSettings')} + }>{t('common:myProfile')} - + - {t('common:workspace', { ns: 'common' })} - }> - {t('common:switchWorkspace', { ns: 'common' })} - - + {/* Workspace Section */} + {t('common:workspace')} + }>{t('common:switchWorkspace')} - - + - }> - {t('common:signOut', { ns: 'common' })} - - + {/* Sign Out Section */} + }> + {t('common:signOut')} + From 1de110818ae4623cba13f78df7b64cc30213a3d3 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:08:29 +0700 Subject: [PATCH 13/35] feat: implement notification dropdown component and add localized notification strings --- .../layouts/components/header.layout.tsx | 6 +- .../notifications/notification-dropdown.tsx | 148 ++++++++++++++++++ packages/core-i18n/src/locales/en/common.json | 10 +- packages/core-i18n/src/locales/id/common.json | 10 +- 4 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx index 8920a2f..d50bc56 100644 --- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -6,7 +6,6 @@ import { Text, useCoreAppShell, ActionIcon, - Indicator, Menu, Avatar, UnstyledButton, @@ -14,6 +13,7 @@ import { } from '@repo/ui/components'; import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown } from 'lucide-react'; import { AppStorageKey, secureStorage } from '../../../../core/storage/local'; +import { NotificationDropdown } from './notifications/notification-dropdown'; // Dummy user data for preview const USER = { @@ -60,11 +60,11 @@ export default function HeaderLayout() { - + - + diff --git a/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx b/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx new file mode 100644 index 0000000..087e7bf --- /dev/null +++ b/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx @@ -0,0 +1,148 @@ +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'; + +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([ + { + id: '1', + title: 'New User Registration', + description: 'John Doe has registered a new account in the system.', + timestamp: t('common:notifications_justNow'), + isRead: false, + }, + { + id: '2', + title: 'System Update Completed', + description: 'The ERP database has been successfully updated to v2.4.', + timestamp: `2 ${t('common:notifications_hoursAgo')}`, + isRead: false, + }, + { + id: '3', + title: 'Weekly Report Ready', + description: 'Your weekly financial summary is ready to be downloaded.', + timestamp: `5 ${t('common:notifications_hoursAgo')}`, + isRead: true, + }, + ]); + + const unreadCount = notifications.filter((n) => !n.isRead).length; + + const handleMarkAllAsRead = () => { + setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true }))); + }; + + return ( + + + {/* Trigger wrapped around the Bell icon */} + setOpened((o) => !o)} style={{ cursor: 'pointer' }}> + + {children} + + + + + + + + {t('common:notifications_title')} + + {unreadCount > 0 && ( + + + {t('common:notifications_markAllAsRead')} + + + )} + + + + {notifications.length === 0 ? ( +
+ + + + {t('common:notifications_emptyState')} + + +
+ ) : ( + notifications.map((item) => ( + + + {item.title} + + + {item.description} + + + {item.timestamp} + + + } + leftSection={ + + + + + + } + active={!item.isRead} + variant="light" + style={{ + borderBottom: '1px solid var(--mantine-color-default-border)', + }} + /> + )) + )} +
+ + {notifications.length > 0 && ( + + + + )} +
+
+ ); +} diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index 498ac39..2646c98 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -19,6 +19,14 @@ "switchWorkspace": "Switch Workspace", "signOut": "Sign Out", "english": "English", - "indonesian": "Indonesia" + "indonesian": "Indonesia", + "notifications_title": "Notifications", + "notifications_unread": "Unread", + "notifications_all": "All", + "notifications_markAllAsRead": "Mark all as read", + "notifications_viewAll": "View all notifications", + "notifications_emptyState": "No new notifications", + "notifications_justNow": "Just now", + "notifications_hoursAgo": "hours ago" } } \ No newline at end of file diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index 0fe528e..0f14717 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -19,6 +19,14 @@ "switchWorkspace": "Ganti Ruang Kerja", "signOut": "Keluar", "english": "Inggris", - "indonesian": "Indonesia" + "indonesian": "Bahasa Indonesia", + "notifications_title": "Notifikasi", + "notifications_unread": "Belum dibaca", + "notifications_all": "Semua", + "notifications_markAllAsRead": "Tandai semua dibaca", + "notifications_viewAll": "Lihat semua notifikasi", + "notifications_emptyState": "Tidak ada notifikasi baru", + "notifications_justNow": "Baru saja", + "notifications_hoursAgo": "jam lalu" } } \ No newline at end of file From 03fc2d203651b63c1278d30c24f55c006c532774 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:18:33 +0700 Subject: [PATCH 14/35] refactor: update notification strings and improve translations in common.json --- .../layouts/components/header.layout.tsx | 2 +- .../notifications/notification-dropdown.tsx | 14 +++++++------- packages/core-i18n/src/locales/en/common.json | 17 ++++++++--------- packages/core-i18n/src/locales/id/common.json | 19 +++++++++---------- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx index d50bc56..3f9ea43 100644 --- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -148,7 +148,7 @@ export default function HeaderLayout() { {/* Application Section */} {t('common:application')} - }>{t('common:accountSettings')} + }>{t('common:settings')} }>{t('common:myProfile')} diff --git a/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx b/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx index 087e7bf..c619a83 100644 --- a/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx +++ b/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx @@ -36,21 +36,21 @@ export function NotificationDropdown({ children }: NotificationDropdownProps) { id: '1', title: 'New User Registration', description: 'John Doe has registered a new account in the system.', - timestamp: t('common:notifications_justNow'), + 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:notifications_hoursAgo')}`, + 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:notifications_hoursAgo')}`, + timestamp: `5 ${t('common:hoursAgo')}`, isRead: true, }, ]); @@ -80,12 +80,12 @@ export function NotificationDropdown({ children }: NotificationDropdownProps) { style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }} > - {t('common:notifications_title')} + {t('common:notificationsTitle')} {unreadCount > 0 && ( - {t('common:notifications_markAllAsRead')} + {t('common:markAllAsRead')} )} @@ -97,7 +97,7 @@ export function NotificationDropdown({ children }: NotificationDropdownProps) { - {t('common:notifications_emptyState')} + {t('common:emptyState')} @@ -138,7 +138,7 @@ export function NotificationDropdown({ children }: NotificationDropdownProps) { {notifications.length > 0 && ( )} diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index 2646c98..e12b59d 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -13,20 +13,19 @@ "preferences": "Preferences", "language": "Language", "application": "Application", - "accountSettings": "Account Settings", "myProfile": "My Profile", "workspace": "Workspace", "switchWorkspace": "Switch Workspace", "signOut": "Sign Out", "english": "English", "indonesian": "Indonesia", - "notifications_title": "Notifications", - "notifications_unread": "Unread", - "notifications_all": "All", - "notifications_markAllAsRead": "Mark all as read", - "notifications_viewAll": "View all notifications", - "notifications_emptyState": "No new notifications", - "notifications_justNow": "Just now", - "notifications_hoursAgo": "hours ago" + "notificationsTitle": "Notifications", + "unread": "Unread", + "all": "All", + "markAllAsRead": "Mark all as read", + "viewAll": "View all notifications", + "emptyState": "No new notifications", + "justNow": "Just now", + "hoursAgo": "hours ago" } } \ No newline at end of file diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index 0f14717..100049d 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -13,20 +13,19 @@ "preferences": "Preferensi", "language": "Bahasa", "application": "Aplikasi", - "accountSettings": "Pengaturan Akun", "myProfile": "Profil Saya", "workspace": "Ruang Kerja", "switchWorkspace": "Ganti Ruang Kerja", "signOut": "Keluar", "english": "Inggris", - "indonesian": "Bahasa Indonesia", - "notifications_title": "Notifikasi", - "notifications_unread": "Belum dibaca", - "notifications_all": "Semua", - "notifications_markAllAsRead": "Tandai semua dibaca", - "notifications_viewAll": "Lihat semua notifikasi", - "notifications_emptyState": "Tidak ada notifikasi baru", - "notifications_justNow": "Baru saja", - "notifications_hoursAgo": "jam lalu" + "indonesian": "Indonesia", + "notificationsTitle": "Notifikasi", + "unread": "Belum dibaca", + "all": "Semua", + "markAllAsRead": "Tandai semua dibaca", + "viewAll": "Lihat semua notifikasi", + "emptyState": "Tidak ada notifikasi baru", + "justNow": "Baru saja", + "hoursAgo": "jam lalu" } } \ No newline at end of file From c9dbe6c204b436f6321311573aadb43601c37d30 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:27:30 +0700 Subject: [PATCH 15/35] feat: add system pages for information, notifications, and settings with corresponding navigation and i18n support --- apps/web/src/apps/modules/index.tsx | 6 ++++++ .../layouts/components/header.layout.tsx | 5 +++-- .../notifications/notification-dropdown.tsx | 3 ++- .../apps/modules/system/information/index.tsx | 17 +++++++++++++++++ .../apps/modules/system/notification/index.tsx | 17 +++++++++++++++++ .../src/apps/modules/system/setting/index.tsx | 17 +++++++++++++++++ packages/core-i18n/src/locales/en/common.json | 8 +++++++- packages/core-i18n/src/locales/id/common.json | 8 +++++++- 8 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/apps/modules/system/information/index.tsx create mode 100644 apps/web/src/apps/modules/system/notification/index.tsx create mode 100644 apps/web/src/apps/modules/system/setting/index.tsx diff --git a/apps/web/src/apps/modules/index.tsx b/apps/web/src/apps/modules/index.tsx index 839c01e..d588af8 100644 --- a/apps/web/src/apps/modules/index.tsx +++ b/apps/web/src/apps/modules/index.tsx @@ -3,12 +3,18 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import ModuleLayout from './layouts/module.layout'; 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 ( } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/apps/modules/layouts/components/header.layout.tsx b/apps/web/src/apps/modules/layouts/components/header.layout.tsx index 3f9ea43..0a5abb0 100644 --- a/apps/web/src/apps/modules/layouts/components/header.layout.tsx +++ b/apps/web/src/apps/modules/layouts/components/header.layout.tsx @@ -14,6 +14,7 @@ import { import { Globe, Bell, HelpCircle, Settings, User, Briefcase, LogOut, ChevronDown } from 'lucide-react'; import { AppStorageKey, secureStorage } from '../../../../core/storage/local'; import { NotificationDropdown } from './notifications/notification-dropdown'; +import { Link } from 'react-router-dom'; // Dummy user data for preview const USER = { @@ -56,7 +57,7 @@ export default function HeaderLayout() { {/* 3. RIGHT SECTION (Actions & Profile) */} - + @@ -148,7 +149,7 @@ export default function HeaderLayout() { {/* Application Section */} {t('common:application')} - }>{t('common:settings')} + }>{t('common:settings')} }>{t('common:myProfile')} diff --git a/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx b/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx index c619a83..97b922e 100644 --- a/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx +++ b/apps/web/src/apps/modules/layouts/components/notifications/notification-dropdown.tsx @@ -14,6 +14,7 @@ import { NavLink, } from '@repo/ui/components'; import { BellOff } from 'lucide-react'; +import { Link } from 'react-router-dom'; interface NotificationItem { id: string; @@ -137,7 +138,7 @@ export function NotificationDropdown({ children }: NotificationDropdownProps) { {notifications.length > 0 && ( - diff --git a/apps/web/src/apps/modules/system/information/index.tsx b/apps/web/src/apps/modules/system/information/index.tsx new file mode 100644 index 0000000..a803ea3 --- /dev/null +++ b/apps/web/src/apps/modules/system/information/index.tsx @@ -0,0 +1,17 @@ +import { Container, Paper, Title, Text } from '@repo/ui/components'; +import { useTranslation } from '@repo/core-i18n'; + +export default function InformationPage() { + const { t } = useTranslation(); + + return ( + + + + {t('common:systemInformation')} + + {t('common:systemInformationDesc')} + + + ); +} diff --git a/apps/web/src/apps/modules/system/notification/index.tsx b/apps/web/src/apps/modules/system/notification/index.tsx new file mode 100644 index 0000000..cd23740 --- /dev/null +++ b/apps/web/src/apps/modules/system/notification/index.tsx @@ -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 ( + + + + {t('common:systemNotifications')} + + {t('common:systemNotificationsDesc')} + + + ); +} diff --git a/apps/web/src/apps/modules/system/setting/index.tsx b/apps/web/src/apps/modules/system/setting/index.tsx new file mode 100644 index 0000000..9df8096 --- /dev/null +++ b/apps/web/src/apps/modules/system/setting/index.tsx @@ -0,0 +1,17 @@ +import { Container, Paper, Title, Text } from '@repo/ui/components'; +import { useTranslation } from '@repo/core-i18n'; + +export default function ConfigurationPage() { + const { t } = useTranslation(); + + return ( + + + + {t('common:configuration')} + + {t('common:configurationDesc')} + + + ); +} diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index e12b59d..9e88c68 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -26,6 +26,12 @@ "viewAll": "View all notifications", "emptyState": "No new notifications", "justNow": "Just now", - "hoursAgo": "hours ago" + "hoursAgo": "hours ago", + "configuration": "System Configuration", + "configurationDesc": "Manage your system settings and preferences here.", + "systemInformation": "System Information", + "systemInformationDesc": "View details about the system version and status.", + "systemNotifications": "All Notifications", + "systemNotificationsDesc": "View and manage all system notifications." } } \ No newline at end of file diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index 100049d..0e90c76 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -26,6 +26,12 @@ "viewAll": "Lihat semua notifikasi", "emptyState": "Tidak ada notifikasi baru", "justNow": "Baru saja", - "hoursAgo": "jam lalu" + "hoursAgo": "jam lalu", + "configuration": "Konfigurasi Sistem", + "configurationDesc": "Kelola pengaturan dan preferensi sistem Anda di sini.", + "systemInformation": "Informasi Sistem", + "systemInformationDesc": "Lihat detail tentang versi dan status sistem.", + "systemNotifications": "Semua Notifikasi", + "systemNotificationsDesc": "Lihat dan kelola semua notifikasi sistem." } } \ No newline at end of file From 34d1dff0f6690248b5918817918a0bd0bb681e5e Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:27:28 +0700 Subject: [PATCH 16/35] feat(sidebar): add expand/collapse functionality and search menu with keyboard shortcuts feat(i18n): add translations for expand/collapse menu and search menu --- .../modules/layouts/components/sidebar.tsx | 146 +++++++++++++++--- packages/core-i18n/src/locales/en/common.json | 5 +- packages/core-i18n/src/locales/id/common.json | 5 +- 3 files changed, 132 insertions(+), 24 deletions(-) diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 744c396..1f855f4 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -1,10 +1,11 @@ -import { memo, useCallback, useMemo, useState, useRef } from 'react'; +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 } from '@repo/ui/components'; import { useCoreAppShell } from '@repo/ui/components'; -import { ChevronsLeft, ChevronsRight, Search, X } from 'lucide-react'; +import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X } from 'lucide-react'; import type { MenuItemType } from '../types/menu.types'; import type { SidebarVariant } from '@repo/ui/components'; +import { useTranslation } from '@repo/core-i18n'; // --------------------------------------------------------------------------- // Props @@ -32,9 +33,17 @@ interface MenuItemExpandedProps { item: MenuItemType; activeKeys: Set; isSearching?: boolean; + expandVersion?: number; + collapseVersion?: number; } -const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSearching }: MenuItemExpandedProps) { +const MenuItemExpanded = memo(function MenuItemExpanded({ + item, + activeKeys, + isSearching, + expandVersion = 0, + collapseVersion = 0, +}: MenuItemExpandedProps) { const Icon = item.icon; const isActive = activeKeys.has(item.key); const hasChildren = item.children && item.children.length > 0; @@ -45,6 +54,14 @@ const MenuItemExpanded = memo(function MenuItemExpanded({ item, activeKeys, isSe const [opened, setOpened] = useState(isActive); const isOpened = isSearching || opened; + useEffect(() => { + if (expandVersion > 0) setOpened(true); + }, [expandVersion]); + + useEffect(() => { + if (collapseVersion > 0) setOpened(false); + }, [collapseVersion]); + return ( {hasChildren && item.children!.map((child) => ( - + ))} ); @@ -195,6 +219,8 @@ export const SidebarMenu = memo(function SidebarMenu({ withToggle = false, withMenuFilter = true, }: SidebarMenuProps) { + const { t } = useTranslation(); + const { pathname } = useLocation(); const { sidebarVariant: contextVariant, setSidebarVariant } = useCoreAppShell(); @@ -205,6 +231,20 @@ export const SidebarMenu = memo(function SidebarMenu({ const inputRef = useRef(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(); @@ -291,6 +331,28 @@ export const SidebarMenu = memo(function SidebarMenu({ } }, [isMini, setSidebarVariant]); + const [isMac, setIsMac] = useState(true); + useEffect(() => { + setIsMac(typeof window !== 'undefined' && navigator.userAgent.includes('Mac')); + }, []); + + const shortcutText = isMac ? '⌘K' : 'Ctrl+K'; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + if (isMini) { + handleExpandAndSearch(); + } else { + inputRef.current?.focus(); + } + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isMini, handleExpandAndSearch]); + // -- Mini (Collapsed) Mode ------------------------------------------------ if (isMini) { return ( @@ -352,29 +414,69 @@ export const SidebarMenu = memo(function SidebarMenu({ borderBottom: '1px solid var(--mantine-color-default-border)', }} > - ) => setSearchQuery(e.currentTarget.value)} - leftSection={} - variant="filled" - radius="md" - size="xs" - rightSection={ - searchQuery ? ( - setSearchQuery('')} size="sm"> - - - ) : null - } - /> + + ) => setSearchQuery(e.currentTarget.value)} + leftSection={} + variant="filled" + radius="md" + size="xs" + style={{ flex: 1 }} + rightSectionWidth={searchQuery ? 30 : 48} + rightSection={ + searchQuery ? ( + setSearchQuery('')} size="sm"> + + + ) : ( + + {shortcutText} + + ) + } + /> + + + {isAllExpanded ? : } + + + )} {filteredItems.map((item) => ( - + ))} diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index 9e88c68..3bdf019 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -32,6 +32,9 @@ "systemInformation": "System Information", "systemInformationDesc": "View details about the system version and status.", "systemNotifications": "All Notifications", - "systemNotificationsDesc": "View and manage all system notifications." + "systemNotificationsDesc": "View and manage all system notifications.", + "expandAll": "Expand all menu", + "collapseAll": "Collapse all menu", + "searchMenu": "Search menu" } } \ No newline at end of file diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index 0e90c76..987ac7a 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -32,6 +32,9 @@ "systemInformation": "Informasi Sistem", "systemInformationDesc": "Lihat detail tentang versi dan status sistem.", "systemNotifications": "Semua Notifikasi", - "systemNotificationsDesc": "Lihat dan kelola semua notifikasi sistem." + "systemNotificationsDesc": "Lihat dan kelola semua notifikasi sistem.", + "expandAll": "Buka semua menu", + "collapseAll": "Tutup semua menu", + "searchMenu": "Cari menu" } } \ No newline at end of file From d91293eac109104ff5784768a1be81f04181c902 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:54:17 +0700 Subject: [PATCH 17/35] style: update sidebar search action icon variant from light to subtle --- apps/web/src/apps/modules/layouts/components/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 1f855f4..63505fc 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -371,7 +371,7 @@ export const SidebarMenu = memo(function SidebarMenu({ justifyContent: 'center', }} > - + From a43ea0697ae846da2b654fbae3a11e024175ea1d Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:21:04 +0700 Subject: [PATCH 18/35] feat: internationalize sidebar collapse and expand labels using translation keys --- apps/web/src/apps/modules/layouts/components/sidebar.tsx | 6 +++--- packages/core-i18n/src/locales/en/common.json | 4 +++- packages/core-i18n/src/locales/id/common.json | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/apps/modules/layouts/components/sidebar.tsx b/apps/web/src/apps/modules/layouts/components/sidebar.tsx index 63505fc..00da3b8 100644 --- a/apps/web/src/apps/modules/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/modules/layouts/components/sidebar.tsx @@ -2,7 +2,7 @@ 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 } from '@repo/ui/components'; import { useCoreAppShell } from '@repo/ui/components'; -import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X } from 'lucide-react'; +import { ChevronsLeft, ChevronsRight, PanelTopClose, PanelTopOpen, Search, X } from 'lucide-react'; import type { MenuItemType } from '../types/menu.types'; import type { SidebarVariant } from '@repo/ui/components'; import { useTranslation } from '@repo/core-i18n'; @@ -387,7 +387,7 @@ export const SidebarMenu = memo(function SidebarMenu({ <> - + @@ -485,7 +485,7 @@ export const SidebarMenu = memo(function SidebarMenu({ } onClick={handleToggle} variant="subtle" diff --git a/packages/core-i18n/src/locales/en/common.json b/packages/core-i18n/src/locales/en/common.json index 3bdf019..2d01dcc 100644 --- a/packages/core-i18n/src/locales/en/common.json +++ b/packages/core-i18n/src/locales/en/common.json @@ -35,6 +35,8 @@ "systemNotificationsDesc": "View and manage all system notifications.", "expandAll": "Expand all menu", "collapseAll": "Collapse all menu", - "searchMenu": "Search menu" + "searchMenu": "Search menu", + "collapse": "Collapse", + "expandSidebar": "Expand Sidebar" } } \ No newline at end of file diff --git a/packages/core-i18n/src/locales/id/common.json b/packages/core-i18n/src/locales/id/common.json index 987ac7a..c7a5d70 100644 --- a/packages/core-i18n/src/locales/id/common.json +++ b/packages/core-i18n/src/locales/id/common.json @@ -35,6 +35,8 @@ "systemNotificationsDesc": "Lihat dan kelola semua notifikasi sistem.", "expandAll": "Buka semua menu", "collapseAll": "Tutup semua menu", - "searchMenu": "Cari menu" + "searchMenu": "Cari menu", + "collapse": "Tutup", + "expandSidebar": "Perluas Sidebar" } } \ No newline at end of file From 430b2313601f9c936b028ca370f6fb1b208de003 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:23:46 +0700 Subject: [PATCH 19/35] fix: add meta tag to prevent translation by Google --- apps/web/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/index.html b/apps/web/index.html index dec270e..9d591cc 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,9 +1,10 @@ - + + Vite + React From 2b8f9a9cbc9aaa0baa5bc924ea46012a852bb216 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:58:55 +0700 Subject: [PATCH 20/35] feat: add full page index component with pagination and actions - Implemented FullPagePageIndex component with mock data for database clusters. - Added pagination and bulk actions for managing database clusters. - Created navigation context hooks for detail, edit, duplicate, and create actions. - Introduced a new store for managing state in full-page and single-page modules. feat: add navigation localization files - Added English and Indonesian localization files for navigation menu items. - Included translations for various modules including CRM, Sales, Supply Chain, and more. feat: create system information shortcuts component - Developed Shortcut component to display keyboard shortcuts with search functionality. - Implemented System component to show placeholder information when system details are unavailable. - Added localization for shortcuts and system information in English and Indonesian. feat: implement global theme store - Created a Zustand store for managing theme color scheme with localStorage persistence. feat: add module page header component - Developed ModulePageHeader component for consistent page header across modules. - Included breadcrumb navigation, title, description, and action buttons. feat: define default privileges for enterprise module - Established default privileges for CRUD operations and other actions in the enterprise module. --- apps/web/package.json | 3 +- apps/web/src/apps/index.tsx | 21 +- .../presentation/locales/en/full-page.json | 3 +- .../presentation/locales/id/full-page.json | 3 +- .../pages-backup/full-page.page.detail.tsx | 131 +++++ .../pages-backup/full-page.page.form.tsx | 110 ++++ .../pages-backup/full-page.page.index.tsx | 424 ++++++++++++++++ .../pages/full-page.page.detail.tsx | 130 ++++- .../pages/full-page.page.form.tsx | 148 +++++- .../pages/full-page.page.index.tsx | 472 ++++++++++++++---- .../presentation/{states => store}/index.ts | 0 .../presentation/{states => store}/index.ts | 0 .../layouts/components/header.layout.tsx | 45 +- .../notifications/notification-dropdown.tsx | 14 +- .../modules/layouts/components/sidebar.tsx | 65 ++- .../apps/modules/layouts/data/menu.data.ts | 68 +-- .../apps/modules/layouts/locales/en/nav.json | 37 ++ .../apps/modules/layouts/locales/id/nav.json | 37 ++ .../apps/modules/layouts/module.layout.tsx | 10 + .../apps/modules/layouts/types/menu.types.ts | 2 +- .../information/components/shortcut/data.ts | 20 + .../information/components/shortcut/index.tsx | 103 ++++ .../information/components/system/index.tsx | 19 + .../apps/modules/system/information/index.tsx | 61 ++- .../information/locales/en/information.json | 34 ++ .../information/locales/id/information.json | 34 ++ .../components/ActionToolsShowcase.tsx | 15 +- .../stock-grid/live-stock-grid.ui.tsx | 59 ++- .../src/apps/showcase/shell-demo/index.tsx | 2 +- apps/web/src/apps/showcase/showcase-view.tsx | 10 +- apps/web/src/core/storage/local/index.ts | 2 + apps/web/src/core/store/theme.store.ts | 29 ++ .../base-remote.data-services.test.ts | 14 +- packages/core-i18n/src/locales/en/common.json | 43 +- packages/core-i18n/src/locales/id/common.json | 43 +- packages/ui/package.json | 1 + .../components/actions-tools/page-actions.tsx | 110 ++-- .../components/actions-tools/row-actions.tsx | 6 +- .../ui/src/components/actions-tools/types.ts | 10 +- .../core-app-shell/core-app-shell.tsx | 26 +- .../core-app-shell/core-page-container.tsx | 61 ++- .../src/components/system-pages/forbidden.tsx | 27 +- .../src/components/system-pages/not-found.tsx | 28 +- .../components/module-page-header.tsx | 310 ++++++++++++ .../constant/default-privilege.ts | 26 + .../enterprise-module/constant/index.ts | 1 + .../enterprise-module/entities/entity.ts | 48 +- .../hooks/use-index-page.context.ts | 15 +- .../foundations/enterprise-module/index.ts | 4 + .../providers/index-page.provider.tsx | 97 ++++ .../providers/module.provider.tsx | 27 +- packages/ui/src/provider/theme-provider.tsx | 13 +- packages/ui/src/theme.css | 41 +- packages/ui/src/theme/tokens/colors.ts | 28 ++ pnpm-lock.yaml | 73 +++ 55 files changed, 2809 insertions(+), 354 deletions(-) create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.detail.tsx create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.form.tsx create mode 100644 apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.index.tsx rename apps/web/src/apps/modules/example/full-page/presentation/{states => store}/index.ts (100%) rename apps/web/src/apps/modules/example/single-page/presentation/{states => store}/index.ts (100%) create mode 100644 apps/web/src/apps/modules/layouts/locales/en/nav.json create mode 100644 apps/web/src/apps/modules/layouts/locales/id/nav.json create mode 100644 apps/web/src/apps/modules/system/information/components/shortcut/data.ts create mode 100644 apps/web/src/apps/modules/system/information/components/shortcut/index.tsx create mode 100644 apps/web/src/apps/modules/system/information/components/system/index.tsx create mode 100644 apps/web/src/apps/modules/system/information/locales/en/information.json create mode 100644 apps/web/src/apps/modules/system/information/locales/id/information.json create mode 100644 apps/web/src/core/store/theme.store.ts create mode 100644 packages/ui/src/foundations/enterprise-module/components/module-page-header.tsx create mode 100644 packages/ui/src/foundations/enterprise-module/constant/default-privilege.ts create mode 100644 packages/ui/src/foundations/enterprise-module/constant/index.ts diff --git a/apps/web/package.json b/apps/web/package.json index bb1468b..fc8add5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,7 +31,8 @@ "react-i18next": "^15.4.0", "react-router-dom": "^7.11.0", "tailwindcss": "^4.1.18", - "zod": "^3.25.36" + "zod": "^3.25.36", + "zustand": "^5.0.14" }, "devDependencies": { "@repo/eslint-config": "workspace:*", diff --git a/apps/web/src/apps/index.tsx b/apps/web/src/apps/index.tsx index 5d6914d..9c10be8 100644 --- a/apps/web/src/apps/index.tsx +++ b/apps/web/src/apps/index.tsx @@ -1,8 +1,9 @@ import { lazy, Suspense, 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/store/theme.store'; const AuthModule = lazy(() => import('./auth')); const AppModule = lazy(() => import('./modules')); @@ -10,7 +11,7 @@ const ShowcaseView = lazy(() => import('./showcase/showcase-view')); const ShellDemo = lazy(() => import('./showcase/shell-demo')); export default function App() { - const [colorScheme, setColorScheme] = useState('light'); + const colorScheme = useThemeStore((s) => s.colorScheme); const [density, setDensity] = useState('compact'); return ( @@ -20,20 +21,10 @@ export default function App() { } /> } /> - - } - /> + } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json index fe973f1..159078d 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json +++ b/apps/web/src/apps/modules/example/full-page/presentation/locales/en/full-page.json @@ -1,5 +1,6 @@ { - "title": "Full Page Management", + "title": "Full Page", + "description": "An example module of a <1>full page layout for detailed forms.", "fields": { "status": "Status", "name": "Name", diff --git a/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json b/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json index 0d7d6ac..7912506 100644 --- a/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json +++ b/apps/web/src/apps/modules/example/full-page/presentation/locales/id/full-page.json @@ -1,5 +1,6 @@ { - "title": "Manajemen Halaman Penuh", + "title": "Halaman Penuh", + "description": "Contoh modul <1>tata letak halaman penuh untuk formulir detail.", "fields": { "status": "Status", "name": "Nama", diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.detail.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.detail.tsx new file mode 100644 index 0000000..c29969d --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.detail.tsx @@ -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 ( + + {/* --- INLINED PAGE HEADER --- */} + + } + > + + Acme Corp + + + Infrastructure + + + Database Clusters + + + prod-db-01 + + + + + + + + + prod-db-01 + + } + style={{ textTransform: 'capitalize' }} + > + Running + + + + + PostgreSQL v15.4 · us-east-1 · Created Oct 12, 2023 + + + + + + + + + + + + + + + + {/* --- END INLINED PAGE HEADER --- */} + + + + General Information + + + View the complete details and configuration for this entity. + + + + + + + + {t('fields.code')} + + WID-001 + + + + {t('fields.name')} + + Dashboard Widget + + + + {t('fields.status')} + + + ACTIVE + + + + + {t('fields.description')} + + Main dashboard widget + + + + + ); +} diff --git a/apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.form.tsx b/apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.form.tsx new file mode 100644 index 0000000..fd05951 --- /dev/null +++ b/apps/web/src/apps/modules/example/full-page/presentation/pages-backup/full-page.page.form.tsx @@ -0,0 +1,110 @@ +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 ( + + {/* --- INLINED PAGE HEADER --- */} + + } + > + + Database Clusters + + + {formPageType === 'create' ? 'Create Cluster' : 'Edit Cluster'} + + + + + + + + {formPageType === 'create' ? 'Create New Cluster' : 'Edit Cluster Configuration'} + + + Configure and provision a new highly available database cluster. + + + + + + + + + + + + + {/* --- END INLINED PAGE HEADER --- */} + + + + {formPageType === 'create' ? 'Create Entity' : 'Edit Entity'} + + + Fill in the required information to configure your entity properly. Fields marked with * are required. + + + + + + + + +