10 Commits
Author SHA1 Message Date
shancheas 050fafd731 feat: implement packing slips module with comprehensive functionality
- Introduced a new Packing Slips module, including routes for creating, editing, and viewing packing slips.
- Added components for packing slip forms, detail views, and index pages, enhancing user experience and data management.
- Integrated language support for English and Indonesian in the packing slips module.
- Developed remote data services and transformers for handling packing slip data, ensuring robust data operations.
- Implemented unit tests to validate functionality and reliability across the new module.

These changes enhance the application by providing a structured approach to packing slip management, improving user experience and data handling.
2026-08-31 09:35:31 +07:00
shancheas 15efa358f9 feat: implement sales invoices module with comprehensive functionality
- Introduced a new Sales Invoices module, including routes for creating, editing, and viewing sales invoices.
- Added components for invoice forms, detail views, and index pages, enhancing user experience and data management.
- Integrated language support for English and Indonesian in the sales invoices module.
- Developed remote data services and transformers for handling sales invoice data, ensuring robust data operations.
- Implemented unit tests to validate functionality and reliability across the new module.

These changes enhance the application by providing a structured approach to sales invoice management, improving user experience and data handling.
2026-08-27 17:43:34 +07:00
shancheas 133e73a070 feat: enhance employee management with purpose-based configurations and improved routing
- Refactored employee management components to support purpose-based configurations for sales and logistics.
- Updated routing to dynamically load employee modules based on the selected purpose, improving modularity and code organization.
- Introduced new utility functions for handling employee positions and module configurations, enhancing flexibility in employee data handling.
- Enhanced form components to conditionally render based on the purpose, improving user experience and data entry accuracy.
- Added unit tests to validate new functionalities and ensure reliability across employee management features.

These changes improve the employee management experience by providing a more tailored approach to handling employee data based on specific business needs.
2026-08-27 15:10:42 +07:00
shancheas b21d647c8b feat: implement coming soon pages and enhance menu structure
- Added a new ComingSoonPage component for displaying a status page for features that are not yet available.
- Integrated the ComingSoonPage into the sales and logistics modules for routes such as invoices, payments, packing slips, and reports.
- Updated the MENU_ITEMS structure to include new nested items for sales and logistics, enhancing the organization of the menu.
- Introduced placeholder functionality in the menu to manage visibility of items that are not yet available.
- Updated navigation labels in both English and Indonesian to reflect new menu items and improve user experience.

These changes enhance the application's navigation and user experience by clearly indicating upcoming features and improving menu organization.
2026-08-27 13:45:23 +07:00
shancheas 6b012a6aae refactor: improve code formatting and organization across multiple components
- Enhanced readability by restructuring code formatting in various components, including API documentation, user management, and sales modules.
- Standardized table and object definitions for better clarity in API documentation.
- Improved layout and indentation in React components for better maintainability.
- Updated unit tests to reflect changes in formatting and ensure consistency.

These changes enhance the overall code quality and maintainability of the project, making it easier for developers to navigate and understand the codebase.
2026-08-27 13:09:57 +07:00
shancheas 105cf3030a feat: implement privilege permissions table and enhance user detail views
- Added a new PrivilegePermissionsTable component to display permissions associated with user privileges in a structured format.
- Refactored DetailPermissions and UserPageDetail components to utilize the new PrivilegePermissionsTable for better organization of privilege information.
- Introduced employeeId support in user management, allowing for enhanced user detail views and improved data handling.
- Updated validation schemas and unit tests to accommodate new employee and privilege relationships.

These changes improve the user management experience by providing clearer visibility into user privileges and associated employee details.
2026-08-27 12:22:27 +07:00
shancheas eadd4e3c81 feat: enhance user entity and transformer with employeeId support
- Added employeeId field to UserEntity and UserDto interfaces for better user management.
- Updated UsersRemoteDataTransformer to handle employeeId mapping and transformation.
- Enhanced unit tests to validate employeeId handling and added new test cases for audit actor UUIDs.
- Modified user page index to display employeeId alongside employee details.

These changes improve the user management module by providing additional employee identification capabilities, enhancing data handling and user experience.
2026-08-27 11:06:08 +07:00
shancheas 9e710a92b2 feat: introduce index layout skill for FULL_PAGE index/list tables
- Added a new skill for managing index and list table layouts in the ERP project, detailing layout and data contracts.
- Updated project guidelines to reference the new index layout skill.
- Introduced rules for page composition, column definitions, audit fields, action column width, and toolbar functionality.
- Created utility functions for computing action column width and formatting audit fields, along with corresponding unit tests to ensure reliability.

These changes enhance the application by providing a structured approach to index layouts, improving consistency and usability across the ERP project.
2026-08-27 11:06:00 +07:00
shancheas b22ce99840 feat: introduce users management module with CRUD functionality
- Added a new Users module, including routes for creating, editing, and viewing user details.
- Implemented UI components for user forms and detail views, with validation schemas for user data.
- Integrated language support for English and Indonesian in the users module.
- Developed unit tests for user remote data services and transformers to ensure functionality and reliability.

These changes enhance the application by providing a structured approach to user management, improving user experience and data handling.
2026-08-27 10:35:50 +07:00
shancheas 4d9c83ee83 feat: implement sales management module with product and order handling
- Introduced a new Sales module, consolidating routes for managing sales requests and orders.
- Added a Products module for handling product details, including creation, editing, and viewing functionalities.
- Implemented UI components for product forms and detail views, with validation schemas for product data.
- Integrated language support for English and Indonesian in the new modules.
- Developed unit tests for sales and product remote data services and transformers to ensure functionality and reliability.

