kempo-inventory 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The core of an inventory system for [kempo](https://www.npmjs.com/package/kempo) CMS. It is deliberately small: every item has just a **SKU**, **name**, **quantity** and **description**. Everything else is a *custom field* that a person adds in the admin, or that another extension registers, so the same base can model anything: parts, books, rentals, supplies.
4
4
 
5
- - Stock changes go through one audited action, with a reason, a note and a movement history
5
+ - Quantity changes go through one audited action, with a reason, a note and a movement history
6
6
  - Custom fields of seven types, managed in **Admin > Inventory > Fields**
7
7
  - A server SDK and a set of hooks so other extensions can extend it (reorder levels, pricing, suppliers, categories...), and fields, categories and items can be owned by an extension
8
8
  - Admin page at **Admin > Inventory**, JSON API under `/inventory/api`
@@ -77,15 +77,15 @@ A photo field (type "Photos / files") can **force how photos are saved**. When y
77
77
  - **Force photo shape**: an aspect ratio such as `1:1`, `4:3` or `16:9`. Each photo added to the field opens in Kempo UI's `<k-image-crop>`, locked to that shape, so only that shape can be saved; **Skip** leaves a photo out.
78
78
  - **Max size (px)**: photos are kept within this many pixels on a side (scaled down, never up). With only a max size and no shape, photos are simply scaled down, with no cropper.
79
79
 
80
- Leave both empty and photos are uploaded as they are. The stored file is the cropped one, so the shape holds everywhere it is shown. A category's picture is always cropped to the `category_image_ratio` setting below. Anything the browser cannot edit (GIF, SVG, HEIC, video) is uploaded untouched. The same two properties (`imageRatio`, `imageMax`) are on the field API, and `<inventory-media-field crop-ratio="1 / 1" crop-max="1600">` applies them in your own forms. Needs kempo-ui 0.5 or newer.
80
+ Leave both empty and photos are uploaded as they are. The stored file is the cropped one, so the shape holds everywhere it is shown. A category's picture is always cropped to the `category_image_ratio` setting below. Anything the browser cannot edit (GIF, SVG, HEIC, video) is uploaded untouched. The same two properties (`imageRatio`, `imageMax`) are on the field API, and `<k-inventory-media-field crop-ratio="1 / 1" crop-max="1600">` applies them in your own forms. Needs kempo-ui 0.5 or newer.
81
81
 
82
82
  ## Image shape
83
83
 
84
- Two settings (**Admin > Settings**) set the aspect ratio images are *shown* at (a display crop; a photo field's forced shape above changes what is stored): `category_image_ratio` and `item_image_ratio`, written `width:height` (`4:3` by default, or `1:1`, `16:9`...). A blank or invalid value falls back to `4:3`. Images are **cropped to fit** the shape, never stretched, and the originals are not changed, so changing the setting reshapes every image at once. The admin's category and item pickers use them, and `GET /inventory/api/fields` returns them as `imageRatios` (CSS, e.g. `"4 / 3"`) so a site can do the same; `<inventory-media-field ratio-for="category">` previews at the category shape.
84
+ Two settings (**Admin > Settings**) set the aspect ratio images are *shown* at (a display crop; a photo field's forced shape above changes what is stored): `category_image_ratio` and `item_image_ratio`, written `width:height` (`4:3` by default, or `1:1`, `16:9`...). A blank or invalid value falls back to `4:3`. Images are **cropped to fit** the shape, never stretched, and the originals are not changed, so changing the setting reshapes every image at once. The admin's category and item pickers use them, and `GET /inventory/api/fields` returns them as `imageRatios` (CSS, e.g. `"4 / 3"`) so a site can do the same; `<k-inventory-media-field ratio-for="category">` previews at the category shape.
85
85
 
86
86
  ## Import and export
87
87
 
88
- **Admin > Inventory** has **Export** and **Import** buttons (admin portal only; the public site has none). Both are limited to the **top tier**: the `kempo-inventory:fields:manage` permission, held by the `kempo-inventory:admin` group. A manager, a viewer or a signed-out visitor gets a 403 (or 401) and does not see the buttons, because an export carries the whole inventory out and an import can rewrite and re-stock all of it.
88
+ **Admin > Inventory** has **Export** and **Import** buttons (admin portal only; the public site has none). Both are limited to the **top tier**: the `kempo-inventory:fields:manage` permission, held by the `kempo-inventory:admin` group. A manager, a viewer or a signed-out visitor gets a 403 (or 401) and does not see the buttons, because an export carries the whole inventory out and an import can rewrite and re-quantity all of it.
89
89
 
90
90
  **Export** downloads the whole inventory in one of two forms:
91
91
 
@@ -95,7 +95,7 @@ Two settings (**Admin > Settings**) set the aspect ratio images are *shown* at (
95
95
  **Import** takes either one (or any zip with an `inventory.json` in this format, including one re-zipped with compression). It is **done on the server**, in two steps:
96
96
 
97
97
  1. **Check.** The file is uploaded once and checked. You see how many items are new, how many already exist (matched by **SKU**; categories by name), what would be ignored, and any rows that cannot be imported and why. Nothing in the inventory has changed yet.
98
- 2. **Import.** For the ones that already exist you choose **Ignore them** (leave what is here exactly as it is; the default) or **Update them** with the file's values (a value the file leaves empty never clears anything, and a changed quantity is recorded as a stock adjustment, "import", so the history stays honest).
98
+ 2. **Import.** For the ones that already exist you choose **Ignore them** (leave what is here exactly as it is; the default) or **Update them** with the file's values (a value the file leaves empty never clears anything, and a changed quantity is recorded as a quantity adjustment, "import", so the history stays honest).
99
99
 
100
100
  Things worth knowing: an import never creates or changes **fields**; a value only comes across when a field with the same key exists and applies to the item's category (the rest are ignored and counted). Rows are independent: one that fails (a missing name, a bad number, a required field with no value) does not stop the others. A field linking to another item is exported as that item's SKU and resolved by SKU on import, including to items created in the same import. Photos only come from the zip; each distinct photo is stored once, to the library new uploads normally go to (as the person importing, with its own file-library rules), at its original size.
101
101
 
@@ -122,11 +122,11 @@ Items and categories can be **tagged**: short labels such as `acrylic`, `red`, `
122
122
  - **Inheritance:** an item does not inherit its category's tags. Searching a category's tag finds the category, and the category is how you reach its items.
123
123
  - The admin lists show a Tags column. To reuse the control in your own forms, `ItemFields.js` exports `tagsValue(tags)`, `suggestTags` and `readTags($tags)` for use with `<k-tags .value=${tagsValue(tags)} .getSuggestions=${suggestTags}>`; `readTags` includes text typed but not yet added.
124
124
 
125
- ## Who can read, and who can see stock counts
125
+ ## Who can read, and who can see quantity counts
126
126
 
127
127
  Reading the inventory needs the `items:read` permission. Turn on the `public_read` setting (off by default) and **anyone can read it, signed in or not**, so it can be shared as a catalogue. Public reading covers items, categories and fields (and photos, see below); writing always needs the matching permission.
128
128
 
129
- **Stock counts are only for people who manage stock.** A caller without `stock:adjust` or `items:update`, whether signed out, signed in with only `items:read`, or reading publicly, gets items **without `quantity`** and no stock history (`GET /inventory/api/movements` is refused). Callers who do manage stock get everything as before.
129
+ **Quantity counts are only for people who manage quantity.** A caller without `quantity:adjust` or `items:update`, whether signed out, signed in with only `items:read`, or reading publicly, gets items **without `quantity`** and no stock history (`GET /inventory/api/movements` is refused). Callers who do manage quantity get everything as before.
130
130
 
131
131
  ## Who can see photos
132
132
 
@@ -186,12 +186,12 @@ scanner.stop(); // release the camera
186
186
  This extension ships the data and the API, plus an admin screen for managing it. A site that wants its own screens (a dashboard, product pages, a storefront) builds them itself, and can reuse the same browser modules the admin page uses:
187
187
 
188
188
  ```javascript
189
- import { getItems, getFields, adjustStock } from '/inventory/sdk.js'; // the API client
189
+ import { getItems, getFields, adjustQuantity } from '/inventory/sdk.js'; // the API client
190
190
  import { openItemDialog, openAdjustDialog } from '/inventory/components/ItemDialog.js';
191
191
  import { fieldTemplate, readField, displayValue } from '/inventory/components/ItemFields.js';
192
192
 
193
193
  openItemDialog({ item, fields, onSaved: item => reload() }); // the form in a dialog, for a screen that really wants one
194
- openAdjustDialog({ item, onSaved: item => reload() }); // add or remove stock
194
+ openAdjustDialog({ item, onSaved: item => reload() }); // add or remove quantity
195
195
  ```
196
196
 
197
197
  **Adding and editing an item are pages, not dialogs.** A dialog closes with a stray tap outside it and takes everything entered with it (photos taken with the camera included), so each gets a page of its own that keeps the work and warns before it is left. In the admin they are **Inventory > New Item** (`/admin/extension/kempo-inventory/new`) and the edit action on a row (`/admin/extension/kempo-inventory/edit?id=…`). A site gets the same from `NewItemPage.js`:
@@ -219,7 +219,7 @@ An extension that adds to inventory declares the dependency, registers its field
219
219
  "dependencies": ["kempo-inventory"],
220
220
  "hooks": {
221
221
  "kempo-inventory:item:before_create": "./hooks/before-create.js",
222
- "kempo-inventory:stock:adjusted": "./hooks/stock-adjusted.js"
222
+ "kempo-inventory:quantity:adjusted": "./hooks/quantity-adjusted.js"
223
223
  }
224
224
  }
225
225
  ```
@@ -254,7 +254,7 @@ Fields, categories and items can each belong to an extension. `owner` is `''` fo
254
254
  |---|---|---|
255
255
  | **Field** | delete it, change its key, type (within the safe conversions), requirement or options | rename it, change its description, list/suggest settings and position |
256
256
  | **Category** | rename it (which rewrites its items), merge into or out of it, delete it | change its image, description and tags, and put items in it |
257
- | **Item** | change its SKU, name and category, delete it | edit description, tags and custom field values, and **adjust stock** |
257
+ | **Item** | change its SKU, name and category, delete it | edit description, tags and custom field values, and **adjust quantity** |
258
258
 
259
259
  A category cannot be renamed or merged by anyone but its owner while it holds items or fields another extension owns, because that would rewrite them. Sending an owned item's current SKU, name and category back unchanged is not a change, so the admin form keeps working.
260
260
 
@@ -295,7 +295,7 @@ To protect more than this (say, the *values* of a field you own), subscribe to t
295
295
  ### Server SDK
296
296
 
297
297
  ```javascript
298
- import { getItems, createItem, adjustStock, registerFields } from 'kempo-inventory/sdk';
298
+ import { getItems, createItem, adjustQuantity, registerFields } from 'kempo-inventory/sdk';
299
299
  ```
300
300
 
301
301
  Every function resolves to `[error, result]`, where `error` is `{ code, msg }` or `null`.
@@ -308,10 +308,10 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
308
308
  | `createItem({ sku, name, description, quantity, fields, userId }, { owner })` | Create. `owner` makes it your extension's |
309
309
  | `updateItem(id, { sku, name, description, fields, userId }, { owner })` | Update; `fields` is merged, and an empty value clears a field. SKU, name and category of an owned item need its `owner` |
310
310
  | `deleteItem(id, { userId, owner })` | Delete an item and its history (owned items need their `owner`) |
311
- | `adjustStock(id, { delta, reason, note, userId })` | The way quantity changes, for anyone. Atomic; refuses to go below zero |
312
- | `adjustStockMany(changes, { reason, note, ref, userId, owner })` | Several stock changes together: every one happens or none does. `changes` is `[{ id, delta }]`. Use it when one event touches several items, such as the materials of a recipe |
311
+ | `adjustQuantity(id, { delta, reason, note, userId })` | The way quantity changes, for anyone. Atomic; refuses to go below zero |
312
+ | `adjustQuantityMany(changes, { reason, note, ref, userId, owner })` | Several quantity changes together: every one happens or none does. `changes` is `[{ id, delta }]`. Use it when one event touches several items, such as the materials of a recipe |
313
313
  | `unregisterItems(owner, { release })` | Delete every item you own, or with `release` hand them back to people |
314
- | `getMovements({ itemId, limit, offset })` | Stock history |
314
+ | `getMovements({ itemId, limit, offset })` | Quantity history |
315
315
  | `getFields({ category, owner })`, `getField(key, category)` | Read field definitions. `getFields()` is every field (each with its `category` key and `categoryName`); with `category` it is what an item in that category shows |
316
316
  | `createField(definition)`, `updateField(key, changes)`, `deleteField(key)` | Manage user-defined fields |
317
317
  | `getCategories({ includeEmpty, owner })`, `saveCategory(name, { newName, image, description, tags }, { owner })`, `deleteCategory(name, { owner })` | The categories, and managing them |
@@ -325,19 +325,19 @@ An item looks like `{ id, sku, name, description, category, tags, quantity, fiel
325
325
 
326
326
  ### Several items at once
327
327
 
328
- `adjustStock` changes one item in its own transaction, so a sale that uses resin, paint and filament could take the resin and then find the paint short. `adjustStockMany` applies the whole set together:
328
+ `adjustQuantity` changes one item in its own transaction, so a sale that uses resin, paint and filament could take the resin and then find the paint short. `adjustQuantityMany` applies the whole set together:
329
329
 
330
330
  ```javascript
331
- import { adjustStockMany } from 'kempo-inventory/sdk';
331
+ import { adjustQuantityMany } from 'kempo-inventory/sdk';
332
332
 
333
- const [error, { items }] = await adjustStockMany(
333
+ const [error, { items }] = await adjustQuantityMany(
334
334
  [{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
335
335
  { reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
336
336
  );
337
- // error.code 409 and error.msg "Insufficient stock of Paint (ml)": nothing changed
337
+ // error.code 409 and error.msg "Insufficient quantity of Paint (ml)": nothing changed
338
338
  ```
339
339
 
340
- Changes to the same item are added together and one that nets to nothing is left out. Every item goes through the same `before_adjust` guard as `adjustStock`, and each fires `stock:adjusted` once everything is committed. `ref` is written on every stock movement (as `[order-1042]` in its note) so the change can be found, and reversed, later.
340
+ Changes to the same item are added together and one that nets to nothing is left out. Every item goes through the same `before_adjust` guard as `adjustQuantity`, and each fires `quantity:adjusted` once everything is committed. `ref` is written on every quantity movement (as `[order-1042]` in its note) so the change can be found, and reversed, later.
341
341
 
342
342
  ### Adding to the item page
343
343
 
@@ -354,7 +354,7 @@ Declare these in your `kempo-config.json` `hooks`. Every payload also carries `a
354
354
  | `kempo-inventory:item:before_create` | `{ draft: { sku, name, description, quantity, fields }, userId }` | edit `draft` |
355
355
  | `kempo-inventory:item:before_update` | `{ item, changes: { sku?, name?, description?, fields? }, userId }` | edit `changes` |
356
356
  | `kempo-inventory:item:before_delete` | `{ item, userId }` | refuse only |
357
- | `kempo-inventory:stock:before_adjust` | `{ item, draft: { delta, reason, note }, userId }` | edit `draft` |
357
+ | `kempo-inventory:quantity:before_adjust` | `{ item, draft: { delta, reason, note }, userId }` | edit `draft` |
358
358
  | `kempo-inventory:category:before_update` | `{ name, newName, changes: { description?, tags?, image? }, userId }` | refuse only |
359
359
  | `kempo-inventory:category:before_delete` | `{ category, name, userId }` (`category` is `null` for one with no record) | refuse only |
360
360
  | `kempo-inventory:field:before_create` | `{ draft }` | refuse only |
@@ -370,7 +370,7 @@ Ownership is checked first, so these only run for changes that would otherwise g
370
370
  | `kempo-inventory:item:created` | `{ item, userId }` |
371
371
  | `kempo-inventory:item:updated` | `{ item, previous, userId }` |
372
372
  | `kempo-inventory:item:deleted` | `{ item, userId }` |
373
- | `kempo-inventory:stock:adjusted` | `{ item, previousQuantity, delta, reason, note, movement, userId }` |
373
+ | `kempo-inventory:quantity:adjusted` | `{ item, previousQuantity, delta, reason, note, movement, userId }` |
374
374
  | `kempo-inventory:category:updated` | `{ category, previous, moved, userId }` |
375
375
  | `kempo-inventory:category:deleted` | `{ category, userId }` |
376
376
  | `kempo-inventory:field:created` | `{ field }` |
@@ -380,7 +380,7 @@ Ownership is checked first, so these only run for changes that would otherwise g
380
380
  For example, a low-stock check that reacts to every adjustment:
381
381
 
382
382
  ```javascript
383
- // hooks/stock-adjusted.js
383
+ // hooks/quantity-adjusted.js
384
384
  export default async ({ item }) => {
385
385
  const level = item.fields.reorderLevel;
386
386
  if(level !== undefined && item.quantity <= level){
@@ -399,7 +399,7 @@ Hooks run one at a time, in registration order, and are awaited, so a slow handl
399
399
  | `kempo-inventory:items:create` | Add items |
400
400
  | `kempo-inventory:items:update` | Edit items |
401
401
  | `kempo-inventory:items:delete` | Delete items (and their history) |
402
- | `kempo-inventory:stock:adjust` | Add or remove stock |
402
+ | `kempo-inventory:quantity:adjust` | Add or remove quantity |
403
403
  | `kempo-inventory:fields:manage` | Create, edit and delete custom fields |
404
404
 
405
405
  Groups, each including everything in the one before it:
@@ -407,7 +407,7 @@ Groups, each including everything in the one before it:
407
407
  | Group | Can |
408
408
  |---|---|
409
409
  | `kempo-inventory:viewer` | View items, fields and movements |
410
- | `kempo-inventory:manager` | Also create, edit and delete items and adjust stock |
410
+ | `kempo-inventory:manager` | Also create, edit and delete items and adjust quantity |
411
411
  | `kempo-inventory:admin` | Also set up the custom fields (`fields:manage`) |
412
412
 
413
413
  Every group also includes `system:admin:access`, because kempo serves extension pages inside the admin panel and redirects anyone without it. That only opens the panel itself: its other sections stay locked behind their own permissions.
@@ -449,4 +449,4 @@ The documentation site is written in `docs-src/` and built into `docs/` (served
449
449
 
450
450
  ## Dashboard widget
451
451
 
452
- Adds a **Inventory** widget to the kempo admin dashboard showing item, unit and out-of-stock counts and the latest stock movements, with a **New item** button. It is declared in `widgets` in `kempo-config.json` and registered when the extension is installed or updated; people only see it if they hold its permission, and can hide, reorder or resize it from the dashboard's Customize dialog. Needs a kempo with the dashboard (see kempo's `docs/dashboard.md`); on an older kempo the entry is ignored.
452
+ Adds a **Inventory** widget to the kempo admin dashboard showing item, unit and out-of-stock counts and the latest quantity movements, with a **New item** button. It is declared in `widgets` in `kempo-config.json` and registered when the extension is installed or updated; people only see it if they hold its permission, and can hide, reorder or resize it from the dashboard's Customize dialog. Needs a kempo with the dashboard (see kempo's `docs/dashboard.md`); on an older kempo the entry is ignored.
@@ -63,7 +63,7 @@ locked: true
63
63
  const [, permData] = await getAllCurrentUserPermissions();
64
64
  const canEdit = new Set(permData?.permissions ?? []).has('kempo-inventory:items:update');
65
65
 
66
- const manageFields = document.createElement('inventory-row-action');
66
+ const manageFields = document.createElement('k-inventory-row-action');
67
67
  manageFields.setAttribute('slot', 'before');
68
68
  manageFields.setAttribute('action', 'fields');
69
69
  manageFields.setAttribute('icon', 'settings');
@@ -71,7 +71,7 @@ locked: true
71
71
  $table.appendChild(manageFields);
72
72
 
73
73
  if(canEdit){
74
- const edit = document.createElement('inventory-row-action');
74
+ const edit = document.createElement('k-inventory-row-action');
75
75
  edit.setAttribute('slot', 'before');
76
76
  edit.setAttribute('action', 'edit');
77
77
  edit.setAttribute('icon', 'edit');
@@ -142,7 +142,7 @@ locked: true
142
142
  </div>
143
143
  <div class="mb">
144
144
  <label class="d-b mb-sm">Image</label>
145
- <inventory-media-field id="cat-image" ratio-for="category" .max=${1} .value=${category?.image ? [category.image.id] : []} .assets=${category?.image ? { [category.image.id]: category.image } : {}}></inventory-media-field>
145
+ <k-inventory-media-field id="cat-image" ratio-for="category" .max=${1} .value=${category?.image ? [category.image.id] : []} .assets=${category?.image ? { [category.image.id]: category.image } : {}}></k-inventory-media-field>
146
146
  </div>
147
147
  </div>
148
148
  `, {
@@ -63,7 +63,7 @@ locked: true
63
63
  for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
64
64
  $table.appendChild($control);
65
65
  };
66
- addControl('inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
66
+ addControl('k-inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
67
67
  $table.setAttribute('request-delete', '');
68
68
  addControl('kc-tc-delete-record', 'after');
69
69
 
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 960 960" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M228.283,855.577l-156.434,-89.949c-7.077,-4.097 -12.571,-9.498 -16.481,-16.202c-3.911,-6.704 -5.866,-14.154 -5.866,-22.348l0,-177.664c0,-8.194 1.955,-15.643 5.866,-22.348c3.911,-6.704 9.405,-12.105 16.481,-16.202l156.434,-89.949c7.077,-4.097 14.526,-6.146 22.348,-6.146c7.822,0 15.271,2.049 22.348,6.146l156.434,89.949c7.077,4.097 12.571,9.498 16.481,16.202c3.911,6.704 5.866,14.154 5.866,22.348l0,177.664c0,8.194 -1.955,15.643 -5.866,22.348c-3.911,6.704 -9.405,12.105 -16.481,16.202l-156.434,89.949c-7.077,4.097 -14.526,6.146 -22.348,6.146c-7.822,0 -15.271,-2.049 -22.348,-6.146Zm111.738,-294.99l43.019,-24.582l-132.41,-76.541l-43.578,25.141l132.969,75.982Zm-111.738,243.59l0,-153.082l-134.086,-77.658l0,153.082l134.086,77.658Zm44.695,0l134.086,-77.658l0,-153.082l-134.086,77.658l0,153.082Zm-22.348,-191.631l43.578,-25.141l-132.41,-76.541l-43.578,25.141l132.41,76.541Zm391.07,243.031l-156.434,-89.949c-7.077,-4.097 -12.571,-9.498 -16.481,-16.202c-3.911,-6.704 -5.866,-14.154 -5.866,-22.348l0,-177.664c0,-8.194 1.955,-15.643 5.866,-22.348c3.911,-6.704 9.405,-12.105 16.481,-16.202l156.434,-89.949c7.077,-4.097 14.526,-6.146 22.348,-6.146c7.822,0 15.271,2.049 22.348,6.146l156.434,89.949c7.077,4.097 12.571,9.498 16.481,16.202c3.911,6.704 5.866,14.154 5.866,22.348l0,177.664c0,8.194 -1.955,15.643 -5.866,22.348c-3.911,6.704 -9.405,12.105 -16.481,16.202l-156.434,89.949c-7.077,4.097 -14.526,6.146 -22.348,6.146c-7.822,0 -15.271,-2.049 -22.348,-6.146Zm44.695,-51.4l134.086,-77.658l0,-153.082l-134.086,77.658l0,153.082Zm-44.695,0l0,-153.082l-134.086,-77.658l0,153.082l134.086,77.658Zm22.348,-191.631l43.578,-25.141l-132.41,-76.541l-43.578,25.141l132.41,76.541Zm89.391,-51.958l43.019,-24.582l-132.41,-76.541l-43.578,25.141l132.969,75.982Zm-324.027,-55.763l-156.434,-89.949c-7.077,-4.097 -12.571,-9.498 -16.481,-16.202c-3.911,-6.704 -5.866,-14.154 -5.866,-22.348l0,-177.664c0,-8.194 1.955,-15.643 5.866,-22.348c3.911,-6.704 9.405,-12.105 16.481,-16.202l156.434,-89.949c7.077,-4.097 14.526,-6.146 22.348,-6.146c7.822,0 15.271,2.049 22.348,6.146l156.434,89.949c7.077,4.097 12.571,9.498 16.481,16.202c3.911,6.704 5.866,14.154 5.866,22.348l0,177.664c0,8.194 -1.955,15.643 -5.866,22.348c-3.911,6.704 -9.405,12.105 -16.481,16.202l-156.434,89.949c-7.077,4.097 -14.526,6.146 -22.348,6.146c-7.822,0 -15.271,-2.049 -22.348,-6.146Zm111.738,-294.99l43.019,-24.582l-132.866,-77.453l-43.122,26.054l132.969,75.982Zm-111.738,243.59l0,-153.082l-134.086,-77.658l0,153.082l134.086,77.658Zm22.348,-191.631l43.578,-25.141l-132.41,-76.541l-43.578,25.141l132.41,76.541Zm22.348,191.631l134.086,-77.658l0,-153.082l-134.086,77.658l0,153.082Z"/></svg>
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 960 960" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:affinity="https://www.affinity.studio/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M440,777l0,-274l-240,-139l0,274l240,139Zm80,0l240,-139l0,-274l-240,139l0,274Zm-80,92l-280,-161c-12.667,-7.333 -22.5,-17 -29.5,-29c-7,-12 -10.5,-25.333 -10.5,-40l0,-318c0,-14.667 3.5,-28 10.5,-40c7,-12 16.833,-21.667 29.5,-29l280,-161c12.667,-7.333 26,-11 40,-11c14,0 27.333,3.667 40,11l280,161c12.667,7.333 22.5,17 29.5,29c7,12 10.5,25.333 10.5,40l0,318c0,14.667 -3.5,28 -10.5,40c-7,12 -16.833,21.667 -29.5,29l-280,161c-12.667,7.333 -26,11 -40,11c-14,0 -27.333,-3.667 -40,-11Zm200,-528l77,-44l-237,-137l-78,45l238,136Zm-160,93l78,-45l-237,-137l-78,45l237,137Z" style="fill-rule:nonzero;"/></svg>
@@ -40,14 +40,14 @@ locked: true
40
40
  <kc-tc-field-sort-hide></kc-tc-field-sort-hide>
41
41
  <div class="flex"></div>
42
42
  <k-permission has="kempo-inventory:fields:manage">
43
- <button id="importBtn" class="btn m0"><k-icon name="arrow" direction="up"></k-icon> Import</button>
43
+ <button id="importBtn" class="btn m0"><k-icon name="download" rotation="180"></k-icon> Import</button>
44
44
  </k-permission>
45
45
  <k-permission has="kempo-inventory:fields:manage">
46
46
  <button id="exportBtn" class="btn m0"><k-icon name="download"></k-icon> Export</button>
47
47
  </k-permission>
48
48
  <a href="/admin/extension/kempo-inventory/categories" class="btn"><k-icon name="folder"></k-icon> Categories</a>
49
49
  <k-permission has="kempo-inventory:fields:manage">
50
- <a href="/admin/extension/kempo-inventory/fields" class="btn"><k-icon name="settings"></k-icon> Fields</a>
50
+ <a href="/admin/extension/kempo-inventory/fields" class="btn"><k-icon name="fields"></k-icon> Fields</a>
51
51
  </k-permission>
52
52
  <k-permission has="kempo-inventory:items:create">
53
53
  <button id="newItemBtn" class="primary m0"><k-icon name="add"></k-icon> New Item</button>
@@ -91,8 +91,8 @@ locked: true
91
91
  for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
92
92
  $table.appendChild($control);
93
93
  };
94
- if(can('items:update')) addControl('inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
95
- if(can('stock:adjust')) addControl('inventory-row-action', 'before', { action: 'adjust', icon: 'repeat', title: 'Adjust stock' });
94
+ if(can('items:update')) addControl('k-inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
95
+ if(can('quantity:adjust')) addControl('k-inventory-row-action', 'before', { action: 'adjust', icon: 'repeat', title: 'Adjust quantity' });
96
96
  if(can('items:delete')){
97
97
  $table.setAttribute('request-delete', '');
98
98
  addControl('kc-tc-delete-record', 'after');
@@ -170,7 +170,7 @@ locked: true
170
170
  Toast.error(`"${record.name}" is managed by the ${record.owner} extension and cannot be deleted here`);
171
171
  return;
172
172
  }
173
- Dialog.confirm(`Delete "${record.name}" and its stock history? This action cannot be undone.`, async confirmed => {
173
+ Dialog.confirm(`Delete "${record.name}" and its quantity history? This action cannot be undone.`, async confirmed => {
174
174
  if(!confirmed){ reject(); return; }
175
175
  const [error] = await deleteItem(record.id);
176
176
  if(error){
@@ -1,3 +1,6 @@
1
- <content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="30">
2
- <k-aside-item icon="archive" group="Commerce" href="/admin/extension/kempo-inventory/" no-expand>Inventory</k-aside-item>
1
+ <content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="87">
2
+ <k-aside-item icon-src="/admin/extension/kempo-inventory/icons/inventory.svg" group="Products" href="/admin/extension/kempo-inventory/" no-expand>Inventory</k-aside-item>
3
+ </content>
4
+ <content id="kempo-inventory-categories-nav" name="Inventory Categories" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="86">
5
+ <k-aside-item icon-src="/admin/extension/kempo-inventory/icons/inventory-categories.svg" group="Products" href="/admin/extension/kempo-inventory/categories/" no-expand>Inventory Categories</k-aside-item>
3
6
  </content>
package/kempo-config.json CHANGED
@@ -5,12 +5,12 @@
5
5
  "schema": "./server/db/schema.js",
6
6
  "widgets": [
7
7
  {
8
- "key": "stock",
8
+ "key": "quantity",
9
9
  "title": "Inventory",
10
- "description": "Item and unit counts, what is out of stock and the latest stock movements.",
11
- "icon": "archive",
12
- "element": "k-inv-widget",
13
- "module": "/inventory/components/StockWidget.js",
10
+ "description": "Item and unit counts, what is out of stock and the latest quantity movements.",
11
+ "iconSrc": "/admin/extension/kempo-inventory/icons/inventory.svg",
12
+ "element": "k-inventory-widget",
13
+ "module": "/inventory/components/QuantityWidget.js",
14
14
  "defaultWidth": 2,
15
15
  "defaultHeight": 2,
16
16
  "minWidth": 1,
@@ -22,7 +22,7 @@
22
22
  "permissions": [
23
23
  {
24
24
  "name": "kempo-inventory:items:read",
25
- "description": "View inventory items, fields and stock movements"
25
+ "description": "View inventory items, fields and quantity movements"
26
26
  },
27
27
  {
28
28
  "name": "kempo-inventory:items:create",
@@ -37,8 +37,8 @@
37
37
  "description": "Delete inventory items"
38
38
  },
39
39
  {
40
- "name": "kempo-inventory:stock:adjust",
41
- "description": "Add or remove stock on inventory items"
40
+ "name": "kempo-inventory:quantity:adjust",
41
+ "description": "Add or remove quantity on inventory items"
42
42
  },
43
43
  {
44
44
  "name": "kempo-inventory:fields:manage",
@@ -56,26 +56,26 @@
56
56
  },
57
57
  {
58
58
  "name": "kempo-inventory:manager",
59
- "description": "Can view and manage items and stock levels",
59
+ "description": "Can view and manage items and quantity levels",
60
60
  "permissions": [
61
61
  "system:admin:access",
62
62
  "kempo-inventory:items:read",
63
63
  "kempo-inventory:items:create",
64
64
  "kempo-inventory:items:update",
65
65
  "kempo-inventory:items:delete",
66
- "kempo-inventory:stock:adjust"
66
+ "kempo-inventory:quantity:adjust"
67
67
  ]
68
68
  },
69
69
  {
70
70
  "name": "kempo-inventory:admin",
71
- "description": "Can manage items and stock levels, and set up the custom fields items have",
71
+ "description": "Can manage items and quantity levels, and set up the custom fields items have",
72
72
  "permissions": [
73
73
  "system:admin:access",
74
74
  "kempo-inventory:items:read",
75
75
  "kempo-inventory:items:create",
76
76
  "kempo-inventory:items:update",
77
77
  "kempo-inventory:items:delete",
78
- "kempo-inventory:stock:adjust",
78
+ "kempo-inventory:quantity:adjust",
79
79
  "kempo-inventory:fields:manage"
80
80
  ]
81
81
  }
@@ -91,7 +91,7 @@
91
91
  "name": "public_read",
92
92
  "value": false,
93
93
  "type": "boolean",
94
- "description": "Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see stock counts. Off by default: reading otherwise needs the items:read permission."
94
+ "description": "Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see quantity counts. Off by default: reading otherwise needs the items:read permission."
95
95
  },
96
96
  {
97
97
  "name": "public_photos",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-inventory",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "A basic inventory management system for kempo cms",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
@@ -15,8 +15,8 @@
15
15
  "type": "module",
16
16
  "main": "sdk.js",
17
17
  "scripts": {
18
- "test": "node scripts/run-tests.js unit node --test",
19
- "test:integration": "node scripts/run-tests.js integration node --test",
18
+ "test": "node scripts/run-tests.js unit node --test --test-concurrency=1",
19
+ "test:integration": "node scripts/run-tests.js integration node --test --test-concurrency=1",
20
20
  "link:local": "npm install ../kempo ../kempo-server ../kempo-ui --no-save --no-legacy-peer-deps --force",
21
21
  "docs:build": "node scripts/build-docs.js",
22
22
  "docs:dev": "npx kempo-server --root docs --port 4050"
@@ -39,8 +39,8 @@
39
39
  "drizzle-orm": "^0.45.2"
40
40
  },
41
41
  "peerDependencies": {
42
- "kempo": ">=4.5.8",
43
- "kempo-ui": ">=0.5.0",
42
+ "kempo": ">=4.7.0",
43
+ "kempo-ui": ">=0.6.0",
44
44
  "kempo-media": ">=0.3.0",
45
45
  "kempo-files": ">=0.1.3",
46
46
  "kempo-thumbs": ">=0.1.3"
@@ -7,8 +7,8 @@ export default async (request, response) => {
7
7
 
8
8
  const [error, item] = await getItem(request.params.id);
9
9
  if(error) return response.status(error.code).json({ error: error.msg });
10
- /* The stock history is all counts, so it is only for people who manage stock. */
11
- const [, movements] = access.canSeeStock ? await getMovements({ itemId: item.id, limit: 100 }) : [null, []];
10
+ /* The quantity history is all counts, so it is only for people who manage quantity. */
11
+ const [, movements] = access.canSeeQuantity ? await getMovements({ itemId: item.id, limit: 100 }) : [null, []];
12
12
  const [, linked] = await getLinked(item);
13
13
  const [, media] = await getMedia(item);
14
14
  response.json({ item: forCaller(item, access), movements: movements || [], linked: linked || {}, media: media || {} });
@@ -1,12 +1,12 @@
1
1
  import authorize from '../../../../../server/utils/authorize.js';
2
- import { adjustStock } from '../../../../../server/utils/items.js';
2
+ import { adjustQuantity } from '../../../../../server/utils/items.js';
3
3
 
4
4
  export default async (request, response) => {
5
- const [authError, auth] = await authorize(request, 'stock:adjust');
5
+ const [authError, auth] = await authorize(request, 'quantity:adjust');
6
6
  if(authError) return response.status(authError.code).json({ error: authError.msg });
7
7
 
8
8
  const { delta, reason, note } = request.body || {};
9
- const [error, item] = await adjustStock(request.params.id, { delta, reason, note, userId: auth.userId });
9
+ const [error, item] = await adjustQuantity(request.params.id, { delta, reason, note, userId: auth.userId });
10
10
  if(error) return response.status(error.code).json({ error: error.msg });
11
11
  response.json({ item });
12
12
  };
@@ -4,7 +4,7 @@ import { getMovements } from '../../../server/utils/items.js';
4
4
  export default async (request, response) => {
5
5
  const [authError, access] = await authorizeRead(request);
6
6
  if(authError) return response.status(authError.code).json({ error: authError.msg });
7
- if(!access.canSeeStock) return response.status(403).json({ error: 'Insufficient permissions' });
7
+ if(!access.canSeeQuantity) return response.status(403).json({ error: 'Insufficient permissions' });
8
8
 
9
9
  const { itemId, limit, offset } = request.query;
10
10
  const [error, movements] = await getMovements({
@@ -118,7 +118,7 @@ export const openImportDialog = ({ onDone = () => {} } = {}) => {
118
118
  </label>
119
119
  <label class="d-f mb" style="gap: var(--spacer_h); align-items: flex-start; cursor: pointer;">
120
120
  <input type="radio" name="onMatch" value="update" ?checked=${state.onMatch === 'update'} @change=${() => { state.onMatch = 'update'; draw(); }}>
121
- <span><strong>Update them</strong> with the values in the file<br><small class="tc-muted">A value the file leaves empty never clears anything. A changed quantity is recorded as a stock adjustment.</small></span>
121
+ <span><strong>Update them</strong> with the values in the file<br><small class="tc-muted">A value the file leaves empty never clears anything. A changed quantity is recorded as a quantity adjustment.</small></span>
122
122
  </label>` : ''}
123
123
  ${problemList(preview.results)}` : ''}
124
124
  ${state.phase === 'importing' ? html`<p><k-spinner size="sm"></k-spinner> Importing. With photos this can take a while; keep this window open.</p>` : ''}
@@ -2,7 +2,7 @@ import { html } from '/kempo-ui/lit-all.min.js';
2
2
  import Toast from '/kempo-ui/components/Toast.js';
3
3
  import Dialog from '/kempo-ui/components/Dialog.js';
4
4
  import '/kempo-ui/components/Table.js';
5
- import { getItem, adjustStock } from '/inventory/sdk.js';
5
+ import { getItem, adjustQuantity } from '/inventory/sdk.js';
6
6
  import { createItemForm } from '/inventory/components/ItemForm.js';
7
7
 
8
8
  /*
@@ -17,7 +17,7 @@ import { createItemForm } from '/inventory/components/ItemForm.js';
17
17
  openItemDialog({ fields, defaults: { fields: { brand: 'Vallejo' } }, onSaved: ... }); // custom fields filled in
18
18
  openItemDialog({ item, fields, onSaved: item => reload() }); // edit an item
19
19
  openItemDialog({ fields, askQuantity: false, defaults: { quantity: 1 }, onSaved }); // no quantity box: the item just exists
20
- openAdjustDialog({ item, onSaved: item => reload() }); // add or remove stock
20
+ openAdjustDialog({ item, onSaved: item => reload() }); // add or remove quantity
21
21
 
22
22
  `fields` is the full field list from getFields(). The form shows the default fields plus those of the
23
23
  item's category, and swaps them as the Category box changes. `onSaved` runs after the change is stored.
@@ -71,7 +71,7 @@ export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
71
71
  <div class="p" style=${SCROLL}>
72
72
  <p class="mb">Current quantity: <strong>${data.item.quantity}</strong></p>
73
73
  <div class="mb">
74
- <label class="d-b mb-sm" for="dlg-delta">Change (negative removes stock)</label>
74
+ <label class="d-b mb-sm" for="dlg-delta">Change (negative removes quantity)</label>
75
75
  <input type="number" id="dlg-delta" class="full" step="1" required>
76
76
  </div>
77
77
  <div class="mb">
@@ -92,7 +92,7 @@ export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
92
92
  <k-table id="historyTable" placeholder="No movements yet."></k-table>
93
93
  </div>
94
94
  `, {
95
- title: `Adjust stock: ${data.item.name}`,
95
+ title: `Adjust quantity: ${data.item.name}`,
96
96
  confirmText: 'Apply',
97
97
  cancelText: 'Close',
98
98
  confirmAction: async event => {
@@ -102,17 +102,17 @@ export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
102
102
  Toast.error('Enter a non-zero whole number');
103
103
  return;
104
104
  }
105
- const [adjustError, adjusted] = await adjustStock(item.id, {
105
+ const [adjustError, adjusted] = await adjustQuantity(item.id, {
106
106
  delta,
107
107
  reason: $dialog.querySelector('#dlg-reason').value,
108
108
  note: $dialog.querySelector('#dlg-note').value,
109
109
  });
110
110
  if(adjustError){
111
- Toast.error(adjustError.msg || 'Failed to adjust stock');
111
+ Toast.error(adjustError.msg || 'Failed to adjust quantity');
112
112
  return;
113
113
  }
114
114
  $dialog.close();
115
- Toast.success('Stock updated');
115
+ Toast.success('Quantity updated');
116
116
  onSaved(adjusted.item);
117
117
  },
118
118
  });
@@ -201,7 +201,7 @@ export class InventoryMediaField extends LitElement {
201
201
  }
202
202
  }
203
203
 
204
- if(!customElements.get('inventory-media-field')) customElements.define('inventory-media-field', InventoryMediaField);
204
+ if(!customElements.get('k-inventory-media-field')) customElements.define('k-inventory-media-field', InventoryMediaField);
205
205
 
206
206
  /*
207
207
  A colour field's control. Colour is optional, so with nothing chosen it offers a button, because a
@@ -312,7 +312,7 @@ class InventoryColorField extends LitElement {
312
312
  }
313
313
  }
314
314
 
315
- if(!customElements.get('inventory-color-field')) customElements.define('inventory-color-field', InventoryColorField);
315
+ if(!customElements.get('k-inventory-color-field')) customElements.define('k-inventory-color-field', InventoryColorField);
316
316
 
317
317
  const searchItems = async (event, ownId) => {
318
318
  const $combobox = event.currentTarget;
@@ -361,7 +361,7 @@ export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = n
361
361
  control = html`<input type="date" id=${id} class="full" .value=${value ?? ''}>`;
362
362
  break;
363
363
  case 'color':
364
- control = html`<inventory-color-field id=${id} .value=${value ?? ''}></inventory-color-field>`;
364
+ control = html`<k-inventory-color-field id=${id} .value=${value ?? ''}></k-inventory-color-field>`;
365
365
  break;
366
366
  case 'rating':
367
367
  control = html`<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
@@ -385,7 +385,7 @@ export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = n
385
385
  @select=${e => { e.currentTarget.dataset.itemId = e.detail.value; e.currentTarget.dataset.label = e.detail.label; }}></k-combobox>`;
386
386
  break;
387
387
  case 'media':
388
- control = html`<inventory-media-field id=${id} .value=${value ?? []} .assets=${media} .cropRatio=${field.imageRatio ?? ''} .cropMax=${field.imageMax ?? 0}></inventory-media-field>`;
388
+ control = html`<k-inventory-media-field id=${id} .value=${value ?? []} .assets=${media} .cropRatio=${field.imageRatio ?? ''} .cropMax=${field.imageMax ?? 0}></k-inventory-media-field>`;
389
389
  break;
390
390
  default:
391
391
  control = field.type === 'text' && field.suggest
@@ -93,7 +93,7 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
93
93
  };
94
94
 
95
95
  render(html`
96
- ${locked ? html`<p class="mb tc-muted">Managed by the ${item.owner} extension. Its SKU, name and category can only be changed there; everything else, including stock, you can edit here.</p>` : ''}
96
+ ${locked ? html`<p class="mb tc-muted">Managed by the ${item.owner} extension. Its SKU, name and category can only be changed there; everything else, including quantity, you can edit here.</p>` : ''}
97
97
  <div class="d-f mb" style="gap: var(--spacer);">
98
98
  <div class="flex">
99
99
  <label class="d-b mb-sm" for="dlg-sku">SKU *</label>
@@ -4,10 +4,10 @@ import { getItems, getMovements } from '/inventory/sdk.js';
4
4
  const stat = (value, label, classes = '') => html`<div class="flex"><div class="h2 m0 ${classes}">${value}</div><div class="small tc-muted">${label}</div></div>`;
5
5
 
6
6
  /*
7
- Stock at a glance. Unit and out-of-stock figures are shown only when the page fetched holds every
7
+ Quantity at a glance. Unit and out-of-stock figures are shown only when the page fetched holds every
8
8
  item, so they are never an under-count.
9
9
  */
10
- export default class StockWidget extends DashboardWidget {
10
+ export default class QuantityWidget extends DashboardWidget {
11
11
  static properties = {
12
12
  ...DashboardWidget.properties,
13
13
  total: { state: true },
@@ -68,4 +68,4 @@ export default class StockWidget extends DashboardWidget {
68
68
  }
69
69
  }
70
70
 
71
- customElements.define('k-inv-widget', StockWidget);
71
+ customElements.define('k-inventory-widget', QuantityWidget);
@@ -7,7 +7,7 @@ import '/kempo-ui/components/Icon.js';
7
7
  The page owns the dialog and the request; this only reports the choice, as an `itemAction`
8
8
  event on the table: { action, record }.
9
9
 
10
- <inventory-row-action action="edit" icon="edit" title="Edit"></inventory-row-action>
10
+ <k-inventory-row-action action="edit" icon="edit" title="Edit"></k-inventory-row-action>
11
11
  */
12
12
  export default class RowAction extends AdminTableControl {
13
13
  static properties = {
@@ -31,4 +31,4 @@ export default class RowAction extends AdminTableControl {
31
31
  render(){ return html`<slot><k-icon name="${this.icon}"></k-icon></slot>`; }
32
32
  }
33
33
 
34
- customElements.define('inventory-row-action', RowAction);
34
+ customElements.define('k-inventory-row-action', RowAction);
package/public/sdk.js CHANGED
@@ -32,7 +32,7 @@ export const getItem = id => req('GET', `${BASE}/items/${enc(id)}`);
32
32
  export const createItem = data => req('POST', `${BASE}/items`, data);
33
33
  export const updateItem = (id, data) => req('PATCH', `${BASE}/items/${enc(id)}`, data);
34
34
  export const deleteItem = id => req('DELETE', `${BASE}/items/${enc(id)}`);
35
- export const adjustStock = (id, data) => req('POST', `${BASE}/items/${enc(id)}/adjust`, data);
35
+ export const adjustQuantity = (id, data) => req('POST', `${BASE}/items/${enc(id)}/adjust`, data);
36
36
  export const getMovements = (params = {}) => req('GET', `${BASE}/movements${buildQuery(params)}`);
37
37
 
38
38
  /*
package/sdk.js CHANGED
@@ -13,7 +13,7 @@
13
13
  };
14
14
 
15
15
  Fields, categories and items can all be owned. Pass your extension name as `owner` and only you can
16
- delete them or change the parts that define them; people keep editing everything else (stock
16
+ delete them or change the parts that define them; people keep editing everything else (quantity
17
17
  included) in the admin. Owner is never read from an HTTP request, only from these server calls:
18
18
 
19
19
  registerCategory(owner, { name, description }) registerField(owner, { ..., category })
@@ -31,11 +31,12 @@ export {
31
31
  updateItem,
32
32
  deleteItem,
33
33
  unregisterItems,
34
- adjustStock,
35
- adjustStockMany,
34
+ adjustQuantity,
35
+ adjustQuantityMany,
36
36
  getMovements,
37
37
  } from './server/utils/items.js';
38
38
 
39
+
39
40
  export {
40
41
  getFields,
41
42
  getField,
@@ -2,14 +2,14 @@ import { currentUserHasPermission, getSession, getSetting } from 'kempo/server/s
2
2
  import authorize from './authorize.js';
3
3
 
4
4
  /*
5
- Who may read the inventory, and whether they may see stock counts.
5
+ Who may read the inventory, and whether they may see quantity counts.
6
6
 
7
7
  Normally reading needs the `items:read` permission. With the `public_read` setting on, anyone can
8
- read, signed in or not, so the inventory can be shared as a catalogue. Either way, **stock counts
9
- are only for people who manage stock**: callers without `stock:adjust` or `items:update` get items
8
+ read, signed in or not, so the inventory can be shared as a catalogue. Either way, **quantity counts
9
+ are only for people who manage quantity**: callers without `quantity:adjust` or `items:update` get items
10
10
  with no `quantity` and no stock history.
11
11
 
12
- Resolves to [null, { userId, canSeeStock, anonymous }] or [{ code, msg }, null].
12
+ Resolves to [null, { userId, canSeeQuantity, anonymous }] or [{ code, msg }, null].
13
13
  */
14
14
  export const publicReadEnabled = async () => {
15
15
  const [, value] = await getSetting('kempo-inventory', 'public_read', false);
@@ -25,19 +25,19 @@ export const authorizeRead = async request => {
25
25
  const token = request.cookies?.session_token;
26
26
  const [error, auth] = await authorize(request, 'items:read');
27
27
  if(!error){
28
- const canSeeStock = (await holds(token, 'stock:adjust')) || (await holds(token, 'items:update'));
29
- return [null, { userId: auth.userId, canSeeStock, anonymous: false }];
28
+ const canSeeQuantity = (await holds(token, 'quantity:adjust')) || (await holds(token, 'items:update'));
29
+ return [null, { userId: auth.userId, canSeeQuantity, anonymous: false }];
30
30
  }
31
31
  if(await publicReadEnabled()){
32
32
  const [sessionError, session] = token ? await getSession({ token }) : [true, null];
33
- return [null, { userId: !sessionError && session?.user ? session.user.id : null, canSeeStock: false, anonymous: true }];
33
+ return [null, { userId: !sessionError && session?.user ? session.user.id : null, canSeeQuantity: false, anonymous: true }];
34
34
  }
35
35
  return [error, null];
36
36
  };
37
37
 
38
- /* An item as the caller may see it: no stock count unless they manage stock. */
38
+ /* An item as the caller may see it: no stock count unless they manage quantity. */
39
39
  export const forCaller = (item, access) => {
40
- if(access.canSeeStock) return item;
40
+ if(access.canSeeQuantity) return item;
41
41
  const { quantity, ...rest } = item;
42
42
  return rest;
43
43
  };
@@ -16,8 +16,8 @@ export const EVENTS = {
16
16
  itemUpdated: 'kempo-inventory:item:updated',
17
17
  itemBeforeDelete: 'kempo-inventory:item:before_delete',
18
18
  itemDeleted: 'kempo-inventory:item:deleted',
19
- stockBeforeAdjust: 'kempo-inventory:stock:before_adjust',
20
- stockAdjusted: 'kempo-inventory:stock:adjusted',
19
+ quantityBeforeAdjust: 'kempo-inventory:quantity:before_adjust',
20
+ quantityAdjusted: 'kempo-inventory:quantity:adjusted',
21
21
  categoryBeforeUpdate: 'kempo-inventory:category:before_update',
22
22
  categoryUpdated: 'kempo-inventory:category:updated',
23
23
  categoryBeforeDelete: 'kempo-inventory:category:before_delete',
@@ -2,7 +2,7 @@ import db from 'kempo/server/db/index.js';
2
2
  import { asc } from 'drizzle-orm';
3
3
  import { kempoInventoryItem, kempoInventoryCategory } from '../db/schema.js';
4
4
  import { getFields } from './fields.js';
5
- import { getItemBySku, createItem, updateItem, adjustStock } from './items.js';
5
+ import { getItemBySku, createItem, updateItem, adjustQuantity } from './items.js';
6
6
  import { getCategories, saveCategory } from './categories.js';
7
7
  import { applicableFields, coerceValues } from './fieldTypes.js';
8
8
  import { normalizeTags } from './tags.js';
@@ -111,13 +111,13 @@ const given = value => value !== undefined && value !== null && value !== '' &&
111
111
 
112
112
  onMatch 'skip' an item or category that already exists (same SKU / same name) is left alone
113
113
  'update' it is updated with the values the file provides; a value the file leaves empty
114
- is never cleared. A changed quantity is applied as a stock adjustment, so the
114
+ is never cleared. A changed quantity is applied as a quantity adjustment, so the
115
115
  history shows it.
116
116
 
117
117
  Rows are independent: one that fails does not stop the others. Resolves to
118
118
  [null, { results, categoryResults, summary }].
119
119
  */
120
- export const importData = async ({ items = [], categories = [], onMatch = 'skip', dryRun = false } = {}, { userId = '', canUpdate = false, canAdjustStock = false, maxRows = MAX_IMPORT_ROWS } = {}) => {
120
+ export const importData = async ({ items = [], categories = [], onMatch = 'skip', dryRun = false } = {}, { userId = '', canUpdate = false, canAdjustQuantity = false, maxRows = MAX_IMPORT_ROWS } = {}) => {
121
121
  if(!['skip', 'update'].includes(onMatch)) return [{ code: 400, msg: 'onMatch must be "skip" or "update"' }, null];
122
122
  if(!Array.isArray(items) || !Array.isArray(categories)) return [{ code: 400, msg: 'items and categories must be lists' }, null];
123
123
  if(items.length > maxRows || categories.length > maxRows) return [{ code: 413, msg: `Send at most ${maxRows} rows at a time` }, null];
@@ -199,7 +199,7 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
199
199
  if(invalid){ fail(result, invalid.msg); continue; }
200
200
 
201
201
  if(existing){
202
- if(quantity !== undefined && quantity !== existing.quantity && !canAdjustStock){ fail(result, 'Changing a quantity needs the stock:adjust permission'); continue; }
202
+ if(quantity !== undefined && quantity !== existing.quantity && !canAdjustQuantity){ fail(result, 'Changing a quantity needs the quantity:adjust permission'); continue; }
203
203
  result.action = 'update';
204
204
  if(dryRun){ summary.updated++; continue; }
205
205
  const changes = { fields: coerced };
@@ -210,7 +210,7 @@ export const importData = async ({ items = [], categories = [], onMatch = 'skip'
210
210
  const [error] = await updateItem(existing.id, { ...changes, userId });
211
211
  if(error && !(error.code === 400 && error.msg === 'No changes provided')){ fail(result, error.msg); continue; }
212
212
  if(quantity !== undefined && quantity !== existing.quantity){
213
- const [adjustError] = await adjustStock(existing.id, { delta: quantity - existing.quantity, reason: 'import', note: 'Imported', userId });
213
+ const [adjustError] = await adjustQuantity(existing.id, { delta: quantity - existing.quantity, reason: 'import', note: 'Imported', userId });
214
214
  if(adjustError){ fail(result, adjustError.msg); continue; }
215
215
  }
216
216
  summary.updated++;
@@ -73,7 +73,7 @@ const mediaIn = ({ items, categories }) => {
73
73
  return paths;
74
74
  };
75
75
 
76
- const PERMISSIONS = { canUpdate: true, canAdjustStock: true, maxRows: MAX_ITEMS }; // the caller is the top tier, which holds all of these
76
+ const PERMISSIONS = { canUpdate: true, canAdjustQuantity: true, maxRows: MAX_ITEMS }; // the caller is the top tier, which holds all of these
77
77
 
78
78
  /* What importing this file would do, changing nothing: { results, categoryResults, summary, media }. */
79
79
  export const previewImport = async (bytes, { userId }) => {
@@ -229,7 +229,7 @@ export const createItem = async ({ sku, name, description = '', category = '', t
229
229
  if(qty > 0){
230
230
  await tx.insert(kempoInventoryMovement).values({
231
231
  id: newId(), itemId: created.id, delta: qty, quantityAfter: qty,
232
- reason: 'initial', note: 'Initial stock', userId, created: now,
232
+ reason: 'initial', note: 'Initial quantity', userId, created: now,
233
233
  });
234
234
  }
235
235
  return created;
@@ -245,7 +245,7 @@ export const createItem = async ({ sku, name, description = '', category = '', t
245
245
 
246
246
  /*
247
247
  Updates sku, name, description and custom field values. Quantity is not editable here: it only
248
- changes through adjustStock, which keeps the movement history honest.
248
+ changes through adjustQuantity, which keeps the movement history honest.
249
249
  */
250
250
  export const updateItem = async (id, { sku, name, description, category, tags, fields: values, userId = '' } = {}, { owner = '' } = {}) => {
251
251
  const [lookupError, existing] = await getItem(id);
@@ -339,13 +339,13 @@ export const deleteItem = async (id, { userId = '', owner = '' } = {}) => {
339
339
  Atomic: the quantity changes in a single UPDATE guarded against going negative,
340
340
  so concurrent adjustments cannot oversell.
341
341
  */
342
- /* Anyone may adjust stock, owned item or not; `owner` only tells hooks which extension did it. */
343
- export const adjustStock = async (id, { delta, reason = 'adjustment', note = '', userId = '', owner = '' } = {}) => {
342
+ /* Anyone may adjust quantity, owned item or not; `owner` only tells hooks which extension did it. */
343
+ export const adjustQuantity = async (id, { delta, reason = 'adjustment', note = '', userId = '', owner = '' } = {}) => {
344
344
  const [lookupError, existing] = await getItem(id);
345
345
  if(lookupError) return [lookupError, null];
346
346
 
347
347
  const draft = { delta, reason, note };
348
- const refused = await guard(EVENTS.stockBeforeAdjust, { item: existing, draft, userId, actor: owner });
348
+ const refused = await guard(EVENTS.quantityBeforeAdjust, { item: existing, draft, userId, actor: owner });
349
349
  if(refused) return [refused, null];
350
350
 
351
351
  const change = toInt(draft.delta, NaN);
@@ -364,16 +364,16 @@ export const adjustStock = async (id, { delta, reason = 'adjustment', note = '',
364
364
  }).returning();
365
365
  return { row, movement };
366
366
  });
367
- if(!result) return [{ code: 409, msg: 'Insufficient stock' }, null];
367
+ if(!result) return [{ code: 409, msg: 'Insufficient quantity' }, null];
368
368
 
369
369
  const item = shape(result.row);
370
- await notify(EVENTS.stockAdjusted, {
370
+ await notify(EVENTS.quantityAdjusted, {
371
371
  item, previousQuantity: item.quantity - change, delta: change,
372
372
  reason: result.movement.reason, note: result.movement.note, movement: result.movement, userId, actor: owner,
373
373
  });
374
374
  return [null, item];
375
375
  } catch {
376
- return [{ code: 500, msg: 'Failed to adjust stock' }, null];
376
+ return [{ code: 500, msg: 'Failed to adjust quantity' }, null];
377
377
  }
378
378
  };
379
379
 
@@ -387,23 +387,23 @@ class Refusal {
387
387
  }
388
388
 
389
389
  /*
390
- Applies several stock changes together: every one happens or none does. This is what makes
391
- taking the materials for a whole recipe safe, where one adjustStock per item could take the resin
390
+ Applies several quantity changes together: every one happens or none does. This is what makes
391
+ taking the materials for a whole recipe safe, where one adjustQuantity per item could take the resin
392
392
  and then fail on the paint. `changes` is [{ id, delta, reason?, note? }]; changes to the same item
393
393
  are added together, and one that nets to nothing is left out. Each item runs through the same
394
- before_adjust guard as adjustStock, and fires the same stock:adjusted notification once everything
394
+ before_adjust guard as adjustQuantity, and fires the same quantity:adjusted notification once everything
395
395
  is committed.
396
396
 
397
- const [error, result] = await adjustStockMany(
397
+ const [error, result] = await adjustQuantityMany(
398
398
  [{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
399
399
  { reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
400
400
  );
401
401
 
402
402
  `ref` is recorded on every movement so the change can be found and reversed later. A refusal for
403
- lack of stock is a 409 that names the item, and nothing has changed. Resolves to
403
+ lack of quantity is a 409 that names the item, and nothing has changed. Resolves to
404
404
  [null, { items }] with the items as they are now.
405
405
  */
406
- export const adjustStockMany = async (changes, { reason = 'adjustment', note = '', ref = '', userId = '', owner = '' } = {}) => {
406
+ export const adjustQuantityMany = async (changes, { reason = 'adjustment', note = '', ref = '', userId = '', owner = '' } = {}) => {
407
407
  if(!Array.isArray(changes) || !changes.length) return [{ code: 400, msg: 'changes must be a non-empty list' }, null];
408
408
 
409
409
  const totals = new Map();
@@ -424,7 +424,7 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
424
424
  try {
425
425
  rows = await db.select().from(kempoInventoryItem).where(inArray(kempoInventoryItem.id, wanted.map(entry => entry.id)));
426
426
  } catch {
427
- return [{ code: 500, msg: 'Failed to adjust stock' }, null];
427
+ return [{ code: 500, msg: 'Failed to adjust quantity' }, null];
428
428
  }
429
429
  const existing = new Map(rows.map(row => [row.id, shape(row)]));
430
430
  const missing = wanted.find(entry => !existing.has(entry.id));
@@ -433,7 +433,7 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
433
433
  const drafts = new Map();
434
434
  for(const entry of wanted){
435
435
  const draft = { delta: entry.delta, reason: entry.reason ?? reason, note: entry.note ?? note };
436
- const refused = await guard(EVENTS.stockBeforeAdjust, { item: existing.get(entry.id), draft, userId, actor: owner });
436
+ const refused = await guard(EVENTS.quantityBeforeAdjust, { item: existing.get(entry.id), draft, userId, actor: owner });
437
437
  if(refused) return [refused, null];
438
438
  const delta = toInt(draft.delta, NaN);
439
439
  if(Number.isNaN(delta) || delta === 0) return [{ code: 400, msg: 'delta must be a non-zero whole number' }, null];
@@ -449,7 +449,7 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
449
449
  .set({ quantity: sql`${kempoInventoryItem.quantity} + ${delta}`, updated: new Date() })
450
450
  .where(and(eq(kempoInventoryItem.id, entry.id), sql`${kempoInventoryItem.quantity} + ${delta} >= 0`))
451
451
  .returning();
452
- if(!row) throw new Refusal({ code: 409, msg: `Insufficient stock of ${existing.get(entry.id).name}` });
452
+ if(!row) throw new Refusal({ code: 409, msg: `Insufficient quantity of ${existing.get(entry.id).name}` });
453
453
  const [movement] = await tx.insert(kempoInventoryMovement).values({
454
454
  id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, reason: why,
455
455
  note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
@@ -462,14 +462,14 @@ export const adjustStockMany = async (changes, { reason = 'adjustment', note = '
462
462
  for(const { row, movement, delta } of applied){
463
463
  const item = shape(row);
464
464
  items.push(item);
465
- await notify(EVENTS.stockAdjusted, {
465
+ await notify(EVENTS.quantityAdjusted, {
466
466
  item, previousQuantity: item.quantity - delta, delta, reason: movement.reason, note: movement.note, movement, userId, actor: owner,
467
467
  });
468
468
  }
469
469
  return [null, { items }];
470
470
  } catch(error) {
471
471
  if(error instanceof Refusal) return [error.error, null];
472
- return [{ code: 500, msg: 'Failed to adjust stock' }, null];
472
+ return [{ code: 500, msg: 'Failed to adjust quantity' }, null];
473
473
  }
474
474
  };
475
475
 
@@ -487,7 +487,7 @@ export const getMovements = async ({ itemId, limit = 50, offset = 0 } = {}) => {
487
487
  /*
488
488
  For an extension's uninstall.js: deletes every item it owns, with their history. With `release`
489
489
  the items are kept and handed back to the people who manage the inventory instead, which is the
490
- right choice when they hold real stock.
490
+ right choice when they hold real quantity.
491
491
  */
492
492
  export const unregisterItems = async (owner, { release = false } = {}) => {
493
493
  if(!owner) return [{ code: 400, msg: 'An owner (your extension name) is required' }, null];
@@ -5,7 +5,7 @@ import { tidyCategory } from './categoryLogic.js';
5
5
  extension that created it, and `actor` is whoever is making the change in the same terms: the
6
6
  HTTP layer never names an actor ('' = a person), only an extension calling the server SDK can.
7
7
 
8
- Ownership never changes what stock does: anyone with the permission can still adjust an owned
8
+ Ownership never changes what quantity does: anyone with the permission can still adjust an owned
9
9
  item's quantity. It protects what the owner builds on:
10
10
 
11
11
  items sku, name, category and deletion belong to the owner
package/update.js CHANGED
@@ -8,6 +8,14 @@ import { getSetting, setSetting, deleteSetting } from 'kempo/server/sdk.js';
8
8
  this on a database that already has the change is harmless.
9
9
  */
10
10
  export default async () => {
11
+ // Stock became quantity everywhere. The permission to change it was kempo-inventory:stock:adjust: move every grant to the new name, so nobody loses the right.
12
+ const OLD = 'kempo-inventory:stock:adjust';
13
+ const NEW = 'kempo-inventory:quantity:adjust';
14
+ await db.execute(sql`INSERT INTO "permission" ("name", "description", "owner", "createdAt") SELECT ${NEW}, "description", "owner", "createdAt" FROM "permission" WHERE "name" = ${OLD} ON CONFLICT DO NOTHING`);
15
+ await db.execute(sql`UPDATE "groupPermission" SET "permissionName" = ${NEW} WHERE "permissionName" = ${OLD} AND NOT EXISTS (SELECT 1 FROM "groupPermission" other WHERE other."groupName" = "groupPermission"."groupName" AND other."permissionName" = ${NEW})`);
16
+ await db.execute(sql`DELETE FROM "groupPermission" WHERE "permissionName" = ${OLD}`);
17
+ await db.execute(sql`DELETE FROM "permission" WHERE "name" = ${OLD}`);
18
+
11
19
  // Next: media_provider is retired. kempo-media sits on kempo-files, so new uploads go through it whenever it is installed.
12
20
  await deleteSetting('kempo-inventory', 'media_provider');
13
21
 
@@ -54,7 +62,7 @@ export default async () => {
54
62
 
55
63
  // 0.9: optional public (signed-out) browsing. Added only when missing, and off.
56
64
  const [, publicRead] = await getSetting('kempo-inventory', 'public_read', null);
57
- if(publicRead === null) await setSetting('kempo-inventory', 'public_read', false, 'boolean', false, 'Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see stock counts. Off by default: reading otherwise needs the items:read permission.');
65
+ if(publicRead === null) await setSetting('kempo-inventory', 'public_read', false, 'boolean', false, 'Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see quantity counts. Off by default: reading otherwise needs the items:read permission.');
58
66
 
59
67
  // 0.8: photos are public by default, so read-only users can see them. Added only when missing.
60
68
  const [, publicSetting] = await getSetting('kempo-inventory', 'public_photos', null);