- Introduced a new sales timeline module with routes and lazy loading for efficient loading. - Updated privilege keys in `api.md` to include `ADMIN.SALES.ACTIVITIES.TIMELINE` for access control. - Enhanced menu data to include the timeline option, improving navigation. - Added company settings module with configuration options for cycle start date and check-in radius. - Implemented remote services and data handling for company settings, ensuring accurate data management. - Enhanced language support for both English and Indonesian in navigation and company settings. These changes significantly improve the application's functionality by adding a timeline feature for sales activities and a comprehensive settings module for company configurations, enhancing user experience and data management.
961 lines
32 KiB
Markdown
961 lines
32 KiB
Markdown
# TrackGo HTTP API — frontend reference
|
||
|
||
Static contract for a frontend agent or UI. Source of truth is this backend’s controllers and DTOs. Live OpenAPI (when the server is running and Swagger is enabled): `GET /docs` (UI) and `GET /docs-json`.
|
||
|
||
Base URL: `http://localhost:{PORT}` (default **3000**). There is **no** global path prefix. JSON keys are **camelCase**. IDs are **UUID v4**.
|
||
|
||
---
|
||
|
||
## 1. Agent rules (read first)
|
||
|
||
1. Send `Authorization: Bearer <accessToken>` on every route except `GET /` and `POST /auth/register|login|refresh|revoke`.
|
||
2. List endpoints return `{ data, meta }`. Detail, create, update, status, import, and bulk return the **bare** DTO or a small object. `DELETE` is **204** with an empty body (except nested plan destinations, which return the plan).
|
||
3. Request dates as ISO datetime or `YYYY-MM-DD`. Response timestamps are **unix milliseconds** (`number`).
|
||
4. Phones are E.164 (`+6281234567890`). Money, quantities, and prices are **decimal strings** (for example `"12500.0000"`), never floats.
|
||
5. Never send `status` on `PATCH /:id`. Use `PATCH /:id/status` or `POST /bulk-status`.
|
||
6. Unknown JSON fields are rejected (`400`). Do not send snake_case aliases.
|
||
7. Gate UI with `GET /auth/me` → `isSuperadmin` or `permissions[KEY][action]`. Superadmin bypasses the matrix.
|
||
8. Cycles and plans are purpose-scoped (`sales` | `logistics`) and use **different privilege keys**. Pass `purpose` on writes (body) and preferably on lists (query).
|
||
9. Cycles and plans `DELETE` / `bulk-delete` **archive** (`status: "archived"`); they do not hard-delete.
|
||
10. Default new-record status is `draft` when omitted.
|
||
|
||
---
|
||
|
||
## 2. Auth
|
||
|
||
### Tokens
|
||
|
||
| Token | Type | Default lifetime | Transport |
|
||
| ------- | --------------------- | -------------------------------------- | ------------------------- |
|
||
| 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 **rotates**: each successful `POST /auth/refresh` returns a new pair; the old refresh token is invalid.
|
||
|
||
Rate limits: register/login **5 / 60s**, refresh/revoke **10 / 60s**, everything else **100 / 60s**. Exceeded → `429`.
|
||
|
||
### Endpoints
|
||
|
||
#### `POST /auth/register` — public — `201`
|
||
|
||
```json
|
||
{ "username": "alice", "password": "password123" }
|
||
```
|
||
|
||
- `username`: 3–32 chars, `^[a-zA-Z0-9_]+$`, stored lowercased
|
||
- `password`: 8–72 chars
|
||
- Conflict: `409` `{ "statusCode": 409, "message": "Username already registered", "error": "Conflict" }`
|
||
|
||
Response:
|
||
|
||
```json
|
||
{ "accessToken": "eyJ…", "refreshToken": "aaaa…" }
|
||
```
|
||
|
||
New users have **no privilege** (`privilegeId` null). They cannot call protected CRUD until an admin assigns a privilege (`PATCH /users/:id/privilege`) or they are promoted to superadmin in the database.
|
||
|
||
#### `POST /auth/login` — public — `200`
|
||
|
||
Same body and response as register. Invalid credentials → `401`.
|
||
|
||
#### `POST /auth/refresh` — public — `200`
|
||
|
||
```json
|
||
{ "refreshToken": "<opaque>" }
|
||
```
|
||
|
||
`refreshToken` min length 32. Invalid → `401`.
|
||
|
||
#### `POST /auth/revoke` — public — `204`
|
||
|
||
Same body. Idempotent: already-invalid tokens still return 204.
|
||
|
||
#### `GET /auth/me` — bearer — `200`
|
||
|
||
```json
|
||
{
|
||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||
"username": "alice",
|
||
"isSuperadmin": false,
|
||
"privilege": { "id": "…", "name": "Sales Staff", "code": "SALES_STAFF" },
|
||
"permissions": {
|
||
"CONFIGURATION.BRANCH": {
|
||
"view": true,
|
||
"create": true,
|
||
"update": true,
|
||
"delete": false,
|
||
"import": false
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
If the user has no privilege, `privilege` is `null` and `permissions` is `{}`.
|
||
|
||
Use `permissions` to hide buttons. Missing key or `false` → treat as denied (API returns `403 Insufficient privilege`).
|
||
|
||
---
|
||
|
||
## 3. Global HTTP contract
|
||
|
||
### Headers
|
||
|
||
```
|
||
Content-Type: application/json
|
||
Authorization: Bearer <accessToken>
|
||
```
|
||
|
||
CSV import uses `multipart/form-data` with field name **`file`** (max 1 MiB).
|
||
|
||
### Pagination (list `GET /resource` only)
|
||
|
||
Query (all optional):
|
||
|
||
| Param | Rules | Default |
|
||
| -------- | ----------- | ------- |
|
||
| `page` | integer ≥ 1 | `1` |
|
||
| `limit` | 1–**200** | `10` |
|
||
| `offset` | integer ≥ 0 | — |
|
||
|
||
If both `page` and `offset` are sent, **`page` wins**. `offset` maps to `page = floor(offset / limit) + 1`.
|
||
|
||
Public response:
|
||
|
||
```json
|
||
{
|
||
"data": [
|
||
/* items */
|
||
],
|
||
"meta": {
|
||
"currentPage": 1,
|
||
"itemCount": 10,
|
||
"itemsPerPage": 10,
|
||
"totalItems": 42,
|
||
"totalPages": 5
|
||
}
|
||
}
|
||
```
|
||
|
||
`search` is case-insensitive and AND-combined with other filters. Resource-specific filters are documented per module.
|
||
|
||
### Non-list responses
|
||
|
||
Unwrapped resource object, or:
|
||
|
||
| Operation | Status | Body |
|
||
| --------------- | ------ | ------------------------------------------ |
|
||
| Create | `201` | resource DTO (detail shape) |
|
||
| Update / status | `200` | resource DTO |
|
||
| Delete | `204` | empty |
|
||
| Bulk delete | `200` | `{ "deleted": number }` |
|
||
| Bulk status | `200` | `{ "updated": number }` |
|
||
| Import | `200` | `{ "imported": number }` |
|
||
| Generate plans | `200` | `{ "created": number, "skipped": number }` |
|
||
|
||
### Errors (NestJS default)
|
||
|
||
```json
|
||
{
|
||
"statusCode": 400,
|
||
"message": "status cannot be updated via PATCH",
|
||
"error": "Bad Request"
|
||
}
|
||
```
|
||
|
||
`message` is a string or an array of validation strings.
|
||
|
||
| Status | Typical cause |
|
||
| ------ | --------------------------------------------------------------------------- |
|
||
| `400` | Validation, extra fields, invalid VO (phone/date/status), `status` on PATCH |
|
||
| `401` | Missing/expired/revoked JWT, bad credentials, invalid refresh |
|
||
| `403` | `Insufficient privilege` |
|
||
| `404` | `{Resource} not found` (for example `Branch not found`) |
|
||
| `409` | Unique conflict (username, code, cycle/plan already exists) |
|
||
| `429` | Rate limit |
|
||
|
||
CSV batch failure:
|
||
|
||
```json
|
||
{
|
||
"statusCode": 400,
|
||
"message": "CSV validation failed",
|
||
"errors": ["row 3: Invalid phone number"]
|
||
}
|
||
```
|
||
|
||
VO messages **do not echo raw input**: `"Invalid phone number"`, `"Invalid date time"`, `"Invalid status"`.
|
||
|
||
### Status
|
||
|
||
Core (configuration, privileges, cycles, plans, settings): `draft` | `active` | `archived`. Omit on create → `draft`.
|
||
|
||
Sales statuses (use **only** these on that resource):
|
||
|
||
| Resource | Allowed |
|
||
| -------------------------- | --------------------------------------------------------- |
|
||
| Sales request | `draft`, `pending`, `approved`, `rejected` |
|
||
| Sales order / packing slip | `draft`, `processed`, `completed`, `cancelled` |
|
||
| Sales invoice | `draft`, `processed`, `partial`, `completed`, `cancelled` |
|
||
| Sales payment | `draft`, `pending`, `approved`, `rejected` |
|
||
|
||
### Dates
|
||
|
||
- **In:** ISO datetime (`2026-08-24T10:00:00+07:00`) or calendar `YYYY-MM-DD`. Naive ISO (no `Z`/offset) is interpreted in `DEFAULT_TIMEZONE` (default `GMT+7`).
|
||
- **Out:** unix **milliseconds** UTC for `date`, `createdAt`, `updatedAt`, `cycleStartDate`, plan `date`.
|
||
- Display timezone is GMT+7 unless the backend env says otherwise. Persist/compare using the numeric ms, not a formatted string.
|
||
|
||
### Phones
|
||
|
||
E.164 compact. Invalid → `400 Invalid phone number`.
|
||
|
||
### Shared write bodies
|
||
|
||
```json
|
||
{ "status": "active" }
|
||
```
|
||
|
||
```json
|
||
{ "ids": ["uuid", "uuid"] }
|
||
```
|
||
|
||
```json
|
||
{ "ids": ["uuid"], "status": "archived" }
|
||
```
|
||
|
||
`ids` must be a non-empty UUID array.
|
||
|
||
---
|
||
|
||
## 4. Privileges
|
||
|
||
Actions: `view` | `create` | `update` | `delete` | `import`.
|
||
|
||
HTTP mapping:
|
||
|
||
| Handler | Action |
|
||
| --------------------------------------------------------------------------------------------------- | -------- |
|
||
| `GET` list / detail | `view` |
|
||
| `POST /` create, `POST /plans/generate` | `create` |
|
||
| `PATCH /:id`, `PATCH /:id/status`, `POST /bulk-status`, nested customer contacts, plan destinations | `update` |
|
||
| `DELETE /:id`, `POST /bulk-delete` | `delete` |
|
||
| `POST /import` | `import` |
|
||
|
||
Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
|
||
|
||
| code | label |
|
||
| ---- | ----- |
|
||
| `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
|
||
| `ADMIN.SETTINGS.USER.USERS` | Users |
|
||
| `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
|
||
| `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
|
||
| `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
|
||
| `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
|
||
| `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
|
||
| `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
|
||
| `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
|
||
| `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
|
||
| `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
|
||
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
|
||
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
|
||
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
|
||
| `ADMIN.SALES.ACTIVITIES.TIMELINE` | Sales timeline |
|
||
| `ADMIN.SALES.REPORT` | Sales reports |
|
||
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
|
||
| `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
|
||
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
|
||
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
|
||
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
|
||
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
|
||
| `MOBILE.SALES.VISIT` | Customer visits |
|
||
| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
|
||
|
||
### Field purpose
|
||
|
||
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
|
||
|
||
| purpose | cycle keys | plan keys |
|
||
| ----------- | ---------- | --------- |
|
||
| `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
|
||
| `logistics` | `ADMIN.LOGISTICS.DATA.CYCLE` | `ADMIN.LOGISTICS.ACTIVITIES.PLAN`, `MOBILE.LOGISTICS.PLAN` |
|
||
|
||
`purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses.
|
||
|
||
Sales plans may attach `invoiceIds` only. Logistics plans may attach `packingSlipIds` only.
|
||
|
||
---
|
||
|
||
## 5. Standard CRUD (most resources)
|
||
|
||
Unless a section says otherwise, each resource below implements:
|
||
|
||
| Method | Path | Status | Notes |
|
||
| -------- | ------------------------- | ------ | --------------------------------------------- |
|
||
| `GET` | `/{resource}` | `200` | Paginated `{ data, meta }` |
|
||
| `GET` | `/{resource}/:id` | `200` | Detail (may include nested arrays list omits) |
|
||
| `POST` | `/{resource}` | `201` | Create |
|
||
| `PATCH` | `/{resource}/:id` | `200` | Update — **no** `status` |
|
||
| `PATCH` | `/{resource}/:id/status` | `200` | `{ status }` |
|
||
| `DELETE` | `/{resource}/:id` | `204` | Hard delete (cycles/plans: archive) |
|
||
| `POST` | `/{resource}/bulk-delete` | `200` | `{ ids }` → `{ deleted }` |
|
||
| `POST` | `/{resource}/bulk-status` | `200` | `{ ids, status }` → `{ updated }` |
|
||
| `POST` | `/{resource}/import` | `200` | multipart `file` → `{ imported }` |
|
||
|
||
Audit fields on primary DTOs: `createdAt`, `updatedAt` (unix ms), `createdBy`, `updatedBy` (user UUID).
|
||
|
||
Name/code patterns used by configuration:
|
||
|
||
- **Code:** `^[A-Za-z0-9_]+$` (no spaces). Max 16 except products (32).
|
||
- **Name** (division, branch, customer, employee): letters with single spaces, max 64.
|
||
- **Product name:** letters, digits, `+ - . / ( )`, max 128.
|
||
|
||
---
|
||
|
||
## 6. Health
|
||
|
||
`GET /` — public — `"Hello World!"` (plain string).
|
||
|
||
---
|
||
|
||
## 7. Users
|
||
|
||
Privilege: `USERS` `update` only (no list/create API).
|
||
|
||
### `PATCH /users/:id/privilege` — `200`
|
||
|
||
```json
|
||
{ "privilegeId": "uuid-or-null" }
|
||
```
|
||
|
||
`null` clears the assignment. Assigned privilege must be **active**. Response: `{ id, username, privilegeId }`.
|
||
|
||
---
|
||
|
||
## 8. Privilege keys
|
||
|
||
Privilege: `PRIVILEGES` `view`.
|
||
|
||
### `GET /privilege-keys`
|
||
|
||
Query: pagination + `search`.
|
||
|
||
Item:
|
||
|
||
```json
|
||
{ "id": "uuid", "code": "PRIVILEGES", "label": "Privileges", "sortOrder": 1 }
|
||
```
|
||
|
||
Use this catalog when building the privilege-matrix editor.
|
||
|
||
---
|
||
|
||
## 9. Privileges
|
||
|
||
Privilege key: `PRIVILEGES`. Standard CRUD + import.
|
||
|
||
List filters: `name`, `code`, `status`, `search` (name/code).
|
||
|
||
**Create** `POST /privileges`:
|
||
|
||
```json
|
||
{
|
||
"name": "Sales Staff",
|
||
"code": "SALES_STAFF",
|
||
"status": "draft",
|
||
"details": [{ "privilegeKeyId": "uuid", "action": "view", "value": true }]
|
||
}
|
||
```
|
||
|
||
`name` max 120, `code` max 64. `details` optional; omitted cells are treated as denied.
|
||
|
||
**Update** `PATCH /privileges/:id`: `name?`, `code?`, `details?` (replaces matrix when sent).
|
||
|
||
**List item** omits `details`. **Detail / create / update** include:
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"name": "Sales Staff",
|
||
"code": "SALES_STAFF",
|
||
"status": "active",
|
||
"details": [
|
||
{
|
||
"id": "uuid",
|
||
"privilegeKeyId": "uuid",
|
||
"keyCode": "SALES.INVOICE",
|
||
"keyLabel": "Sales invoices",
|
||
"sortOrder": 11,
|
||
"action": "view",
|
||
"value": true
|
||
}
|
||
],
|
||
"createdAt": 1710000000000,
|
||
"updatedAt": 1710000000000,
|
||
"createdBy": "uuid",
|
||
"updatedBy": "uuid"
|
||
}
|
||
```
|
||
|
||
Delete of a privilege assigned to users → `409`.
|
||
|
||
CSV headers: `name`, `code` (optional `status`).
|
||
|
||
---
|
||
|
||
## 10. Divisions
|
||
|
||
Key: `CONFIGURATION.DIVISION`. Standard CRUD + import.
|
||
|
||
**Create:** `{ name, code, status? }`
|
||
|
||
List filters: `name`, `code`, `status`, `search`.
|
||
|
||
DTO: `{ id, name, code, status, createdAt, updatedAt, createdBy, updatedBy }`
|
||
|
||
CSV: `name`, `code` (optional `status`).
|
||
|
||
---
|
||
|
||
## 11. Branches
|
||
|
||
Key: `CONFIGURATION.BRANCH`. Standard CRUD + import.
|
||
|
||
**Create (required):** `code`, `name`, `phone`, `address`, `workingDaysStart`, `workingDaysEnd`, `workingHoursStart`, `workingHoursEnd`
|
||
|
||
**Optional:** `latitude` (−90…90), `longitude` (−180…180), `nfcId` (max 64), `divisionId`, `status`
|
||
|
||
Weekdays: `monday` … `sunday`. Hours: `HH:mm` 24-hour (`08:00`). Address max 255.
|
||
|
||
**Update** may set `latitude`, `longitude`, `nfcId`, `divisionId` to `null` to clear.
|
||
|
||
List filters: `code`, `name`, `phone`, `address`, `divisionId`, `nfcId`, `status`, `workingDaysStart`, `workingDaysEnd`, `workingHoursStart`, `workingHoursEnd`, `search` (code/name/address).
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"code": "JKT_01",
|
||
"name": "Jakarta Pusat",
|
||
"phone": "+6281234567890",
|
||
"address": "Jl Sudirman No 1",
|
||
"latitude": -6.2,
|
||
"longitude": 106.8,
|
||
"workingDaysStart": "monday",
|
||
"workingDaysEnd": "friday",
|
||
"workingHoursStart": "08:00",
|
||
"workingHoursEnd": "17:00",
|
||
"nfcId": "NFC-001",
|
||
"divisionId": "uuid",
|
||
"status": "active",
|
||
"createdAt": 1710000000000,
|
||
"updatedAt": 1710000000000,
|
||
"createdBy": "uuid",
|
||
"updatedBy": "uuid"
|
||
}
|
||
```
|
||
|
||
`latitude` / `longitude` / `nfcId` / `divisionId` may be `null`.
|
||
|
||
CSV required: `code`, `name`, `phone`, `address`, `workingDaysStart`, `workingDaysEnd`, `workingHoursStart`, `workingHoursEnd`. Optional: `latitude`, `longitude`, `nfcId`, `divisionId`, `status`.
|
||
|
||
---
|
||
|
||
## 12. Customers
|
||
|
||
Key: `CONFIGURATION.CUSTOMER`. Standard CRUD + import, plus nested contacts.
|
||
|
||
**Create (required):** `code`, `name`, `phone`, `address`
|
||
|
||
**Optional:** `latitude`, `longitude`, `nfcId`, `status`, `contacts[]`
|
||
|
||
Contact create: `name` required; `jobTitle?`, `phone?`, `mobilePhone?`, `notes?` (notes max 255).
|
||
|
||
**List** omits `contacts`. **Detail / create / update / contact mutations** include `contacts`.
|
||
|
||
### Nested contacts (privilege = customer **update**)
|
||
|
||
| Method | Path | Status | Body | Response |
|
||
| -------- | ------------------------------------ | ------ | --------------- | ------------------ |
|
||
| `POST` | `/customers/:id/contacts` | `200` | create contact | full `CustomerDto` |
|
||
| `PATCH` | `/customers/:id/contacts/:contactId` | `200` | partial contact | full `CustomerDto` |
|
||
| `DELETE` | `/customers/:id/contacts/:contactId` | `204` | — | empty |
|
||
|
||
List filters: `code`, `name`, `phone`, `address`, `nfcId`, `status`, `search` (code/name/address).
|
||
|
||
CSV: `code`, `name`, `phone`, `address` (optional lat/long/nfc/status). **No contacts in CSV.**
|
||
|
||
---
|
||
|
||
## 13. Employees
|
||
|
||
Key: `CONFIGURATION.EMPLOYEE`. Standard CRUD + import.
|
||
|
||
**Create:** `{ code, name, phone, position, status? }`
|
||
|
||
`position`: `sales` | `driver` | `crew`
|
||
|
||
List filters: `code`, `name`, `phone`, `position`, `status`, `search` (code/name).
|
||
|
||
DTO: `{ id, code, name, phone, position, status, createdAt, updatedAt, createdBy, updatedBy }`
|
||
|
||
CSV: `code`, `name`, `phone`, `position`.
|
||
|
||
---
|
||
|
||
## 14. Products
|
||
|
||
Key: `CONFIGURATION.PRODUCT`. Standard CRUD + import.
|
||
|
||
**Create:** `{ code, name, unit?, price?, brand?, status? }`
|
||
|
||
`price` is a decimal **string** (`"12500.0000"`). `unit` letters/numbers, max 16. `brand` max 64.
|
||
|
||
List filters: `code`, `name`, `unit`, `brand`, `status`, `search` (code/name).
|
||
|
||
DTO: `{ id, code, name, unit, price, brand, status, …audit }` with `unit`/`price`/`brand` nullable.
|
||
|
||
CSV: `code`, `name` (optional `unit`, `price`, `brand`, `status`).
|
||
|
||
---
|
||
|
||
## 15. Sales documents (shared)
|
||
|
||
Sales codes (optional on create; auto-generated if omitted): max 32, `^[A-Za-z0-9][A-Za-z0-9_-]*$`.
|
||
|
||
Line input:
|
||
|
||
```json
|
||
{ "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" }
|
||
```
|
||
|
||
`price` optional on input; response always includes `price` as a decimal string.
|
||
|
||
Image input (requests, orders, payments):
|
||
|
||
```json
|
||
{ "url": "https://cdn.example.com/a.png", "description": "optional" }
|
||
```
|
||
|
||
`url` max 2048, `description` max 255.
|
||
|
||
**List items omit** `products` / `images` / payment `invoices`. **GET :id, POST, PATCH, PATCH status** include them with generated `id` on each nested row.
|
||
|
||
Quantity/price/amount/balance are **strings**.
|
||
|
||
---
|
||
|
||
## 16. Sales requests
|
||
|
||
Key: `SALES.REQUEST`. Standard CRUD + import.
|
||
|
||
**Create (required):** `date`, `salesPersonId`, `branchId`, `divisionId`, `customerId`, `address`, `products[]`
|
||
|
||
**Optional:** `code`, `latitude`, `longitude`, `notes` (max 1024), `images[]`, `status`
|
||
|
||
List filters: `code`, `status`, `customerId`, `salesPersonId`, `branchId`, `divisionId`, `search`
|
||
|
||
List DTO:
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"code": "SR-20260824-0001",
|
||
"date": 1756000000000,
|
||
"salesPersonId": "uuid",
|
||
"branchId": "uuid",
|
||
"divisionId": "uuid",
|
||
"customerId": "uuid",
|
||
"address": "Jl Sudirman 1",
|
||
"latitude": null,
|
||
"longitude": null,
|
||
"notes": null,
|
||
"status": "draft",
|
||
"createdAt": 1756000000000,
|
||
"updatedAt": 1756000000000,
|
||
"createdBy": "uuid",
|
||
"updatedBy": "uuid"
|
||
}
|
||
```
|
||
|
||
Detail adds:
|
||
|
||
```json
|
||
{
|
||
"products": [{ "id": "uuid", "productId": "uuid", "quantity": "2.0000", "price": "12500.0000" }],
|
||
"images": [{ "id": "uuid", "url": "https://…", "description": null }]
|
||
}
|
||
```
|
||
|
||
Import CSV required: `date`, `salesPersonId`, `branchId`, `divisionId`, `customerId`, `address`. Products/images empty on import.
|
||
|
||
---
|
||
|
||
## 17. Sales orders
|
||
|
||
Key: `SALES.ORDER`. Same shape as requests, plus optional `salesRequestId` on create (copies missing fields from the request).
|
||
|
||
Response includes `salesRequestId` (`string | null`). Statuses: `draft` | `processed` | `completed` | `cancelled`.
|
||
|
||
List filters: `code`, `status`, `customerId`, `salesPersonId`, `branchId`, `divisionId`, `search`.
|
||
|
||
---
|
||
|
||
## 18. Packing slips
|
||
|
||
Key: `SALES.PACKING_SLIP`. Statuses: `draft` | `processed` | `completed` | `cancelled`.
|
||
|
||
**Create fields are optional in JSON.** If `salesOrderId` is set, missing `date` / `customerId` / `address` / `products` are copied from the order. Service still requires a resolved date, customer, and address.
|
||
|
||
Optional: `code`, `salesOrderNumber`, `latitude`, `longitude`, `notes`, `status`.
|
||
|
||
List filters: `code`, `status`, `customerId`, `salesOrderId`, `search`.
|
||
|
||
DTO: `{ id, code, salesOrderId, salesOrderNumber, date, customerId, address, latitude, longitude, notes, status, …audit }`
|
||
|
||
Detail adds `products[]`.
|
||
|
||
CSV required: `date`, `customerId`, `address`.
|
||
|
||
---
|
||
|
||
## 19. Sales invoices
|
||
|
||
Key: `SALES.INVOICE`. Statuses: `draft` | `processed` | `partial` | `completed` | `cancelled`.
|
||
|
||
**Create fields optional.** May derive from `salesOrderId` and/or `packingSlipId`.
|
||
|
||
List filters: `code`, `status`, `customerId`, `salesPersonId`, `branchId`, `divisionId`, `salesOrderId`, `packingSlipId`, `search`.
|
||
|
||
DTO extras vs order: `salesOrderCode`, `packingSlipId`, `packingSlipCode`, `balance` (decimal string). Detail adds `products[]`.
|
||
|
||
CSV required: `date`, `salesPersonId`, `branchId`, `divisionId`, `customerId`.
|
||
|
||
---
|
||
|
||
## 20. Sales payments
|
||
|
||
Key: `SALES.PAYMENT`. Statuses: `draft` | `pending` | `approved` | `rejected`.
|
||
|
||
**Create (required):** `date`, `invoices` (non-empty allocations)
|
||
|
||
```json
|
||
{
|
||
"date": "2026-08-24T10:00:00+07:00",
|
||
"invoices": [{ "invoiceId": "uuid", "amount": "10000.0000" }],
|
||
"notes": null,
|
||
"images": [],
|
||
"status": "draft"
|
||
}
|
||
```
|
||
|
||
List filters: `code`, `status`, `search`.
|
||
|
||
List DTO: `{ id, code, date, notes, status, …audit }`
|
||
|
||
Detail adds `invoices[{ id, invoiceId, amount }]` and `images[{ id, url, description }]`.
|
||
|
||
CSV required: `date` (allocations empty on import).
|
||
|
||
---
|
||
|
||
## 21. Company settings
|
||
|
||
Key: `CONFIGURATION.SETTING`. Singleton — no list/CRUD.
|
||
|
||
| Method | Path | Action | Notes |
|
||
| ------- | ----------- | ------ | ----------------------------------------------------------------- |
|
||
| `GET` | `/settings` | view | `404` `{ "message": "Settings not configured" }` if never patched |
|
||
| `PATCH` | `/settings` | update | upserts |
|
||
|
||
**Patch body:** `{ "cycleStartDate": "2026-01-05" }` (`YYYY-MM-DD` only).
|
||
|
||
Response:
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"cycleStartDate": 1767546000000,
|
||
"status": "active",
|
||
"createdAt": 1710000000000,
|
||
"updatedAt": 1710000000000,
|
||
"createdBy": "uuid",
|
||
"updatedBy": "uuid"
|
||
}
|
||
```
|
||
|
||
`cycleStartDate` is unix ms for **start of that calendar day** in `DEFAULT_TIMEZONE`. Plan generate rejects ranges that start before this date.
|
||
|
||
---
|
||
|
||
## 22. Cycles
|
||
|
||
Privilege: `RequireFieldPrivilege('cycle', action)` → `SALES.CYCLE` or `LOGISTICS.CYCLE`.
|
||
|
||
No hard delete: `DELETE` / `bulk-delete` archive. **Has CSV import.** Standard list/detail/create/update/status otherwise.
|
||
|
||
**Create:**
|
||
|
||
```json
|
||
{
|
||
"employeeId": "uuid",
|
||
"purpose": "sales",
|
||
"cycleNumber": 1,
|
||
"weekdays": {
|
||
"monday": {
|
||
"startBranchId": "uuid",
|
||
"endBranchId": "uuid",
|
||
"customerIds": ["uuid"]
|
||
}
|
||
},
|
||
"status": "draft"
|
||
}
|
||
```
|
||
|
||
- `purpose`: `sales` | `logistics`
|
||
- `cycleNumber`: integer ≥ 1
|
||
- `weekdays`: object keyed by `monday`…`sunday`. **Omitted days are days off.** Any present day must include `startBranchId`, `endBranchId`, and a **non-empty** `customerIds`. Incomplete day → `400 Weekday must be complete`.
|
||
- Unique per `(employeeId, purpose, cycleNumber)` → `409 Cycle already exists for this employee`
|
||
- All referenced branches/customers must have usable lat/lng or route build fails (`400 Weekday route is incomplete`)
|
||
|
||
**Response `weekdays` is an array**, not an object:
|
||
|
||
```json
|
||
{
|
||
"id": "uuid",
|
||
"employeeId": "uuid",
|
||
"purpose": "sales",
|
||
"cycleNumber": 1,
|
||
"weekdays": [
|
||
{
|
||
"id": "uuid",
|
||
"weekday": "monday",
|
||
"startBranchId": "uuid",
|
||
"endBranchId": "uuid",
|
||
"routeGeometry": {
|
||
"type": "LineString",
|
||
"coordinates": [
|
||
[106.8456, -6.2088],
|
||
[107.0, -6.3]
|
||
]
|
||
},
|
||
"destinations": [{ "id": "uuid", "customerId": "uuid", "sortOrder": 0 }]
|
||
}
|
||
],
|
||
"status": "draft",
|
||
"createdAt": 1710000000000,
|
||
"updatedAt": 1710000000000,
|
||
"createdBy": "uuid",
|
||
"updatedBy": "uuid"
|
||
}
|
||
```
|
||
|
||
`routeGeometry.coordinates` are GeoJSON `[longitude, latitude]` pairs.
|
||
|
||
List filters: `employeeId`, `purpose`, `cycleNumber`, `status`, `search`. Without `purpose`, results are limited to purposes the caller can view.
|
||
|
||
CSV required: `employeeCode`, `purpose`, `cycleNumber`, `weekday`, `customerCodes`, `startBranchCode`, `endBranchCode`. Multiple rows with the same employee/purpose/cycleNumber merge weekdays.
|
||
|
||
---
|
||
|
||
## 23. Plans
|
||
|
||
Privilege: `RequireFieldPrivilege('plan', action)` → `SALES.PLAN` or `LOGISTICS.PLAN`.
|
||
|
||
**No CSV import.** Delete archives. Extra routes: generate, add/remove destinations.
|
||
|
||
| Method | Path | Action | Status | Body / response |
|
||
| -------- | ---------------------------------------- | ------ | ------- | ------------------------------------------------------------ |
|
||
| `GET` | `/plans` | view | 200 | paginated |
|
||
| `GET` | `/plans/:id` | view | 200 | `PlanDto` |
|
||
| `POST` | `/plans/generate` | create | 200 | `{ employeeId, purpose, from, to }` → `{ created, skipped }` |
|
||
| `POST` | `/plans` | create | 201 | see below |
|
||
| `PATCH` | `/plans/:id` | update | 200 | no status |
|
||
| `PATCH` | `/plans/:id/status` | update | 200 | `{ status }` |
|
||
| `POST` | `/plans/:id/destinations` | update | 200 | `{ customerId, afterDestinationId? }` → `PlanDto` |
|
||
| `DELETE` | `/plans/:id/destinations/:destinationId` | update | **200** | `PlanDto` (not 204) |
|
||
| `DELETE` | `/plans/:id` | delete | 204 | archives |
|
||
| `POST` | `/plans/bulk-delete` | delete | 200 | `{ deleted }` |
|
||
| `POST` | `/plans/bulk-status` | update | 200 | `{ updated }` |
|
||
|
||
Register static paths (`generate`, `bulk-delete`, `bulk-status`) before `:id`.
|
||
|
||
### Create
|
||
|
||
```json
|
||
{
|
||
"employeeId": "uuid",
|
||
"purpose": "sales",
|
||
"date": "2026-01-12",
|
||
"startBranchId": "uuid",
|
||
"endBranchId": "uuid",
|
||
"customerIds": ["uuid"],
|
||
"invoiceIds": ["uuid"],
|
||
"packingSlipIds": [],
|
||
"status": "draft"
|
||
}
|
||
```
|
||
|
||
`date` is `YYYY-MM-DD`. `customerIds` must be non-empty. Unique per employee+date+purpose → `409 Plan already exists for this employee`.
|
||
|
||
- Sales + `packingSlipIds` → `400 Sales plans cannot include packing slips`
|
||
- Logistics + `invoiceIds` → `400 Logistics plans cannot include invoices`
|
||
|
||
Response `date` is unix ms. Includes `routeGeometry`, `destinations`, `invoiceIds`, `packingSlipIds`.
|
||
|
||
### Generate
|
||
|
||
```json
|
||
{
|
||
"employeeId": "uuid",
|
||
"purpose": "sales",
|
||
"from": "2026-01-12",
|
||
"to": "2026-01-25"
|
||
}
|
||
```
|
||
|
||
Materializes **active** plans from the employee’s cycles over `[from, to]` (inclusive calendar days).
|
||
|
||
Requires settings (`GET /settings`). Errors:
|
||
|
||
- `404 Settings not configured`
|
||
- `400 Invalid date range` (`to` before `from`)
|
||
- `400 Date is before the cycle start date`
|
||
- `400 User has no sales cycle` / `User has no logistics cycle`
|
||
|
||
`skipped` counts days that already have a plan or have no matching weekday template.
|
||
|
||
Generated plans are created with status **`active`**.
|
||
|
||
### Destinations
|
||
|
||
`POST /plans/:id/destinations`:
|
||
|
||
- `customerId` required
|
||
- `afterDestinationId` optional: insert after that stop; omit to append
|
||
- Duplicate customer → `409 Customer is already on this plan`
|
||
- Unknown `afterDestinationId` → `400 Destination is not on this plan`
|
||
|
||
`DELETE …/destinations/:destinationId`:
|
||
|
||
- Last remaining stop → `400 A live plan must keep at least one destination`
|
||
- Unknown id → `404 Destination not found`
|
||
|
||
Both return the full plan (route geometry is recomputed).
|
||
|
||
List filters: `employeeId`, `purpose`, `date` (`YYYY-MM-DD`), `status`, `search`.
|
||
|
||
---
|
||
|
||
## 24. CSV import
|
||
|
||
`POST /{resource}/import` — `multipart/form-data`, field **`file`**.
|
||
|
||
- Max 1_048_576 bytes
|
||
- Must be CSV mime or filename ending `.csv`
|
||
- Fail the whole batch on row errors
|
||
- Optional `status` column; omitted → `draft`
|
||
- `createdBy` / `updatedBy` = current user
|
||
- Plans have **no** import
|
||
|
||
---
|
||
|
||
## 25. Suggested frontend flows
|
||
|
||
### Session
|
||
|
||
1. `POST /auth/login` → store both tokens.
|
||
2. Attach access token to every request.
|
||
3. On `401`, `POST /auth/refresh`; if that fails, logout.
|
||
4. Logout: `POST /auth/revoke` then drop tokens.
|
||
5. After login, `GET /auth/me` and cache `permissions` / `isSuperadmin` for nav and buttons.
|
||
|
||
### Configuration screens
|
||
|
||
Standard list + drawer/form. Status chip uses `/status` and `/bulk-status`. Import uses the CSV headers in each section.
|
||
|
||
### Sales documents
|
||
|
||
List without lines. Open detail for `products` / `images`. Creating an order from a request: `POST /sales-orders` with `salesRequestId` and any overrides. Packing slip / invoice can be seeded from parent IDs.
|
||
|
||
### Field (cycles / plans)
|
||
|
||
1. Ensure `PATCH /settings` has a `cycleStartDate`.
|
||
2. Create cycles per employee + purpose (`weekdays` object in, array out).
|
||
3. `POST /plans/generate` for a date range, or `POST /plans` for a one-off day.
|
||
4. Edit stops with destination add/remove; do not send `status` on plan PATCH.
|
||
5. Scope lists with `?purpose=sales` or `logistics` so privilege and filters match the screen.
|
||
|
||
### Privilege editor
|
||
|
||
1. `GET /privilege-keys?limit=200` for the matrix axes.
|
||
2. `GET /privileges/:id` for cells (`details`).
|
||
3. `PATCH /privileges/:id` with the full `details` array.
|
||
4. Assign with `PATCH /users/:id/privilege`.
|
||
|
||
---
|
||
|
||
## 26. TypeScript shapes (copy)
|
||
|
||
```ts
|
||
type Uuid = string;
|
||
type UnixMs = number;
|
||
type DecimalString = string;
|
||
type CoreStatus = 'draft' | 'active' | 'archived';
|
||
type FieldPurpose = 'sales' | 'logistics';
|
||
type Weekday = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
|
||
type PrivilegeAction = 'view' | 'create' | 'update' | 'delete' | 'import';
|
||
|
||
type PaginationMeta = {
|
||
currentPage: number;
|
||
itemCount: number;
|
||
itemsPerPage: number;
|
||
totalItems: number;
|
||
totalPages: number;
|
||
};
|
||
|
||
type Paginated<T> = { data: T[]; meta: PaginationMeta };
|
||
|
||
type Audit = {
|
||
createdAt: UnixMs;
|
||
updatedAt: UnixMs;
|
||
createdBy: Uuid;
|
||
updatedBy: Uuid;
|
||
};
|
||
|
||
type TokenPair = { accessToken: string; refreshToken: string };
|
||
|
||
type Me = {
|
||
id: Uuid;
|
||
username: string;
|
||
isSuperadmin: boolean;
|
||
privilege: { id: Uuid; name: string; code: string } | null;
|
||
permissions: Record<string, Record<PrivilegeAction, boolean>>;
|
||
};
|
||
|
||
type RouteGeometry = {
|
||
type: 'LineString';
|
||
coordinates: ReadonlyArray<readonly [number, number]>; // [lng, lat]
|
||
};
|
||
|
||
type HttpError = {
|
||
statusCode: number;
|
||
message: string | string[];
|
||
error?: string;
|
||
errors?: string[];
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 27. Endpoint index
|
||
|
||
Public: `GET /`, `POST /auth/register`, `POST /auth/login`, `POST /auth/refresh`, `POST /auth/revoke`.
|
||
|
||
Bearer: `GET /auth/me`, `PATCH /users/:id/privilege`, `GET /privilege-keys`, `GET /settings`, `PATCH /settings`.
|
||
|
||
CRUD families: `/privileges`, `/divisions`, `/branches`, `/customers`, `/employees`, `/products`, `/sales-requests`, `/sales-orders`, `/packing-slips`, `/sales-invoices`, `/sales-payments`, `/cycles`, `/plans`.
|
||
|
||
Extras: customer contacts, `POST /plans/generate`, plan destinations.
|
||
|
||
Live schema: `GET /docs-json` (non-production, or `SWAGGER_ENABLED=true`).
|