Compare commits
10
Commits
ee6c3273d9
...
050fafd731
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050fafd731 | ||
|
|
15efa358f9 | ||
|
|
133e73a070 | ||
|
|
b21d647c8b | ||
|
|
6b012a6aae | ||
|
|
105cf3030a | ||
|
|
eadd4e3c81 | ||
|
|
9e710a92b2 | ||
|
|
b22ce99840 | ||
|
|
4d9c83ee83 |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-2
@@ -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>
|
||||||
|
|||||||
+12
-1
@@ -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';
|
||||||
|
|||||||
+7
-1
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-1
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-3
@@ -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;
|
};
|
||||||
|
}
|
||||||
|
|||||||
+9
@@ -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');
|
||||||
export const employeesDataService = new EmployeesRemoteDataServices(apiClient, {
|
const transformer = new EmployeesRemoteDataTransformer(purpose ? employeePositionsForPurpose(purpose) : undefined);
|
||||||
apiUrl: employeesModuleConfig.apiUrl,
|
return new EmployeesRemoteDataServices(apiClient, {
|
||||||
moduleKey: employeesModuleConfig.moduleKey,
|
apiUrl: config.apiUrl,
|
||||||
transformer: employeesDataTransformer,
|
moduleKey: config.moduleKey,
|
||||||
|
transformer,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const employeesDataService = createEmployeeDataService();
|
||||||
|
export const salesEmployeesDataService = createEmployeeDataService('sales');
|
||||||
|
export const logisticsEmployeesDataService = createEmployeeDataService('logistics');
|
||||||
|
|||||||
+49
-2
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-3
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-3
@@ -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') } }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-3
@@ -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>
|
||||||
|
|||||||
+13
-3
@@ -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"
|
||||||
|
|||||||
+17
-16
@@ -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>
|
||||||
|
|||||||
+6
-4
@@ -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` },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+20
-7
@@ -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
-5
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+54
@@ -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';
|
||||||
+11
@@ -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';
|
||||||
+29
@@ -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,
|
||||||
|
});
|
||||||
+67
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+45
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+35
@@ -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()),
|
||||||
|
});
|
||||||
|
};
|
||||||
+41
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+56
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+42
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
+23
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+47
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+52
@@ -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;
|
||||||
|
|||||||
+1
-6
@@ -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) {
|
||||||
|
|||||||
+10
-3
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+7
-2
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+13
@@ -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';
|
||||||
+11
@@ -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';
|
||||||
+13
@@ -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,
|
||||||
|
});
|
||||||
+49
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
@@ -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"
|
||||||
|
}
|
||||||
+33
@@ -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"
|
||||||
|
}
|
||||||
+38
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+61
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+72
@@ -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 }),
|
||||||
|
}));
|
||||||
+3
-1
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-6
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+7
-2
@@ -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 />
|
||||||
|
|||||||
+10
-3
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+58
@@ -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';
|
||||||
+11
@@ -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,
|
||||||
|
});
|
||||||
+49
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -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
Reference in New Issue
Block a user