Genesis
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
web/dist
|
||||||
|
dist
|
||||||
|
certs/
|
||||||
|
*.log
|
||||||
530
README.md
Normal file
530
README.md
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
# jtlsrv
|
||||||
|
|
||||||
|
Self-signed HTTPS debug server for **JTL-POS** pairing and sync, now split into a reusable library and a web admin UI.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── lib/ # jtl-pos-server library
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── index.js # public exports (createStore, createJtlPosServer, createAdminServer, actions, seed)
|
||||||
|
│ │ ├── store.js # in-memory state + reducer actions
|
||||||
|
│ │ ├── actions.js # action creators for CRUD + pairing
|
||||||
|
│ │ ├── seed.js # sample data
|
||||||
|
│ │ ├── jtl-server.js # JTL-POS HTTPS protocol handler
|
||||||
|
│ │ └── admin-server.js # Express admin API (/admin/api)
|
||||||
|
│ └── package.json
|
||||||
|
├── web/ # Vite + React + MUI admin UI
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.jsx
|
||||||
|
│ │ ├── App.jsx
|
||||||
|
│ │ ├── api.js
|
||||||
|
│ │ ├── components/CrudTable.jsx
|
||||||
|
│ │ └── pages/*.jsx
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.js
|
||||||
|
├── certs/ # self-signed TLS certificate
|
||||||
|
├── server.js # thin wrapper: HTTPS POS server + admin API
|
||||||
|
├── test-client.js # probe real upstream or local server
|
||||||
|
└── generate-cert.js # create self-signed TLS cert
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # install root deps (express, concurrently, uuid, local lib)
|
||||||
|
cd web && npm install && cd ..
|
||||||
|
npm run cert # once, before first start
|
||||||
|
npm run dev # starts POS server + admin API + Vite dev server
|
||||||
|
```
|
||||||
|
|
||||||
|
- POS HTTPS endpoint: `https://localhost:8086`
|
||||||
|
- Admin JSON API: `http://localhost:8087/admin/api`
|
||||||
|
- Admin web UI: `http://localhost:5173`
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `8086` | HTTPS POS server port |
|
||||||
|
| `ADMIN_PORT` | `8087` | Admin API / static UI port |
|
||||||
|
| `AUTH_TOKEN` | `9a2e3036ed9c47e389741d9dbb7590e9` | bearer token issued to paired devices |
|
||||||
|
| `PAIRING_CODE` | `307018` | pre-registered 6-digit pairing code |
|
||||||
|
|
||||||
|
## Production build of the web UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # builds web/ into web/dist
|
||||||
|
npm start # starts POS + admin API and serves web/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
After `npm run build`, `server.js` serves the built admin SPA from `web/dist` on `ADMIN_PORT`.
|
||||||
|
|
||||||
|
## Using the web admin
|
||||||
|
|
||||||
|
Open `http://localhost:5173` (dev) or `http://localhost:8087` (built).
|
||||||
|
|
||||||
|
| Page | What you can do |
|
||||||
|
|---|---|
|
||||||
|
| **Dashboard** | View entity counts and paired devices |
|
||||||
|
| **Pairing** | See/generate 6-digit pairing codes, list and revoke paired devices |
|
||||||
|
| **Products** | CRUD products; mark products as composite and assign component products |
|
||||||
|
| **Categories** | CRUD category tree via `pid` |
|
||||||
|
| **Customers** | CRUD customers |
|
||||||
|
| **Customer Groups** | CRUD customer groups |
|
||||||
|
| **Deleted** | View deletion events and manually record new deletions |
|
||||||
|
|
||||||
|
The admin API lives under `/admin/api` and mutates the same in-memory store as the POS sync endpoint.
|
||||||
|
|
||||||
|
## Programmatic use
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { createStore, createJtlPosServer, createAdminServer, actions } = require('./lib/src');
|
||||||
|
|
||||||
|
const store = createStore();
|
||||||
|
store.dispatch(actions.setPairingCode('307018'));
|
||||||
|
|
||||||
|
const posHandler = createJtlPosServer(store, { authToken: '...' });
|
||||||
|
https.createServer({ key, cert }, posHandler).listen(8086);
|
||||||
|
|
||||||
|
const { server: adminServer } = createAdminServer(store, { adminPort: 8087 });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol overview
|
||||||
|
|
||||||
|
- **Transport:** HTTPS (TLS required)
|
||||||
|
- **Base path:** `/api/v1/`
|
||||||
|
- **Methods observed:** `GET` only (empty body)
|
||||||
|
- **Content type:** `application/json; charset=utf-8`
|
||||||
|
- **Numeric fields:** counts and IDs are returned as **strings**, not JSON numbers
|
||||||
|
|
||||||
|
### Request headers (JTL-POS client)
|
||||||
|
|
||||||
|
| Header | Example | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `accept` | `application/json` | |
|
||||||
|
| `content-type` | `application/json` | sent even on GET |
|
||||||
|
| `cache-control` | `no-cache` | |
|
||||||
|
| `authorization` | `Bearer <authToken>` | required after pairing |
|
||||||
|
| `version` | `1.0.11.14` | POS app version |
|
||||||
|
| `system` | `JTL-POS` | |
|
||||||
|
| `charset` | `utf-8` | |
|
||||||
|
| `accept-encoding` | `gzip` | upstream may gzip responses |
|
||||||
|
| `user-agent` | `Dalvik/2.1.0 …` | Android |
|
||||||
|
|
||||||
|
### Error responses
|
||||||
|
|
||||||
|
Unknown paths return **404** with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Message": "No HTTP resource was found that matches the request URI 'https://…'."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pairing errors (upstream, German) observed on `/api/v1/client`:
|
||||||
|
|
||||||
|
| Situation | Message |
|
||||||
|
|---|---|
|
||||||
|
| Step 2 before step 1 | `Die erste Anfrage wurde noch nicht erfolgreich durchgeführt.` |
|
||||||
|
| Step 1 repeated | `Die erste Anfrage wurde bereits erfolgreich durchgeführt.` |
|
||||||
|
| Wrong code | `Der Authentifizierungscode ist falsch.` / `Keinen passenden Authentifizierungscode gefunden.` |
|
||||||
|
|
||||||
|
Auth codes are **single-use** and expire quickly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 1: Pairing (`/api/v1/client`)
|
||||||
|
|
||||||
|
No `Authorization` header. Two-step GET handshake using a 6-digit code shown in JTL Wawi (or generated in the admin UI).
|
||||||
|
|
||||||
|
### Step 1 — first 4 digits
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/client?authCode=3070&name=001
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (fields vary; `authCode` is `null` on success):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authCode": null,
|
||||||
|
"authToken": "9a2e3036ed9c47e389741d9dbb7590e9",
|
||||||
|
"certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879",
|
||||||
|
"certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3",
|
||||||
|
"mandantId": "1",
|
||||||
|
"mandantName": null,
|
||||||
|
"mandantDatabase": null,
|
||||||
|
"serverFingerprint": "39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE",
|
||||||
|
"name": null,
|
||||||
|
"serverTimestamp": "2026-06-16 16:37:53"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`serverFingerprint` is present in step 1; `mandantName` / `mandantDatabase` are null.
|
||||||
|
|
||||||
|
### Step 2 — full 6 digits
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/client?authCode=307018&name=001
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authCode": "307018",
|
||||||
|
"authToken": "9a2e3036ed9c47e389741d9dbb7590e9",
|
||||||
|
"certificateFingerprint": "BC2114CF407A42724BEEF417960F76DCBF9DE879",
|
||||||
|
"certificateSerialNumber": "00BFC8BEACDB981B165210EF111CB9D3",
|
||||||
|
"mandantId": "1",
|
||||||
|
"mandantName": "eB-Standard",
|
||||||
|
"mandantDatabase": "eazybusiness",
|
||||||
|
"serverFingerprint": null,
|
||||||
|
"name": null,
|
||||||
|
"serverTimestamp": "2026-06-16 16:37:53"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`authToken` is the bearer token used for all subsequent sync calls. `serverTimestamp` format: `YYYY-MM-DD HH:MM:SS`.
|
||||||
|
|
||||||
|
Probe pairing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-client.js 307018 # runs step 1 + step 2
|
||||||
|
node test-client.js 3070 # step 1 only
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow 2: Sync
|
||||||
|
|
||||||
|
After pairing, the POS polls with `Authorization: Bearer <authToken>`.
|
||||||
|
|
||||||
|
### `/api/v1/init` — sync manifest
|
||||||
|
|
||||||
|
Returns how many entities still need syncing, based on the client's stored cursors.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/init?mandantId=1
|
||||||
|
&lastChangedCategory=0
|
||||||
|
&lastChangedCustomer=0
|
||||||
|
&lastChangedCustomerGroup=0
|
||||||
|
&lastChangedProduct=0
|
||||||
|
&lastChangedConfigurationGroup=0
|
||||||
|
&lastChangedConfigurationItem=0
|
||||||
|
&lastChangedCompositeProduct=0
|
||||||
|
&lastChangedDeletedEntity=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (fresh mandant, all cursors at `0`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.10.12.0",
|
||||||
|
"product_count": "3",
|
||||||
|
"category_count": "2",
|
||||||
|
"customer_count": "3",
|
||||||
|
"customerGroup_count": "1",
|
||||||
|
"compositeProduct_count": "1",
|
||||||
|
"configurationGroup_count": "0",
|
||||||
|
"configurationItem_count": "0",
|
||||||
|
"deletedEntity_count": "1",
|
||||||
|
"max_orderId_count": "0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `*_count` is the number of records with `lastChanged` **greater than** the matching `lastChanged*` query parameter. Example: if the client has synced customer groups up to cursor `7323`, pass `lastChangedCustomerGroup=7323` and `customerGroup_count` becomes `"0"`.
|
||||||
|
|
||||||
|
Typical poll sequence after initial sync:
|
||||||
|
|
||||||
|
1. `init` with all cursors at latest values → counts are `"0"` when fully synced
|
||||||
|
2. If a count is `> 0`, POS fetches the corresponding entity endpoint
|
||||||
|
3. POS updates its local cursor to the highest `lastChanged` received
|
||||||
|
4. Repeat
|
||||||
|
|
||||||
|
Probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-client.js init <authToken>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Incremental sync pattern
|
||||||
|
|
||||||
|
Entity list endpoints use the same cursor model:
|
||||||
|
|
||||||
|
- Query param: `lastChanged<EntityType>=<cursor>` (client's last known value)
|
||||||
|
- Response: JSON **array** of records where `record.lastChanged > cursor`
|
||||||
|
- Empty array `[]` means the client is up to date
|
||||||
|
- After sync, the client stores `max(lastChanged)` and sends that on the next poll
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `/api/v1/customergroup`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/customergroup?mandantId=1&lastChangedCustomerGroup=0
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"customerGroupId": "1",
|
||||||
|
"name": "Endkunden",
|
||||||
|
"standard": "1",
|
||||||
|
"discountPercent": "0.00",
|
||||||
|
"lastChanged": "7323"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| `lastChangedCustomerGroup` | Result |
|
||||||
|
|---|---|
|
||||||
|
| `0` | all groups changed since beginning |
|
||||||
|
| `7323` | `[]` (client has this revision) |
|
||||||
|
| `7324` | `[]` |
|
||||||
|
|
||||||
|
Probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-client.js customergroup <authToken>
|
||||||
|
node test-client.js customergroup <authToken> 7323
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `/api/v1/deletedentity`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/deletedentity?mandantId=1&limit=200&lastChangedDeletedEntity=0
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entityId": "2",
|
||||||
|
"entityType": "6",
|
||||||
|
"lastChanged": "8281"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `entityId` | ID of the deleted record |
|
||||||
|
| `entityType` | numeric type code (`6` observed for a customer group) |
|
||||||
|
| `lastChanged` | revision cursor for this deletion event |
|
||||||
|
|
||||||
|
| `lastChangedDeletedEntity` | Result |
|
||||||
|
|---|---|
|
||||||
|
| `0` | pending deletions |
|
||||||
|
| `8281` | `[]` (client has processed this deletion) |
|
||||||
|
|
||||||
|
`limit` caps how many records are returned per request (POS uses `200`).
|
||||||
|
|
||||||
|
Probe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-client.js deletedentity <authToken>
|
||||||
|
node test-client.js deletedentity <authToken> 8281
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `/api/v1/category`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/category?mandantId=1&limit=20&lastChangedCategory=0
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"_id": "1",
|
||||||
|
"imghash": null,
|
||||||
|
"imgsrc": null,
|
||||||
|
"name": "Haupt",
|
||||||
|
"pid": "0",
|
||||||
|
"discounts": [],
|
||||||
|
"sort": "0",
|
||||||
|
"updated_at": "2026-06-16 22:42:49",
|
||||||
|
"created_at": "2026-06-16 22:42:49",
|
||||||
|
"lastChanged": "10252"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`pid` is the parent category ID (`"0"` = root). The server also serves a sample child category `Getränke` (`_id: "2"`, `pid: "1"`).
|
||||||
|
|
||||||
|
Probe: `node test-client.js category <authToken>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `/api/v1/product`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/product?mandantId=1&limit=20&lastChangedProduct=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Large product objects; key fields:
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `_id` | product ID |
|
||||||
|
| `name`, `sku`, `price`, `tax_rate` | basics |
|
||||||
|
| `categories_id` / `categories` | category linkage |
|
||||||
|
| `isCompositeProduct` | `"1"` if this product is a bundle/set |
|
||||||
|
| `prices` | per customer group / customer overrides |
|
||||||
|
| `lastChanged` | sync cursor |
|
||||||
|
|
||||||
|
Upstream example products: `a1` (normal), `a2` (`isCompositeProduct: "1"`). The debug server also adds `Cola 0,5l`.
|
||||||
|
|
||||||
|
Composite **definitions** (which components make up a bundle) are **not** embedded in the product object — see `/api/v1/productcomposite`.
|
||||||
|
|
||||||
|
Probe: `node test-client.js product <authToken>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `/api/v1/customer`
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/customer?mandantId=1&limit=20&lastChangedCustomer=0
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"customerNumber": "0",
|
||||||
|
"firstname": "",
|
||||||
|
"lastname": "kjhkjh",
|
||||||
|
"company": "kjhkjh",
|
||||||
|
"city": "kjhkjhkjh",
|
||||||
|
"country": "Deutschland",
|
||||||
|
"customerGroupId": "1",
|
||||||
|
"discount": "0.00",
|
||||||
|
"lastChanged": "13246",
|
||||||
|
"debtorNumber": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The debug server also includes sample customers `Muster GmbH` and `Max Mustermann`.
|
||||||
|
|
||||||
|
Probe: `node test-client.js customer <authToken>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `/api/v1/productcomposite` — composite product components
|
||||||
|
|
||||||
|
There is **no** `/api/v1/compositeproduct` endpoint (returns 404). Composite bundles use this path instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/productcomposite?mandantId=1&limit=20&lastChangedCompositeProduct=0
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"productId": "2",
|
||||||
|
"productIdComponent": "1",
|
||||||
|
"quantity": "1.00",
|
||||||
|
"lastChanged": "13227"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `productId` | the composite/bundle product (`a2`, `isCompositeProduct: "1"`) |
|
||||||
|
| `productIdComponent` | component product included in the bundle |
|
||||||
|
| `quantity` | how many units of the component |
|
||||||
|
|
||||||
|
`init.compositeProduct_count` tracks pending rows here, separate from `product_count`.
|
||||||
|
|
||||||
|
Probe: `node test-client.js productcomposite <authToken>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Observed sync order
|
||||||
|
|
||||||
|
After pairing, the POS roughly follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
init → customergroup → init → category → init → product → init →
|
||||||
|
productcomposite → init → customer → init → deletedentity → init → …
|
||||||
|
```
|
||||||
|
|
||||||
|
`init` is called before and after each entity fetch. Counts in `init` drive which endpoint the POS calls next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implemented in server
|
||||||
|
|
||||||
|
| Endpoint | Sample data |
|
||||||
|
|---|---|
|
||||||
|
| `/api/v1/client` | pairing flow |
|
||||||
|
| `/api/v1/init` | dynamic counts from cursors |
|
||||||
|
| `/api/v1/customergroup` | 1 group (Endkunden) |
|
||||||
|
| `/api/v1/category` | 2 categories (Haupt + Getränke) |
|
||||||
|
| `/api/v1/product` | 3 products (a1, a2 composite, Cola) |
|
||||||
|
| `/api/v1/productcomposite` | 1 bundle link (a2 → a1) |
|
||||||
|
| `/api/v1/customer` | 3 customers |
|
||||||
|
| `/api/v1/deletedentity` | 1 deletion event |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin API endpoints
|
||||||
|
|
||||||
|
All prefixed with `/admin/api`.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/init` | counts + full state snapshot |
|
||||||
|
| GET | `/state` | current in-memory state |
|
||||||
|
| GET | `/categories` | list categories |
|
||||||
|
| POST | `/categories` | create category |
|
||||||
|
| PATCH | `/categories/:id` | update category |
|
||||||
|
| DELETE | `/categories/:id` | delete category |
|
||||||
|
| GET | `/products` | list products |
|
||||||
|
| POST | `/products` | create product |
|
||||||
|
| PATCH | `/products/:id` | update product |
|
||||||
|
| DELETE | `/products/:id` | delete product |
|
||||||
|
| GET | `/customers` | list customers |
|
||||||
|
| POST | `/customers` | create customer |
|
||||||
|
| PATCH | `/customers/:id` | update customer |
|
||||||
|
| DELETE | `/customers/:id` | delete customer |
|
||||||
|
| GET | `/customer-groups` | list customer groups |
|
||||||
|
| POST | `/customer-groups` | create group |
|
||||||
|
| PATCH | `/customer-groups/:id` | update group |
|
||||||
|
| DELETE | `/customer-groups/:id` | delete group |
|
||||||
|
| GET | `/product-composites` | list composite links |
|
||||||
|
| POST | `/product-composites` | add or update composite link |
|
||||||
|
| DELETE | `/product-composites/:parent/:component` | remove link |
|
||||||
|
| GET | `/pairing` | pending codes + paired devices |
|
||||||
|
| POST | `/pairing` | generate a new 6-digit code (`{ name }`) |
|
||||||
|
| POST | `/pairing/revoke` | revoke a pending code |
|
||||||
|
| DELETE | `/devices/:token` | revoke a paired device |
|
||||||
|
| GET | `/deleted` | list deletion events |
|
||||||
|
| POST | `/deleted` | record a deletion (`{ entityId, entityType }`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints not yet implemented
|
||||||
|
|
||||||
|
| `init` count field | Endpoint | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `configurationGroup_count` | `/api/v1/configurationgroup` | returns `[]` upstream (no data yet) |
|
||||||
|
| `configurationItem_count` | `/api/v1/configurationitem` | returns `[]` upstream (no data yet) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development workflow
|
||||||
|
|
||||||
|
1. Run `npm run dev` to start both servers and the web UI.
|
||||||
|
2. Point POS at `https://<host>:8086` and pair with code `307018` (or generate a new one in the web UI).
|
||||||
|
3. Manage sample data through the web admin; counts in `/api/v1/init` update automatically.
|
||||||
|
4. If the POS requests an unknown path, replay it against the upstream server with `test-client.js`, then extend `lib/src/jtl-server.js` or the relevant action in `lib/src/store.js`.
|
||||||
|
5. For list endpoints, ensure records are filtered by `lastChanged > cursor` and honors `limit`.
|
||||||
|
6. Keep IDs and counts as strings to match upstream.
|
||||||
20
generate-cert.js
Normal file
20
generate-cert.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const certsDir = path.join(__dirname, 'certs');
|
||||||
|
const keyPath = path.join(certsDir, 'key.pem');
|
||||||
|
const certPath = path.join(certsDir, 'cert.pem');
|
||||||
|
|
||||||
|
fs.mkdirSync(certsDir, { recursive: true });
|
||||||
|
|
||||||
|
const subject = '/CN=localhost/O=JTL Debug Server/C=DE';
|
||||||
|
const san = 'subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0';
|
||||||
|
|
||||||
|
execSync(
|
||||||
|
`openssl req -x509 -newkey rsa:2048 -nodes -keyout "${keyPath}" -out "${certPath}" -days 3650 -subj "${subject}" -addext "${san}"`,
|
||||||
|
{ stdio: 'inherit' }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Wrote ${keyPath}`);
|
||||||
|
console.log(`Wrote ${certPath}`);
|
||||||
3
lib/index.js
Normal file
3
lib/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = require('./src');
|
||||||
15
lib/package.json
Normal file
15
lib/package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "jtl-pos-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Reusable Node.js library implementing the JTL-POS pairing and sync protocol",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
lib/src/actions.js
Normal file
92
lib/src/actions.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
function setPairingCode(code, name = 'JTL-POS') {
|
||||||
|
return { type: 'SET_PAIRING_CODE', payload: { code, name } };
|
||||||
|
}
|
||||||
|
function revokePairingCode(code) {
|
||||||
|
return { type: 'REVOKE_PAIRING_CODE', payload: { code } };
|
||||||
|
}
|
||||||
|
function registerDevice(token, name = 'JTL-POS') {
|
||||||
|
return { type: 'REGISTER_DEVICE', payload: { token, name } };
|
||||||
|
}
|
||||||
|
function revokeDevice(token) {
|
||||||
|
return { type: 'REVOKE_DEVICE', payload: { token } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomerGroup(customerGroup) {
|
||||||
|
return { type: 'CREATE_CUSTOMER_GROUP', payload: customerGroup };
|
||||||
|
}
|
||||||
|
function updateCustomerGroup(customerGroup) {
|
||||||
|
return { type: 'UPDATE_CUSTOMER_GROUP', payload: customerGroup };
|
||||||
|
}
|
||||||
|
function deleteCustomerGroup(customerGroupId) {
|
||||||
|
return { type: 'DELETE_CUSTOMER_GROUP', payload: { customerGroupId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCategory(category) {
|
||||||
|
return { type: 'CREATE_CATEGORY', payload: category };
|
||||||
|
}
|
||||||
|
function updateCategory(category) {
|
||||||
|
return { type: 'UPDATE_CATEGORY', payload: category };
|
||||||
|
}
|
||||||
|
function deleteCategory(_id) {
|
||||||
|
return { type: 'DELETE_CATEGORY', payload: { _id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProduct(product) {
|
||||||
|
return { type: 'CREATE_PRODUCT', payload: product };
|
||||||
|
}
|
||||||
|
function updateProduct(product) {
|
||||||
|
return { type: 'UPDATE_PRODUCT', payload: product };
|
||||||
|
}
|
||||||
|
function deleteProduct(_id) {
|
||||||
|
return { type: 'DELETE_PRODUCT', payload: { _id } };
|
||||||
|
}
|
||||||
|
function setCompositeComponent(productId, productIdComponent, quantity = '1.00') {
|
||||||
|
return {
|
||||||
|
type: 'SET_COMPOSITE_COMPONENT',
|
||||||
|
payload: { productId, productIdComponent, quantity },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function removeCompositeComponent(productId, productIdComponent) {
|
||||||
|
return {
|
||||||
|
type: 'REMOVE_COMPOSITE_COMPONENT',
|
||||||
|
payload: { productId, productIdComponent },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomer(customer) {
|
||||||
|
return { type: 'CREATE_CUSTOMER', payload: customer };
|
||||||
|
}
|
||||||
|
function updateCustomer(customer) {
|
||||||
|
return { type: 'UPDATE_CUSTOMER', payload: customer };
|
||||||
|
}
|
||||||
|
function deleteCustomer(id) {
|
||||||
|
return { type: 'DELETE_CUSTOMER', payload: { id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordDeletedEntity(entityId, entityType) {
|
||||||
|
return { type: 'RECORD_DELETED_ENTITY', payload: { entityId, entityType } };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
setPairingCode,
|
||||||
|
revokePairingCode,
|
||||||
|
registerDevice,
|
||||||
|
revokeDevice,
|
||||||
|
createCategory,
|
||||||
|
updateCategory,
|
||||||
|
deleteCategory,
|
||||||
|
createProduct,
|
||||||
|
updateProduct,
|
||||||
|
deleteProduct,
|
||||||
|
setCompositeComponent,
|
||||||
|
removeCompositeComponent,
|
||||||
|
createCustomer,
|
||||||
|
updateCustomer,
|
||||||
|
deleteCustomer,
|
||||||
|
createCustomerGroup,
|
||||||
|
updateCustomerGroup,
|
||||||
|
deleteCustomerGroup,
|
||||||
|
recordDeletedEntity,
|
||||||
|
};
|
||||||
176
lib/src/admin-server.js
Normal file
176
lib/src/admin-server.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('node:path');
|
||||||
|
const actions = require('./actions');
|
||||||
|
|
||||||
|
function randomSixDigitCode() {
|
||||||
|
return String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdminServer(store, options = {}) {
|
||||||
|
const {
|
||||||
|
adminPort = Number(process.env.ADMIN_PORT) || 8087,
|
||||||
|
staticDir = path.join(process.cwd(), 'web', 'dist'),
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// CORS for Vite dev server
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/init', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
counts: store.getInitCounts(),
|
||||||
|
state: store.getState(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/state', (req, res) => {
|
||||||
|
res.json(store.getState());
|
||||||
|
});
|
||||||
|
|
||||||
|
function crudRoutes(basePath, collectionName, idField, actionCreators) {
|
||||||
|
app.get(basePath, (req, res) => {
|
||||||
|
const items = store.getState()[collectionName];
|
||||||
|
res.json(items);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(basePath, (req, res) => {
|
||||||
|
const payload = req.body;
|
||||||
|
store.dispatch(actionCreators.create(payload));
|
||||||
|
const items = store.getState()[collectionName];
|
||||||
|
const created =
|
||||||
|
items.find((i) => i[idField] === String(payload[idField])) ||
|
||||||
|
items[items.length - 1];
|
||||||
|
res.status(201).json(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch(`${basePath}/:id`, (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const updates = req.body;
|
||||||
|
store.dispatch(actionCreators.update({ ...updates, [idField]: id }));
|
||||||
|
res.json(store.getState()[collectionName].find((i) => i[idField] === id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put(`${basePath}/:id`, (req, res) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
const updates = req.body;
|
||||||
|
store.dispatch(actionCreators.update({ ...updates, [idField]: id }));
|
||||||
|
res.json(store.getState()[collectionName].find((i) => i[idField] === id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete(`${basePath}/:id`, (req, res) => {
|
||||||
|
store.dispatch(actionCreators.delete(req.params.id));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
crudRoutes('/admin/api/customer-groups', 'customerGroups', 'customerGroupId', {
|
||||||
|
create: actions.createCustomerGroup,
|
||||||
|
update: actions.updateCustomerGroup,
|
||||||
|
delete: actions.deleteCustomerGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
crudRoutes('/admin/api/categories', 'categories', '_id', {
|
||||||
|
create: actions.createCategory,
|
||||||
|
update: actions.updateCategory,
|
||||||
|
delete: actions.deleteCategory,
|
||||||
|
});
|
||||||
|
|
||||||
|
crudRoutes('/admin/api/products', 'products', '_id', {
|
||||||
|
create: actions.createProduct,
|
||||||
|
update: actions.updateProduct,
|
||||||
|
delete: actions.deleteProduct,
|
||||||
|
});
|
||||||
|
|
||||||
|
crudRoutes('/admin/api/customers', 'customers', 'id', {
|
||||||
|
create: actions.createCustomer,
|
||||||
|
update: actions.updateCustomer,
|
||||||
|
delete: actions.deleteCustomer,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/product-composites', (req, res) => {
|
||||||
|
res.json(store.getState().productComposites);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/api/product-composites', (req, res) => {
|
||||||
|
const { productId, productIdComponent, quantity } = req.body;
|
||||||
|
store.dispatch(actions.setCompositeComponent(productId, productIdComponent, quantity));
|
||||||
|
res.status(201).json(
|
||||||
|
store.getState().productComposites.find(
|
||||||
|
(c) => c.productId === productId && c.productIdComponent === productIdComponent
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin/api/product-composites/:productId/:productIdComponent', (req, res) => {
|
||||||
|
store.dispatch(
|
||||||
|
actions.removeCompositeComponent(req.params.productId, req.params.productIdComponent)
|
||||||
|
);
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/pairing', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
authCodes: Object.values(store.getState().authCodes),
|
||||||
|
pairedDevices: Object.values(store.getState().pairedDevices),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/api/pairing', (req, res) => {
|
||||||
|
const { name = 'JTL-POS' } = req.body || {};
|
||||||
|
const code = req.body?.code || randomSixDigitCode();
|
||||||
|
store.dispatch(actions.setPairingCode(code, name));
|
||||||
|
res.status(201).json({ code, name });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/api/pairing/revoke', (req, res) => {
|
||||||
|
const { code } = req.body || {};
|
||||||
|
if (code) {
|
||||||
|
store.dispatch(actions.revokePairingCode(code));
|
||||||
|
}
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin/api/devices/:token', (req, res) => {
|
||||||
|
store.dispatch(actions.revokeDevice(req.params.token));
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/deleted', (req, res) => {
|
||||||
|
res.json(store.getState().deletedEntities);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/api/deleted', (req, res) => {
|
||||||
|
const { entityId, entityType } = req.body;
|
||||||
|
store.dispatch(actions.recordDeletedEntity(entityId, entityType));
|
||||||
|
res.status(201).json(store.getState().deletedEntities[store.getState().deletedEntities.length - 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve built static files for the admin SPA
|
||||||
|
const expressStaticExists = require('node:fs').existsSync(staticDir);
|
||||||
|
if (expressStaticExists) {
|
||||||
|
app.use(express.static(staticDir));
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(staticDir, 'index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = app.listen(adminPort, '0.0.0.0', () => {
|
||||||
|
console.log(`Admin web API listening on http://0.0.0.0:${adminPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { app, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createAdminServer };
|
||||||
15
lib/src/index.js
Normal file
15
lib/src/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createStore } = require('./store');
|
||||||
|
const { createJtlPosServer } = require('./jtl-server');
|
||||||
|
const { createAdminServer } = require('./admin-server');
|
||||||
|
const actions = require('./actions');
|
||||||
|
const seed = require('./seed');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createStore,
|
||||||
|
createJtlPosServer,
|
||||||
|
createAdminServer,
|
||||||
|
actions,
|
||||||
|
seed,
|
||||||
|
};
|
||||||
141
lib/src/jtl-server.js
Normal file
141
lib/src/jtl-server.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { serverTimestamp } = require('./seed');
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, statusCode, body) {
|
||||||
|
const responseBody = JSON.stringify(body);
|
||||||
|
res.writeHead(statusCode, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(responseBody),
|
||||||
|
});
|
||||||
|
res.end(responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createJtlPosServer(store, config = {}) {
|
||||||
|
const { authToken = process.env.AUTH_TOKEN || '9a2e3036ed9c47e389741d9dbb7590e9' } = config;
|
||||||
|
const {
|
||||||
|
certificateFingerprint = 'BC2114CF407A42724BEEF417960F76DCBF9DE879',
|
||||||
|
certificateSerialNumber = '00BFC8BEACDB981B165210EF111CB9D3',
|
||||||
|
serverFingerprint = '39-6D-BD-DE-F3-5C-5A-EA-C2-19-CF-EB-A7-A9-58-2F-20-3F-20-F7-3D-E6-CA-8E-AE-FD-28-30-37-A6-45-AE',
|
||||||
|
mandantId = '1',
|
||||||
|
mandantName = 'eB-Standard',
|
||||||
|
mandantDatabase = 'eazybusiness',
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
function buildClientStep1() {
|
||||||
|
return {
|
||||||
|
authCode: null,
|
||||||
|
authToken,
|
||||||
|
certificateFingerprint,
|
||||||
|
certificateSerialNumber,
|
||||||
|
mandantId,
|
||||||
|
mandantName: null,
|
||||||
|
mandantDatabase: null,
|
||||||
|
serverFingerprint,
|
||||||
|
name: null,
|
||||||
|
serverTimestamp: serverTimestamp(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientStep2(authCode) {
|
||||||
|
return {
|
||||||
|
authCode,
|
||||||
|
authToken,
|
||||||
|
certificateFingerprint,
|
||||||
|
certificateSerialNumber,
|
||||||
|
mandantId,
|
||||||
|
mandantName,
|
||||||
|
mandantDatabase,
|
||||||
|
serverFingerprint: null,
|
||||||
|
name: null,
|
||||||
|
serverTimestamp: serverTimestamp(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const url = new URL(req.url, 'https://localhost');
|
||||||
|
|
||||||
|
const route = (method, pathname) => method === req.method && url.pathname === pathname;
|
||||||
|
|
||||||
|
if (route('GET', '/api/v1/client')) {
|
||||||
|
const authCode = url.searchParams.get('authCode') || '';
|
||||||
|
const name = url.searchParams.get('name') || 'JTL-POS';
|
||||||
|
const state = store.getState();
|
||||||
|
|
||||||
|
// Step 1 — the first 4 digits of a pre-registered 6-digit pairing code.
|
||||||
|
if (authCode.length <= 4 && authCode.length > 0) {
|
||||||
|
return sendJson(res, 200, buildClientStep1());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 — full 6-digit code must exist in the pending authCodes map.
|
||||||
|
if (authCode.length === 6) {
|
||||||
|
if (state.authCodes[authCode]) {
|
||||||
|
store.dispatch({ type: 'REVOKE_PAIRING_CODE', payload: { code: authCode } });
|
||||||
|
store.dispatch({ type: 'REGISTER_DEVICE', payload: { token: authToken, name } });
|
||||||
|
return sendJson(res, 200, buildClientStep2(authCode));
|
||||||
|
}
|
||||||
|
return sendJson(res, 400, { Message: 'Der Authentifizierungscode ist falsch.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 400, { Message: 'Keinen passenden Authentifizierungscode gefunden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route('GET', '/api/v1/init')) {
|
||||||
|
const params = {
|
||||||
|
lastChangedCategory: url.searchParams.get('lastChangedCategory') || '0',
|
||||||
|
lastChangedCustomer: url.searchParams.get('lastChangedCustomer') || '0',
|
||||||
|
lastChangedCustomerGroup: url.searchParams.get('lastChangedCustomerGroup') || '0',
|
||||||
|
lastChangedProduct: url.searchParams.get('lastChangedProduct') || '0',
|
||||||
|
lastChangedConfigurationGroup: url.searchParams.get('lastChangedConfigurationGroup') || '0',
|
||||||
|
lastChangedConfigurationItem: url.searchParams.get('lastChangedConfigurationItem') || '0',
|
||||||
|
lastChangedCompositeProduct: url.searchParams.get('lastChangedCompositeProduct') || '0',
|
||||||
|
lastChangedDeletedEntity: url.searchParams.get('lastChangedDeletedEntity') || '0',
|
||||||
|
};
|
||||||
|
return sendJson(res, 200, store.getInitCounts(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityEndpoints = {
|
||||||
|
'/api/v1/customergroup': { collection: 'customerGroups', param: 'lastChangedCustomerGroup', limit: '200' },
|
||||||
|
'/api/v1/deletedentity': { collection: 'deletedEntities', param: 'lastChangedDeletedEntity', limit: '200' },
|
||||||
|
'/api/v1/category': { collection: 'categories', param: 'lastChangedCategory', limit: '20' },
|
||||||
|
'/api/v1/customer': { collection: 'customers', param: 'lastChangedCustomer', limit: '20' },
|
||||||
|
'/api/v1/product': { collection: 'products', param: 'lastChangedProduct', limit: '20' },
|
||||||
|
'/api/v1/productcomposite': { collection: 'productComposites', param: 'lastChangedCompositeProduct', limit: '20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [pathname, { collection, param, limit: defaultLimit }] of Object.entries(entityEndpoints)) {
|
||||||
|
if (route('GET', pathname)) {
|
||||||
|
const cursor = url.searchParams.get(param) || '0';
|
||||||
|
const limit = url.searchParams.get('limit') || defaultLimit;
|
||||||
|
const response = store.selectFilteredListFromCollection(collection, cursor, limit);
|
||||||
|
return sendJson(res, 200, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, {
|
||||||
|
Message: `No HTTP resource was found that matches the request URI '${url}'.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return async function requestListener(req, res) {
|
||||||
|
const body = await readBody(req);
|
||||||
|
// Attach raw body for optional logging by the wrapper
|
||||||
|
req.rawBody = body;
|
||||||
|
try {
|
||||||
|
await handle(req, res);
|
||||||
|
} catch (err) {
|
||||||
|
sendJson(res, 500, { Message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createJtlPosServer };
|
||||||
239
lib/src/seed.js
Normal file
239
lib/src/seed.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
function serverTimestamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function productFixture(overrides) {
|
||||||
|
return {
|
||||||
|
imghash: null,
|
||||||
|
imgsrc: null,
|
||||||
|
sort: '0',
|
||||||
|
price: '0.00',
|
||||||
|
p_price: '0.00',
|
||||||
|
discountable: '0',
|
||||||
|
deposit: '0',
|
||||||
|
discount: '',
|
||||||
|
d_price: '0.0',
|
||||||
|
tax_rate: '19',
|
||||||
|
tax_rate2: '',
|
||||||
|
use_in_out_tax: '0',
|
||||||
|
barcode: '',
|
||||||
|
use_stock: '0',
|
||||||
|
q_div: '0',
|
||||||
|
unit: null,
|
||||||
|
single_bookable: '0',
|
||||||
|
annotation: '',
|
||||||
|
status: '0',
|
||||||
|
tags: '',
|
||||||
|
categories_id: '1',
|
||||||
|
is_parent: '0',
|
||||||
|
parent: '0',
|
||||||
|
variants: '',
|
||||||
|
print_kitchen_receipt: '0',
|
||||||
|
deposit_name: '',
|
||||||
|
attributes: [],
|
||||||
|
configurationGroups: '',
|
||||||
|
options: null,
|
||||||
|
hasBestBeforeDate: '0',
|
||||||
|
hasLotNumber: '0',
|
||||||
|
hasSerialNumber: '0',
|
||||||
|
PLU: '',
|
||||||
|
short_description: '',
|
||||||
|
minStock: '0',
|
||||||
|
container: [],
|
||||||
|
reservedQuantity: '0.00',
|
||||||
|
deliveryDetails: [],
|
||||||
|
isbn: '',
|
||||||
|
manufacturerName: null,
|
||||||
|
han: '',
|
||||||
|
productType: '0',
|
||||||
|
voucherData: null,
|
||||||
|
inputPrice: '0',
|
||||||
|
inputQuantity: '0',
|
||||||
|
categories: [{ categoryId: '1' }],
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
customerGroupId: '1',
|
||||||
|
customerId: '0',
|
||||||
|
price: '0.00',
|
||||||
|
quantity: '0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSeedState() {
|
||||||
|
return {
|
||||||
|
customerGroups: [
|
||||||
|
{
|
||||||
|
customerGroupId: '1',
|
||||||
|
name: 'Endkunden',
|
||||||
|
standard: '1',
|
||||||
|
discountPercent: '0.00',
|
||||||
|
lastChanged: '7323',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
_id: '1',
|
||||||
|
imghash: null,
|
||||||
|
imgsrc: null,
|
||||||
|
name: 'Haupt',
|
||||||
|
pid: '0',
|
||||||
|
discounts: [],
|
||||||
|
sort: '0',
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
created_at: serverTimestamp(),
|
||||||
|
lastChanged: '10252',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: '2',
|
||||||
|
imghash: null,
|
||||||
|
imgsrc: null,
|
||||||
|
name: 'Getränke',
|
||||||
|
pid: '1',
|
||||||
|
discounts: [],
|
||||||
|
sort: '1',
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
created_at: serverTimestamp(),
|
||||||
|
lastChanged: '10300',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
customerNumber: '0',
|
||||||
|
firstname: '',
|
||||||
|
lastname: 'kjhkjh',
|
||||||
|
title: '',
|
||||||
|
company: 'kjhkjh',
|
||||||
|
address: '',
|
||||||
|
addressSupplement: '',
|
||||||
|
city: 'kjhkjhkjh',
|
||||||
|
postalCode: '',
|
||||||
|
state: '',
|
||||||
|
country: 'Deutschland',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
customerGroupId: '1',
|
||||||
|
salutation: '',
|
||||||
|
birthday: null,
|
||||||
|
discount: '0.00',
|
||||||
|
taxIdNumber: '',
|
||||||
|
lastChanged: '13246',
|
||||||
|
debtorNumber: '0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
customerNumber: '1001',
|
||||||
|
firstname: 'Erika',
|
||||||
|
lastname: 'Musterfrau',
|
||||||
|
title: '',
|
||||||
|
company: 'Muster GmbH',
|
||||||
|
address: 'Hauptstraße 1',
|
||||||
|
addressSupplement: '',
|
||||||
|
city: 'Berlin',
|
||||||
|
postalCode: '10115',
|
||||||
|
state: '',
|
||||||
|
country: 'Deutschland',
|
||||||
|
phone: '+49 30 123456',
|
||||||
|
email: 'erika@muster.de',
|
||||||
|
customerGroupId: '1',
|
||||||
|
salutation: 'Frau',
|
||||||
|
birthday: null,
|
||||||
|
discount: '0.00',
|
||||||
|
taxIdNumber: '',
|
||||||
|
lastChanged: '13310',
|
||||||
|
debtorNumber: '1001',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
customerNumber: '1002',
|
||||||
|
firstname: 'Max',
|
||||||
|
lastname: 'Mustermann',
|
||||||
|
title: '',
|
||||||
|
company: '',
|
||||||
|
address: 'Nebenweg 5',
|
||||||
|
addressSupplement: '',
|
||||||
|
city: 'Hamburg',
|
||||||
|
postalCode: '20095',
|
||||||
|
state: '',
|
||||||
|
country: 'Deutschland',
|
||||||
|
phone: '',
|
||||||
|
email: 'max@example.de',
|
||||||
|
customerGroupId: '1',
|
||||||
|
salutation: 'Herr',
|
||||||
|
birthday: null,
|
||||||
|
discount: '5.00',
|
||||||
|
taxIdNumber: '',
|
||||||
|
lastChanged: '13320',
|
||||||
|
debtorNumber: '1002',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
productFixture({
|
||||||
|
_id: '1',
|
||||||
|
name: 'a1',
|
||||||
|
sku: '1',
|
||||||
|
quantity: '0',
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
created_at: serverTimestamp(),
|
||||||
|
isCompositeProduct: '0',
|
||||||
|
lastChanged: '13111',
|
||||||
|
}),
|
||||||
|
productFixture({
|
||||||
|
_id: '2',
|
||||||
|
name: 'a2',
|
||||||
|
sku: '2',
|
||||||
|
quantity: '0.00',
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
created_at: serverTimestamp(),
|
||||||
|
isCompositeProduct: '1',
|
||||||
|
lastChanged: '13228',
|
||||||
|
}),
|
||||||
|
productFixture({
|
||||||
|
_id: '3',
|
||||||
|
name: 'Cola 0,5l',
|
||||||
|
sku: '3',
|
||||||
|
quantity: '24',
|
||||||
|
price: '1.49',
|
||||||
|
p_price: '1.49',
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
created_at: serverTimestamp(),
|
||||||
|
isCompositeProduct: '0',
|
||||||
|
categories_id: '2',
|
||||||
|
categories: [{ categoryId: '2' }],
|
||||||
|
lastChanged: '13300',
|
||||||
|
prices: [
|
||||||
|
{
|
||||||
|
customerGroupId: '1',
|
||||||
|
customerId: '0',
|
||||||
|
price: '1.49',
|
||||||
|
quantity: '0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
productComposites: [
|
||||||
|
{
|
||||||
|
productId: '2',
|
||||||
|
productIdComponent: '1',
|
||||||
|
quantity: '1.00',
|
||||||
|
lastChanged: '13227',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deletedEntities: [
|
||||||
|
{
|
||||||
|
entityId: '2',
|
||||||
|
entityType: '6',
|
||||||
|
lastChanged: '8281',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
authCodes: {},
|
||||||
|
pairedDevices: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSeedState, productFixture, serverTimestamp };
|
||||||
316
lib/src/store.js
Normal file
316
lib/src/store.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createSeedState } = require('./seed');
|
||||||
|
|
||||||
|
function withLimit(records, limit) {
|
||||||
|
const max = Number(limit);
|
||||||
|
return Number.isFinite(max) && max > 0 ? records.slice(0, max) : records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByCursor(records, cursor) {
|
||||||
|
const n = Number(cursor) || 0;
|
||||||
|
return records.filter((record) => Number(record.lastChanged) > n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return String(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeId(n) {
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStore(options = {}) {
|
||||||
|
const state = options.initialState || createSeedState();
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
if (!state.authCodes) state.authCodes = {};
|
||||||
|
if (!state.pairedDevices) state.pairedDevices = {};
|
||||||
|
if (!state.customerGroups) state.customerGroups = [];
|
||||||
|
if (!state.categories) state.categories = [];
|
||||||
|
if (!state.customers) state.customers = [];
|
||||||
|
if (!state.products) state.products = [];
|
||||||
|
if (!state.productComposites) state.productComposites = [];
|
||||||
|
if (!state.deletedEntities) state.deletedEntities = [];
|
||||||
|
|
||||||
|
function getState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listeners.forEach((l) => l(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch(action) {
|
||||||
|
const { type, payload } = action;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
// Auth / pairing
|
||||||
|
case 'SET_PAIRING_CODE': {
|
||||||
|
const { code, name = 'JTL-POS' } = payload;
|
||||||
|
state.authCodes[code] = { code, name, createdAt: Date.now() };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'REVOKE_PAIRING_CODE': {
|
||||||
|
delete state.authCodes[payload.code];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'REGISTER_DEVICE': {
|
||||||
|
const { token, name, createdAt = Date.now() } = payload;
|
||||||
|
state.pairedDevices[token] = { name, token, createdAt };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'REVOKE_DEVICE': {
|
||||||
|
delete state.pairedDevices[payload.token];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer groups
|
||||||
|
case 'CREATE_CUSTOMER_GROUP':
|
||||||
|
case 'UPDATE_CUSTOMER_GROUP': {
|
||||||
|
const group = payload;
|
||||||
|
const id = group.customerGroupId || generateId();
|
||||||
|
const idx = state.customerGroups.findIndex(
|
||||||
|
(g) => g.customerGroupId === id
|
||||||
|
);
|
||||||
|
const next = {
|
||||||
|
...group,
|
||||||
|
customerGroupId: id,
|
||||||
|
standard: String(group.standard ?? (idx >= 0 ? state.customerGroups[idx].standard : '0')),
|
||||||
|
discountPercent: String(group.discountPercent ?? '0.00'),
|
||||||
|
lastChanged: generateId(),
|
||||||
|
};
|
||||||
|
if (idx >= 0) state.customerGroups[idx] = next;
|
||||||
|
else state.customerGroups.push(next);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'DELETE_CUSTOMER_GROUP': {
|
||||||
|
const id = payload.customerGroupId;
|
||||||
|
state.customerGroups = state.customerGroups.filter(
|
||||||
|
(g) => g.customerGroupId !== id
|
||||||
|
);
|
||||||
|
state.deletedEntities.push({
|
||||||
|
entityId: id,
|
||||||
|
entityType: '6',
|
||||||
|
lastChanged: generateId(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
case 'CREATE_CATEGORY':
|
||||||
|
case 'UPDATE_CATEGORY': {
|
||||||
|
const cat = payload;
|
||||||
|
const id = cat._id || generateId();
|
||||||
|
const idx = state.categories.findIndex((c) => c._id === id);
|
||||||
|
const now = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
const next = {
|
||||||
|
imghash: null,
|
||||||
|
imgsrc: null,
|
||||||
|
discounts: [],
|
||||||
|
sort: '0',
|
||||||
|
...cat,
|
||||||
|
_id: id,
|
||||||
|
pid: String(cat.pid ?? '0'),
|
||||||
|
updated_at: now,
|
||||||
|
created_at: idx >= 0 ? state.categories[idx].created_at : now,
|
||||||
|
lastChanged: generateId(),
|
||||||
|
};
|
||||||
|
if (idx >= 0) state.categories[idx] = next;
|
||||||
|
else state.categories.push(next);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'DELETE_CATEGORY': {
|
||||||
|
const id = payload._id;
|
||||||
|
state.categories = state.categories.filter((c) => c._id !== id);
|
||||||
|
state.deletedEntities.push({
|
||||||
|
entityId: id,
|
||||||
|
entityType: '2',
|
||||||
|
lastChanged: generateId(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products
|
||||||
|
case 'CREATE_PRODUCT':
|
||||||
|
case 'UPDATE_PRODUCT': {
|
||||||
|
const prod = payload;
|
||||||
|
const id = prod._id || generateId();
|
||||||
|
const idx = state.products.findIndex((p) => p._id === id);
|
||||||
|
const defaults = createSeedState().products[0];
|
||||||
|
if (idx >= 0) {
|
||||||
|
state.products[idx] = {
|
||||||
|
...state.products[idx],
|
||||||
|
...prod,
|
||||||
|
_id: id,
|
||||||
|
isCompositeProduct: String(prod.isCompositeProduct ?? state.products[idx].isCompositeProduct ?? '0'),
|
||||||
|
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
lastChanged: generateId(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.products.push({
|
||||||
|
...defaults,
|
||||||
|
...prod,
|
||||||
|
_id: id,
|
||||||
|
isCompositeProduct: String(prod.isCompositeProduct ?? '0'),
|
||||||
|
created_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19),
|
||||||
|
lastChanged: generateId(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'DELETE_PRODUCT': {
|
||||||
|
const id = payload._id;
|
||||||
|
state.products = state.products.filter((p) => p._id !== id);
|
||||||
|
state.deletedEntities.push({
|
||||||
|
entityId: id,
|
||||||
|
entityType: '1',
|
||||||
|
lastChanged: generateId(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product composites
|
||||||
|
case 'SET_COMPOSITE_COMPONENT': {
|
||||||
|
const { productId, productIdComponent, quantity = '1.00' } = payload;
|
||||||
|
const idx = state.productComposites.findIndex(
|
||||||
|
(c) => c.productId === productId && c.productIdComponent === productIdComponent
|
||||||
|
);
|
||||||
|
const next = {
|
||||||
|
productId,
|
||||||
|
productIdComponent,
|
||||||
|
quantity: String(quantity),
|
||||||
|
lastChanged: generateId(),
|
||||||
|
};
|
||||||
|
if (idx >= 0) state.productComposites[idx] = next;
|
||||||
|
else state.productComposites.push(next);
|
||||||
|
// Mark the parent product as composite
|
||||||
|
const parentIdx = state.products.findIndex((p) => p._id === productId);
|
||||||
|
if (parentIdx >= 0 && state.products[parentIdx].isCompositeProduct !== '1') {
|
||||||
|
state.products[parentIdx].isCompositeProduct = '1';
|
||||||
|
state.products[parentIdx].lastChanged = generateId();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'REMOVE_COMPOSITE_COMPONENT': {
|
||||||
|
const { productId, productIdComponent } = payload;
|
||||||
|
state.productComposites = state.productComposites.filter(
|
||||||
|
(c) => !(c.productId === productId && c.productIdComponent === productIdComponent)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customers
|
||||||
|
case 'CREATE_CUSTOMER':
|
||||||
|
case 'UPDATE_CUSTOMER': {
|
||||||
|
const cust = payload;
|
||||||
|
const id = cust.id || generateId();
|
||||||
|
const idx = state.customers.findIndex((c) => c.id === id);
|
||||||
|
const next = {
|
||||||
|
customerNumber: '0',
|
||||||
|
firstname: '',
|
||||||
|
lastname: '',
|
||||||
|
title: '',
|
||||||
|
company: '',
|
||||||
|
address: '',
|
||||||
|
addressSupplement: '',
|
||||||
|
city: '',
|
||||||
|
postalCode: '',
|
||||||
|
state: '',
|
||||||
|
country: 'Deutschland',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
customerGroupId: '1',
|
||||||
|
salutation: '',
|
||||||
|
birthday: null,
|
||||||
|
discount: '0.00',
|
||||||
|
taxIdNumber: '',
|
||||||
|
debtorNumber: '0',
|
||||||
|
...cust,
|
||||||
|
id,
|
||||||
|
lastChanged: generateId(),
|
||||||
|
};
|
||||||
|
if (idx >= 0) state.customers[idx] = next;
|
||||||
|
else state.customers.push(next);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'DELETE_CUSTOMER': {
|
||||||
|
const id = payload.id;
|
||||||
|
state.customers = state.customers.filter((c) => c.id !== id);
|
||||||
|
state.deletedEntities.push({
|
||||||
|
entityId: id,
|
||||||
|
entityType: '3',
|
||||||
|
lastChanged: generateId(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RECORD_DELETED_ENTITY': {
|
||||||
|
const { entityId, entityType } = payload;
|
||||||
|
state.deletedEntities.push({
|
||||||
|
entityId,
|
||||||
|
entityType: String(entityType),
|
||||||
|
lastChanged: generateId(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFilteredListFromCollection(collectionName, cursor, limit) {
|
||||||
|
return withLimit(filterByCursor(state[collectionName], cursor), limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitCounts(params = {}) {
|
||||||
|
return {
|
||||||
|
version: '1.10.12.0',
|
||||||
|
product_count: String(
|
||||||
|
selectFilteredListFromCollection('products', params.lastChangedProduct, Infinity).length
|
||||||
|
),
|
||||||
|
category_count: String(
|
||||||
|
selectFilteredListFromCollection('categories', params.lastChangedCategory, Infinity).length
|
||||||
|
),
|
||||||
|
customer_count: String(
|
||||||
|
selectFilteredListFromCollection('customers', params.lastChangedCustomer, Infinity).length
|
||||||
|
),
|
||||||
|
customerGroup_count: String(
|
||||||
|
selectFilteredListFromCollection('customerGroups', params.lastChangedCustomerGroup, Infinity).length
|
||||||
|
),
|
||||||
|
compositeProduct_count: String(
|
||||||
|
selectFilteredListFromCollection(
|
||||||
|
'productComposites',
|
||||||
|
params.lastChangedCompositeProduct,
|
||||||
|
Infinity
|
||||||
|
).length
|
||||||
|
),
|
||||||
|
configurationGroup_count: '0',
|
||||||
|
configurationItem_count: '0',
|
||||||
|
deletedEntity_count: String(
|
||||||
|
selectFilteredListFromCollection('deletedEntities', params.lastChangedDeletedEntity, Infinity).length
|
||||||
|
),
|
||||||
|
max_orderId_count: '0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState,
|
||||||
|
dispatch,
|
||||||
|
subscribe,
|
||||||
|
getInitCounts,
|
||||||
|
selectFilteredListFromCollection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createStore, withLimit, filterByCursor };
|
||||||
1149
package-lock.json
generated
Normal file
1149
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "jtlsrv",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Self-signed HTTPS debug server for JTL POS with web admin UI",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"cert": "node generate-cert.js",
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "concurrently --names=server,web --prefix-colors=auto npm:dev:server npm:dev:web",
|
||||||
|
"dev:server": "node server.js",
|
||||||
|
"dev:web": "cd web && npm run dev",
|
||||||
|
"build": "npm run build:web",
|
||||||
|
"build:web": "cd web && npm run build",
|
||||||
|
"test:client": "node test-client.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"jtl-pos-server": "file:./lib",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
server.js
Normal file
91
server.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const https = require('node:https');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { createStore, createJtlPosServer, createAdminServer } = require('./lib/src');
|
||||||
|
const actions = require('./lib/src/actions');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 8086;
|
||||||
|
const ADMIN_PORT = Number(process.env.ADMIN_PORT) || 8087;
|
||||||
|
const AUTH_TOKEN =
|
||||||
|
process.env.AUTH_TOKEN || '9a2e3036ed9c47e389741d9dbb7590e9';
|
||||||
|
const PAIRING_CODE = process.env.PAIRING_CODE || '307018';
|
||||||
|
|
||||||
|
const certsDir = path.join(__dirname, 'certs');
|
||||||
|
const keyPath = path.join(certsDir, 'key.pem');
|
||||||
|
const certPath = path.join(certsDir, 'cert.pem');
|
||||||
|
|
||||||
|
if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) {
|
||||||
|
console.error('Missing TLS certificate. Run: npm run cert');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverTimestamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBody(buffer) {
|
||||||
|
if (!buffer.length) {
|
||||||
|
return '(empty)';
|
||||||
|
}
|
||||||
|
const text = buffer.toString('utf8');
|
||||||
|
if (/^[\x09\x0A\x0D\x20-\x7E\u0080-\uFFFF]*$/.test(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return `[binary ${buffer.length} bytes]\n${buffer.toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = createStore();
|
||||||
|
|
||||||
|
// Pre-register the fixed pairing code so the POS can pair out of the box.
|
||||||
|
store.dispatch(actions.setPairingCode(PAIRING_CODE, 'JTL-POS'));
|
||||||
|
// Treat the fixed auth token as a pre-paired device for display in the admin UI.
|
||||||
|
store.dispatch(actions.registerDevice(AUTH_TOKEN, 'JTL-POS'));
|
||||||
|
|
||||||
|
const jtlHandler = createJtlPosServer(store, { authToken: AUTH_TOKEN });
|
||||||
|
|
||||||
|
const loggedJtlHandler = async (req, res) => {
|
||||||
|
const started = Date.now();
|
||||||
|
const originalEnd = res.end.bind(res);
|
||||||
|
|
||||||
|
console.log('\n--- incoming POS request ---');
|
||||||
|
console.log(`${req.method} ${req.url}`);
|
||||||
|
console.log('remote:', req.socket.remoteAddress, req.socket.remotePort);
|
||||||
|
console.log('headers:', JSON.stringify(req.headers, null, 2));
|
||||||
|
|
||||||
|
await jtlHandler(req, res);
|
||||||
|
|
||||||
|
// req.rawBody is attached by jtlHandler's body reader
|
||||||
|
console.log('body:', formatBody(req.rawBody ?? Buffer.alloc(0)));
|
||||||
|
console.log(`responded ${res.statusCode} in ${Date.now() - started}ms`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpsServer = https.createServer(
|
||||||
|
{
|
||||||
|
key: fs.readFileSync(keyPath),
|
||||||
|
cert: fs.readFileSync(certPath),
|
||||||
|
},
|
||||||
|
loggedJtlHandler
|
||||||
|
);
|
||||||
|
|
||||||
|
httpsServer.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`HTTPS POS server listening on https://0.0.0.0:${PORT}`);
|
||||||
|
console.log(`Certificate: ${certPath}`);
|
||||||
|
console.log('Import cert.pem into JTL POS / Windows trust store if required.');
|
||||||
|
});
|
||||||
|
|
||||||
|
const { server: adminServer } = createAdminServer(store, {
|
||||||
|
adminPort: ADMIN_PORT,
|
||||||
|
staticDir: path.join(__dirname, 'web', 'dist'),
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
console.log('\nShutting down...');
|
||||||
|
httpsServer.close();
|
||||||
|
adminServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
202
test-client.js
Normal file
202
test-client.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
const https = require('node:https');
|
||||||
|
const { gunzipSync } = require('node:zlib');
|
||||||
|
|
||||||
|
const HOST = process.env.JTL_HOST || '192.168.178.81';
|
||||||
|
const PORT = Number(process.env.JTL_PORT) || 4433;
|
||||||
|
|
||||||
|
const INIT_QUERY =
|
||||||
|
'mandantId=1&lastChangedCategory=0&lastChangedCustomer=0&lastChangedCustomerGroup=0&lastChangedProduct=0&lastChangedConfigurationGroup=0&lastChangedConfigurationItem=0&lastChangedCompositeProduct=0&lastChangedDeletedEntity=0';
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.error('Usage:');
|
||||||
|
console.error(' node test-client.js <authCode> [name] # 6-digit: step1+2, 4-digit: step1 only');
|
||||||
|
console.error(' node test-client.js init <authToken>');
|
||||||
|
console.error(' node test-client.js customergroup <authToken> [lastChangedCustomerGroup]');
|
||||||
|
console.error(' node test-client.js deletedentity <authToken> [lastChangedDeletedEntity]');
|
||||||
|
console.error(' node test-client.js category <authToken> [lastChangedCategory]');
|
||||||
|
console.error(' node test-client.js customer <authToken> [lastChangedCustomer]');
|
||||||
|
console.error(' node test-client.js product <authToken> [lastChangedProduct]');
|
||||||
|
console.error(' node test-client.js productcomposite <authToken> [lastChangedCompositeProduct]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function posHeaders(authToken) {
|
||||||
|
const headers = {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
version: '1.0.11.14',
|
||||||
|
system: 'JTL-POS',
|
||||||
|
charset: 'utf-8',
|
||||||
|
connection: 'Keep-Alive',
|
||||||
|
'user-agent':
|
||||||
|
'Dalvik/2.1.0 (Linux; U; Android 14; SM-A528B Build/UP1A.231005.007)',
|
||||||
|
'accept-encoding': 'gzip',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (authToken) {
|
||||||
|
headers.authorization = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBody(buffer, headers) {
|
||||||
|
if (headers['content-encoding'] === 'gzip') {
|
||||||
|
return gunzipSync(buffer);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(path, authToken) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: HOST,
|
||||||
|
port: PORT,
|
||||||
|
path,
|
||||||
|
method: 'GET',
|
||||||
|
headers: posHeaders(authToken),
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks);
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
headers: res.headers,
|
||||||
|
body: decodeBody(raw, res.headers),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function printResponse(res) {
|
||||||
|
console.log(`status: ${res.statusCode}`);
|
||||||
|
console.log('headers:', JSON.stringify(res.headers, null, 2));
|
||||||
|
console.log('body:');
|
||||||
|
try {
|
||||||
|
console.log(JSON.stringify(JSON.parse(res.body.toString('utf8')), null, 2));
|
||||||
|
} catch {
|
||||||
|
console.log(res.body.toString('utf8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clientRequest(authCode, name, label) {
|
||||||
|
const path = `/api/v1/client?authCode=${encodeURIComponent(authCode)}&name=${encodeURIComponent(name)}`;
|
||||||
|
console.log(`${label} GET https://${HOST}:${PORT}${path}\n`);
|
||||||
|
const res = await request(path);
|
||||||
|
printResponse(res);
|
||||||
|
console.log();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const mode = process.argv[2];
|
||||||
|
|
||||||
|
if (!mode) {
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'init') {
|
||||||
|
const authToken = process.argv[3] || process.env.AUTH_TOKEN;
|
||||||
|
if (!authToken) {
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `/api/v1/init?${INIT_QUERY}`;
|
||||||
|
console.log(`GET https://${HOST}:${PORT}${path}\n`);
|
||||||
|
|
||||||
|
const res = await request(path, authToken);
|
||||||
|
printResponse(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'customergroup') {
|
||||||
|
const authToken = process.argv[3] || process.env.AUTH_TOKEN;
|
||||||
|
if (!authToken) {
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastChanged = process.argv[4] || '0';
|
||||||
|
const path = `/api/v1/customergroup?mandantId=1&lastChangedCustomerGroup=${encodeURIComponent(lastChanged)}`;
|
||||||
|
console.log(`GET https://${HOST}:${PORT}${path}\n`);
|
||||||
|
|
||||||
|
const res = await request(path, authToken);
|
||||||
|
printResponse(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'deletedentity') {
|
||||||
|
const authToken = process.argv[3] || process.env.AUTH_TOKEN;
|
||||||
|
if (!authToken) {
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastChanged = process.argv[4] || '0';
|
||||||
|
const path = `/api/v1/deletedentity?mandantId=1&limit=200&lastChangedDeletedEntity=${encodeURIComponent(lastChanged)}`;
|
||||||
|
console.log(`GET https://${HOST}:${PORT}${path}\n`);
|
||||||
|
|
||||||
|
const res = await request(path, authToken);
|
||||||
|
printResponse(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityModes = {
|
||||||
|
category: {
|
||||||
|
param: 'lastChangedCategory',
|
||||||
|
path: '/api/v1/category?mandantId=1&limit=20',
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
param: 'lastChangedCustomer',
|
||||||
|
path: '/api/v1/customer?mandantId=1&limit=20',
|
||||||
|
},
|
||||||
|
product: {
|
||||||
|
param: 'lastChangedProduct',
|
||||||
|
path: '/api/v1/product?mandantId=1&limit=20',
|
||||||
|
},
|
||||||
|
productcomposite: {
|
||||||
|
param: 'lastChangedCompositeProduct',
|
||||||
|
path: '/api/v1/productcomposite?mandantId=1&limit=20',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (entityModes[mode]) {
|
||||||
|
const authToken = process.argv[3] || process.env.AUTH_TOKEN;
|
||||||
|
if (!authToken) {
|
||||||
|
usage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { param, path: basePath } = entityModes[mode];
|
||||||
|
const lastChanged = process.argv[4] || '0';
|
||||||
|
const path = `${basePath}&${param}=${encodeURIComponent(lastChanged)}`;
|
||||||
|
console.log(`GET https://${HOST}:${PORT}${path}\n`);
|
||||||
|
|
||||||
|
const res = await request(path, authToken);
|
||||||
|
printResponse(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = process.argv[3] || process.env.CLIENT_NAME || '001';
|
||||||
|
const fullCode = mode;
|
||||||
|
|
||||||
|
if (fullCode.length === 6) {
|
||||||
|
await clientRequest(fullCode.slice(0, 4), name, '=== step 1 (4 digits) ===');
|
||||||
|
await clientRequest(fullCode, name, '=== step 2 (6 digits) ===');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await clientRequest(fullCode, name, '=== step 1 (4 digits) ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('request failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>JTL-POS Admin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2650
web/package-lock.json
generated
Normal file
2650
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "jtl-pos-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.0",
|
||||||
|
"@mui/icons-material": "^6.4.6",
|
||||||
|
"@mui/material": "^6.4.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"vite": "^6.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
web/src/App.jsx
Normal file
91
web/src/App.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Routes, Route, NavLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Box,
|
||||||
|
CssBaseline,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
|
import BluetoothSearchingIcon from '@mui/icons-material/BluetoothSearching';
|
||||||
|
import InventoryIcon from '@mui/icons-material/Inventory';
|
||||||
|
import CategoryIcon from '@mui/icons-material/Category';
|
||||||
|
import PeopleIcon from '@mui/icons-material/People';
|
||||||
|
import LoyaltyIcon from '@mui/icons-material/Loyalty';
|
||||||
|
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||||
|
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Pairing from './pages/Pairing';
|
||||||
|
import Products from './pages/Products';
|
||||||
|
import Categories from './pages/Categories';
|
||||||
|
import Customers from './pages/Customers';
|
||||||
|
import CustomerGroups from './pages/CustomerGroups';
|
||||||
|
import Deleted from './pages/Deleted';
|
||||||
|
|
||||||
|
const drawerWidth = 240;
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: <DashboardIcon /> },
|
||||||
|
{ path: '/pairing', label: 'Pairing', icon: <BluetoothSearchingIcon /> },
|
||||||
|
{ path: '/products', label: 'Products', icon: <InventoryIcon /> },
|
||||||
|
{ path: '/categories', label: 'Categories', icon: <CategoryIcon /> },
|
||||||
|
{ path: '/customers', label: 'Customers', icon: <PeopleIcon /> },
|
||||||
|
{ path: '/customer-groups', label: 'Customer Groups', icon: <LoyaltyIcon /> },
|
||||||
|
{ path: '/deleted', label: 'Deleted', icon: <DeleteSweepIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" noWrap component="div">
|
||||||
|
JTL-POS Admin
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: drawerWidth,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar />
|
||||||
|
<List>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<ListItem key={item.path} disablePadding>
|
||||||
|
<ListItemButton component={NavLink} to={item.path}>
|
||||||
|
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Drawer>
|
||||||
|
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||||
|
<Toolbar />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/pairing" element={<Pairing />} />
|
||||||
|
<Route path="/products" element={<Products />} />
|
||||||
|
<Route path="/categories" element={<Categories />} />
|
||||||
|
<Route path="/customers" element={<Customers />} />
|
||||||
|
<Route path="/customer-groups" element={<CustomerGroups />} />
|
||||||
|
<Route path="/deleted" element={<Deleted />} />
|
||||||
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
64
web/src/api.js
Normal file
64
web/src/api.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const BASE = import.meta.env.VITE_ADMIN_API_BASE || '/admin/api';
|
||||||
|
|
||||||
|
async function request(method, path, body) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${BASE}${path}`, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${method} ${path} failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: (path) => request('GET', path),
|
||||||
|
post: (path, body) => request('POST', path, body),
|
||||||
|
patch: (path, body) => request('PATCH', path, body),
|
||||||
|
put: (path, body) => request('PUT', path, body),
|
||||||
|
delete: (path) => request('DELETE', path),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const init = () => api.get('/init');
|
||||||
|
export const getState = () => api.get('/state');
|
||||||
|
|
||||||
|
export const listCategories = () => api.get('/categories');
|
||||||
|
export const createCategory = (cat) => api.post('/categories', cat);
|
||||||
|
export const updateCategory = (id, cat) => api.patch(`/categories/${id}`, cat);
|
||||||
|
export const deleteCategory = (id) => api.delete(`/categories/${id}`);
|
||||||
|
|
||||||
|
export const listProducts = () => api.get('/products');
|
||||||
|
export const createProduct = (prod) => api.post('/products', prod);
|
||||||
|
export const updateProduct = (id, prod) => api.patch(`/products/${id}`, prod);
|
||||||
|
export const deleteProduct = (id) => api.delete(`/products/${id}`);
|
||||||
|
|
||||||
|
export const listCustomers = () => api.get('/customers');
|
||||||
|
export const createCustomer = (cust) => api.post('/customers', cust);
|
||||||
|
export const updateCustomer = (id, cust) => api.patch(`/customers/${id}`, cust);
|
||||||
|
export const deleteCustomer = (id) => api.delete(`/customers/${id}`);
|
||||||
|
|
||||||
|
export const listCustomerGroups = () => api.get('/customer-groups');
|
||||||
|
export const createCustomerGroup = (g) => api.post('/customer-groups', g);
|
||||||
|
export const updateCustomerGroup = (id, g) => api.patch(`/customer-groups/${id}`, g);
|
||||||
|
export const deleteCustomerGroup = (id) => api.delete(`/customer-groups/${id}`);
|
||||||
|
|
||||||
|
export const listComposites = () => api.get('/product-composites');
|
||||||
|
export const setComposite = (comp) => api.post('/product-composites', comp);
|
||||||
|
export const removeComposite = (productId, componentId) =>
|
||||||
|
api.delete(`/product-composites/${productId}/${componentId}`);
|
||||||
|
|
||||||
|
export const getPairing = () => api.get('/pairing');
|
||||||
|
export const createPairingCode = (name) => api.post('/pairing', { name });
|
||||||
|
export const revokePairingCode = (code) => api.post('/pairing/revoke', { code });
|
||||||
|
export const revokeDevice = (token) => api.delete(`/devices/${token}`);
|
||||||
|
|
||||||
|
export const listDeleted = () => api.get('/deleted');
|
||||||
|
export const recordDeleted = (entity) => api.post('/deleted', entity);
|
||||||
150
web/src/components/CrudTable.jsx
Normal file
150
web/src/components/CrudTable.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
|
||||||
|
export default function CrudTable({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
idField,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
emptyItem = {},
|
||||||
|
title,
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [form, setForm] = useState({});
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
setAdding(true);
|
||||||
|
setForm({ ...emptyItem });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(row) {
|
||||||
|
setEditing(row[idField]);
|
||||||
|
setForm({ ...row });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setEditing(null);
|
||||||
|
setAdding(false);
|
||||||
|
setForm({});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await onSave(editing || adding ? form : null);
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(key, value) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditing(id) {
|
||||||
|
return editing === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mb: 2 }} alignItems="center">
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={startAdd}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key}>{col.label}</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{adding && (
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={form[col.key] ?? ''}
|
||||||
|
onChange={(e) => updateField(col.key, e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={save}>
|
||||||
|
<SaveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={cancel}>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{rows.map((row) =>
|
||||||
|
isEditing(row[idField]) ? (
|
||||||
|
<TableRow key={row[idField]}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={form[col.key] ?? ''}
|
||||||
|
onChange={(e) => updateField(col.key, e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={save}>
|
||||||
|
<SaveIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="small" onClick={cancel}>
|
||||||
|
<CancelIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
<TableRow key={row[idField]}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key}>{row[col.key] ?? ''}</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={() => startEdit(row)}>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => onDelete(row[idField])}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
web/src/main.jsx
Normal file
23
web/src/main.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
69
web/src/pages/Categories.jsx
Normal file
69
web/src/pages/Categories.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import CrudTable from '../components/CrudTable';
|
||||||
|
import { listCategories, createCategory, updateCategory, deleteCategory } from '../api';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: '_id', label: 'ID' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'pid', label: 'Parent ID' },
|
||||||
|
{ key: 'sort', label: 'Sort' },
|
||||||
|
{ key: 'lastChanged', label: 'Last changed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Categories() {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const data = await listCategories();
|
||||||
|
setRows(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(form) {
|
||||||
|
try {
|
||||||
|
if (form._id && rows.some((r) => r._id === form._id)) {
|
||||||
|
await updateCategory(form._id, form);
|
||||||
|
} else {
|
||||||
|
await createCategory(form);
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
try {
|
||||||
|
await deleteCategory(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Categories
|
||||||
|
</Typography>
|
||||||
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
<CrudTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
idField="_id"
|
||||||
|
onSave={handleSave}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
emptyItem={{ name: '', pid: '0', sort: '0' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
web/src/pages/CustomerGroups.jsx
Normal file
68
web/src/pages/CustomerGroups.jsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import CrudTable from '../components/CrudTable';
|
||||||
|
import { listCustomerGroups, createCustomerGroup, updateCustomerGroup, deleteCustomerGroup } from '../api';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'customerGroupId', label: 'ID' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'standard', label: 'Standard' },
|
||||||
|
{ key: 'discountPercent', label: 'Discount %' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CustomerGroups() {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const data = await listCustomerGroups();
|
||||||
|
setRows(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(form) {
|
||||||
|
try {
|
||||||
|
if (form.customerGroupId && rows.some((r) => r.customerGroupId === form.customerGroupId)) {
|
||||||
|
await updateCustomerGroup(form.customerGroupId, form);
|
||||||
|
} else {
|
||||||
|
await createCustomerGroup(form);
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
try {
|
||||||
|
await deleteCustomerGroup(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Customer Groups
|
||||||
|
</Typography>
|
||||||
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
<CrudTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
idField="customerGroupId"
|
||||||
|
onSave={handleSave}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
emptyItem={{ name: '', standard: '0', discountPercent: '0.00' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
web/src/pages/Customers.jsx
Normal file
71
web/src/pages/Customers.jsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import CrudTable from '../components/CrudTable';
|
||||||
|
import { listCustomers, createCustomer, updateCustomer, deleteCustomer } from '../api';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'id', label: 'ID' },
|
||||||
|
{ key: 'customerNumber', label: 'Number' },
|
||||||
|
{ key: 'firstname', label: 'First name' },
|
||||||
|
{ key: 'lastname', label: 'Last name' },
|
||||||
|
{ key: 'company', label: 'Company' },
|
||||||
|
{ key: 'city', label: 'City' },
|
||||||
|
{ key: 'customerGroupId', label: 'Group ID' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Customers() {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const data = await listCustomers();
|
||||||
|
setRows(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(form) {
|
||||||
|
try {
|
||||||
|
if (form.id && rows.some((r) => r.id === form.id)) {
|
||||||
|
await updateCustomer(form.id, form);
|
||||||
|
} else {
|
||||||
|
await createCustomer(form);
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
try {
|
||||||
|
await deleteCustomer(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Customers
|
||||||
|
</Typography>
|
||||||
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
<CrudTable
|
||||||
|
columns={columns}
|
||||||
|
rows={rows}
|
||||||
|
idField="id"
|
||||||
|
onSave={handleSave}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
emptyItem={{ customerNumber: '0', firstname: '', lastname: '', company: '', city: '', customerGroupId: '1' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
web/src/pages/Dashboard.jsx
Normal file
61
web/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Box, Card, CardContent, Grid, Typography, Chip } from '@mui/material';
|
||||||
|
import { init } from '../api';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
init().then(setData).catch((e) => setError(e.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const counts = data?.counts || {};
|
||||||
|
const devices = data?.state?.pairedDevices
|
||||||
|
? Object.values(data.state.pairedDevices)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const countCards = [
|
||||||
|
{ label: 'Products', value: counts.product_count },
|
||||||
|
{ label: 'Categories', value: counts.category_count },
|
||||||
|
{ label: 'Customers', value: counts.customer_count },
|
||||||
|
{ label: 'Customer Groups', value: counts.customerGroup_count },
|
||||||
|
{ label: 'Composites', value: counts.compositeProduct_count },
|
||||||
|
{ label: 'Deleted', value: counts.deletedEntity_count },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
{countCards.map((c) => (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={c.label}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="textSecondary" gutterBottom>
|
||||||
|
{c.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h3">{c.value ?? '-'}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Paired Devices
|
||||||
|
</Typography>
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<Typography color="textSecondary">No devices paired yet.</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<Chip key={d.token} label={d.name || d.token.slice(0, 8)} title={d.token} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
web/src/pages/Deleted.jsx
Normal file
112
web/src/pages/Deleted.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { listDeleted, recordDeleted } from '../api';
|
||||||
|
|
||||||
|
const entityTypes = {
|
||||||
|
1: 'Product',
|
||||||
|
2: 'Category',
|
||||||
|
3: 'Customer',
|
||||||
|
6: 'Customer Group',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Deleted() {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [entityId, setEntityId] = useState('');
|
||||||
|
const [entityType, setEntityType] = useState('1');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const data = await listDeleted();
|
||||||
|
setRows(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await recordDeleted({ entityId, entityType });
|
||||||
|
setEntityId('');
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Deleted Entities
|
||||||
|
</Typography>
|
||||||
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
|
||||||
|
<Box component="form" onSubmit={handleAdd} sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||||
|
<TextField
|
||||||
|
label="Entity ID"
|
||||||
|
value={entityId}
|
||||||
|
onChange={(e) => setEntityId(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Entity type"
|
||||||
|
value={entityType}
|
||||||
|
onChange={(e) => setEntityType(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
required
|
||||||
|
helperText="1=Product,2=Category,3=Customer,6=Group"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" type="submit" startIcon={<AddIcon />}>
|
||||||
|
Record deletion
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Entity ID</TableCell>
|
||||||
|
<TableCell>Entity Type</TableCell>
|
||||||
|
<TableCell>Last Changed</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3}>No deleted entities</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={`${row.entityId}-${row.lastChanged}`}>
|
||||||
|
<TableCell>{row.entityId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.entityType} {entityTypes[row.entityType] ? `(${entityTypes[row.entityType]})` : ''}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.lastChanged}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
web/src/pages/Pairing.jsx
Normal file
163
web/src/pages/Pairing.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import {
|
||||||
|
getPairing,
|
||||||
|
createPairingCode,
|
||||||
|
revokePairingCode,
|
||||||
|
revokeDevice,
|
||||||
|
} from '../api';
|
||||||
|
|
||||||
|
export default function Pairing() {
|
||||||
|
const [data, setData] = useState({ authCodes: [], pairedDevices: [] });
|
||||||
|
const [name, setName] = useState('JTL-POS');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const d = await getPairing();
|
||||||
|
setData(d);
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateCode() {
|
||||||
|
try {
|
||||||
|
const res = await createPairingCode(name || 'JTL-POS');
|
||||||
|
setMsg(`New pairing code: ${res.code}`);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeCode(code) {
|
||||||
|
try {
|
||||||
|
await revokePairingCode(code);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeDev(token) {
|
||||||
|
try {
|
||||||
|
await revokeDevice(token);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Pairing
|
||||||
|
</Typography>
|
||||||
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
{msg && <Typography color="primary">{msg}</Typography>}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
label="Device name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={generateCode}>
|
||||||
|
Generate 6-digit code
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Pending pairing codes
|
||||||
|
</Typography>
|
||||||
|
<TableContainer component={Paper} sx={{ mb: 3 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Code</TableCell>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.authCodes.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3}>No pending codes</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{data.authCodes.map((code) => (
|
||||||
|
<TableRow key={code.code}>
|
||||||
|
<TableCell>{code.code}</TableCell>
|
||||||
|
<TableCell>{code.name}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" color="error" onClick={() => revokeCode(code.code)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Paired devices
|
||||||
|
</Typography>
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Name</TableCell>
|
||||||
|
<TableCell>Token</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{data.pairedDevices.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3}>No paired devices</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{data.pairedDevices.map((dev) => (
|
||||||
|
<TableRow key={dev.token}>
|
||||||
|
<TableCell>{dev.name}</TableCell>
|
||||||
|
<TableCell>{dev.token.slice(0, 16)}…</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" color="error" onClick={() => revokeDev(dev.token)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
web/src/pages/Products.jsx
Normal file
319
web/src/pages/Products.jsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import {
|
||||||
|
listProducts,
|
||||||
|
createProduct,
|
||||||
|
updateProduct,
|
||||||
|
deleteProduct,
|
||||||
|
listComposites,
|
||||||
|
setComposite,
|
||||||
|
removeComposite,
|
||||||
|
} from '../api';
|
||||||
|
|
||||||
|
const productColumns = [
|
||||||
|
{ key: '_id', label: 'ID' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'sku', label: 'SKU' },
|
||||||
|
{ key: 'price', label: 'Price' },
|
||||||
|
{ key: 'tax_rate', label: 'Tax %' },
|
||||||
|
{ key: 'categories_id', label: 'Category ID' },
|
||||||
|
{ key: 'isCompositeProduct', label: 'Composite' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Products() {
|
||||||
|
const [products, setProducts] = useState([]);
|
||||||
|
const [composites, setComposites] = useState([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [form, setForm] = useState({});
|
||||||
|
|
||||||
|
const [compositeParent, setCompositeParent] = useState('');
|
||||||
|
const [compositeComponent, setCompositeComponent] = useState('');
|
||||||
|
const [compositeQuantity, setCompositeQuantity] = useState('1.00');
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const [p, c] = await Promise.all([listProducts(), listComposites()]);
|
||||||
|
setProducts(p);
|
||||||
|
setComposites(c);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await updateProduct(form._id, form);
|
||||||
|
} else {
|
||||||
|
await createProduct(form);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
setForm({});
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
try {
|
||||||
|
await deleteProduct(id);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addComposite() {
|
||||||
|
try {
|
||||||
|
await setComposite({
|
||||||
|
productId: compositeParent,
|
||||||
|
productIdComponent: compositeComponent,
|
||||||
|
quantity: compositeQuantity,
|
||||||
|
});
|
||||||
|
setCompositeComponent('');
|
||||||
|
setCompositeQuantity('1.00');
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComposite(productId, componentId) {
|
||||||
|
try {
|
||||||
|
await removeComposite(productId, componentId);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
setEditing('new');
|
||||||
|
setForm({ name: '', sku: '', price: '0.00', tax_rate: '19', categories_id: '1', isCompositeProduct: '0' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(row) {
|
||||||
|
setEditing(row._id);
|
||||||
|
setForm({ ...row });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm({});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(key, value) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const compositeProducts = products.filter((p) => p.isCompositeProduct === '1');
|
||||||
|
const nonCompositeProducts = products.filter((p) => p.isCompositeProduct !== '1');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Products
|
||||||
|
</Typography>
|
||||||
|
{error && <Typography color="error">{error}</Typography>}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={startAdd}>
|
||||||
|
Add product
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TableContainer component={Paper} sx={{ mb: 4 }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{productColumns.map((col) => (
|
||||||
|
<TableCell key={col.key}>{col.label}</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{editing === 'new' && (
|
||||||
|
<EditableRow
|
||||||
|
columns={productColumns}
|
||||||
|
form={form}
|
||||||
|
onChange={updateField}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={cancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{products.map((row) =>
|
||||||
|
editing === row._id ? (
|
||||||
|
<EditableRow
|
||||||
|
key={row._id}
|
||||||
|
columns={productColumns}
|
||||||
|
form={form}
|
||||||
|
onChange={updateField}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={cancel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TableRow key={row._id}>
|
||||||
|
{productColumns.map((col) => (
|
||||||
|
<TableCell key={col.key}>
|
||||||
|
{col.key === 'isCompositeProduct'
|
||||||
|
? row[col.key] === '1'
|
||||||
|
? 'Yes'
|
||||||
|
: 'No'
|
||||||
|
: row[col.key] ?? ''}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button size="small" onClick={() => startEdit(row)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<IconButton size="small" color="error" onClick={() => handleDelete(row._id)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Composite components
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={2} sx={{ mb: 2 }} alignItems="center">
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Composite product</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={compositeParent}
|
||||||
|
label="Composite product"
|
||||||
|
onChange={(e) => setCompositeParent(e.target.value)}
|
||||||
|
>
|
||||||
|
{compositeProducts.map((p) => (
|
||||||
|
<MenuItem key={p._id} value={p._id}>
|
||||||
|
{p.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||||
|
<InputLabel>Component</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={compositeComponent}
|
||||||
|
label="Component"
|
||||||
|
onChange={(e) => setCompositeComponent(e.target.value)}
|
||||||
|
>
|
||||||
|
{nonCompositeProducts.map((p) => (
|
||||||
|
<MenuItem key={p._id} value={p._id}>
|
||||||
|
{p.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
label="Quantity"
|
||||||
|
value={compositeQuantity}
|
||||||
|
onChange={(e) => setCompositeQuantity(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={addComposite} disabled={!compositeParent || !compositeComponent}>
|
||||||
|
Add component
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Composite</TableCell>
|
||||||
|
<TableCell>Component</TableCell>
|
||||||
|
<TableCell>Quantity</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{composites.map((c) => (
|
||||||
|
<TableRow key={`${c.productId}-${c.productIdComponent}`}>
|
||||||
|
<TableCell>{products.find((p) => p._id === c.productId)?.name || c.productId}</TableCell>
|
||||||
|
<TableCell>{products.find((p) => p._id === c.productIdComponent)?.name || c.productIdComponent}</TableCell>
|
||||||
|
<TableCell>{c.quantity}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" color="error" onClick={() => deleteComposite(c.productId, c.productIdComponent)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableRow({ columns, form, onChange, onSave, onCancel }) {
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key}>
|
||||||
|
{col.key === 'isCompositeProduct' ? (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={String(form[col.key]) === '1'}
|
||||||
|
onChange={(e) => onChange(col.key, e.target.checked ? '1' : '0')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Composite"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={form[col.key] ?? ''}
|
||||||
|
onChange={(e) => onChange(col.key, e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
<TableCell align="right">
|
||||||
|
<Button size="small" onClick={onSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
web/vite.config.js
Normal file
18
web/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/admin/api': {
|
||||||
|
target: 'http://localhost:8087',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user