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 +26 -26
- package/admin/categories/index.page.html +3 -3
- package/admin/fields/index.page.html +1 -1
- package/admin/icons/inventory-categories.svg +1 -0
- package/admin/icons/inventory.svg +1 -0
- package/admin/index.page.html +5 -5
- package/admin/nav.global.html +5 -2
- package/kempo-config.json +13 -13
- package/package.json +5 -5
- package/public/api/items/[id]/GET.js +2 -2
- package/public/api/items/[id]/adjust/POST.js +3 -3
- package/public/api/movements/GET.js +1 -1
- package/public/components/ImportExport.js +1 -1
- package/public/components/ItemDialog.js +7 -7
- package/public/components/ItemFields.js +4 -4
- package/public/components/ItemForm.js +1 -1
- package/public/components/{StockWidget.js → QuantityWidget.js} +3 -3
- package/public/components/RowAction.js +2 -2
- package/public/sdk.js +1 -1
- package/sdk.js +4 -3
- package/server/utils/access.js +9 -9
- package/server/utils/events.js +2 -2
- package/server/utils/importExport.js +5 -5
- package/server/utils/importFile.js +1 -1
- package/server/utils/items.js +20 -20
- package/server/utils/ownership.js +1 -1
- package/update.js +9 -1
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
|
-
-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
**
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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,
|
|
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
|
-
| `
|
|
312
|
-
| `
|
|
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 })` |
|
|
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
|
-
`
|
|
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 {
|
|
331
|
+
import { adjustQuantityMany } from 'kempo-inventory/sdk';
|
|
332
332
|
|
|
333
|
-
const [error, { items }] = await
|
|
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
|
|
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 `
|
|
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:
|
|
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:
|
|
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/
|
|
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:
|
|
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
|
|
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
|
|
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>
|
package/admin/index.page.html
CHANGED
|
@@ -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="
|
|
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="
|
|
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('
|
|
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
|
|
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){
|
package/admin/nav.global.html
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
-
<content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="
|
|
2
|
-
<k-aside-item icon="
|
|
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": "
|
|
8
|
+
"key": "quantity",
|
|
9
9
|
"title": "Inventory",
|
|
10
|
-
"description": "Item and unit counts, what is out of stock and the latest
|
|
11
|
-
"
|
|
12
|
-
"element": "k-
|
|
13
|
-
"module": "/inventory/components/
|
|
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
|
|
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:
|
|
41
|
-
"description": "Add or remove
|
|
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
|
|
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:
|
|
66
|
+
"kempo-inventory:quantity:adjust"
|
|
67
67
|
]
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
"name": "kempo-inventory:admin",
|
|
71
|
-
"description": "Can manage items and
|
|
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:
|
|
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
|
|
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
|
+
"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.
|
|
43
|
-
"kempo-ui": ">=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
|
|
11
|
-
const [, movements] = access.
|
|
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 {
|
|
2
|
+
import { adjustQuantity } from '../../../../../server/utils/items.js';
|
|
3
3
|
|
|
4
4
|
export default async (request, response) => {
|
|
5
|
-
const [authError, auth] = await authorize(request, '
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
111
|
+
Toast.error(adjustError.msg || 'Failed to adjust quantity');
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
114
|
$dialog.close();
|
|
115
|
-
Toast.success('
|
|
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
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
35
|
-
|
|
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,
|
package/server/utils/access.js
CHANGED
|
@@ -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
|
|
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, **
|
|
9
|
-
are only for people who manage
|
|
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,
|
|
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
|
|
29
|
-
return [null, { userId: auth.userId,
|
|
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,
|
|
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
|
|
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.
|
|
40
|
+
if(access.canSeeQuantity) return item;
|
|
41
41
|
const { quantity, ...rest } = item;
|
|
42
42
|
return rest;
|
|
43
43
|
};
|
package/server/utils/events.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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 && !
|
|
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
|
|
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,
|
|
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 }) => {
|
package/server/utils/items.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
343
|
-
export const
|
|
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.
|
|
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
|
|
367
|
+
if(!result) return [{ code: 409, msg: 'Insufficient quantity' }, null];
|
|
368
368
|
|
|
369
369
|
const item = shape(result.row);
|
|
370
|
-
await notify(EVENTS.
|
|
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
|
|
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
|
|
391
|
-
taking the materials for a whole recipe safe, where one
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|