These changes enhance the application by providing a structured approach to sales management, improving user experience and data handling.
2026-08-26 15:29:46 +07:00
243 changed files with 8648 additions and 688 deletions
+99
View File
@@ -0,0 +1,99 @@
---
name: index-layout
description: Use whenever building or editing a FULL_PAGE index / list table in the ERP project (master-data lists, transaction lists, system users/privileges, any EnterpriseDataTable index). Governs LAYOUT and table contracts ONLY — page chrome, module columnDefs vs shared prefix/postfix columns, action-column width, audit field names, and the search/filter/reload toolbar. Does not govern colors, fonts, border-radius, or other visual styling. Trigger this any time an index page, list table, row-action column, audit columns, or index toolbar is added or restructured — including when the user says "build a list page", "index table", or "add a refresh button".
---
# Index Layout
Layout and data contracts for **FULL_PAGE index / list tables**. Visual style (color, weight, radius, shadows) belongs to `@repo/ui`, not this skill.
Walk these rules in order whenever you add or change an index table.
## Project chrome (do not rebuild)
Index routes already render inside `EnterpriseIndexPageProvider` → `EnterpriseDataTable`. That chrome owns:
- Page header (title, description, breadcrumbs, Create)
- Toolbar: search, filter, **reload**
- Prefix columns: selection, row actions, status
- Postfix columns: Created by, Created at, Updated by, Updated at
- Pagination / server-side row model
**Do not** re-implement the toolbar, action column, status column, or audit columns in a module page. This skill governs the **`columnDefs` you pass in** and the **entity/transformer field names** the shared table reads.
## 1. Page composition
```tsx
<EnterpriseIndexPageProvider pageHeaderProps={{ title, description, icon, breadcrumbs }}>
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
</EnterpriseIndexPageProvider>
```
Copy `example/full-page`. Do not invent a third index layout.
## 2. Module `columnDefs` = business fields only
Module index pages declare **business columns only** (code, name, relations, flags).
Do **not** redeclare:
- `action_column` / row actions
- `status` (the shared status badge column)
- `createdBy` / `createdAt` / `updatedBy` / `updatedAt`
```tsx
// GOOD
const columnDefs = [
{ field: 'username', headerName: t('common:fields.username'), minWidth: 160 },
{
field: 'privilege',
headerName: t('common:fields.privilege'),
valueGetter: ({ data }) => relationLabel(data?.privilege),
},
];
// BAD — duplicates shared chrome
const columnDefs = [
{ colId: 'action_column', width: 180 },
{ field: 'created_at', headerName: 'Created at' },
];
```
## 3. Audit fields are camelCase
API and transformers map:
| Entity field | Meaning |
| ------------ | ------------------------------------- |
| `createdBy` | actor string, `{ username }`, or uuid |
| `createdAt` | unix ms |
| `updatedBy` | actor string, `{ username }`, or uuid |
| `updatedAt` | unix ms |
`EnterpriseDataTable` postfix columns bind those names and fall back to legacy snake_case (`creator_name`, `created_at`, `editor_name`, `updated_at`) for leftover local/Pouch rows.
Empty values render as `-`. Nested actors display `username` || `name` || `id`.
Do not alias audit fields to snake_case in TrackGo transformers.
## 4. Action column width is shared
Row actions stay a single nowrap icon row (`RowActions` with `responsiveView={false}`). Width is computed in `EnterpriseDataTable` from privileges + `moduleType` (`View` always, then Edit / Duplicate / Activate-or-Deactivate or transaction flags / Delete). `flex: 0` so the column does not shrink.
Do **not** override action column `width` / `minWidth` in module `columnDefs` or `customPrefixColumn` unless the module adds extra custom row actions **and** you widen via `customPrefixColumn` to fit them.
## 5. Toolbar always includes reload
The index toolbar is search + filter + reload. Reload must call `gridApi.refreshServerSide({ purge: true })` (same as search/filter) — **not** `window.location.reload()`.
Label: `common:actions.reload` (“Reload” / “Muat Ulang”). Icon: `RefreshCw`.
Do not add a second module-level refresh control that duplicates this.
## Checklist
- [ ] Index page uses `EnterpriseIndexPageProvider` + `EnterpriseDataTable` (no custom table chrome)
- [ ] `columnDefs` are business fields only
- [ ] Transformer maps `createdAt` / `createdBy` / `updatedAt` / `updatedBy`
- [ ] Action column width left to the shared table
- [ ] Reload is the shared toolbar button, not a full browser refresh
@@ -177,5 +177,6 @@ See `apps/web/.env.example`. Typical `VITE_*` keys: `VITE_APP_ENV`, `VITE_API_BA
- `.agents/skills/coding-standards/` — TypeScript/React practices - `.agents/skills/coding-standards/` — TypeScript/React practices
- `.agents/skills/form-layout/` — form layout - `.agents/skills/form-layout/` — form layout
- `.agents/skills/detail-layout/` — detail page layout - `.agents/skills/detail-layout/` — detail page layout
- `.agents/skills/index-layout/` — index / list table layout
- `.agents/skills/tdd-workflow/` — TDD - `.agents/skills/tdd-workflow/` — TDD
- `.agents/skills/security-review/` — frontend/Electron security - `.agents/skills/security-review/` — frontend/Electron security
+1
View File
@@ -27,6 +27,7 @@ Reference usage: `apps/showcase` (ui-components, forms, action-tools, shell-demo
Detail **content** layout (section stack, key-value grids, status blocks, tabs for many categories): follow `.agents/skills/detail-layout/SKILL.md` — layout/position only. Detail **content** layout (section stack, key-value grids, status blocks, tabs for many categories): follow `.agents/skills/detail-layout/SKILL.md` — layout/position only.
Form **content** layout: follow `.agents/skills/form-layout/SKILL.md`. Form **content** layout: follow `.agents/skills/form-layout/SKILL.md`.
Index / list layout (toolbar, action column, audit columns): follow `.agents/skills/index-layout/SKILL.md`.
Entities with `latitude` / `longitude`: follow `web-location-maps.mdc` — form must pick coords on `LocationMap`; detail must render `LocationMap`. Entities with `latitude` / `longitude`: follow `web-location-maps.mdc` — form must pick coords on `LocationMap`; detail must render `LocationMap`.
Every page header (`pageHeaderProps`) should include i18n `title`, `description`, `breadcrumbs`, and Lucide `icon` when useful. Every page header (`pageHeaderProps`) should include i18n `title`, `description`, `breadcrumbs`, and Lucide `icon` when useful.
+33
View File
@@ -0,0 +1,33 @@
---
description: Index / list table contracts for FULL_PAGE modules (actions, audit columns, reload)
globs: apps/web/src/apps/**/*.page.index.tsx,packages/ui/src/foundations/enterprise-module/components/data-table/**
alwaysApply: false
---
# Web Index Table
Index pages: `EnterpriseIndexPageProvider` + `EnterpriseDataTable`. Do not rebuild the toolbar or default columns. Full layout: `.agents/skills/index-layout/SKILL.md`.
## Module columnDefs
Business fields only. Do **not** redeclare action, status, or audit columns.
```tsx
// GOOD
<EnterpriseDataTable columnDefs={[{ field: 'code' }, { field: 'name' }]} filterConfig={filterConfig} />
// BAD — duplicates shared prefix/postfix
columnDefs={[{ colId: 'action_column', width: 180 }, { field: 'created_at' }]}
```
## Audit fields
Transformers map camelCase: `createdBy`, `createdAt`, `updatedBy`, `updatedAt`. The shared table postfix binds those names (with snake_case fallback). Do not emit `creator_name` / `created_at` in TrackGo transformers.
## Action column
Width is computed in `EnterpriseDataTable` from privileges (`flex: 0`, nowrap icons). Do not hard-code `width: 180` in module pages.
## Reload
Toolbar includes search, filter, and reload. Reload calls `gridApi.refreshServerSide({ purge: true })` — never `window.location.reload()`. i18n: `common:actions.reload`.
+25 -38
View File
@@ -26,7 +26,7 @@ Base URL: `http://localhost:{PORT}` (default **3000**). There is **no** global p
### Tokens ### Tokens
| Token | Type | Default lifetime | Transport | | Token | Type | Default lifetime | Transport |
| ----- | ---- | ---------------- | --------- | | ------- | --------------------- | -------------------------------------- | ------------------------- |
| Access | JWT HS256 | `15m` (`JWT_ACCESS_EXPIRES_IN`) | `Authorization: Bearer …` | | Access | JWT HS256 | `15m` (`JWT_ACCESS_EXPIRES_IN`) | `Authorization: Bearer …` |
| Refresh | Opaque 64-char string | 7 days (`REFRESH_TOKEN_EXPIRES_IN_MS`) | JSON body `refreshToken` | | Refresh | Opaque 64-char string | 7 days (`REFRESH_TOKEN_EXPIRES_IN_MS`) | JSON body `refreshToken` |
@@ -112,7 +112,7 @@ CSV import uses `multipart/form-data` with field name **`file`** (max 1 MiB).
Query (all optional): Query (all optional):
| Param | Rules | Default | | Param | Rules | Default |
| ----- | ----- | ------- | | -------- | ----------- | ------- |
| `page` | integer ≥ 1 | `1` | | `page` | integer ≥ 1 | `1` |
| `limit` | 1–**200** | `10` | | `limit` | 1–**200** | `10` |
| `offset` | integer ≥ 0 | — | | `offset` | integer ≥ 0 | — |
@@ -123,7 +123,9 @@ Public response:
```json ```json
{ {
"data": [ /* items */ ], "data": [
/* items */
],
"meta": { "meta": {
"currentPage": 1, "currentPage": 1,
"itemCount": 10, "itemCount": 10,
@@ -141,7 +143,7 @@ Public response:
Unwrapped resource object, or: Unwrapped resource object, or:
| Operation | Status | Body | | Operation | Status | Body |
| --------- | ------ | ---- | | --------------- | ------ | ------------------------------------------ |
| Create | `201` | resource DTO (detail shape) | | Create | `201` | resource DTO (detail shape) |
| Update / status | `200` | resource DTO | | Update / status | `200` | resource DTO |
| Delete | `204` | empty | | Delete | `204` | empty |
@@ -163,7 +165,7 @@ Unwrapped resource object, or:
`message` is a string or an array of validation strings. `message` is a string or an array of validation strings.
| Status | Typical cause | | Status | Typical cause |
| ------ | ------------- | | ------ | --------------------------------------------------------------------------- |
| `400` | Validation, extra fields, invalid VO (phone/date/status), `status` on PATCH | | `400` | Validation, extra fields, invalid VO (phone/date/status), `status` on PATCH |
| `401` | Missing/expired/revoked JWT, bad credentials, invalid refresh | | `401` | Missing/expired/revoked JWT, bad credentials, invalid refresh |
| `403` | `Insufficient privilege` | | `403` | `Insufficient privilege` |
@@ -190,7 +192,7 @@ Core (configuration, privileges, cycles, plans, settings): `draft` | `active` |
Sales statuses (use **only** these on that resource): Sales statuses (use **only** these on that resource):
| Resource | Allowed | | Resource | Allowed |
| -------- | ------- | | -------------------------- | --------------------------------------------------------- |
| Sales request | `draft`, `pending`, `approved`, `rejected` | | Sales request | `draft`, `pending`, `approved`, `rejected` |
| Sales order / packing slip | `draft`, `processed`, `completed`, `cancelled` | | Sales order / packing slip | `draft`, `processed`, `completed`, `cancelled` |
| Sales invoice | `draft`, `processed`, `partial`, `completed`, `cancelled` | | Sales invoice | `draft`, `processed`, `partial`, `completed`, `cancelled` |
@@ -231,7 +233,7 @@ Actions: `view` | `create` | `update` | `delete` | `import`.
HTTP mapping: HTTP mapping:
| Handler | Action | | Handler | Action |
| ------- | ------ | | --------------------------------------------------------------------------------------------------- | -------- |
| `GET` list / detail | `view` | | `GET` list / detail | `view` |
| `POST /` create, `POST /plans/generate` | `create` | | `POST /` create, `POST /plans/generate` | `create` |
| `PATCH /:id`, `PATCH /:id/status`, `POST /bulk-status`, nested customer contacts, plan destinations | `update` | | `PATCH /:id`, `PATCH /:id/status`, `POST /bulk-status`, nested customer contacts, plan destinations | `update` |
@@ -241,7 +243,7 @@ HTTP mapping:
Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`): Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
| code | label | | code | label |
| ---- | ----- | | ------------------------ | ---------------- |
| `PRIVILEGES` | Privileges | | `PRIVILEGES` | Privileges |
| `USERS` | Users | | `USERS` | Users |
| `CONFIGURATION.DIVISION` | Divisions | | `CONFIGURATION.DIVISION` | Divisions |
@@ -265,7 +267,7 @@ Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`: Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
| purpose | cycle key | plan key | | purpose | cycle key | plan key |
| ------- | --------- | -------- | | ----------- | ----------------- | ---------------- |
| `sales` | `SALES.CYCLE` | `SALES.PLAN` | | `sales` | `SALES.CYCLE` | `SALES.PLAN` |
| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` | | `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` |
@@ -280,7 +282,7 @@ Sales plans may attach `invoiceIds` only. Logistics plans may attach `packingSli
Unless a section says otherwise, each resource below implements: Unless a section says otherwise, each resource below implements:
| Method | Path | Status | Notes | | Method | Path | Status | Notes |
| ------ | ---- | ------ | ----- | | -------- | ------------------------- | ------ | --------------------------------------------- |
| `GET` | `/{resource}` | `200` | Paginated `{ data, meta }` | | `GET` | `/{resource}` | `200` | Paginated `{ data, meta }` |
| `GET` | `/{resource}/:id` | `200` | Detail (may include nested arrays list omits) | | `GET` | `/{resource}/:id` | `200` | Detail (may include nested arrays list omits) |
| `POST` | `/{resource}` | `201` | Create | | `POST` | `/{resource}` | `201` | Create |
@@ -352,9 +354,7 @@ List filters: `name`, `code`, `status`, `search` (name/code).
"name": "Sales Staff", "name": "Sales Staff",
"code": "SALES_STAFF", "code": "SALES_STAFF",
"status": "draft", "status": "draft",
"details": [ "details": [{ "privilegeKeyId": "uuid", "action": "view", "value": true }]
{ "privilegeKeyId": "uuid", "action": "view", "value": true }
]
} }
``` ```
@@ -468,7 +468,7 @@ Contact create: `name` required; `jobTitle?`, `phone?`, `mobilePhone?`, `notes?`
### Nested contacts (privilege = customer **update**) ### Nested contacts (privilege = customer **update**)
| Method | Path | Status | Body | Response | | Method | Path | Status | Body | Response |
| ------ | ---- | ------ | ---- | -------- | | -------- | ------------------------------------ | ------ | --------------- | ------------------ |
| `POST` | `/customers/:id/contacts` | `200` | create contact | full `CustomerDto` | | `POST` | `/customers/:id/contacts` | `200` | create contact | full `CustomerDto` |
| `PATCH` | `/customers/:id/contacts/:contactId` | `200` | partial contact | full `CustomerDto` | | `PATCH` | `/customers/:id/contacts/:contactId` | `200` | partial contact | full `CustomerDto` |
| `DELETE` | `/customers/:id/contacts/:contactId` | `204` | — | empty | | `DELETE` | `/customers/:id/contacts/:contactId` | `204` | — | empty |
@@ -574,12 +574,8 @@ Detail adds:
```json ```json
{ {
"products": [ "products": [{ "id": "uuid", "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" }],
{ "id": "uuid", "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" } "images": [{ "id": "uuid", "url": "https://…", "description": null }]
],
"images": [
{ "id": "uuid", "url": "https://…", "description": null }
]
} }
``` ```
@@ -660,7 +656,7 @@ CSV required: `date` (allocations empty on import).
Key: `CONFIGURATION.SETTING`. Singleton — no list/CRUD. Key: `CONFIGURATION.SETTING`. Singleton — no list/CRUD.
| Method | Path | Action | Notes | | Method | Path | Action | Notes |
| ------ | ---- | ------ | ----- | | ------- | ----------- | ------ | ----------------------------------------------------------------- |
| `GET` | `/settings` | view | `404` `{ "message": "Settings not configured" }` if never patched | | `GET` | `/settings` | view | `404` `{ "message": "Settings not configured" }` if never patched |
| `PATCH` | `/settings` | update | upserts | | `PATCH` | `/settings` | update | upserts |
@@ -730,11 +726,12 @@ No hard delete: `DELETE` / `bulk-delete` archive. **Has CSV import.** Standard l
"endBranchId": "uuid", "endBranchId": "uuid",
"routeGeometry": { "routeGeometry": {
"type": "LineString", "type": "LineString",
"coordinates": [[106.8456, -6.2088], [107.0, -6.3]] "coordinates": [
}, [106.8456, -6.2088],
"destinations": [ [107.0, -6.3]
{ "id": "uuid", "customerId": "uuid", "sortOrder": 0 }
] ]
},
"destinations": [{ "id": "uuid", "customerId": "uuid", "sortOrder": 0 }]
} }
], ],
"status": "draft", "status": "draft",
@@ -760,7 +757,7 @@ Privilege: `RequireFieldPrivilege('plan', action)` → `SALES.PLAN` or `LOGISTIC
**No CSV import.** Delete archives. Extra routes: generate, add/remove destinations. **No CSV import.** Delete archives. Extra routes: generate, add/remove destinations.
| Method | Path | Action | Status | Body / response | | Method | Path | Action | Status | Body / response |
| ------ | ---- | ------ | ------ | --------------- | | -------- | ---------------------------------------- | ------ | ------- | ------------------------------------------------------------ |
| `GET` | `/plans` | view | 200 | paginated | | `GET` | `/plans` | view | 200 | paginated |
| `GET` | `/plans/:id` | view | 200 | `PlanDto` | | `GET` | `/plans/:id` | view | 200 | `PlanDto` |
| `POST` | `/plans/generate` | create | 200 | `{ employeeId, purpose, from, to }` → `{ created, skipped }` | | `POST` | `/plans/generate` | create | 200 | `{ employeeId, purpose, from, to }` → `{ created, skipped }` |
@@ -898,14 +895,7 @@ type UnixMs = number;
type DecimalString = string; type DecimalString = string;
type CoreStatus = 'draft' | 'active' | 'archived'; type CoreStatus = 'draft' | 'active' | 'archived';
type FieldPurpose = 'sales' | 'logistics'; type FieldPurpose = 'sales' | 'logistics';
type Weekday = type Weekday = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
| 'monday'
| 'tuesday'
| 'wednesday'
| 'thursday'
| 'friday'
| 'saturday'
| 'sunday';
type PrivilegeAction = 'view' | 'create' | 'update' | 'delete' | 'import'; type PrivilegeAction = 'view' | 'create' | 'update' | 'delete' | 'import';
type PaginationMeta = { type PaginationMeta = {
@@ -932,10 +922,7 @@ type Me = {
username: string; username: string;
isSuperadmin: boolean; isSuperadmin: boolean;
privilege: { id: Uuid; name: string; code: string } | null; privilege: { id: Uuid; name: string; code: string } | null;
permissions: Record< permissions: Record<string, Record<PrivilegeAction, boolean>>;
string,
Record<PrivilegeAction, boolean>
>;
}; };
type RouteGeometry = { type RouteGeometry = {
+1 -11
View File
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@repo/ui/provider';
import { StatusPage, AgGridProvider } from '@repo/ui/components'; import { StatusPage, AgGridProvider } from '@repo/ui/components';
import { useTranslation } from '@repo/core-i18n'; import { useTranslation } from '@repo/core-i18n';
import { LoadingScreen } from '../core/components/loading-screen'; import { LoadingScreen } from '../core/components/loading-screen';
import { ComingSoonPage } from '../core/components/coming-soon-page';
import { useThemeStore } from '../core/stores/theme.store'; import { useThemeStore } from '../core/stores/theme.store';
import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker'; import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker';
@@ -53,17 +54,6 @@ function MaintenancePage() {
); );
} }
function ComingSoonPage() {
const { t } = useTranslation();
return (
<StatusPage
title={t('common:systemPages.comingSoon.title')}
heading={t('common:systemPages.comingSoon.heading')}
description={t('common:systemPages.comingSoon.description')}
/>
);
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ path: '/auth/*', element: <AuthModule /> }, { path: '/auth/*', element: <AuthModule /> },
{ path: '/app/*', element: <AppModule /> }, { path: '/app/*', element: <AppModule /> },
+4 -2
View File
@@ -8,8 +8,9 @@ const SystemSetting = lazy(() => import('./modules/system/setting'));
const SystemInformation = lazy(() => import('./modules/system/information')); const SystemInformation = lazy(() => import('./modules/system/information'));
const SystemNotification = lazy(() => import('./modules/system/notification')); const SystemNotification = lazy(() => import('./modules/system/notification'));
const PrivilegesModule = lazy(() => import('./modules/system/privileges/presentation/factory')); const PrivilegesModule = lazy(() => import('./modules/system/privileges/presentation/factory'));
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
const ConfigurationModule = lazy(() => import('./modules/configuration')); const ConfigurationModule = lazy(() => import('./modules/configuration'));
const SalesFieldModule = lazy(() => import('./modules/field/sales')); const SalesModule = lazy(() => import('./modules/sales'));
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics')); const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
export default function AppModule() { export default function AppModule() {
@@ -22,8 +23,9 @@ export default function AppModule() {
<Route path="/system/information" element={<SystemInformation />} /> <Route path="/system/information" element={<SystemInformation />} />
<Route path="/system/notifications" element={<SystemNotification />} /> <Route path="/system/notifications" element={<SystemNotification />} />
<Route path="/system/privileges/*" element={<PrivilegesModule />} /> <Route path="/system/privileges/*" element={<PrivilegesModule />} />
<Route path="/system/users/*" element={<UsersModule />} />
<Route path="/configuration/*" element={<ConfigurationModule />} /> <Route path="/configuration/*" element={<ConfigurationModule />} />
<Route path="/sales/*" element={<SalesFieldModule />} /> <Route path="/sales/*" element={<SalesModule />} />
<Route path="/logistics/*" element={<LogisticsFieldModule />} /> <Route path="/logistics/*" element={<LogisticsFieldModule />} />
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} /> <Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} /> <Route path="*" element={<Navigate to={'/404'} replace={true} />} />
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import navEn from '../languages/en/nav.json';
import navId from '../languages/id/nav.json';
import { MENU_ITEMS } from './menu.data';
import type { MenuItemType } from '../types/menu.types';
const childKeys = (item: MenuItemType | undefined): string[] => (item?.children ?? []).map((child) => child.key);
const findItem = (items: MenuItemType[], key: string): MenuItemType | undefined =>
items.find((item) => item.key === key);
const flatten = (items: MenuItemType[]): MenuItemType[] =>
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
describe('MENU_ITEMS', () => {
it('orders top-level items as dashboard, sales, logistics, settings', () => {
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'sales', 'logistics', 'settings']);
});
it('nests sales as data, activities, then reports', () => {
const sales = findItem(MENU_ITEMS, 'sales');
expect(childKeys(sales)).toEqual(['sales-data', 'sales-activities', 'sales-reports']);
expect(childKeys(findItem(sales?.children ?? [], 'sales-data'))).toEqual(['sales-employees', 'sales-cycles']);
expect(findItem(sales?.children ?? [], 'sales-data')?.children?.[0]?.path).toBe('/app/sales/employees/index');
expect(childKeys(findItem(sales?.children ?? [], 'sales-activities'))).toEqual([
'sales-requests',
'sales-orders',
'sales-invoices',
'sales-payments',
'sales-plans',
]);
});
it('nests logistics as data, activities, then reports', () => {
const logistics = findItem(MENU_ITEMS, 'logistics');
expect(childKeys(logistics)).toEqual(['logistics-data', 'logistics-activities', 'logistics-reports']);
expect(childKeys(findItem(logistics?.children ?? [], 'logistics-data'))).toEqual([
'logistics-employees',
'logistics-cycles',
]);
expect(findItem(logistics?.children ?? [], 'logistics-data')?.children?.[0]?.path).toBe(
'/app/logistics/employees/index',
);
expect(childKeys(findItem(logistics?.children ?? [], 'logistics-activities'))).toEqual([
'logistics-packing-slips',
'logistics-plans',
]);
});
it('nests settings as data then user', () => {
const settings = findItem(MENU_ITEMS, 'settings');
expect(childKeys(settings)).toEqual(['settings-data', 'settings-user']);
expect(childKeys(findItem(settings?.children ?? [], 'settings-data'))).toEqual([
'configuration-branches',
'configuration-divisions',
'configuration-customers',
'configuration-products',
]);
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
'system-users',
'system-privileges',
]);
});
it('uses unique keys across the whole tree', () => {
const keys = flatten(MENU_ITEMS).map((item) => item.key);
expect(new Set(keys).size).toBe(keys.length);
});
it('resolves every label in both locales', () => {
for (const item of flatten(MENU_ITEMS)) {
const key = item.label.replace('nav:', '');
expect(navEn).toHaveProperty(key);
expect(navId).toHaveProperty(key);
}
});
});
+117 -225
View File
@@ -7,25 +7,16 @@ import {
Users, Users,
Box, Box,
Layers, Layers,
Warehouse,
Building2,
MapPin, MapPin,
Activity, Activity,
Globe,
Briefcase,
Phone,
ShoppingCart, ShoppingCart,
Truck, Truck,
HardHat,
Factory,
Calculator,
Receipt, Receipt,
PiggyBank,
Calendar, Calendar,
Clock,
Shield, Shield,
FileSearch,
Repeat, Repeat,
Package,
ClipboardList,
} from 'lucide-react'; } from 'lucide-react';
import type { MenuItemType } from '../types/menu.types'; import type { MenuItemType } from '../types/menu.types';
@@ -42,32 +33,6 @@ export const MENU_ITEMS: MenuItemType[] = [
icon: LayoutDashboard, icon: LayoutDashboard,
path: '/app/dashboard', path: '/app/dashboard',
}, },
{
key: 'crm',
label: 'nav:crm',
icon: Users,
path: '/app/crm',
children: [
{
key: 'crm-leads',
label: 'nav:crm-leads',
icon: Briefcase,
path: '/app/crm/leads',
},
{
key: 'crm-pipelines',
label: 'nav:crm-pipelines',
icon: Activity,
path: '/app/crm/pipelines',
},
{
key: 'crm-contacts',
label: 'nav:crm-contacts',
icon: Phone,
path: '/app/crm/contacts',
},
],
},
{ {
key: 'sales', key: 'sales',
label: 'nav:sales', label: 'nav:sales',
@@ -75,22 +40,17 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/sales', path: '/app/sales',
children: [ children: [
{ {
key: 'sales-quotations', key: 'sales-data',
label: 'nav:sales-quotations', label: 'nav:data',
icon: FileText, icon: Database,
path: '/app/sales/quotations', path: '/app/sales/data',
}, children: [
{ {
key: 'sales-orders', key: 'sales-employees',
label: 'nav:sales-orders', label: 'nav:configuration-employees',
icon: Box, icon: Users,
path: '/app/sales/orders', path: '/app/sales/employees/index',
}, moduleKey: 'CONFIGURATION.EMPLOYEE',
{
key: 'sales-invoices',
label: 'nav:sales-invoices',
icon: Receipt,
path: '/app/sales/invoices',
}, },
{ {
key: 'sales-cycles', key: 'sales-cycles',
@@ -99,6 +59,42 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/sales/cycles/index', path: '/app/sales/cycles/index',
moduleKey: 'SALES.CYCLE', moduleKey: 'SALES.CYCLE',
}, },
],
},
{
key: 'sales-activities',
label: 'nav:activities',
icon: Activity,
path: '/app/sales/activities',
children: [
{
key: 'sales-requests',
label: 'nav:sales-requests',
icon: ClipboardList,
path: '/app/sales/requests/index',
moduleKey: 'SALES.REQUEST',
},
{
key: 'sales-orders',
label: 'nav:sales-orders',
icon: Box,
path: '/app/sales/orders/index',
moduleKey: 'SALES.ORDER',
},
{
key: 'sales-invoices',
label: 'nav:sales-invoices',
icon: Receipt,
path: '/app/sales/invoices/index',
moduleKey: 'SALES.INVOICE',
},
{
key: 'sales-payments',
label: 'nav:sales-payments',
icon: CreditCard,
path: '/app/sales/payments/index',
moduleKey: 'SALES.PAYMENT',
},
{ {
key: 'sales-plans', key: 'sales-plans',
label: 'nav:sales-plans', label: 'nav:sales-plans',
@@ -108,12 +104,34 @@ export const MENU_ITEMS: MenuItemType[] = [
}, },
], ],
}, },
{
key: 'sales-reports',
label: 'nav:reports-coming-soon',
icon: FileText,
path: '/app/sales/reports',
isPlaceholder: true,
},
],
},
{ {
key: 'logistics', key: 'logistics',
label: 'nav:logistics', label: 'nav:logistics',
icon: Truck, icon: Truck,
path: '/app/logistics', path: '/app/logistics',
children: [ children: [
{
key: 'logistics-data',
label: 'nav:data',
icon: Database,
path: '/app/logistics/data',
children: [
{
key: 'logistics-employees',
label: 'nav:configuration-employees',
icon: Users,
path: '/app/logistics/employees/index',
moduleKey: 'CONFIGURATION.EMPLOYEE',
},
{ {
key: 'logistics-cycles', key: 'logistics-cycles',
label: 'nav:logistics-cycles', label: 'nav:logistics-cycles',
@@ -121,6 +139,21 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/logistics/cycles/index', path: '/app/logistics/cycles/index',
moduleKey: 'LOGISTICS.CYCLE', moduleKey: 'LOGISTICS.CYCLE',
}, },
],
},
{
key: 'logistics-activities',
label: 'nav:activities',
icon: Activity,
path: '/app/logistics/activities',
children: [
{
key: 'logistics-packing-slips',
label: 'nav:logistics-packing-slips',
icon: Package,
path: '/app/logistics/packing-slips/index',
moduleKey: 'SALES.PACKING_SLIP',
},
{ {
key: 'logistics-plans', key: 'logistics-plans',
label: 'nav:logistics-plans', label: 'nav:logistics-plans',
@@ -131,120 +164,11 @@ export const MENU_ITEMS: MenuItemType[] = [
], ],
}, },
{ {
key: 'supply-chain', key: 'logistics-reports',
label: 'nav:supply-chain', label: 'nav:reports-coming-soon',
icon: Truck, icon: FileText,
path: '/app/supply-chain', path: '/app/logistics/reports',
children: [ isPlaceholder: true,
{
key: 'sc-inventory',
label: 'nav:sc-inventory',
icon: Box,
path: '/app/supply-chain/inventory',
children: [
{
key: 'sc-inventory-products',
label: 'nav:sc-inventory-products',
icon: Layers,
path: '/app/supply-chain/inventory/products',
},
{
key: 'sc-inventory-categories',
label: 'nav:sc-inventory-categories',
icon: Globe,
path: '/app/supply-chain/inventory/categories',
},
{
key: 'sc-inventory-adjustments',
label: 'nav:sc-inventory-adjustments',
icon: FileSearch,
path: '/app/supply-chain/inventory/adjustments',
},
],
},
{
key: 'sc-warehouses',
label: 'nav:sc-warehouses',
icon: Warehouse,
path: '/app/supply-chain/warehouses',
},
{
key: 'sc-logistics',
label: 'nav:sc-logistics',
icon: Globe,
path: '/app/supply-chain/logistics',
},
],
},
{
key: 'manufacturing',
label: 'nav:manufacturing',
icon: Factory,
path: '/app/manufacturing',
children: [
{
key: 'mfg-bom',
label: 'nav:mfg-bom',
icon: Layers,
path: '/app/manufacturing/bom',
},
{
key: 'mfg-work-orders',
label: 'nav:mfg-work-orders',
icon: HardHat,
path: '/app/manufacturing/work-orders',
},
],
},
{
key: 'hris',
label: 'nav:hris',
icon: Briefcase,
path: '/app/hris',
children: [
{
key: 'hris-employees',
label: 'nav:hris-employees',
icon: Users,
path: '/app/hris/employees',
},
{
key: 'hris-attendance',
label: 'nav:hris-attendance',
icon: Clock,
path: '/app/hris/attendance',
},
{
key: 'hris-payroll',
label: 'nav:hris-payroll',
icon: CreditCard,
path: '/app/hris/payroll',
},
{
key: 'hris-calendar',
label: 'nav:hris-calendar',
icon: Calendar,
path: '/app/hris/calendar',
},
],
},
{
key: 'accounting',
label: 'nav:accounting',
icon: Calculator,
path: '/app/accounting',
children: [
{
key: 'acc-gl',
label: 'nav:acc-gl',
icon: Database,
path: '/app/accounting/general-ledger',
},
{
key: 'acc-taxes',
label: 'nav:acc-taxes',
icon: PiggyBank,
path: '/app/accounting/taxes',
}, },
], ],
}, },
@@ -255,38 +179,11 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/settings', path: '/app/settings',
children: [ children: [
{ {
key: 'settings-general', key: 'settings-data',
label: 'nav:settings-general', label: 'nav:data',
icon: Settings, icon: Database,
path: '/app/settings/general', path: '/app/settings/data',
},
{
key: 'settings-security',
label: 'nav:settings-security',
icon: Shield,
path: '/app/settings/security',
},
{
key: 'long-text-2',
label: 'nav:long-text-2',
icon: FileText,
path: '/app/settings/long-menu-test',
},
],
},
{
key: 'configuration',
label: 'nav:configuration',
icon: Building2,
path: '/app/configuration',
children: [ children: [
{
key: 'configuration-divisions',
label: 'nav:configuration-divisions',
icon: Layers,
path: '/app/configuration/divisions/index',
moduleKey: 'CONFIGURATION.DIVISION',
},
{ {
key: 'configuration-branches', key: 'configuration-branches',
label: 'nav:configuration-branches', label: 'nav:configuration-branches',
@@ -294,6 +191,13 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/configuration/branches/index', path: '/app/configuration/branches/index',
moduleKey: 'CONFIGURATION.BRANCH', moduleKey: 'CONFIGURATION.BRANCH',
}, },
{
key: 'configuration-divisions',
label: 'nav:configuration-divisions',
icon: Layers,
path: '/app/configuration/divisions/index',
moduleKey: 'CONFIGURATION.DIVISION',
},
{ {
key: 'configuration-customers', key: 'configuration-customers',
label: 'nav:configuration-customers', label: 'nav:configuration-customers',
@@ -302,20 +206,27 @@ export const MENU_ITEMS: MenuItemType[] = [
moduleKey: 'CONFIGURATION.CUSTOMER', moduleKey: 'CONFIGURATION.CUSTOMER',
}, },
{ {
key: 'configuration-employees', key: 'configuration-products',
label: 'nav:configuration-employees', label: 'nav:configuration-products',
icon: Users, icon: Package,
path: '/app/configuration/employees/index', path: '/app/configuration/products/index',
moduleKey: 'CONFIGURATION.EMPLOYEE', moduleKey: 'CONFIGURATION.PRODUCT',
}, },
], ],
}, },
{ {
key: 'system-group', key: 'settings-user',
label: 'nav:system', label: 'nav:user',
icon: Shield, icon: Users,
path: '/app/system', path: '/app/settings/user',
children: [ children: [
{
key: 'system-users',
label: 'nav:system-users',
icon: Users,
path: '/app/system/users/index',
moduleKey: 'USERS',
},
{ {
key: 'system-privileges', key: 'system-privileges',
label: 'nav:system-privileges', label: 'nav:system-privileges',
@@ -325,25 +236,6 @@ export const MENU_ITEMS: MenuItemType[] = [
}, },
], ],
}, },
{
key: 'example-module',
label: 'nav:example-module',
icon: Database,
path: '/app/example-module',
children: [
{
key: 'example-full-page',
label: 'nav:example-full-page',
icon: LayoutDashboard,
path: '/app/example/full-page/index',
moduleKey: 'EXAMPLE_FULL_PAGE',
},
// {
// key: 'example-single-page',
// label: 'nav:example-single-page',
// icon: FileText,
// path: '/app/example/single-page/index',
// },
], ],
}, },
]; ];
@@ -6,8 +6,10 @@
"crm-contacts": "Contacts", "crm-contacts": "Contacts",
"sales": "Sales", "sales": "Sales",
"sales-quotations": "Quotations", "sales-quotations": "Quotations",
"sales-requests": "Sales Requests",
"sales-orders": "Sales Orders", "sales-orders": "Sales Orders",
"sales-invoices": "Invoices", "sales-invoices": "Sales Invoices",
"sales-payments": "Sales Payments",
"supply-chain": "Supply Chain", "supply-chain": "Supply Chain",
"sc-inventory": "Inventory Management", "sc-inventory": "Inventory Management",
"sc-inventory-products": "Products", "sc-inventory-products": "Products",
@@ -26,7 +28,11 @@
"accounting": "Accounting", "accounting": "Accounting",
"acc-gl": "General Ledger", "acc-gl": "General Ledger",
"acc-taxes": "Taxes", "acc-taxes": "Taxes",
"settings": "Settings & Configuration", "data": "Data",
"activities": "Activities",
"reports-coming-soon": "Reports (Coming Soon)",
"user": "User",
"settings": "Settings",
"settings-general": "General Settings", "settings-general": "General Settings",
"settings-security": "Security", "settings-security": "Security",
"long-text-2": "Extremely Long Menu Name To Test Text Truncation Handling Properly", "long-text-2": "Extremely Long Menu Name To Test Text Truncation Handling Properly",
@@ -35,6 +41,7 @@
"example-single-page": "Example Single Page", "example-single-page": "Example Single Page",
"system": "System", "system": "System",
"system-privileges": "Privileges", "system-privileges": "Privileges",
"system-users": "Users",
"configuration": "Configuration", "configuration": "Configuration",
"configuration-divisions": "Divisions", "configuration-divisions": "Divisions",
"configuration-branches": "Branches", "configuration-branches": "Branches",
@@ -44,5 +51,7 @@
"logistics": "Logistics", "logistics": "Logistics",
"logistics-cycles": "Logistics Cycles", "logistics-cycles": "Logistics Cycles",
"logistics-plans": "Logistics Plans", "logistics-plans": "Logistics Plans",
"configuration-employees": "Employees" "logistics-packing-slips": "Packing Slips",
"configuration-employees": "Employees",
"configuration-products": "Products"
} }
@@ -6,8 +6,10 @@
"crm-contacts": "Kontak", "crm-contacts": "Kontak",
"sales": "Penjualan", "sales": "Penjualan",
"sales-quotations": "Penawaran", "sales-quotations": "Penawaran",
"sales-requests": "Permintaan Penjualan",
"sales-orders": "Pesanan Penjualan", "sales-orders": "Pesanan Penjualan",
"sales-invoices": "Faktur", "sales-invoices": "Faktur Penjualan",
"sales-payments": "Pembayaran Penjualan",
"supply-chain": "Rantai Pasok", "supply-chain": "Rantai Pasok",
"sc-inventory": "Manajemen Inventaris", "sc-inventory": "Manajemen Inventaris",
"sc-inventory-products": "Produk", "sc-inventory-products": "Produk",
@@ -26,7 +28,11 @@
"accounting": "Akuntansi", "accounting": "Akuntansi",
"acc-gl": "Buku Besar", "acc-gl": "Buku Besar",
"acc-taxes": "Pajak", "acc-taxes": "Pajak",
"settings": "Pengaturan & Konfigurasi", "data": "Data",
"activities": "Aktivitas",
"reports-coming-soon": "Laporan (Segera Hadir)",
"user": "Pengguna",
"settings": "Pengaturan",
"settings-general": "Pengaturan Umum", "settings-general": "Pengaturan Umum",
"settings-security": "Keamanan", "settings-security": "Keamanan",
"long-text-2": "Nama Menu Sangat Panjang Untuk Menguji Penanganan Pemotongan Teks Dengan Baik", "long-text-2": "Nama Menu Sangat Panjang Untuk Menguji Penanganan Pemotongan Teks Dengan Baik",
@@ -35,6 +41,7 @@
"example-single-page": "Contoh Halaman Tunggal", "example-single-page": "Contoh Halaman Tunggal",
"system": "Sistem", "system": "Sistem",
"system-privileges": "Hak Akses", "system-privileges": "Hak Akses",
"system-users": "Pengguna",
"configuration": "Konfigurasi", "configuration": "Konfigurasi",
"configuration-divisions": "Divisi", "configuration-divisions": "Divisi",
"configuration-branches": "Cabang", "configuration-branches": "Cabang",
@@ -44,5 +51,7 @@
"logistics": "Logistik", "logistics": "Logistik",
"logistics-cycles": "Siklus Logistik", "logistics-cycles": "Siklus Logistik",
"logistics-plans": "Rencana Logistik", "logistics-plans": "Rencana Logistik",
"configuration-employees": "Karyawan" "logistics-packing-slips": "Surat Jalan",
"configuration-employees": "Karyawan",
"configuration-products": "Produk"
} }
@@ -16,4 +16,6 @@ export interface MenuItemType {
children?: MenuItemType[]; children?: MenuItemType[];
/** Privilege catalog key used to hide the item when ALLOW_VIEW is false */ /** Privilege catalog key used to hide the item when ALLOW_VIEW is false */
moduleKey?: string; moduleKey?: string;
/** Coming-soon leaf that must not keep an otherwise-empty parent visible */
isPlaceholder?: boolean;
} }
@@ -29,9 +29,7 @@ describe('CustomersRemoteDataTransformer', () => {
it('includes named contacts on create and omits status', () => { it('includes named contacts on create and omits status', () => {
const payload = transformer.transformCreatePayload(dto); const payload = transformer.transformCreatePayload(dto);
expect(payload.contacts).toEqual([ expect(payload.contacts).toEqual([{ name: 'Andi Pratama', jobTitle: 'Manager', phone: '+6281111111111' }]);
{ name: 'Andi Pratama', jobTitle: 'Manager', phone: '+6281111111111' },
]);
expect(payload).not.toHaveProperty('status'); expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id'); expect(payload).not.toHaveProperty('id');
}); });
@@ -13,7 +13,11 @@ import {
Text, Text,
notifications, notifications,
} from '@repo/ui/components'; } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleConfigContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import {
useDetailPageContext,
useEnterpriseModuleConfigContext,
useEnterpriseModuleTranslationContext,
} from '@repo/ui/foundations';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Pencil, Plus, Trash2 } from 'lucide-react'; import { Pencil, Plus, Trash2 } from 'lucide-react';
@@ -131,7 +135,12 @@ export function DetailContacts() {
<ActionIcon variant="subtle" onClick={() => openEdit(contact)} aria-label={t('edit_contact')}> <ActionIcon variant="subtle" onClick={() => openEdit(contact)} aria-label={t('edit_contact')}>
<Pencil size={16} /> <Pencil size={16} />
</ActionIcon> </ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => handleDelete(contact)} aria-label={t('delete_contact')}> <ActionIcon
variant="subtle"
color="red"
onClick={() => handleDelete(contact)}
aria-label={t('delete_contact')}
>
<Trash2 size={16} /> <Trash2 size={16} />
</ActionIcon> </ActionIcon>
</Group> </Group>
@@ -1,4 +1,15 @@
import { ActionIcon, Box, Button, FieldTextInput, FieldTextarea, Group, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components'; import {
ActionIcon,
Box,
Button,
FieldTextInput,
FieldTextarea,
Group,
Paper,
SimpleGrid,
Stack,
Text,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray } from '@repo/ui/form'; import { useFieldArray } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client'; import type { AxiosInstance } from '@repo/core-api/http-client';
import { EmployeesRemoteDataServices } from './employee.remote.service'; import { EmployeesRemoteDataServices, serializeRepeatQuery } from './employee.remote.service';
import { EmployeesRemoteDataTransformer } from '../domain/transformers/employee.remote.transformer'; import { EmployeesRemoteDataTransformer } from '../domain/transformers/employee.remote.transformer';
function createMockHttpClient(): AxiosInstance { function createMockHttpClient(): AxiosInstance {
@@ -51,4 +51,10 @@ describe('EmployeesRemoteDataServices', () => {
expect.objectContaining({ url: '/employees/bulk-delete', method: 'POST' }), expect.objectContaining({ url: '/employees/bulk-delete', method: 'POST' }),
); );
}); });
it('serializes position arrays as repeated query keys', () => {
expect(serializeRepeatQuery({ page: 1, position: ['driver', 'crew'] })).toBe(
'page=1&position=driver&position=crew',
);
});
}); });
@@ -1,8 +1,25 @@
import type { AxiosInstance } from '@repo/core-api/http-client'; import type { AxiosInstance, AxiosRequestConfig } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services'; import type { DataServicesConfig } from '@repo/core-api/data-services';
import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services'; import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services';
import type { EmployeeEntity } from '../domain/entities'; import type { EmployeeEntity } from '../domain/entities';
export function serializeRepeatQuery(params: Record<string, unknown>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
search.append(key, String(item));
}
continue;
}
search.append(key, String(value));
}
return search.toString();
}
export class EmployeesRemoteDataServices extends TrackGoRemoteDataServices<EmployeeEntity> { export class EmployeesRemoteDataServices extends TrackGoRemoteDataServices<EmployeeEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<EmployeeEntity>) { constructor(httpClient: AxiosInstance, config: DataServicesConfig<EmployeeEntity>) {
super(httpClient, { super(httpClient, {
@@ -10,4 +27,11 @@ export class EmployeesRemoteDataServices extends TrackGoRemoteDataServices<Emplo
apiUrl: config.apiUrl ?? '/employees', apiUrl: config.apiUrl ?? '/employees',
}); });
} }
async getMany<T = unknown>(config?: AxiosRequestConfig) {
return super.getMany<T>({
...config,
paramsSerializer: serializeRepeatQuery,
});
}
} }
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { createEmployeeModuleConfig } from './employee.constants';
import { employeePositionsForPurpose } from '../entities';
describe('employee purpose helpers', () => {
it('maps sales to the sales position and logistics to crew and driver', () => {
expect(employeePositionsForPurpose('sales')).toEqual(['sales']);
expect(employeePositionsForPurpose('logistics')).toEqual(['driver', 'crew']);
});
it('uses distinct web urls per purpose', () => {
expect(createEmployeeModuleConfig('sales').webUrl).toBe('/app/sales/employees');
expect(createEmployeeModuleConfig('logistics').webUrl).toBe('/app/logistics/employees');
});
it('keeps a shared employee privilege key', () => {
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
});
});
@@ -1,11 +1,14 @@
import { ModuleConfigEntity } from '@repo/ui/foundations'; import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import type { EmployeeEntity } from '../entities'; import type { EmployeeEntity } from '../entities';
export const employeesModuleConfig: ModuleConfigEntity<EmployeeEntity> = { export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> {
return {
moduleKey: 'CONFIGURATION.EMPLOYEE', moduleKey: 'CONFIGURATION.EMPLOYEE',
translationNamespace: 'EMPLOYEES', translationNamespace: 'EMPLOYEES',
apiUrl: '/employees', apiUrl: '/employees',
webUrl: '/app/configuration/employees', webUrl: `/app/${purpose}/employees`,
moduleCategory: 'FULL_PAGE', moduleCategory: 'FULL_PAGE',
moduleType: 'MASTER_DATA', moduleType: 'MASTER_DATA',
} as const; };
}
@@ -1,9 +1,18 @@
import { BaseEntity } from '@repo/core-api/data-services'; import { BaseEntity } from '@repo/core-api/data-services';
import type { ConfigurationStatus } from '../../../divisions/domain/entities'; import type { ConfigurationStatus } from '../../../divisions/domain/entities';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
export const EMPLOYEE_POSITIONS = ['sales', 'driver', 'crew'] as const; export const EMPLOYEE_POSITIONS = ['sales', 'driver', 'crew'] as const;
export type EmployeePosition = (typeof EMPLOYEE_POSITIONS)[number]; export type EmployeePosition = (typeof EMPLOYEE_POSITIONS)[number];
export const SALES_EMPLOYEE_POSITIONS = ['sales'] as const satisfies readonly EmployeePosition[];
export const LOGISTICS_EMPLOYEE_POSITIONS = ['driver', 'crew'] as const satisfies readonly EmployeePosition[];
export function employeePositionsForPurpose(purpose: FieldPurpose): readonly EmployeePosition[] {
return purpose === 'logistics' ? LOGISTICS_EMPLOYEE_POSITIONS : SALES_EMPLOYEE_POSITIONS;
}
export interface EmployeeEntity extends BaseEntity { export interface EmployeeEntity extends BaseEntity {
code: string; code: string;
name: string; name: string;
@@ -1,12 +1,20 @@
import { apiClient } from '../../../../../../../core/lib/api-client'; import { apiClient } from '../../../../../../../core/lib/api-client';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import { EmployeesRemoteDataServices } from '../../data/employee.remote.service'; import { EmployeesRemoteDataServices } from '../../data/employee.remote.service';
import { employeesModuleConfig } from '../constants/employee.constants'; import { createEmployeeModuleConfig } from '../constants/employee.constants';
import { employeePositionsForPurpose } from '../entities';
import { EmployeesRemoteDataTransformer } from '../transformers/employee.remote.transformer'; import { EmployeesRemoteDataTransformer } from '../transformers/employee.remote.transformer';
export const employeesDataTransformer = new EmployeesRemoteDataTransformer(); export function createEmployeeDataService(purpose?: FieldPurpose) {
const config = createEmployeeModuleConfig(purpose ?? 'sales');
const transformer = new EmployeesRemoteDataTransformer(purpose ? employeePositionsForPurpose(purpose) : undefined);
return new EmployeesRemoteDataServices(apiClient, {
apiUrl: config.apiUrl,
moduleKey: config.moduleKey,
transformer,
});
}
export const employeesDataService = new EmployeesRemoteDataServices(apiClient, { export const employeesDataService = createEmployeeDataService();
apiUrl: employeesModuleConfig.apiUrl, export const salesEmployeesDataService = createEmployeeDataService('sales');
moduleKey: employeesModuleConfig.moduleKey, export const logisticsEmployeesDataService = createEmployeeDataService('logistics');
transformer: employeesDataTransformer,
});
@@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest';
import { EmployeesRemoteDataTransformer } from './employee.remote.transformer'; import { EmployeesRemoteDataTransformer } from './employee.remote.transformer';
import type { EmployeeEntity } from '../entities'; import type { EmployeeEntity } from '../entities';
const transformer = new EmployeesRemoteDataTransformer();
const dto = { const dto = {
id: 'emp-1', id: 'emp-1',
code: 'EMP_01', code: 'EMP_01',
@@ -18,6 +16,8 @@ const dto = {
}; };
describe('EmployeesRemoteDataTransformer', () => { describe('EmployeesRemoteDataTransformer', () => {
const transformer = new EmployeesRemoteDataTransformer();
it('maps dto fields onto the entity', () => { it('maps dto fields onto the entity', () => {
const entity = transformer.transformToEntity(dto); const entity = transformer.transformToEntity(dto);
expect(entity).toMatchObject({ expect(entity).toMatchObject({
@@ -52,4 +52,51 @@ describe('EmployeesRemoteDataTransformer', () => {
expect(payload).not.toHaveProperty('status'); expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id'); expect(payload).not.toHaveProperty('id');
}); });
it('leaves list filters unchanged when no positions are scoped', () => {
const filter = transformer.transformPayloadFilter({ name: 'Ada', status: 'active' });
expect(filter).toEqual({ name: 'Ada', status: 'active' });
});
it('injects the sales position array into list filters', () => {
const salesTransformer = new EmployeesRemoteDataTransformer(['sales']);
expect(salesTransformer.transformPayloadFilter({ name: 'Ada' })).toEqual({
name: 'Ada',
position: ['sales'],
});
});
it('narrows a selected logistics position to an array still inside the allowed set', () => {
const logisticsTransformer = new EmployeesRemoteDataTransformer(['driver', 'crew']);
expect(logisticsTransformer.transformPayloadFilter({ position: 'crew' })).toEqual({
position: ['crew'],
});
});
it('falls back to the allowed logistics positions when the selected position is invalid', () => {
const logisticsTransformer = new EmployeesRemoteDataTransformer(['driver', 'crew']);
expect(logisticsTransformer.transformPayloadFilter({ position: 'sales' })).toEqual({
position: ['driver', 'crew'],
});
});
it('forces sales position on create when the module is sales-scoped', () => {
const salesTransformer = new EmployeesRemoteDataTransformer(['sales']);
expect(salesTransformer.transformCreatePayload({ ...dto, position: 'driver' })).toEqual({
code: 'EMP_01',
name: 'Ada Lovelace',
phone: '+6281234567890',
position: 'sales',
});
});
it('keeps a logistics position on create when it is allowed', () => {
const logisticsTransformer = new EmployeesRemoteDataTransformer(['driver', 'crew']);
expect(logisticsTransformer.transformCreatePayload({ ...dto, position: 'crew' }).position).toBe('crew');
});
it('drops a disallowed position on logistics create instead of forwarding it', () => {
const logisticsTransformer = new EmployeesRemoteDataTransformer(['driver', 'crew']);
expect(logisticsTransformer.transformCreatePayload({ ...dto, position: 'sales' }).position).toBeUndefined();
});
}); });
@@ -1,7 +1,12 @@
import { BaseDataTransformer } from '@repo/core-api/data-services'; import { BaseDataTransformer } from '@repo/core-api/data-services';
import type { EmployeeDto, EmployeeEntity } from '../entities'; import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import type { EmployeeDto, EmployeeEntity, EmployeePosition } from '../entities';
export class EmployeesRemoteDataTransformer extends BaseDataTransformer<EmployeeEntity> { export class EmployeesRemoteDataTransformer extends BaseDataTransformer<EmployeeEntity> {
constructor(private readonly allowedPositions?: readonly EmployeePosition[]) {
super();
}
transformToEntity(dto: EmployeeDto | EmployeeEntity): EmployeeEntity { transformToEntity(dto: EmployeeDto | EmployeeEntity): EmployeeEntity {
return { return {
id: dto.id, id: dto.id,
@@ -26,7 +31,7 @@ export class EmployeesRemoteDataTransformer extends BaseDataTransformer<Employee
code: entity.code, code: entity.code,
name: entity.name, name: entity.name,
phone: entity.phone, phone: entity.phone,
position: entity.position, position: this.resolvePosition(entity.position),
}; };
} }
@@ -35,7 +40,39 @@ export class EmployeesRemoteDataTransformer extends BaseDataTransformer<Employee
code: entity.code, code: entity.code,
name: entity.name, name: entity.name,
phone: entity.phone, phone: entity.phone,
position: entity.position, position: this.resolvePosition(entity.position),
}; };
} }
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
if (!this.allowedPositions) {
return omitEmptyFields(filter);
}
return omitEmptyFields({ ...filter, position: this.resolveFilterPositions(filter.position) });
}
private resolvePosition(position?: EmployeePosition): EmployeePosition | undefined {
if (!this.allowedPositions) {
return position;
}
if (position && this.allowedPositions.includes(position)) {
return position;
}
return this.allowedPositions.length === 1 ? this.allowedPositions[0] : undefined;
}
private resolveFilterPositions(selected: unknown): EmployeePosition[] {
const allowed = [...this.allowedPositions!];
if (typeof selected === 'string' && allowed.includes(selected as EmployeePosition)) {
return [selected as EmployeePosition];
}
if (Array.isArray(selected)) {
const intersection = selected.filter((value): value is EmployeePosition => allowed.includes(value));
if (intersection.length > 0) {
return intersection;
}
}
return allowed;
}
} }
@@ -22,4 +22,16 @@ describe('createEmployeeSchema', () => {
it('rejects an unknown position', () => { it('rejects an unknown position', () => {
expect(schema.safeParse({ ...valid, position: 'manager' }).success).toBe(false); expect(schema.safeParse({ ...valid, position: 'manager' }).success).toBe(false);
}); });
it('rejects a logistics position when the schema is sales-scoped', () => {
const salesSchema = createEmployeeSchema(t, ['sales']);
expect(salesSchema.safeParse({ ...valid, position: 'driver' }).success).toBe(false);
expect(salesSchema.safeParse(valid).success).toBe(true);
});
it('rejects a sales position when the schema is logistics-scoped', () => {
const logisticsSchema = createEmployeeSchema(t, ['driver', 'crew']);
expect(logisticsSchema.safeParse(valid).success).toBe(false);
expect(logisticsSchema.safeParse({ ...valid, position: 'crew' }).success).toBe(true);
});
}); });
@@ -4,14 +4,18 @@ import {
configNameSchema, configNameSchema,
configPhoneSchema, configPhoneSchema,
} from '../../../../../../../core/domain/configuration-field-validators'; } from '../../../../../../../core/domain/configuration-field-validators';
import { EMPLOYEE_POSITIONS } from '../entities'; import { EMPLOYEE_POSITIONS, type EmployeePosition } from '../entities';
export const createEmployeeSchema = (t: (key: string) => string) => { export const createEmployeeSchema = (
t: (key: string) => string,
positions: readonly EmployeePosition[] = EMPLOYEE_POSITIONS,
) => {
const allowed = positions as [EmployeePosition, ...EmployeePosition[]];
return z.object({ return z.object({
code: configCodeSchema(t), code: configCodeSchema(t),
name: configNameSchema(t), name: configNameSchema(t),
phone: configPhoneSchema(t), phone: configPhoneSchema(t),
position: z.enum(EMPLOYEE_POSITIONS, { position: z.enum(allowed, {
required_error: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.position') } }), required_error: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.position') } }),
}), }),
}); });
@@ -1,8 +1,8 @@
import { Box, FieldTextInput, FieldSelect, Paper, SimpleGrid, Text } from '@repo/ui/components'; import { Box, FieldTextInput, FieldSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { EMPLOYEE_POSITIONS } from '../../../domain/entities'; import type { EmployeePosition } from '../../../domain/entities';
export function FormGeneral() { export function FormGeneral({ positions }: { positions: readonly EmployeePosition[] }) {
const { formControl } = useFormPageContext(); const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
@@ -37,14 +37,16 @@ export function FormGeneral() {
required required
radius="md" radius="md"
/> />
{positions.length > 1 ? (
<FieldSelect <FieldSelect
control={formControl.control} control={formControl.control}
name="position" name="position"
label={t('common:fields.position')} label={t('common:fields.position')}
data={EMPLOYEE_POSITIONS.map((value) => ({ value, label: t(`position_${value}`) }))} data={positions.map((value) => ({ value, label: t(`position_${value}`) }))}
required required
radius="md" radius="md"
/> />
) : null}
</SimpleGrid> </SimpleGrid>
</Box> </Box>
</Paper> </Paper>
@@ -2,9 +2,17 @@ import { SimpleGrid } from '@repo/ui/components';
import { FieldTextInput, FieldSelect } from '@repo/ui/form'; import { FieldTextInput, FieldSelect } from '@repo/ui/form';
import { UseFormReturn } from 'react-hook-form'; import { UseFormReturn } from 'react-hook-form';
import { statusFilterOptions } from '../../../../shared/status-filter-options'; import { statusFilterOptions } from '../../../../shared/status-filter-options';
import { EMPLOYEE_POSITIONS } from '../../../domain/entities'; import type { EmployeePosition } from '../../../domain/entities';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => { export const FilterFormContent = ({
form,
t,
positions,
}: {
form: UseFormReturn<any>;
t: (key: string) => string;
positions: readonly EmployeePosition[];
}) => {
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput <FieldTextInput
@@ -25,13 +33,15 @@ export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (k
label={t('common:fields.phone')} label={t('common:fields.phone')}
placeholder={`Enter ${t('common:fields.phone')}`} placeholder={`Enter ${t('common:fields.phone')}`}
/> />
{positions.length > 1 ? (
<FieldSelect <FieldSelect
control={form.control} control={form.control}
name="position" name="position"
label={t('common:fields.position')} label={t('common:fields.position')}
clearable clearable
data={EMPLOYEE_POSITIONS.map((value) => ({ value, label: t(`position_${value}`) }))} data={positions.map((value) => ({ value, label: t(`position_${value}`) }))}
/> />
) : null}
<FieldSelect <FieldSelect
control={form.control} control={form.control}
name="status" name="status"
@@ -2,10 +2,11 @@ import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations'; import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n'; import { registerModuleNamespace } from '@repo/core-i18n';
import { employeesModuleConfig } from '../../domain/constants'; import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import { employeesDataService } from '../../domain/factories'; import { createEmployeeModuleConfig } from '../../domain/constants';
import { logisticsEmployeesDataService, salesEmployeesDataService } from '../../domain/factories';
import { EmployeeEntity } from '../../domain/entities'; import { EmployeeEntity } from '../../domain/entities';
import { employeesStore } from '../store'; import { logisticsEmployeesStore, salesEmployeesStore } from '../store';
import employeesId from '../languages/id/employees.json'; import employeesId from '../languages/id/employees.json';
import employeesEn from '../languages/en/employees.json'; import employeesEn from '../languages/en/employees.json';
@@ -14,25 +15,25 @@ const IndexPage = lazy(() => import('../pages/employee.page.index'));
const FormPage = lazy(() => import('../pages/employee.page.form')); const FormPage = lazy(() => import('../pages/employee.page.form'));
const DetailPage = lazy(() => import('../pages/employee.page.detail')); const DetailPage = lazy(() => import('../pages/employee.page.detail'));
registerModuleNamespace(employeesModuleConfig.translationNamespace, { registerModuleNamespace('EMPLOYEES', {
id: employeesId, id: employeesId,
en: employeesEn, en: employeesEn,
}); });
export default function EmployeesModule() { export default function EmployeesModule({ purpose }: { purpose: FieldPurpose }) {
const config = createEmployeeModuleConfig(purpose);
const dataService = purpose === 'sales' ? salesEmployeesDataService : logisticsEmployeesDataService;
const store = purpose === 'sales' ? salesEmployeesStore : logisticsEmployeesStore;
return ( return (
<EnterpriseModuleProvider<EmployeeEntity> <EnterpriseModuleProvider<EmployeeEntity> config={config} dataServices={dataService} store={store}>
config={employeesModuleConfig}
dataServices={employeesDataService}
store={employeesStore}
>
<Routes> <Routes>
<Route path="/index" element={<IndexPage />} /> <Route path="/index" element={<IndexPage purpose={purpose} />} />
<Route path="/detail/:dataId" element={<DetailPage />} /> <Route path="/detail/:dataId" element={<DetailPage purpose={purpose} />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} /> <Route path="/edit/:dataId" element={<FormPage purpose={purpose} formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} /> <Route path="/duplicate/:dataId" element={<FormPage purpose={purpose} formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} /> <Route path="/create" element={<FormPage purpose={purpose} formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${employeesModuleConfig.webUrl}/index`} replace={true} />} /> <Route path="/" element={<Navigate to={`${config.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} /> <Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes> </Routes>
</EnterpriseModuleProvider> </EnterpriseModuleProvider>
@@ -1,9 +1,11 @@
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { employeesModuleConfig } from '../../domain/constants'; import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import { createEmployeeModuleConfig } from '../../domain/constants';
import { DetailGeneral } from '../components/detail-component/detail-general'; import { DetailGeneral } from '../components/detail-component/detail-general';
export default function EmployeePageDetail() { export default function EmployeePageDetail({ purpose }: { purpose: FieldPurpose }) {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const moduleConfig = createEmployeeModuleConfig(purpose);
return ( return (
<EnterpriseDetailPageProvider <EnterpriseDetailPageProvider
@@ -12,8 +14,8 @@ export default function EmployeePageDetail() {
title: t('detail_page_title'), title: t('detail_page_title'),
description: t('detail_page_description'), description: t('detail_page_description'),
breadcrumbs: [ breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' }, { label: t(`nav:${purpose}`), type: 'text' },
{ label: t('nav:configuration-employees'), type: 'link', href: `${employeesModuleConfig.webUrl}/index` }, { label: t('nav:configuration-employees'), type: 'link', href: `${moduleConfig.webUrl}/index` },
], ],
}} }}
> >
@@ -2,12 +2,22 @@ import { useMemo } from 'react';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations'; import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { employeesModuleConfig } from '../../domain/constants'; import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import { createEmployeeModuleConfig } from '../../domain/constants';
import { employeePositionsForPurpose } from '../../domain/entities';
import { createEmployeeSchema } from '../../domain/validators/employee.validator'; import { createEmployeeSchema } from '../../domain/validators/employee.validator';
import { FormGeneral } from '../components/form-component/form-general'; import { FormGeneral } from '../components/form-component/form-general';
export default function EmployeePageForm({ formPageType }: { formPageType: FormPageType }) { export default function EmployeePageForm({
formPageType,
purpose,
}: {
formPageType: FormPageType;
purpose: FieldPurpose;
}) {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const moduleConfig = createEmployeeModuleConfig(purpose);
const positions = employeePositionsForPurpose(purpose);
const title = useMemo(() => { const title = useMemo(() => {
if (formPageType === 'CREATE') { if (formPageType === 'CREATE') {
@@ -22,8 +32,11 @@ export default function EmployeePageForm({ formPageType }: { formPageType: FormP
return { title: '', description: '' }; return { title: '', description: '' };
}, [formPageType, t]); }, [formPageType, t]);
const validator = useMemo(() => createEmployeeSchema(t), [t]); const validator = useMemo(() => createEmployeeSchema(t, positions), [positions, t]);
const formControl = useForm({ resolver: zodResolver(validator) }); const formControl = useForm({
resolver: zodResolver(validator),
defaultValues: positions.length === 1 ? { position: positions[0] } : undefined,
});
return ( return (
<EnterpriseFormPageProvider <EnterpriseFormPageProvider
@@ -36,12 +49,12 @@ export default function EmployeePageForm({ formPageType }: { formPageType: FormP
title: title?.title, title: title?.title,
description: title?.description, description: title?.description,
breadcrumbs: [ breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' }, { label: t(`nav:${purpose}`), type: 'text' },
{ label: t('nav:configuration-employees'), type: 'link', href: `${employeesModuleConfig.webUrl}/index` }, { label: t('nav:configuration-employees'), type: 'link', href: `${moduleConfig.webUrl}/index` },
], ],
}} }}
> >
<FormGeneral /> <FormGeneral positions={positions} />
</EnterpriseFormPageProvider> </EnterpriseFormPageProvider>
); );
} }
@@ -7,11 +7,13 @@ import {
import { ColDef, Text } from '@repo/ui/components'; import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n'; import { Trans } from '@repo/core-i18n';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import { employeePositionsForPurpose, type EmployeeEntity } from '../../domain/entities';
import { FilterFormContent } from '../components/index-component/filter-content'; import { FilterFormContent } from '../components/index-component/filter-content';
import type { EmployeeEntity } from '../../domain/entities';
export default function EmployeePageIndex() { export default function EmployeePageIndex({ purpose }: { purpose: FieldPurpose }) {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const positions = employeePositionsForPurpose(purpose);
const columnDefs: ColDef<EmployeeEntity>[] = useMemo(() => { const columnDefs: ColDef<EmployeeEntity>[] = useMemo(() => {
return [ return [
@@ -31,10 +33,10 @@ export default function EmployeePageIndex() {
return { return {
renderBody: (form: any) => { renderBody: (form: any) => {
if (!form) return null; if (!form) return null;
return <FilterFormContent form={form} t={t} />; return <FilterFormContent form={form} t={t} positions={positions} />;
}, },
}; };
}, [t]); }, [positions, t]);
return ( return (
<EnterpriseIndexPageProvider <EnterpriseIndexPageProvider
@@ -45,7 +47,7 @@ export default function EmployeePageIndex() {
), ),
icon: Users, icon: Users,
breadcrumbs: [ breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' }, { label: t(`nav:${purpose}`), type: 'text' },
{ label: t('nav:configuration-employees'), type: 'text' }, { label: t('nav:configuration-employees'), type: 'text' },
], ],
}} }}
@@ -4,19 +4,20 @@ import { EmployeeEntity } from '../../domain/entities';
export interface EmployeesStoreState extends EnterpriseModuleState<EmployeeEntity> {} export interface EmployeesStoreState extends EnterpriseModuleState<EmployeeEntity> {}
export const employeesStore = create<EmployeesStoreState>((set) => ({ export function createEmployeesStore() {
return create<EmployeesStoreState>((set) => ({
metaData: { limit: 15 }, metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }), setMetaData: (data) => set({ metaData: data }),
filterData: {}, filterData: {},
setFilterData: (data) => set({ filterData: data }), setFilterData: (data) => set({ filterData: data }),
selectedRows: [], selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }), setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [], privileges: [],
setPrivileges: (privileges) => set({ privileges }), setPrivileges: (privileges) => set({ privileges }),
tableConfig: null, tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }), setTableConfig: (config) => set({ tableConfig: config }),
})); }));
}
export const salesEmployeesStore = createEmployeesStore();
export const logisticsEmployeesStore = createEmployeesStore();
@@ -1,10 +1,11 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { WEB_URL } from '../../../../core/constants/web-url';
const DivisionsModule = lazy(() => import('./divisions/presentation/factory')); const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
const BranchesModule = lazy(() => import('./branches/presentation/factory')); const BranchesModule = lazy(() => import('./branches/presentation/factory'));
const CustomersModule = lazy(() => import('./customers/presentation/factory')); const CustomersModule = lazy(() => import('./customers/presentation/factory'));
const EmployeesModule = lazy(() => import('./employees/presentation/factory')); const ProductsModule = lazy(() => import('./products/presentation/factory'));
export default function ConfigurationModule() { export default function ConfigurationModule() {
return ( return (
@@ -12,7 +13,8 @@ export default function ConfigurationModule() {
<Route path="/divisions/*" element={<DivisionsModule />} /> <Route path="/divisions/*" element={<DivisionsModule />} />
<Route path="/branches/*" element={<BranchesModule />} /> <Route path="/branches/*" element={<BranchesModule />} />
<Route path="/customers/*" element={<CustomersModule />} /> <Route path="/customers/*" element={<CustomersModule />} />
<Route path="/employees/*" element={<EmployeesModule />} /> <Route path="/products/*" element={<ProductsModule />} />
<Route path="/employees/*" element={<Navigate to={`${WEB_URL.SALES_EMPLOYEES}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} /> <Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes> </Routes>
); );
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { ProductsRemoteDataServices } from './product.remote.service';
import { ProductsRemoteDataTransformer } from '../domain/transformers/product.remote.transformer';
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
defaults: {} as AxiosInstance['defaults'],
interceptors: {
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
},
getUri: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
head: vi.fn(),
options: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
postForm: vi.fn(),
putForm: vi.fn(),
patchForm: vi.fn(),
} as unknown as AxiosInstance;
}
describe('ProductsRemoteDataServices', () => {
let httpClient: AxiosInstance;
let service: ProductsRemoteDataServices;
beforeEach(() => {
httpClient = createMockHttpClient();
service = new ProductsRemoteDataServices(httpClient, {
apiUrl: '/products',
moduleKey: 'CONFIGURATION.PRODUCT',
transformer: new ProductsRemoteDataTransformer(),
});
});
it('uses PATCH when editing a product', async () => {
await service.edit('prd-1', { name: 'Widget', code: 'SKU_001' } as any);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/products/prd-1', method: 'PATCH' }),
);
});
it('bulk-deletes via POST /products/bulk-delete', async () => {
await service.batchDelete(['prd-1']);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/products/bulk-delete', method: 'POST' }),
);
});
});
@@ -0,0 +1,13 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services';
import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services';
import type { ProductEntity } from '../domain/entities';
export class ProductsRemoteDataServices extends TrackGoRemoteDataServices<ProductEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<ProductEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/products',
});
}
}
@@ -0,0 +1 @@
export * from './product.constants';
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { ProductEntity } from '../entities';
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
moduleKey: 'CONFIGURATION.PRODUCT',
translationNamespace: 'PRODUCTS',
apiUrl: '/products',
webUrl: '/app/configuration/products',
moduleCategory: 'FULL_PAGE',
moduleType: 'MASTER_DATA',
} as const;
@@ -0,0 +1 @@
export * from './product.entity';
@@ -0,0 +1,29 @@
import { BaseEntity } from '@repo/core-api/data-services';
import type { ConfigurationStatus } from '../../../divisions/domain/entities';
export interface ProductEntity extends BaseEntity {
code: string;
name: string;
unit?: string | null;
price?: string | null;
brand?: string | null;
status?: ConfigurationStatus;
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
export interface ProductDto {
id?: string;
code: string;
name: string;
unit?: string | null;
price?: string | null;
brand?: string | null;
status?: ConfigurationStatus;
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
@@ -0,0 +1,12 @@
import { apiClient } from '../../../../../../../core/lib/api-client';
import { ProductsRemoteDataServices } from '../../data/product.remote.service';
import { productsModuleConfig } from '../constants/product.constants';
import { ProductsRemoteDataTransformer } from '../transformers/product.remote.transformer';
export const productsDataTransformer = new ProductsRemoteDataTransformer();
export const productsDataService = new ProductsRemoteDataServices(apiClient, {
apiUrl: productsModuleConfig.apiUrl,
moduleKey: productsModuleConfig.moduleKey,
transformer: productsDataTransformer,
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { ProductsRemoteDataTransformer } from './product.remote.transformer';
import type { ProductEntity } from '../entities';
const transformer = new ProductsRemoteDataTransformer();
const dto = {
id: 'prd-1',
code: 'SKU_001',
name: 'Widget Plus (2.0)',
unit: 'PCS',
price: '12500.0000',
brand: 'Acme',
status: 'active' as const,
createdAt: 1,
updatedAt: 2,
createdBy: 'u1',
updatedBy: 'u2',
};
describe('ProductsRemoteDataTransformer', () => {
it('maps dto fields onto the entity', () => {
const entity = transformer.transformToEntity(dto);
expect(entity).toMatchObject({
id: 'prd-1',
code: 'SKU_001',
name: 'Widget Plus (2.0)',
unit: 'PCS',
price: '12500.0000',
brand: 'Acme',
});
});
it('omits empty optional fields on create', () => {
const entity: ProductEntity = {
...dto,
unit: '',
price: '',
brand: '',
status: 'draft',
};
const payload = transformer.transformCreatePayload(entity);
expect(payload).toEqual({
code: 'SKU_001',
name: 'Widget Plus (2.0)',
});
expect(payload).not.toHaveProperty('status');
});
it('converts empty optional fields to null on edit', () => {
const payload = transformer.transformEditPayload({
...dto,
unit: '',
price: '',
brand: '',
});
expect(payload).toEqual({
code: 'SKU_001',
name: 'Widget Plus (2.0)',
unit: null,
price: null,
brand: null,
});
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id');
});
});
@@ -0,0 +1,45 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import type { ProductDto, ProductEntity } from '../entities';
export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> {
transformToEntity(dto: ProductDto | ProductEntity): ProductEntity {
return {
id: dto.id,
code: dto.code,
name: dto.name,
unit: dto.unit ?? null,
price: dto.price ?? null,
brand: dto.brand ?? null,
status: dto.status,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
createdBy: dto.createdBy,
updatedBy: dto.updatedBy,
};
}
transformToDTO(entity: ProductEntity): ProductEntity {
return { ...entity };
}
transformCreatePayload(entity: Partial<ProductEntity>): Partial<ProductEntity> {
return omitEmptyFields({
code: entity.code,
name: entity.name,
unit: entity.unit,
price: entity.price,
brand: entity.brand,
});
}
transformEditPayload(entity: Partial<ProductEntity>): Partial<ProductEntity> {
return {
code: entity.code,
name: entity.name,
unit: emptyToNull(entity.unit) as string | null,
price: emptyToNull(entity.price) as string | null,
brand: emptyToNull(entity.brand) as string | null,
};
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { createProductSchema } from './product.validator';
describe('createProductSchema', () => {
const t = (key: string) => key;
const schema = createProductSchema(t);
const valid = {
code: 'SKU_001',
name: 'Widget Plus (2.0)',
unit: 'PCS',
price: '12500.0000',
brand: 'Acme',
};
it('accepts a complete payload', () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it('accepts optional unit, price, and brand as empty', () => {
expect(schema.safeParse({ code: 'SKU_002', name: 'Plain Widget' }).success).toBe(true);
});
it('rejects an empty code', () => {
expect(schema.safeParse({ ...valid, code: '' }).success).toBe(false);
});
it('rejects a name with unsupported characters', () => {
expect(schema.safeParse({ ...valid, name: 'Widget @ Home' }).success).toBe(false);
});
it('rejects a non-decimal price', () => {
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(false);
});
});
@@ -0,0 +1,35 @@
import { z } from 'zod';
import { compose, required, maxLength } from '@repo/ui/validators';
import { configCodeSchema } from '../../../../../../../core/domain/configuration-field-validators';
import { optionalDecimalStringSchema } from '../../../../../../../core/domain/decimal-string.schema';
const PRODUCT_NAME_MAX = 128;
const PRODUCT_NAME_PATTERN = /^[A-Za-z0-9+\-./()]+(?: [A-Za-z0-9+\-./()]+)*$/;
const PRODUCT_CODE_MAX = 32;
function emptyToUndefined(value: unknown) {
if (value === '' || value === null || value === undefined) {
return undefined;
}
return value;
}
function productNameSchema(t: (key: string) => string) {
return compose(
z.string(),
required(t('common:fields.name')),
maxLength(PRODUCT_NAME_MAX, t('common:fields.name')),
).regex(PRODUCT_NAME_PATTERN, {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.name') } }),
});
}
export const createProductSchema = (t: (key: string) => string) => {
return z.object({
code: configCodeSchema(t, PRODUCT_CODE_MAX),
name: productNameSchema(t),
unit: z.preprocess(emptyToUndefined, compose(z.string(), maxLength(16, t('common:fields.unit'))).optional()),
price: optionalDecimalStringSchema(t, 'common:fields.price'),
brand: z.preprocess(emptyToUndefined, compose(z.string(), maxLength(64, t('common:fields.brand'))).optional()),
});
};
@@ -0,0 +1,41 @@
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { ProductEntity } from '../../../domain/entities';
export function DetailGeneral() {
const { detailData } = useDetailPageContext<ProductEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.code')} value={data?.code} />
<FieldValue label={t('common:fields.name')} value={data?.name} />
<FieldValue label={t('common:fields.unit')} value={data?.unit} />
<FieldValue label={t('common:fields.price')} value={data?.price} />
<FieldValue label={t('common:fields.brand')} value={data?.brand} />
<FieldValue
label={t('common:fields.status')}
value={data?.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
<FieldValue
label={t('common:fields.createdAt')}
value={data?.createdAt}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.updatedAt')}
value={data?.updatedAt}
render={(val) => <RenderDate value={val as any} />}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,56 @@
import { Box, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
export function FormGeneral() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={formControl.control}
name="code"
label={t('common:fields.code')}
placeholder="e.g. SKU_001"
required
radius="md"
/>
<FieldTextInput
name="name"
control={formControl.control}
label={t('common:fields.name')}
placeholder="e.g. Widget Plus (2.0)"
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="unit"
label={t('common:fields.unit')}
placeholder="e.g. PCS"
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="price"
label={t('common:fields.price')}
placeholder="12500.0000"
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="brand"
label={t('common:fields.brand')}
placeholder="e.g. Acme"
radius="md"
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,42 @@
import { SimpleGrid } from '@repo/ui/components';
import { FieldTextInput, FieldSelect } from '@repo/ui/form';
import { UseFormReturn } from 'react-hook-form';
import { statusFilterOptions } from '../../../../shared/status-filter-options';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={form.control}
name="code"
label={t('common:fields.code')}
placeholder={`Enter ${t('common:fields.code')}`}
/>
<FieldTextInput
control={form.control}
name="name"
label={t('common:fields.name')}
placeholder={`Enter ${t('common:fields.name')}`}
/>
<FieldTextInput
control={form.control}
name="unit"
label={t('common:fields.unit')}
placeholder={`Enter ${t('common:fields.unit')}`}
/>
<FieldTextInput
control={form.control}
name="brand"
label={t('common:fields.brand')}
placeholder={`Enter ${t('common:fields.brand')}`}
/>
<FieldSelect
control={form.control}
name="status"
label={t('common:fields.status')}
clearable
data={statusFilterOptions(t)}
/>
</SimpleGrid>
);
};
@@ -0,0 +1,40 @@
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 { productsModuleConfig } from '../../domain/constants';
import { productsDataService } from '../../domain/factories';
import { ProductEntity } from '../../domain/entities';
import { productsStore } from '../store';
import productsId from '../languages/id/products.json';
import productsEn from '../languages/en/products.json';
const IndexPage = lazy(() => import('../pages/product.page.index'));
const FormPage = lazy(() => import('../pages/product.page.form'));
const DetailPage = lazy(() => import('../pages/product.page.detail'));
registerModuleNamespace(productsModuleConfig.translationNamespace, {
id: productsId,
en: productsEn,
});
export default function ProductsModule() {
return (
<EnterpriseModuleProvider<ProductEntity>
config={productsModuleConfig}
dataServices={productsDataService}
store={productsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${productsModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,16 @@
{
"title": "Products",
"detail_page_title": "Product Detail",
"create_page_title": "New Product",
"edit_page_title": "Edit Product",
"duplicate_page_title": "Duplicate Product",
"description": "Manage <1>products</1> used on sales requests and sales orders.",
"detail_page_description": "Review product identity, unit, price, and brand.",
"create_page_description": "Create a product with a unique code and name.",
"edit_page_description": "Update product identity, unit, price, and brand.",
"duplicate_page_description": "Copy an existing product to create a new one.",
"section_general": "General",
"status_draft": "Draft",
"status_active": "Active",
"status_archived": "Archived"
}
@@ -0,0 +1,16 @@
{
"title": "Produk",
"detail_page_title": "Detail Produk",
"create_page_title": "Produk Baru",
"edit_page_title": "Ubah Produk",
"duplicate_page_title": "Duplikat Produk",
"description": "Kelola <1>produk</1> yang dipakai pada permintaan penjualan dan pesanan penjualan.",
"detail_page_description": "Tinjau identitas, satuan, harga, dan merek produk.",
"create_page_description": "Buat produk dengan kode unik dan nama.",
"edit_page_description": "Perbarui identitas, satuan, harga, dan merek produk.",
"duplicate_page_description": "Salin produk yang ada untuk membuat data baru.",
"section_general": "Umum",
"status_draft": "Draft",
"status_active": "Aktif",
"status_archived": "Diarsipkan"
}
@@ -0,0 +1,23 @@
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { productsModuleConfig } from '../../domain/constants';
import { DetailGeneral } from '../components/detail-component/detail-general';
export default function ProductPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-products'), type: 'link', href: `${productsModuleConfig.webUrl}/index` },
],
}}
>
<DetailGeneral />
</EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,47 @@
import { useMemo } from 'react';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { productsModuleConfig } from '../../domain/constants';
import { createProductSchema } from '../../domain/validators/product.validator';
import { FormGeneral } from '../components/form-component/form-general';
export default function ProductPageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext();
const title = useMemo(() => {
if (formPageType === 'CREATE') {
return { title: t('create_page_title'), description: t('create_page_description') };
}
if (formPageType === 'EDIT') {
return { title: t('edit_page_title'), description: t('edit_page_description') };
}
if (formPageType === 'DUPLICATE') {
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
}
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createProductSchema(t), [t]);
const formControl = useForm({ resolver: zodResolver(validator) });
return (
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-products'), type: 'link', href: `${productsModuleConfig.webUrl}/index` },
],
}}
>
<FormGeneral />
</EnterpriseFormPageProvider>
);
}
@@ -0,0 +1,52 @@
import { useMemo } from 'react';
import {
EnterpriseIndexPageProvider,
useEnterpriseModuleTranslationContext,
EnterpriseDataTable,
} from '@repo/ui/foundations';
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Package } from 'lucide-react';
import { FilterFormContent } from '../components/index-component/filter-content';
import type { ProductEntity } from '../../domain/entities';
export default function ProductPageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
const columnDefs: ColDef<ProductEntity>[] = useMemo(() => {
return [
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
{ field: 'unit', headerName: t('common:fields.unit'), minWidth: 100 },
{ field: 'price', headerName: t('common:fields.price'), minWidth: 140 },
{ field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 },
];
}, [t]);
const filterConfig = useMemo(() => {
return {
renderBody: (form: any) => {
if (!form) return null;
return <FilterFormContent form={form} t={t} />;
},
};
}, [t]);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: Package,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-products'), type: 'text' },
],
}}
>
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,22 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { ProductEntity } from '../../domain/entities';
export interface ProductsStoreState extends EnterpriseModuleState<ProductEntity> {}
export const productsStore = create<ProductsStoreState>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -40,9 +40,7 @@ describe('CyclesRemoteDataServices', () => {
it('uses PATCH when editing a cycle', async () => { it('uses PATCH when editing a cycle', async () => {
await service.edit('cyc-1', { cycleNumber: 2 } as any); await service.edit('cyc-1', { cycleNumber: 2 } as any);
expect(httpClient.request).toHaveBeenCalledWith( expect(httpClient.request).toHaveBeenCalledWith(expect.objectContaining({ url: '/cycles/cyc-1', method: 'PATCH' }));
expect.objectContaining({ url: '/cycles/cyc-1', method: 'PATCH' }),
);
}); });
it('bulk-deletes via POST /cycles/bulk-delete', async () => { it('bulk-deletes via POST /cycles/bulk-delete', async () => {
@@ -51,7 +51,9 @@ export interface CycleDto {
employeeId: string; employeeId: string;
purpose: FieldPurpose; purpose: FieldPurpose;
cycleNumber: number; cycleNumber: number;
weekdays?: CycleWeekdayEntity[] | Record<string, { startBranchId: string; endBranchId: string; customerIds: string[] }>; weekdays?:
| CycleWeekdayEntity[]
| Record<string, { startBranchId: string; endBranchId: string; customerIds: string[] }>;
status?: ConfigurationStatus; status?: ConfigurationStatus;
createdAt?: number; createdAt?: number;
updatedAt?: number; updatedAt?: number;
@@ -2,12 +2,7 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import { WEEKDAYS } from '../../../../../../../core/domain/configuration-field-validators'; import { WEEKDAYS } from '../../../../../../../core/domain/configuration-field-validators';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import type { import type { CycleEntity, CycleWeekdayEntity, CycleWeekdayRow, CycleWeekdayWrite } from '../entities';
CycleEntity,
CycleWeekdayEntity,
CycleWeekdayRow,
CycleWeekdayWrite,
} from '../entities';
function relationId(value: unknown): string | undefined { function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) { if (value && typeof value === 'object' && 'id' in value) {
@@ -1,12 +1,19 @@
import { Box, FieldAsyncSelect, FieldNumberInput, Paper, SimpleGrid, Text } from '@repo/ui/components'; import { Box, FieldAsyncSelect, FieldNumberInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import {
import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; useEnterpriseModuleTranslationContext,
useEnterpriseModuleConfigContext,
useFormPageContext,
} from '@repo/ui/foundations';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { relationLabel } from '../../../../shared/relation-label'; import { relationLabel } from '../../../../shared/relation-label';
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
export function FormGeneral() { export function FormGeneral() {
const { formControl } = useFormPageContext(); const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { config } = useEnterpriseModuleConfigContext();
const purpose = purposeFromModuleKey(config.moduleKey);
const employee = formControl.watch('employee') as EmployeeEntity | null | undefined; const employee = formControl.watch('employee') as EmployeeEntity | null | undefined;
return ( return (
@@ -25,7 +32,7 @@ export function FormGeneral() {
labelKey="name" labelKey="name"
required required
searchable searchable
loadOptions={loadEmployeeOptions} loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
defaultOptions={employee ? [employee] : []} defaultOptions={employee ? [employee] : []}
renderLabel={relationLabel} renderLabel={relationLabel}
/> />
@@ -1,12 +1,17 @@
import { SimpleGrid } from '@repo/ui/components'; import { SimpleGrid } from '@repo/ui/components';
import { FieldAsyncSelect, FieldNumberInput, FieldSelect } from '@repo/ui/form'; import { FieldAsyncSelect, FieldNumberInput, FieldSelect } from '@repo/ui/form';
import { UseFormReturn } from 'react-hook-form'; import { UseFormReturn } from 'react-hook-form';
import { useEnterpriseModuleConfigContext } from '@repo/ui/foundations';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options'; import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options';
import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { relationLabel } from '../../../../shared/relation-label'; import { relationLabel } from '../../../../shared/relation-label';
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => { export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
const { config } = useEnterpriseModuleConfigContext();
const purpose = purposeFromModuleKey(config.moduleKey);
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldAsyncSelect<EmployeeEntity> <FieldAsyncSelect<EmployeeEntity>
@@ -18,7 +23,7 @@ export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (k
labelKey="name" labelKey="name"
clearable clearable
searchable searchable
loadOptions={loadEmployeeOptions} loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
renderLabel={relationLabel} renderLabel={relationLabel}
/> />
<FieldNumberInput <FieldNumberInput
@@ -44,7 +44,18 @@ export default function CyclePageForm({ formPageType }: { formPageType: FormPage
formControl={formControl} formControl={formControl}
formPageType={formPageType} formPageType={formPageType}
ignoreKeyDuplicate={['id']} ignoreKeyDuplicate={['id']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'purpose', 'employeeId', 'weekdays', 'routeGeometry']} ignoreKeyUpdate={[
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
'id',
'purpose',
'employeeId',
'weekdays',
'routeGeometry',
]}
highlightDataKey="cycleNumber" highlightDataKey="cycleNumber"
pageHeaderProps={{ pageHeaderProps={{
title: title?.title, title: title?.title,
@@ -29,7 +29,8 @@ export default function CyclePageIndex() {
field: 'weekdayRows', field: 'weekdayRows',
headerName: t('section_weekdays'), headerName: t('section_weekdays'),
minWidth: 140, minWidth: 140,
valueGetter: ({ data }) => data?.weekdayRows?.filter((row) => row.enabled).length ?? data?.weekdays?.length ?? 0, valueGetter: ({ data }) =>
data?.weekdayRows?.filter((row) => row.enabled).length ?? data?.weekdays?.length ?? 0,
}, },
]; ];
}, [t]); }, [t]);
@@ -1,14 +1,20 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-soon-page';
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../cycles/presentation/factory')); const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
const PlansModule = lazy(() => import('../plans/presentation/factory')); const PlansModule = lazy(() => import('../plans/presentation/factory'));
const PackingSlipsModule = lazy(() => import('../packing-slips/presentation/factory'));
export default function LogisticsFieldModule() { export default function LogisticsFieldModule() {
return ( return (
<Routes> <Routes>
<Route path="/employees/*" element={<EmployeesModule purpose="logistics" />} />
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} /> <Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} /> <Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} /> <Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes> </Routes>
); );
@@ -0,0 +1,13 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services';
import { SalesDocumentRemoteDataServices } from '../../../sales/shared/sales-document.remote.service';
import type { PackingSlipEntity } from '../domain/entities';
export class PackingSlipsRemoteDataServices extends SalesDocumentRemoteDataServices<PackingSlipEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<PackingSlipEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/packing-slips',
});
}
}
@@ -0,0 +1 @@
export * from './packing-slip.constants';
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { PackingSlipEntity } from '../entities';
export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = {
moduleKey: 'SALES.PACKING_SLIP',
translationNamespace: 'PACKING_SLIPS',
apiUrl: '/packing-slips',
webUrl: '/app/logistics/packing-slips',
moduleCategory: 'FULL_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1 @@
export * from './packing-slip.entity';
@@ -0,0 +1,13 @@
import type { SalesDocumentDto, SalesDocumentEntity } from '../../../sales/shared/sales-document.entity';
export interface PackingSlipEntity extends SalesDocumentEntity {
salesOrderId?: string | null;
salesOrder?: { id: string; code?: string; name?: string } | null;
salesOrderCode?: string | null;
}
export interface PackingSlipDto extends SalesDocumentDto {
salesOrderId?: string | null;
salesOrder?: { id: string; code?: string; name?: string } | null;
salesOrderCode?: string | null;
}
@@ -0,0 +1,12 @@
import { apiClient } from '../../../../../../../core/lib/api-client';
import { PackingSlipsRemoteDataServices } from '../../data/packing-slip.remote.service';
import { packingSlipsModuleConfig } from '../constants';
import { PackingSlipsRemoteDataTransformer } from '../transformers/packing-slip.remote.transformer';
export const packingSlipsDataTransformer = new PackingSlipsRemoteDataTransformer();
export const packingSlipsModuleDataService = new PackingSlipsRemoteDataServices(apiClient, {
apiUrl: packingSlipsModuleConfig.apiUrl,
moduleKey: packingSlipsModuleConfig.moduleKey,
transformer: packingSlipsDataTransformer,
});
@@ -0,0 +1,49 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import {
mapSalesDocumentFromDto,
relationId,
toLookup,
toSalesFilterPayload,
toSalesWritePayload,
} from '../../../sales/shared/sales-document.mapper';
import type { PackingSlipDto, PackingSlipEntity } from '../entities';
export class PackingSlipsRemoteDataTransformer extends BaseDataTransformer<PackingSlipEntity> {
transformToEntity(dto: PackingSlipDto | PackingSlipEntity): PackingSlipEntity {
const packingDto = dto as PackingSlipDto;
const base = mapSalesDocumentFromDto(packingDto);
const salesOrder = toLookup(packingDto.salesOrder, packingDto.salesOrderId);
return {
...base,
salesOrderId: relationId(packingDto.salesOrder) ?? packingDto.salesOrderId ?? null,
salesOrder,
salesOrderCode: packingDto.salesOrderCode ?? salesOrder?.code ?? null,
};
}
transformToDTO(entity: PackingSlipEntity): PackingSlipEntity {
return { ...entity };
}
transformCreatePayload(entity: Partial<PackingSlipEntity>): Partial<PackingSlipEntity> {
return omitEmptyFields(
toSalesWritePayload(entity, {
includeSalesOrderId: true,
omitImages: true,
omitStaffFields: true,
}),
) as Partial<PackingSlipEntity>;
}
transformEditPayload(entity: Partial<PackingSlipEntity>): Partial<PackingSlipEntity> {
return toSalesWritePayload(entity, {
omitImages: true,
omitStaffFields: true,
}) as Partial<PackingSlipEntity>;
}
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
return toSalesFilterPayload(filter);
}
}
@@ -0,0 +1,64 @@
import { Box, FieldAsyncSelect, FieldDatePicker, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
import { loadSalesOrderOptions } from '../../../../sales/shared/load-sales-order-options';
import { relationLabel } from '../../../../shared/relation-label';
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
import type { SalesOrderEntity } from '../../../../sales/orders/domain/entities';
export function FormPackingGeneral() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const customer = formControl.watch('customer');
const salesOrder = formControl.watch('salesOrder');
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={formControl.control}
name="code"
label={t('common:fields.code')}
placeholder="e.g. PS-20260826-0001"
radius="md"
/>
<FieldDatePicker
control={formControl.control}
name="date"
label={t('common:fields.date')}
required
radius="md"
/>
<FieldAsyncSelect<CustomerEntity>
control={formControl.control}
name="customer"
label={t('common:fields.customer')}
valueKey="id"
labelKey="name"
required
searchable
loadOptions={loadCustomerOptions}
defaultOptions={customer ? [customer] : []}
renderLabel={relationLabel}
/>
<FieldAsyncSelect<SalesOrderEntity>
control={formControl.control}
name="salesOrder"
label={t('common:fields.salesOrder')}
valueKey="id"
labelKey="code"
searchable
clearable
loadOptions={loadSalesOrderOptions}
defaultOptions={salesOrder ? [salesOrder] : []}
renderLabel={relationLabel}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,40 @@
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 { packingSlipsModuleConfig } from '../../domain/constants';
import { packingSlipsModuleDataService } from '../../domain/factories';
import { PackingSlipEntity } from '../../domain/entities';
import { packingSlipsStore } from '../store';
import packingSlipsId from '../languages/id/packing-slips.json';
import packingSlipsEn from '../languages/en/packing-slips.json';
const IndexPage = lazy(() => import('../pages/packing-slip.page.index'));
const FormPage = lazy(() => import('../pages/packing-slip.page.form'));
const DetailPage = lazy(() => import('../pages/packing-slip.page.detail'));
registerModuleNamespace(packingSlipsModuleConfig.translationNamespace, {
id: packingSlipsId,
en: packingSlipsEn,
});
export default function PackingSlipsModule() {
return (
<EnterpriseModuleProvider<PackingSlipEntity>
config={packingSlipsModuleConfig}
dataServices={packingSlipsModuleDataService}
store={packingSlipsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${packingSlipsModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,33 @@
{
"title": "Packing Slips",
"detail_page_title": "Packing Slip Detail",
"create_page_title": "New Packing Slip",
"edit_page_title": "Edit Packing Slip",
"duplicate_page_title": "Duplicate Packing Slip",
"description": "Create <1>packing slips</1> from a sales order or as a standalone document.",
"detail_page_description": "Review packing header, location, and product quantities.",
"create_page_description": "Create a packing slip, optionally sourced from a sales order.",
"edit_page_description": "Update packing header, location, products, and notes.",
"duplicate_page_description": "Copy an existing packing slip to create a new one.",
"section_general": "General",
"section_location": "Location",
"section_products": "Products",
"section_notes": "Notes",
"add_line": "Add line",
"remove_line": "Remove line",
"empty_products": "No product lines.",
"change_status": "Change status",
"import_csv": "Import CSV",
"csv_file": "CSV file",
"import_success": "CSV imported.",
"status_updated": "Status updated.",
"action_complete": "Complete",
"action_cancel": "Cancel",
"complete_packing": "Complete packing",
"complete_packing_help": "Enter delivered quantity for each line. Remaining quantity opens a new packing slip.",
"delivered_quantity": "Delivered quantity",
"status_draft": "Draft",
"status_processed": "Processed",
"status_completed": "Completed",
"status_cancelled": "Cancelled"
}
@@ -0,0 +1,33 @@
{
"title": "Surat Jalan",
"detail_page_title": "Detail Surat Jalan",
"create_page_title": "Surat Jalan Baru",
"edit_page_title": "Ubah Surat Jalan",
"duplicate_page_title": "Duplikat Surat Jalan",
"description": "Buat <1>surat jalan</1> dari pesanan penjualan atau sebagai dokumen mandiri.",
"detail_page_description": "Tinjau header, lokasi, dan kuantitas produk surat jalan.",
"create_page_description": "Buat surat jalan, opsional dari pesanan penjualan.",
"edit_page_description": "Perbarui header, lokasi, produk, dan catatan surat jalan.",
"duplicate_page_description": "Salin surat jalan yang ada untuk membuat data baru.",
"section_general": "Umum",
"section_location": "Lokasi",
"section_products": "Produk",
"section_notes": "Catatan",
"add_line": "Tambah baris",
"remove_line": "Hapus baris",
"empty_products": "Tidak ada baris produk.",
"change_status": "Ubah status",
"import_csv": "Impor CSV",
"csv_file": "File CSV",
"import_success": "CSV berhasil diimpor.",
"status_updated": "Status diperbarui.",
"action_complete": "Selesaikan",
"action_cancel": "Batalkan",
"complete_packing": "Selesaikan packing",
"complete_packing_help": "Masukkan kuantitas terkirim untuk setiap baris. Sisa kuantitas akan membuka surat jalan baru.",
"delivered_quantity": "Kuantitas terkirim",
"status_draft": "Draft",
"status_processed": "Diproses",
"status_completed": "Selesai",
"status_cancelled": "Dibatalkan"
}
@@ -0,0 +1,38 @@
import { Stack } from '@repo/ui/components';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { packingSlipsModuleConfig } from '../../domain/constants';
import { DetailGeneral } from '../../../sales/shared/detail-general';
import { DetailLocation } from '../../../sales/shared/detail-location';
import { DetailProducts } from '../../../sales/shared/detail-products';
import { useSalesDocumentActions } from '../../../sales/shared/use-sales-document-actions';
import { salesOrdersModuleConfig } from '../../../sales/orders/domain/constants';
import type { PackingSlipEntity } from '../../domain/entities';
export default function PackingSlipPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
const actions = useSalesDocumentActions('packing');
return (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:logistics'), type: 'text' },
{ label: t('nav:logistics-packing-slips'), type: 'link', href: `${packingSlipsModuleConfig.webUrl}/index` },
],
}}
customPageActions={(data, pageActions) =>
actions.detailStatusActions(data as PackingSlipEntity, pageActions ?? [])
}
>
<Stack gap="md">
<DetailGeneral salesOrderHref={(id) => `${salesOrdersModuleConfig.webUrl}/detail/${id}`} />
<DetailLocation />
<DetailProducts />
</Stack>
{actions.modals}
</EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { Stack } from '@repo/ui/components';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { packingSlipsModuleConfig } from '../../domain/constants';
import { createSalesPackingSlipSchema } from '../../../sales/shared/sales-document.validator';
import { FormPackingGeneral } from '../components/form-component/form-packing-general';
import { FormLocation } from '../../../sales/shared/form-location';
import { FormProducts } from '../../../sales/shared/form-products';
import { FormNotes } from '../../../sales/shared/form-notes';
export default function PackingSlipPageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext();
const title = useMemo(() => {
if (formPageType === 'CREATE') {
return { title: t('create_page_title'), description: t('create_page_description') };
}
if (formPageType === 'EDIT') {
return { title: t('edit_page_title'), description: t('edit_page_description') };
}
if (formPageType === 'DUPLICATE') {
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
}
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createSalesPackingSlipSchema(t), [t]);
const formControl = useForm({
resolver: zodResolver(validator),
defaultValues: {
products: [{ quantity: '1', price: '' }],
},
});
return (
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:logistics'), type: 'text' },
{ label: t('nav:logistics-packing-slips'), type: 'link', href: `${packingSlipsModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<FormPackingGeneral />
<FormLocation />
<FormProducts />
<FormNotes />
</Stack>
</EnterpriseFormPageProvider>
);
}
@@ -0,0 +1,72 @@
import { useMemo } from 'react';
import {
EnterpriseIndexPageProvider,
useEnterpriseModuleTranslationContext,
EnterpriseDataTable,
} from '@repo/ui/foundations';
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Package } from 'lucide-react';
import { SalesFilterFormContent } from '../../../sales/shared/filter-content';
import { useSalesDocumentActions } from '../../../sales/shared/use-sales-document-actions';
import { relationLabel } from '../../shared/relation-label';
import type { PackingSlipEntity } from '../../domain/entities';
export default function PackingSlipPageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
const actions = useSalesDocumentActions('packing');
const columnDefs: ColDef<PackingSlipEntity>[] = useMemo(
() => [
{ field: 'code', headerName: t('common:fields.code'), minWidth: 160 },
{ field: 'date', headerName: t('common:fields.date'), minWidth: 140 },
{
field: 'customerId',
headerName: t('common:fields.customer'),
minWidth: 180,
valueGetter: ({ data }) => relationLabel(data?.customer) || data?.customerId,
},
{
field: 'salesOrderId',
headerName: t('common:fields.salesOrder'),
minWidth: 160,
valueGetter: ({ data }) => relationLabel(data?.salesOrder) || data?.salesOrderCode || data?.salesOrderId,
},
],
[t],
);
const filterConfig = useMemo(
() => ({
renderBody: (form: any) => (form ? <SalesFilterFormContent form={form} t={t} documentType="packing" /> : null),
}),
[t],
);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: Package,
breadcrumbs: [
{ label: t('nav:logistics'), type: 'text' },
{ label: t('nav:logistics-packing-slips'), type: 'text' },
],
}}
customPageActions={(pageActions) =>
actions.importPageAction ? [actions.importPageAction, ...(pageActions ?? [])] : pageActions
}
>
<EnterpriseDataTable
columnDefs={columnDefs}
filterConfig={filterConfig}
customRowActions={(data, defaultActions) => actions.namedRowActions(data, defaultActions)}
customBulkActions={(rows, defaultActions) => actions.namedBulkActions(rows, defaultActions)}
/>
{actions.modals}
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,18 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { PackingSlipEntity } from '../../domain/entities';
export interface PackingSlipsStoreState extends EnterpriseModuleState<PackingSlipEntity> {}
export const packingSlipsStore = create<PackingSlipsStoreState>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -65,7 +65,9 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
if (this.purpose === 'sales') { if (this.purpose === 'sales') {
payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds; payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds;
} else { } else {
payload.packingSlipIds = relationIds(entity.packingSlips).length ? relationIds(entity.packingSlips) : entity.packingSlipIds; payload.packingSlipIds = relationIds(entity.packingSlips).length
? relationIds(entity.packingSlips)
: entity.packingSlipIds;
} }
return payload; return payload;
} }
@@ -1,6 +1,24 @@
import { ActionIcon, Box, Button, FieldAsyncSelect, FieldValue, Group, Paper, RenderDate, SimpleGrid, Stack, StatusBadge, Text, notifications } from '@repo/ui/components'; import {
ActionIcon,
Box,
Button,
FieldAsyncSelect,
FieldValue,
Group,
Paper,
RenderDate,
SimpleGrid,
Stack,
StatusBadge,
Text,
notifications,
} from '@repo/ui/components';
import { RouteMap } from '@repo/ui/map'; import { RouteMap } from '@repo/ui/map';
import { useDetailPageContext, useEnterpriseModuleTranslationContext, useEnterpriseModuleDataServiceContext } from '@repo/ui/foundations'; import {
useDetailPageContext,
useEnterpriseModuleTranslationContext,
useEnterpriseModuleDataServiceContext,
} from '@repo/ui/foundations';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { relationLabel } from '../../../../shared/relation-label'; import { relationLabel } from '../../../../shared/relation-label';
@@ -39,10 +57,24 @@ export function DetailGeneral() {
</Text> </Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl"> <SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.employee')} value={relationLabel(data?.employee) || data?.employeeId} /> <FieldValue label={t('common:fields.employee')} value={relationLabel(data?.employee) || data?.employeeId} />
<FieldValue label={t('common:fields.date')} value={data?.date} render={(val) => <RenderDate value={val as any} />} /> <FieldValue
<FieldValue label={t('common:fields.startBranch')} value={relationLabel(data?.startBranch) || data?.startBranchId} /> label={t('common:fields.date')}
<FieldValue label={t('common:fields.endBranch')} value={relationLabel(data?.endBranch) || data?.endBranchId} /> value={data?.date}
<FieldValue label={t('common:fields.status')} value={data?.status} render={(val) => <StatusBadge status={String(val ?? '')} />} /> render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.startBranch')}
value={relationLabel(data?.startBranch) || data?.startBranchId}
/>
<FieldValue
label={t('common:fields.endBranch')}
value={relationLabel(data?.endBranch) || data?.endBranchId}
/>
<FieldValue
label={t('common:fields.status')}
value={data?.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
</SimpleGrid> </SimpleGrid>
</Paper> </Paper>
@@ -93,6 +125,21 @@ export function DetailGeneral() {
</Button> </Button>
</Group> </Group>
</Paper> </Paper>
{(data?.purpose === 'sales' ? data?.invoices : data?.packingSlips)?.length ? (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_attachments')}
</Text>
<Stack gap="xs">
{(data.purpose === 'sales' ? data.invoices : data?.packingSlips)?.map((item) => (
<Text key={item.id} size="sm">
{relationLabel(item) || item.id}
</Text>
))}
</Stack>
</Paper>
) : null}
</Stack> </Stack>
); );
} }
@@ -1,7 +1,11 @@
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components'; import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext, useEnterpriseModuleConfigContext } from '@repo/ui/foundations'; import {
useEnterpriseModuleTranslationContext,
useFormPageContext,
useEnterpriseModuleConfigContext,
} from '@repo/ui/foundations';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose'; import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { loadBranchOptions } from '../../../../shared/load-branch-options'; import { loadBranchOptions } from '../../../../shared/load-branch-options';
import { loadCustomerOptions } from '../../../../shared/load-customer-options'; import { loadCustomerOptions } from '../../../../shared/load-customer-options';
import { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories'; import { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories';
@@ -38,7 +42,7 @@ export function FormGeneral() {
labelKey="name" labelKey="name"
required required
searchable searchable
loadOptions={loadEmployeeOptions} loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
defaultOptions={employee ? [employee] : []} defaultOptions={employee ? [employee] : []}
renderLabel={relationLabel} renderLabel={relationLabel}
/> />
@@ -1,12 +1,17 @@
import { SimpleGrid } from '@repo/ui/components'; import { SimpleGrid } from '@repo/ui/components';
import { FieldAsyncSelect, FieldDatePicker, FieldSelect } from '@repo/ui/form'; import { FieldAsyncSelect, FieldDatePicker, FieldSelect } from '@repo/ui/form';
import { UseFormReturn } from 'react-hook-form'; import { UseFormReturn } from 'react-hook-form';
import { useEnterpriseModuleConfigContext } from '@repo/ui/foundations';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options'; import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options';
import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { relationLabel } from '../../../../shared/relation-label'; import { relationLabel } from '../../../../shared/relation-label';
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => { export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
const { config } = useEnterpriseModuleConfigContext();
const purpose = purposeFromModuleKey(config.moduleKey);
return ( return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldAsyncSelect<EmployeeEntity> <FieldAsyncSelect<EmployeeEntity>
@@ -17,7 +22,7 @@ export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (k
labelKey="name" labelKey="name"
clearable clearable
searchable searchable
loadOptions={loadEmployeeOptions} loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
renderLabel={relationLabel} renderLabel={relationLabel}
/> />
<FieldDatePicker control={form.control} name="date" label={t('common:fields.date')} clearable /> <FieldDatePicker control={form.control} name="date" label={t('common:fields.date')} clearable />
@@ -2,8 +2,13 @@ import { useMemo } from 'react';
import { Button, FieldAsyncSelect, FieldDatePicker, Group, Modal, Stack, notifications } from '@repo/ui/components'; import { Button, FieldAsyncSelect, FieldDatePicker, Group, Modal, Stack, notifications } from '@repo/ui/components';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useEnterpriseModuleTranslationContext, useEnterpriseModuleDataServiceContext } from '@repo/ui/foundations'; import {
import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; useEnterpriseModuleConfigContext,
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleTranslationContext,
} from '@repo/ui/foundations';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { relationLabel } from '../../../../shared/relation-label'; import { relationLabel } from '../../../../shared/relation-label';
import { createGeneratePlansSchema } from '../../../domain/validators/plan.validator'; import { createGeneratePlansSchema } from '../../../domain/validators/plan.validator';
import type { PlansRemoteDataServices } from '../../../data/plan.remote.service'; import type { PlansRemoteDataServices } from '../../../data/plan.remote.service';
@@ -12,6 +17,8 @@ import type { PlanEntity } from '../../../domain/entities';
export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClose: () => void }) { export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const { t } = useEnterpriseModuleTranslationContext(); const { t } = useEnterpriseModuleTranslationContext();
const { config } = useEnterpriseModuleConfigContext();
const purpose = purposeFromModuleKey(config.moduleKey);
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>(); const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
const validator = useMemo(() => createGeneratePlansSchema(t), [t]); const validator = useMemo(() => createGeneratePlansSchema(t), [t]);
const form = useForm({ resolver: zodResolver(validator) }); const form = useForm({ resolver: zodResolver(validator) });
@@ -45,7 +52,7 @@ export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClo
labelKey="name" labelKey="name"
required required
searchable searchable
loadOptions={loadEmployeeOptions} loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
renderLabel={relationLabel} renderLabel={relationLabel}
/> />
<FieldDatePicker control={form.control as any} name="from" label={t('common:fields.from')} required /> <FieldDatePicker control={form.control as any} name="from" label={t('common:fields.from')} required />
@@ -19,6 +19,7 @@
"add_destination": "Add destination", "add_destination": "Add destination",
"remove_destination": "Remove destination", "remove_destination": "Remove destination",
"empty_route": "No route geometry", "empty_route": "No route geometry",
"section_attachments": "Attachments",
"purpose_sales": "Sales", "purpose_sales": "Sales",
"purpose_logistics": "Logistics", "purpose_logistics": "Logistics",
"status_draft": "Draft", "status_draft": "Draft",
@@ -19,6 +19,7 @@
"add_destination": "Tambah destinasi", "add_destination": "Tambah destinasi",
"remove_destination": "Hapus destinasi", "remove_destination": "Hapus destinasi",
"empty_route": "Tidak ada geometri rute", "empty_route": "Tidak ada geometri rute",
"section_attachments": "Lampiran",
"purpose_sales": "Penjualan", "purpose_sales": "Penjualan",
"purpose_logistics": "Logistik", "purpose_logistics": "Logistik",
"status_draft": "Draft", "status_draft": "Draft",
@@ -39,7 +39,20 @@ export default function PlanPageForm({ formPageType }: { formPageType: FormPageT
formControl={formControl} formControl={formControl}
formPageType={formPageType} formPageType={formPageType}
ignoreKeyDuplicate={['id']} ignoreKeyDuplicate={['id']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'purpose', 'employeeId', 'destinations', 'routeGeometry', 'invoiceIds', 'packingSlipIds']} ignoreKeyUpdate={[
'status',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
'id',
'purpose',
'employeeId',
'destinations',
'routeGeometry',
'invoiceIds',
'packingSlipIds',
]}
highlightDataKey="date" highlightDataKey="date"
pageHeaderProps={{ pageHeaderProps={{
title: title?.title, title: title?.title,
@@ -1,15 +0,0 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
const PlansModule = lazy(() => import('../plans/presentation/factory'));
export default function SalesFieldModule() {
return (
<Routes>
<Route path="/cycles/*" element={<CyclesModule purpose="sales" />} />
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
}
@@ -1,5 +1,27 @@
import { employeesDataService } from '../../configuration/employees/domain/factories'; import type { LoadOptionsFn } from '@repo/ui/form';
import type { FieldPurpose } from '../../../../../core/domain/field-purpose';
import {
employeesDataService,
logisticsEmployeesDataService,
salesEmployeesDataService,
} from '../../configuration/employees/domain/factories';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities'; import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
import { createOptionLoader } from './create-option-loader'; import { createOptionLoader } from './create-option-loader';
export const loadEmployeeOptions = createOptionLoader<EmployeeEntity>((config) => employeesDataService.getMany(config)); function employeeServiceForPurpose(purpose?: FieldPurpose) {
if (purpose === 'sales') {
return salesEmployeesDataService;
}
if (purpose === 'logistics') {
return logisticsEmployeesDataService;
}
return employeesDataService;
}
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
const service = employeeServiceForPurpose(purpose);
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config));
}
export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales');
export const loadLogisticsEmployeeOptions = loadEmployeeOptionsForPurpose('logistics');
@@ -1,6 +1,4 @@
export function relationLabel( export function relationLabel(item: { code?: string | null; name?: string; id?: string | number } | null | undefined) {
item: { code?: string | null; name?: string; id?: string | number } | null | undefined,
) {
if (!item) return ''; if (!item) return '';
if (item.code && item.name) return `${item.code} - ${item.name}`; if (item.code && item.name) return `${item.code} - ${item.name}`;
return item.name || item.code || (item.id == null ? '' : String(item.id)); return item.name || item.code || (item.id == null ? '' : String(item.id));
@@ -0,0 +1,27 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EmbeddedComingSoonPage } from '../../../../core/components/coming-soon-page';
const RequestsModule = lazy(() => import('./requests/presentation/factory'));
const OrdersModule = lazy(() => import('./orders/presentation/factory'));
const EmployeesModule = lazy(() => import('../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
const InvoicesModule = lazy(() => import('./invoices/presentation/factory'));
const PaymentsModule = lazy(() => import('./payments/presentation/factory'));
export default function SalesModule() {
return (
<Routes>
<Route path="/employees/*" element={<EmployeesModule purpose="sales" />} />
<Route path="/requests/*" element={<RequestsModule />} />
<Route path="/orders/*" element={<OrdersModule />} />
<Route path="/cycles/*" element={<CyclesModule purpose="sales" />} />
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
<Route path="/invoices/*" element={<InvoicesModule />} />
<Route path="/payments/*" element={<PaymentsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
}
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { SalesInvoicesRemoteDataServices } from './sales-invoice.remote.service';
import { SalesInvoicesRemoteDataTransformer } from '../domain/transformers/sales-invoice.remote.transformer';
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
defaults: {} as AxiosInstance['defaults'],
interceptors: {
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
},
getUri: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
head: vi.fn(),
options: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
postForm: vi.fn(),
putForm: vi.fn(),
patchForm: vi.fn(),
} as unknown as AxiosInstance;
}
describe('SalesInvoicesRemoteDataServices', () => {
let httpClient: AxiosInstance;
let service: SalesInvoicesRemoteDataServices;
beforeEach(() => {
httpClient = createMockHttpClient();
service = new SalesInvoicesRemoteDataServices(httpClient, {
apiUrl: '/sales-invoices',
moduleKey: 'SALES.INVOICE',
transformer: new SalesInvoicesRemoteDataTransformer(),
});
});
it('uses PATCH when editing', async () => {
await service.edit('so-1', { address: 'Jl Sudirman 1' } as any);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/sales-invoices/so-1', method: 'PATCH' }),
);
});
it('bulk-changes status via POST /bulk-status', async () => {
await service.bulkChangeStatus(['so-1'], 'processed');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/sales-invoices/bulk-status',
method: 'POST',
data: { ids: ['so-1'], status: 'processed' },
}),
);
});
});
@@ -0,0 +1,13 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services';
import { SalesDocumentRemoteDataServices } from '../../shared/sales-document.remote.service';
import type { SalesInvoiceEntity } from '../domain/entities';
export class SalesInvoicesRemoteDataServices extends SalesDocumentRemoteDataServices<SalesInvoiceEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<SalesInvoiceEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/sales-invoices',
});
}
}
@@ -0,0 +1 @@
export * from './sales-invoice.constants';
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesInvoiceEntity } from '../entities';
export const salesInvoicesModuleConfig: ModuleConfigEntity<SalesInvoiceEntity> = {
moduleKey: 'SALES.INVOICE',
translationNamespace: 'SALES_INVOICES',
apiUrl: '/sales-invoices',
webUrl: '/app/sales/invoices',
moduleCategory: 'FULL_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1 @@
export * from './sales-invoice.entity';
@@ -0,0 +1,21 @@
import type { SalesDocumentDto, SalesDocumentEntity } from '../../../shared/sales-document.entity';
export interface SalesInvoiceEntity extends SalesDocumentEntity {
salesOrderId?: string | null;
salesOrder?: { id: string; code?: string; name?: string } | null;
salesOrderCode?: string | null;
packingSlipId?: string | null;
packingSlip?: { id: string; code?: string; name?: string } | null;
packingSlipCode?: string | null;
balance?: string | null;
}
export interface SalesInvoiceDto extends SalesDocumentDto {
salesOrderId?: string | null;
salesOrder?: { id: string; code?: string; name?: string } | null;
salesOrderCode?: string | null;
packingSlipId?: string | null;
packingSlip?: { id: string; code?: string; name?: string } | null;
packingSlipCode?: string | null;
balance?: string | null;
}
@@ -0,0 +1,12 @@
import { apiClient } from '../../../../../../../core/lib/api-client';
import { SalesInvoicesRemoteDataServices } from '../../data/sales-invoice.remote.service';
import { salesInvoicesModuleConfig } from '../constants/sales-invoice.constants';
import { SalesInvoicesRemoteDataTransformer } from '../transformers/sales-invoice.remote.transformer';
export const salesInvoicesDataTransformer = new SalesInvoicesRemoteDataTransformer();
export const salesInvoicesDataService = new SalesInvoicesRemoteDataServices(apiClient, {
apiUrl: salesInvoicesModuleConfig.apiUrl,
moduleKey: salesInvoicesModuleConfig.moduleKey,
transformer: salesInvoicesDataTransformer,
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { SalesInvoicesRemoteDataTransformer } from './sales-invoice.remote.transformer';
const transformer = new SalesInvoicesRemoteDataTransformer();
describe('SalesInvoicesRemoteDataTransformer', () => {
it('keeps nested sales order, packing slip, and balance from the API response', () => {
const entity = transformer.transformToEntity({
id: 'inv-1',
date: Date.UTC(2026, 7, 26),
address: 'depok',
salesOrder: { id: 'so-1', code: 'SO-20260801-0001' },
packingSlip: { id: 'ps-1', code: 'PS-1' },
salesOrderCode: 'SO-20260801-0001',
packingSlipCode: 'PS-1',
balance: '10000.0000',
products: [],
} as any);
expect(entity.salesOrder).toEqual({ id: 'so-1', code: 'SO-20260801-0001' });
expect(entity.salesOrderId).toBe('so-1');
expect(entity.packingSlipId).toBe('ps-1');
expect(entity.balance).toBe('10000.0000');
});
it('includes parent ids on create, omits them on edit, and never writes images', () => {
const entity = {
date: '2026-08-26',
salesPerson: { id: 'emp-1' } as any,
branch: { id: 'br-1' } as any,
division: { id: 'div-1' } as any,
customer: { id: 'cus-1' } as any,
address: 'Jl Sudirman 1',
products: [{ product: { id: 'prd-1' } as any, quantity: '1', price: '10.0000' }],
images: [{ url: 'https://cdn.example/a.png' }],
salesOrder: { id: 'so-1' } as any,
packingSlip: { id: 'ps-1' } as any,
};
const createPayload = transformer.transformCreatePayload(entity);
const editPayload = transformer.transformEditPayload(entity);
expect(createPayload.salesOrderId).toBe('so-1');
expect(createPayload.packingSlipId).toBe('ps-1');
expect(createPayload).not.toHaveProperty('images');
expect(editPayload).not.toHaveProperty('salesOrderId');
expect(editPayload).not.toHaveProperty('images');
});
});
@@ -0,0 +1,51 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import {
mapSalesDocumentFromDto,
relationId,
toLookup,
toSalesFilterPayload,
toSalesWritePayload,
} from '../../../shared/sales-document.mapper';
import type { SalesInvoiceDto, SalesInvoiceEntity } from '../entities';
export class SalesInvoicesRemoteDataTransformer extends BaseDataTransformer<SalesInvoiceEntity> {
transformToEntity(dto: SalesInvoiceDto | SalesInvoiceEntity): SalesInvoiceEntity {
const invoiceDto = dto as SalesInvoiceDto;
const base = mapSalesDocumentFromDto(invoiceDto);
const salesOrder = toLookup(invoiceDto.salesOrder, invoiceDto.salesOrderId);
const packingSlip = toLookup(invoiceDto.packingSlip, invoiceDto.packingSlipId);
return {
...base,
salesOrderId: relationId(invoiceDto.salesOrder) ?? invoiceDto.salesOrderId ?? null,
salesOrder,
salesOrderCode: invoiceDto.salesOrderCode ?? salesOrder?.code ?? null,
packingSlipId: relationId(invoiceDto.packingSlip) ?? invoiceDto.packingSlipId ?? null,
packingSlip,
packingSlipCode: invoiceDto.packingSlipCode ?? packingSlip?.code ?? null,
balance: invoiceDto.balance ?? null,
};
}
transformToDTO(entity: SalesInvoiceEntity): SalesInvoiceEntity {
return { ...entity };
}
transformCreatePayload(entity: Partial<SalesInvoiceEntity>): Partial<SalesInvoiceEntity> {
return omitEmptyFields(
toSalesWritePayload(entity, {
includeSalesOrderId: true,
includePackingSlipId: true,
omitImages: true,
}),
) as Partial<SalesInvoiceEntity>;
}
transformEditPayload(entity: Partial<SalesInvoiceEntity>): Partial<SalesInvoiceEntity> {
return toSalesWritePayload(entity, { omitImages: true }) as Partial<SalesInvoiceEntity>;
}
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
return toSalesFilterPayload(filter);
}
}
@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react';
import { Box, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { loadSalesOrderOptions } from '../../../../shared/load-sales-order-options';
import { loadPackingSlipOptions, packingSlipsDataService } from '../../../../../field/shared/lookup.factories';
import { relationLabel } from '../../../../../field/shared/relation-label';
import { salesRequestToFormValues } from '../../../../shared/sales-document.mapper';
import { salesOrdersDataService } from '../../../../orders/domain/factories';
import type { SalesOrderEntity } from '../../../../orders/domain/entities';
import type { LookupEntity } from '../../../../../field/shared/lookup.entity';
export function FormInvoiceSource() {
const { formControl, isCreate } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const salesOrder = formControl.watch('salesOrder');
const packingSlip = formControl.watch('packingSlip');
const appliedOrderId = useRef<string | null>(null);
const appliedPackingSlipId = useRef<string | null>(null);
useEffect(() => {
const id = salesOrder?.id;
if (!isCreate || !id || appliedOrderId.current === id) return;
appliedOrderId.current = id;
void salesOrdersDataService.getOne(id).then((result: { data?: { data?: SalesOrderEntity } }) => {
const entity = (result.data as { data?: SalesOrderEntity })?.data;
if (!entity) return;
const values = salesRequestToFormValues(entity);
formControl.reset({
...formControl.getValues(),
salesOrder,
...values,
images: [],
});
});
}, [formControl, isCreate, salesOrder]);
useEffect(() => {
const id = packingSlip?.id;
if (!isCreate || !id || appliedPackingSlipId.current === id) return;
appliedPackingSlipId.current = id;
void packingSlipsDataService.getOne(id).then((result: { data?: { data?: LookupEntity } }) => {
const entity = (result.data as { data?: LookupEntity })?.data;
if (!entity) return;
formControl.reset({
...formControl.getValues(),
packingSlip: entity,
});
});
}, [formControl, isCreate, packingSlip]);
if (!isCreate) return null;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_source')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldAsyncSelect<SalesOrderEntity>
control={formControl.control}
name="salesOrder"
label={t('common:fields.salesOrder')}
valueKey="id"
labelKey="code"
searchable
clearable
loadOptions={loadSalesOrderOptions}
defaultOptions={salesOrder ? [salesOrder] : []}
renderLabel={relationLabel}
/>
<FieldAsyncSelect<LookupEntity>
control={formControl.control}
name="packingSlip"
label={t('common:fields.packingSlip')}
valueKey="id"
labelKey="code"
searchable
clearable
loadOptions={loadPackingSlipOptions}
defaultOptions={packingSlip ? [packingSlip] : []}
renderLabel={relationLabel}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,40 @@
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 { salesInvoicesModuleConfig } from '../../domain/constants';
import { salesInvoicesDataService } from '../../domain/factories';
import { SalesInvoiceEntity } from '../../domain/entities';
import { salesInvoicesStore } from '../store';
import salesInvoicesId from '../languages/id/sales-invoices.json';
import salesInvoicesEn from '../languages/en/sales-invoices.json';
const IndexPage = lazy(() => import('../pages/sales-invoice.page.index'));
const FormPage = lazy(() => import('../pages/sales-invoice.page.form'));
const DetailPage = lazy(() => import('../pages/sales-invoice.page.detail'));
registerModuleNamespace(salesInvoicesModuleConfig.translationNamespace, {
id: salesInvoicesId,
en: salesInvoicesEn,
});
export default function SalesInvoicesModule() {
return (
<EnterpriseModuleProvider<SalesInvoiceEntity>
config={salesInvoicesModuleConfig}
dataServices={salesInvoicesDataService}
store={salesInvoicesStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${salesInvoicesModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}

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