kempo-inventory 0.5.9 → 0.6.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 +1 -1
- package/kempo-config.json +1 -1
- package/package.json +2 -2
- package/public/api/items/[id]/GET.js +5 -4
- package/public/components/ItemFields.js +52 -18
- package/public/components/ItemForm.js +13 -11
- package/public/components/ItemQuantity.js +8 -16
- package/public/icons/barcode_scanner.svg +1 -0
- package/public-items/[id]/index.page.html +108 -72
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ Items and categories can be **tagged**: short labels such as `acrylic`, `red`, `
|
|
|
128
128
|
|
|
129
129
|
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.
|
|
130
130
|
|
|
131
|
-
**
|
|
131
|
+
**The item page shows the count to everyone who can read it.** `GET /inventory/api/items/[id]` returns the `quantity` and the stock history to any reader, signed in or not, so the item page's Quantity tab looks the same for everyone (only people with `quantity:adjust` get the form to change it). Who made a change (`userId`) is only sent to callers with `quantity:adjust` or `items:update`. Lists and searches (`GET /inventory/api/items`) and `GET /inventory/api/movements` still leave the count out for those without `quantity:adjust` or `items:update`.
|
|
132
132
|
|
|
133
133
|
## Who can see photos
|
|
134
134
|
|
package/kempo-config.json
CHANGED
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"name": "public_read",
|
|
104
104
|
"value": false,
|
|
105
105
|
"type": "boolean",
|
|
106
|
-
"description": "Let anyone, signed in or not, browse the inventory (items, categories, photos).
|
|
106
|
+
"description": "Let anyone, signed in or not, browse the inventory (items, categories, photos). The item page shows them the quantity and its history, but they cannot change it. Off by default: reading otherwise needs the items:read permission."
|
|
107
107
|
},
|
|
108
108
|
{
|
|
109
109
|
"name": "public_photos",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo-inventory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A basic inventory management system for kempo cms",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Dustin Poissant <dustinpoissant@gmail.com>",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"kempo": ">=4.11.0",
|
|
44
|
-
"kempo-ui": ">=1.
|
|
44
|
+
"kempo-ui": ">=1.5.0",
|
|
45
45
|
"kempo-media": ">=0.3.0",
|
|
46
46
|
"kempo-files": ">=0.1.3",
|
|
47
47
|
"kempo-thumbs": ">=0.1.3"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { authorizeRead
|
|
1
|
+
import { authorizeRead } from '../../../../server/utils/access.js';
|
|
2
2
|
import { getItem, getMovements, getLinked, getMedia } from '../../../../server/utils/items.js';
|
|
3
3
|
|
|
4
4
|
export default async (request, response) => {
|
|
@@ -7,9 +7,10 @@ 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 [,
|
|
10
|
+
/* The item page shows the count and history to everyone who can read it; who made a change is only for people who manage quantity. */
|
|
11
|
+
const [, found] = await getMovements({ itemId: item.id, limit: 100 });
|
|
12
|
+
const movements = (found || []).map(({ userId, ...movement }) => access.canSeeQuantity ? { ...movement, userId } : movement);
|
|
12
13
|
const [, linked] = await getLinked(item);
|
|
13
14
|
const [, media] = await getMedia(item);
|
|
14
|
-
response.json({ item
|
|
15
|
+
response.json({ item, movements, linked: linked || {}, media: media || {} });
|
|
15
16
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { LitElement, html } from '/kempo-ui/lit-all.min.js';
|
|
2
2
|
import Toast from '/kempo-ui/components/Toast.js';
|
|
3
|
+
import PhotoViewer from '/kempo-ui/components/PhotoViewer.js';
|
|
3
4
|
import '/kempo-ui/components/Toggle.js';
|
|
4
5
|
import '/kempo-ui/components/Combobox.js';
|
|
5
6
|
import '/kempo-ui/components/Icon.js';
|
|
@@ -112,6 +113,7 @@ export class InventoryMediaField extends LitElement {
|
|
|
112
113
|
assets: { type: Object },
|
|
113
114
|
uploading: { type: Number, state: true },
|
|
114
115
|
max: { type: Number },
|
|
116
|
+
readOnly: { type: Boolean, attribute: 'readonly' }, // show the files only: no adding, removing or reordering, and a click opens the photo viewer
|
|
115
117
|
ratioFor: { type: String, attribute: 'ratio-for' }, // 'item' (default) or 'category': whose aspect ratio the previews use
|
|
116
118
|
ratio: { type: String, state: true },
|
|
117
119
|
cropRatio: { type: String, attribute: 'crop-ratio' }, // force photos to this shape as they are added, e.g. '1 / 1'
|
|
@@ -175,28 +177,38 @@ export class InventoryMediaField extends LitElement {
|
|
|
175
177
|
this.ratio = ratios[this.ratioFor || 'item'] ?? '4 / 3';
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
/* Read only: the photo viewer opens over the page, with arrows and keys to move between this field's photos. */
|
|
181
|
+
openPhoto = id => {
|
|
182
|
+
const images = this.value.map(v => this.assets[v]).filter(asset => asset?.kind === 'image');
|
|
183
|
+
PhotoViewer.open(images.map(asset => ({ src: asset.path, caption: asset.alt || asset.name })), images.indexOf(this.assets[id]));
|
|
184
|
+
};
|
|
185
|
+
|
|
178
186
|
render(){
|
|
179
187
|
return html`
|
|
180
188
|
<div class="d-f" style="flex-wrap: wrap; gap: var(--spacer_h); align-items: center;">
|
|
181
189
|
${this.value.map((id, index) => {
|
|
182
190
|
const asset = this.assets[id];
|
|
183
|
-
const ordered = this.max > 1 && this.value.length > 1;
|
|
191
|
+
const ordered = !this.readOnly && this.max > 1 && this.value.length > 1;
|
|
184
192
|
return html`
|
|
185
193
|
<div class="media-tile" style="position: relative; width: 5rem; aspect-ratio: ${this.ratio ?? '4 / 3'}; border: 1px solid var(--c_border); border-radius: var(--radius); overflow: hidden; display: flex; align-items: center; justify-content: center;" title=${asset?.name ?? 'Missing file'}>
|
|
186
194
|
${asset?.thumbnail || asset?.kind === 'image'
|
|
187
195
|
? html`<img src=${asset.thumbnail ?? asset.path} alt=${asset.alt || asset.name} style="width: 100%; height: 100%; object-fit: cover;">`
|
|
188
196
|
: html`<small style="padding: 2px; text-align: center; word-break: break-all;">${asset?.name ?? 'Missing'}</small>`}
|
|
189
|
-
|
|
197
|
+
${this.readOnly
|
|
198
|
+
? (asset?.kind === 'image'
|
|
199
|
+
? html`<a href=${asset.path} style="position: absolute; inset: 0;" aria-label=${`View ${asset.alt || asset.name}`} @click=${e => { e.preventDefault(); this.openPhoto(id); }}></a>`
|
|
200
|
+
: asset ? html`<a href=${asset.path} target="_blank" rel="noopener" style="position: absolute; inset: 0;" aria-label=${`Open ${asset.name}`}></a>` : '')
|
|
201
|
+
: html`<button type="button" class="no-btn" style="position: absolute; top: 0; right: 0; background: var(--c_bg); line-height: 1; padding: 2px;" aria-label="Remove" @click=${() => this.remove(id)}><k-icon name="close"></k-icon></button>`}
|
|
190
202
|
${ordered ? (index === 0
|
|
191
203
|
? html`<span class="media-primary" style="position: absolute; left: 0; bottom: 0; right: 0; background: var(--c_bg); font-size: 0.7rem; text-align: center; line-height: 1.4;"><k-icon name="star_filled"></k-icon> Primary</span>`
|
|
192
204
|
: html`<button type="button" class="no-btn media-make-primary" style="position: absolute; left: 0; bottom: 0; right: 0; background: var(--c_bg); font-size: 0.7rem; line-height: 1.4; cursor: pointer;" aria-label="Make this the primary photo" title="Make this the primary photo" @click=${() => this.makePrimary(id)}><k-icon name="star"></k-icon> Make primary</button>`) : ''}
|
|
193
205
|
</div>`;
|
|
194
206
|
})}
|
|
195
|
-
|
|
207
|
+
${this.readOnly ? '' : html`<label class="btn" style="margin: 0; cursor: pointer;">
|
|
196
208
|
<k-icon name="add"></k-icon> ${this.uploading ? `Uploading ${this.uploading}…` : 'Add files'}
|
|
197
209
|
<input type="file" multiple accept=${MEDIA_ACCEPT} style="display: none;"
|
|
198
210
|
@change=${e => { const files = [...e.target.files]; e.target.value = ''; this.addFiles(files); }}>
|
|
199
|
-
</label
|
|
211
|
+
</label>`}
|
|
200
212
|
</div>`;
|
|
201
213
|
}
|
|
202
214
|
}
|
|
@@ -281,6 +293,7 @@ export const readTags = $tags => {
|
|
|
281
293
|
class InventoryColorField extends LitElement {
|
|
282
294
|
static properties = {
|
|
283
295
|
value: { type: String },
|
|
296
|
+
readOnly: { type: Boolean, attribute: 'readonly' },
|
|
284
297
|
};
|
|
285
298
|
|
|
286
299
|
constructor(){
|
|
@@ -296,6 +309,12 @@ class InventoryColorField extends LitElement {
|
|
|
296
309
|
}
|
|
297
310
|
|
|
298
311
|
render(){
|
|
312
|
+
if(this.readOnly) return this.value
|
|
313
|
+
? html`<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
314
|
+
<span style="display: inline-block; width: 4rem; height: 4rem; border: 1px solid var(--c_border); border-radius: var(--radius); background: ${this.value};"></span>
|
|
315
|
+
<span class="tc-muted">${this.value}</span>
|
|
316
|
+
</div>`
|
|
317
|
+
: '';
|
|
299
318
|
return this.value
|
|
300
319
|
? html`<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
301
320
|
<k-color-picker format="hex" .value=${this.value} @change=${e => {
|
|
@@ -345,58 +364,73 @@ const preloadSuggestions = ($combobox, field) => {
|
|
|
345
364
|
/*
|
|
346
365
|
The form control for one field, with its label, a required marker and its description.
|
|
347
366
|
*/
|
|
348
|
-
export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = null } = {}) => {
|
|
367
|
+
export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = null, readOnly = false } = {}) => {
|
|
349
368
|
const id = inputId(field);
|
|
350
369
|
const link = field.type === 'item' && value ? linked[value] : null;
|
|
351
370
|
const linkLabel = link ? `${link.name} (${link.sku})` : '';
|
|
371
|
+
/* Read only, a box that only offers choices (a list, a search) is a plain read-only input holding its text, so every box looks alike. */
|
|
372
|
+
const plain = text => html`<input type="text" id=${id} class="full" readonly .value=${text}>`;
|
|
352
373
|
let control;
|
|
353
374
|
switch(field.type){
|
|
354
375
|
case 'longtext':
|
|
355
|
-
control = html`<textarea id=${id} class="full" rows="3" .value=${value ?? ''}></textarea>`;
|
|
376
|
+
control = html`<textarea id=${id} class="full" rows="3" ?readonly=${readOnly} .value=${value ?? ''}></textarea>`;
|
|
356
377
|
break;
|
|
357
378
|
case 'url':
|
|
358
|
-
control = html`<input type="url" id=${id} class="full" placeholder
|
|
379
|
+
control = html`<input type="url" id=${id} class="full" placeholder=${readOnly ? '' : 'https://'} ?readonly=${readOnly} .value=${value ?? ''}>`;
|
|
359
380
|
break;
|
|
360
381
|
case 'number':
|
|
361
|
-
control = html`<input type="number" step="any" id=${id} class="full" .value=${value ?? ''}>`;
|
|
382
|
+
control = html`<input type="number" step="any" id=${id} class="full" ?readonly=${readOnly} .value=${value ?? ''}>`;
|
|
362
383
|
break;
|
|
363
384
|
case 'date':
|
|
364
|
-
control = html`<input type="date" id=${id} class="full" .value=${value ?? ''}>`;
|
|
385
|
+
control = html`<input type="date" id=${id} class="full" ?readonly=${readOnly} .value=${value ?? ''}>`;
|
|
365
386
|
break;
|
|
366
387
|
case 'color':
|
|
367
|
-
control = html`<k-inventory-color-field id=${id} .value=${value ?? ''}></k-inventory-color-field>`;
|
|
388
|
+
control = html`<k-inventory-color-field id=${id} ?readonly=${readOnly} .value=${value ?? ''}></k-inventory-color-field>`;
|
|
368
389
|
break;
|
|
369
390
|
case 'rating':
|
|
370
391
|
control = html`<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
371
|
-
<k-rating id=${id} .value=${Number(value) || 0}></k-rating>
|
|
372
|
-
|
|
392
|
+
<k-rating id=${id} ?disabled=${readOnly} .value=${Number(value) || 0}></k-rating>
|
|
393
|
+
${readOnly ? '' : html`<button type="button" class="no-btn tc-muted" style="cursor: pointer;" aria-label="Clear the rating" @click=${e => { e.currentTarget.previousElementSibling.value = 0; }}>clear</button>`}
|
|
373
394
|
</div>`;
|
|
374
395
|
break;
|
|
375
396
|
case 'boolean':
|
|
376
|
-
control = html`<k-toggle id=${id} .value=${Boolean(value)}></k-toggle>`;
|
|
397
|
+
control = html`<k-toggle id=${id} ?disabled=${readOnly} .value=${Boolean(value)}></k-toggle>`;
|
|
377
398
|
break;
|
|
378
399
|
case 'select':
|
|
379
|
-
control = html`<select id=${id} class="full">
|
|
400
|
+
control = readOnly ? plain(value ?? '') : html`<select id=${id} class="full">
|
|
380
401
|
<option value="">${field.required ? 'Choose…' : 'None'}</option>
|
|
381
402
|
${field.options.map(o => html`<option value=${o} ?selected=${o === value}>${o}</option>`)}
|
|
382
403
|
</select>`;
|
|
383
404
|
break;
|
|
384
405
|
case 'item':
|
|
385
|
-
control = html`<k-combobox id=${id} class="full" placeholder="Search items…"
|
|
406
|
+
control = readOnly ? plain(linkLabel || (value ? '(deleted item)' : '')) : html`<k-combobox id=${id} class="full" placeholder="Search items…"
|
|
386
407
|
.value=${linkLabel} data-item-id=${value ?? ''} data-label=${linkLabel}
|
|
387
408
|
@search=${e => searchItems(e, ownId)}
|
|
388
409
|
@select=${e => { e.currentTarget.dataset.itemId = e.detail.value; e.currentTarget.dataset.label = e.detail.label; }}></k-combobox>`;
|
|
389
410
|
break;
|
|
390
411
|
case 'media':
|
|
391
|
-
control = html`<k-inventory-media-field id=${id} .value=${value ?? []} .assets=${media} .cropRatio=${field.imageRatio ?? ''} .cropMax=${field.imageMax ?? 0}></k-inventory-media-field>`;
|
|
412
|
+
control = html`<k-inventory-media-field id=${id} ?readonly=${readOnly} .value=${value ?? []} .assets=${media} .cropRatio=${field.imageRatio ?? ''} .cropMax=${field.imageMax ?? 0}></k-inventory-media-field>`;
|
|
392
413
|
break;
|
|
393
414
|
default:
|
|
394
|
-
control = field.type === 'text' && field.suggest
|
|
415
|
+
control = field.type === 'text' && field.suggest && !readOnly
|
|
395
416
|
? html`<k-combobox id=${id} class="full" debounce-ms="150" .value=${value ?? ''}
|
|
396
417
|
empty-message="Nothing saved yet. Type to add one." no-results-message="New value"
|
|
397
418
|
@focusin=${e => preloadSuggestions(e.currentTarget, field)}
|
|
398
419
|
@search=${e => loadSuggestions(e.currentTarget, field, e.detail.value)}></k-combobox>`
|
|
399
|
-
: html`<input type="text" id=${id} class="full" .value=${value ?? ''}>`;
|
|
420
|
+
: html`<input type="text" id=${id} class="full" ?readonly=${readOnly} .value=${value ?? ''}>`;
|
|
421
|
+
}
|
|
422
|
+
if(readOnly && field.type === 'url' && /^https?:\/\//i.test(String(value ?? '').trim())){
|
|
423
|
+
/* Only web addresses get a link; anything else (a javascript: or data: address, say) stays plain text. */
|
|
424
|
+
control = html`<div class="d-f" style="gap: var(--spacer_h);">
|
|
425
|
+
<div class="flex">${control}</div>
|
|
426
|
+
<a class="btn" href=${String(value).trim()} target="_blank" rel="noopener" aria-label="Open in a new tab" title="Open in a new tab"><k-icon name="open_in_browser"></k-icon></a>
|
|
427
|
+
</div>`;
|
|
428
|
+
}
|
|
429
|
+
if(readOnly && link){
|
|
430
|
+
control = html`<div class="d-f" style="gap: var(--spacer_h);">
|
|
431
|
+
<div class="flex">${control}</div>
|
|
432
|
+
<a class="btn" href="/items/${value}" aria-label="Open this item" title="Open this item"><k-icon name="arrow" direction="right"></k-icon></a>
|
|
433
|
+
</div>`;
|
|
400
434
|
}
|
|
401
435
|
return html`<div class="mb">
|
|
402
436
|
<label class="d-b mb-sm" for=${id}>${field.label}${field.required ? ' *' : ''}</label>
|
|
@@ -31,7 +31,7 @@ const offerCategories = async $combobox => {
|
|
|
31
31
|
if(!error) $combobox.setOptions(data.categories.map(c => c.name));
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
export const createItemForm = ({ item = null, fields = [], defaults = {}, askQuantity = true, context = { linked: {}, media: {} }, inDialog = false } = {}) => {
|
|
34
|
+
export const createItemForm = ({ item = null, fields = [], defaults = {}, askQuantity = true, context = { linked: {}, media: {} }, inDialog = false, readOnly = false } = {}) => {
|
|
35
35
|
const editing = Boolean(item);
|
|
36
36
|
/* An item an extension owns keeps its SKU, name and category: the server refuses changes to them, so the boxes are not offered. */
|
|
37
37
|
const locked = editing && Boolean(item.owner);
|
|
@@ -89,40 +89,42 @@ export const createItemForm = ({ item = null, fields = [], defaults = {}, askQua
|
|
|
89
89
|
whenever the category's fields changed the order.
|
|
90
90
|
*/
|
|
91
91
|
render(nothing, $box);
|
|
92
|
-
render(html`${shown.map(f => fieldTemplate(f, f.key in kept ? kept[f.key] : (item?.fields[f.key] ?? defaults.fields?.[f.key]), context))}`, $box);
|
|
92
|
+
render(html`${shown.map(f => fieldTemplate(f, f.key in kept ? kept[f.key] : (item?.fields[f.key] ?? defaults.fields?.[f.key]), { ...context, readOnly }))}`, $box);
|
|
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 you can edit here, and quantity in the Quantity tab.</p>` : ''}
|
|
96
|
+
${locked && !readOnly ? html`<p class="mb tc-muted">Managed by the ${item.owner} extension. Its SKU, name and category can only be changed there; everything else you can edit here, and quantity in the Quantity tab.</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>
|
|
100
100
|
<div class="d-f" style="gap: var(--spacer_h);">
|
|
101
101
|
<input type="text" id="dlg-sku" class="flex" style="width: auto; min-width: 0;" autocapitalize="off" autocomplete="off"
|
|
102
|
-
.value=${item?.sku ?? defaults.sku ?? ''} ?disabled=${locked} @keydown=${skuKeydown}>
|
|
103
|
-
|
|
102
|
+
.value=${item?.sku ?? defaults.sku ?? ''} ?disabled=${locked && !readOnly} ?readonly=${readOnly} @keydown=${skuKeydown}>
|
|
103
|
+
${readOnly ? '' : html`<button type="button" class="btn" id="dlg-scan" aria-label="Scan a barcode" title="Scan a barcode" ?disabled=${locked} @click=${scan}><k-icon src="/inventory/icons/barcode_scanner.svg"></k-icon></button>`}
|
|
104
104
|
</div>
|
|
105
105
|
</div>
|
|
106
106
|
</div>
|
|
107
|
-
<div class="mb"><label class="d-b mb-sm" for="dlg-name">Name *</label><input type="text" id="dlg-name" class="full" ?disabled=${locked} .value=${item?.name ?? defaults.name ?? ''}></div>
|
|
107
|
+
<div class="mb"><label class="d-b mb-sm" for="dlg-name">Name *</label><input type="text" id="dlg-name" class="full" ?disabled=${locked && !readOnly} ?readonly=${readOnly} .value=${item?.name ?? defaults.name ?? ''}></div>
|
|
108
108
|
<div class="mb">
|
|
109
109
|
<label class="d-b mb-sm" for="dlg-category">Category</label>
|
|
110
|
-
|
|
110
|
+
${readOnly ? html`<input type="text" id="dlg-category" class="full" readonly .value=${item?.category ?? ''}>` : html`<k-combobox id="dlg-category" class="full" debounce-ms="150" ?disabled=${locked} .value=${item?.category ?? defaults.category ?? ''}
|
|
111
111
|
empty-message="No categories yet. Type to add one." no-results-message="New category"
|
|
112
112
|
@focusin=${e => offerCategories(e.currentTarget)}
|
|
113
|
-
@search=${showFields} @select=${() => setTimeout(showFields)} @change=${() => setTimeout(showFields)} @focusout=${() => setTimeout(showFields, 150)}></k-combobox
|
|
113
|
+
@search=${showFields} @select=${() => setTimeout(showFields)} @change=${() => setTimeout(showFields)} @focusout=${() => setTimeout(showFields, 150)}></k-combobox>`}
|
|
114
114
|
</div>
|
|
115
115
|
<div class="d-f mb" style="align-items: flex-end; gap: var(--spacer);">
|
|
116
116
|
<div class="flex">
|
|
117
117
|
<label class="d-b mb-sm" for="dlg-unit">Unit
|
|
118
118
|
<small class="d-b tc-muted">What the quantity counts, such as oz or g. Leave empty to use the category's unit.</small>
|
|
119
119
|
</label>
|
|
120
|
-
<input type="text" id="dlg-unit" class="full" maxlength="20" .value=${item?.unit ?? defaults.unit ?? ''}>
|
|
120
|
+
<input type="text" id="dlg-unit" class="full" maxlength="20" ?readonly=${readOnly} .value=${item?.unit ?? defaults.unit ?? ''}>
|
|
121
121
|
</div>
|
|
122
122
|
${editing || !askQuantity ? '' : html`<div style="width: 9rem;"><label class="d-b mb-sm" for="dlg-qty">Starting quantity</label><input type="number" id="dlg-qty" class="full" min="0" step="1" .value=${String(defaults.quantity ?? 0)}></div>`}
|
|
123
123
|
</div>
|
|
124
|
-
<div class="mb"><label class="d-b mb-sm" for="dlg-tags">Tags</label
|
|
125
|
-
|
|
124
|
+
<div class="mb"><label class="d-b mb-sm" for="dlg-tags">Tags</label>${readOnly
|
|
125
|
+
? html`<input type="text" id="dlg-tags" class="full" readonly .value=${(item?.tags ?? []).join(', ')}>`
|
|
126
|
+
: html`<k-tags id="dlg-tags" .value=${tagsValue(item?.tags ?? defaults.tags)} .getSuggestions=${suggestTags} @input=${lowerTagInput} @change=${lowerTagChips}></k-tags>`}</div>
|
|
127
|
+
<div class="mb"><label class="d-b mb-sm" for="dlg-description">Description</label><textarea id="dlg-description" class="full" rows="2" ?readonly=${readOnly} .value=${item?.description ?? ''}></textarea></div>
|
|
126
128
|
<div id="dlg-fields"></div>
|
|
127
129
|
`, $root);
|
|
128
130
|
showFields();
|
|
@@ -108,38 +108,30 @@ export default class ItemQuantity extends ShadowComponent {
|
|
|
108
108
|
const setting = this.mode === 'set';
|
|
109
109
|
return html`
|
|
110
110
|
<h4>${setting ? 'Set the count' : 'Add or remove'}</h4>
|
|
111
|
-
<div class="d-f"
|
|
111
|
+
<div class="d-f">
|
|
112
112
|
<div class="mb mrq">
|
|
113
|
-
<label class="d-b mbq" for="qtyMode">How
|
|
114
|
-
<small class="d-b tc-muted">Set the count, or add and remove.</small>
|
|
115
|
-
</label>
|
|
113
|
+
<label class="d-b mbq" for="qtyMode">How</label>
|
|
116
114
|
<select id="qtyMode" class="m0" @change=${event => { this.mode = event.target.value; this.amount = ''; }}>
|
|
117
115
|
<option value="change" ?selected=${!setting}>Add or remove</option>
|
|
118
116
|
<option value="set" ?selected=${setting}>Set to</option>
|
|
119
117
|
</select>
|
|
120
118
|
</div>
|
|
121
|
-
<div class="mb mrq" style="width:
|
|
122
|
-
<label class="d-b mbq" for="qtyAmount">${setting ? 'Count' : 'Change'}${this.unit ? ` (${this.unit})` : ''}
|
|
123
|
-
<small class="d-b tc-muted">${setting
|
|
124
|
-
? 'The count it should be now. The change is worked out for you.'
|
|
125
|
-
: 'Add a number to receive stock, or use a minus sign to remove it.'}</small>
|
|
126
|
-
</label>
|
|
119
|
+
<div class="mb mrq" style="width: 12rem;">
|
|
120
|
+
<label class="d-b mbq" for="qtyAmount">${setting ? 'Count' : 'Change'}${this.unit ? ` (${this.unit})` : ''} <span class="tc-danger">*</span></label>
|
|
127
121
|
<input id="qtyAmount" type="number" step="any" class="full m0" .value=${this.amount}
|
|
128
122
|
@input=${event => { this.amount = event.target.value; }}>
|
|
129
123
|
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="d-f">
|
|
130
126
|
<div class="mb mrq">
|
|
131
|
-
<label class="d-b mbq" for="qtyReason">Reason
|
|
132
|
-
<small class="d-b tc-muted">Optional. Why it changed, if you want it recorded.</small>
|
|
133
|
-
</label>
|
|
127
|
+
<label class="d-b mbq" for="qtyReason">Reason</label>
|
|
134
128
|
<select id="qtyReason" class="m0" @change=${event => { this.reason = event.target.value; }}>
|
|
135
129
|
<option value="" ?selected=${this.reason === ''}>No reason</option>
|
|
136
130
|
${Object.entries(REASONS).map(([value, label]) => html`<option value=${value} ?selected=${value === this.reason}>${label}</option>`)}
|
|
137
131
|
</select>
|
|
138
132
|
</div>
|
|
139
133
|
<div class="flex mb mrq" style="min-width: 12rem;">
|
|
140
|
-
<label class="d-b mbq" for="qtyNote">Note
|
|
141
|
-
<small class="d-b tc-muted">Optional. Shown in the history.</small>
|
|
142
|
-
</label>
|
|
134
|
+
<label class="d-b mbq" for="qtyNote">Note</label>
|
|
143
135
|
<input id="qtyNote" type="text" class="full m0" maxlength="200" .value=${this.note}
|
|
144
136
|
@input=${event => { this.note = event.target.value; }}>
|
|
145
137
|
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 -960 960 960"><path d="M40-120v-182h60v122h122v60H40Zm697 0v-60h122v-122h60v182H737ZM153-231v-499h80v499h-80Zm121 0v-499h42v499h-42Zm122 0v-499h83v499h-83Zm125 0v-499h121v499H521Zm163 0v-499h42v499h-42Zm83 0v-499h38v499h-38ZM40-658v-182h182v60H100v122H40Zm819 0v-122H737v-60h182v182h-60Z"/></svg>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
title: Item
|
|
3
|
-
description: A single inventory item
|
|
3
|
+
description: A single inventory item, to read or, with permission, to edit
|
|
4
4
|
-->
|
|
5
5
|
<page template="default" title="Item">
|
|
6
6
|
<content>
|
|
@@ -8,126 +8,162 @@
|
|
|
8
8
|
<k-tabs active="details" hash-routing>
|
|
9
9
|
<k-tab for="details">Details</k-tab>
|
|
10
10
|
<k-tab-content name="details" class="pt">
|
|
11
|
-
<div id="
|
|
11
|
+
<div id="itemHeader"></div>
|
|
12
|
+
<div id="itemBody"></div>
|
|
13
|
+
</k-tab-content>
|
|
14
|
+
<k-tab for="quantity">Quantity</k-tab>
|
|
15
|
+
<k-tab-content name="quantity" class="pt">
|
|
16
|
+
<k-inventory-item-quantity id="itemQuantity"></k-inventory-item-quantity>
|
|
12
17
|
</k-tab-content>
|
|
13
18
|
<location name="inventory-item-tabs" />
|
|
14
19
|
</k-tabs>
|
|
15
20
|
</div>
|
|
16
21
|
</content>
|
|
17
22
|
<content location="scripts">
|
|
18
|
-
<script type="module" src="/kempo-ui/components/Tabs.js"></script>
|
|
23
|
+
<script type="module" src="/kempo-ui/components/Tabs.js"></script>
|
|
24
|
+
<script type="module">
|
|
19
25
|
import { html, render } from '/kempo-ui/lit-all.min.js';
|
|
20
26
|
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
21
27
|
import Toast from '/kempo-ui/components/Toast.js';
|
|
22
|
-
import PhotoViewer from '/kempo-ui/components/PhotoViewer.js';
|
|
23
|
-
import '/kempo-ui/components/Rating.js';
|
|
24
28
|
import { getSession, getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
25
29
|
import { getItem, deleteItem, getFields } from '/inventory/sdk.js';
|
|
26
|
-
import {
|
|
30
|
+
import { createItemForm } from '/inventory/components/ItemForm.js';
|
|
31
|
+
import '/inventory/components/ItemQuantity.js';
|
|
27
32
|
|
|
33
|
+
/*
|
|
34
|
+
One page for an item, the same for everyone: Details, Quantity and whatever extensions add to the
|
|
35
|
+
inventory-item-tabs location (Barcodes, say). Details is the item form: editable for people who may
|
|
36
|
+
update items, the same form with every box read only for everyone else.
|
|
37
|
+
*/
|
|
28
38
|
const id = location.pathname.split('/').filter(Boolean).pop();
|
|
29
39
|
const $app = document.getElementById('app');
|
|
30
|
-
const $
|
|
40
|
+
const $header = document.getElementById('itemHeader');
|
|
41
|
+
const $body = document.getElementById('itemBody');
|
|
31
42
|
|
|
32
43
|
const [, session] = await getSession();
|
|
33
|
-
const
|
|
34
|
-
const [, permissionData] = signedIn ? await getAllCurrentUserPermissions() : [null, null];
|
|
44
|
+
const [, permissionData] = session?.user ? await getAllCurrentUserPermissions() : [null, null];
|
|
35
45
|
const permissions = new Set(permissionData?.permissions ?? []);
|
|
36
46
|
const can = name => permissions.has(`kempo-inventory:${name}`);
|
|
47
|
+
const canEdit = can('items:update');
|
|
48
|
+
|
|
49
|
+
/* Everyone sees the count and its history; only people who may adjust it get the form (the component checks). */
|
|
50
|
+
document.getElementById('itemQuantity').itemId = id;
|
|
51
|
+
|
|
52
|
+
let fields = [];
|
|
53
|
+
let current = null;
|
|
54
|
+
let form = null;
|
|
55
|
+
let dirty = false;
|
|
56
|
+
let saving = false;
|
|
57
|
+
|
|
37
58
|
const [fieldsError, fieldData] = await getFields();
|
|
38
59
|
if(fieldsError){
|
|
39
60
|
$app.innerHTML = '<p class="tc-danger">You need to sign in to view this item.</p>';
|
|
40
61
|
$app.hidden = false;
|
|
41
62
|
Toast.error(fieldsError.msg || 'Failed to load the inventory fields');
|
|
42
63
|
} else {
|
|
64
|
+
fields = fieldData.fields;
|
|
43
65
|
$app.hidden = false;
|
|
44
|
-
|
|
66
|
+
$body.style.maxWidth = '44rem';
|
|
67
|
+
await show();
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
/* The category is a built-in property of every item; '' means uncategorised. */
|
|
48
|
-
/* Only web addresses become links; anything else (a javascript: or data: address, say) shows as plain text. */
|
|
49
|
-
function isWebLink(value){
|
|
50
|
-
try {
|
|
51
|
-
const { protocol } = new URL(String(value).trim());
|
|
52
|
-
return protocol === 'http:' || protocol === 'https:';
|
|
53
|
-
} catch {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
71
|
function categoryOf(item){ return (item.category ?? '').trim(); }
|
|
58
72
|
function categoryHref(name){ return name ? `/category?name=${encodeURIComponent(name)}` : '/category?uncategorised=1'; }
|
|
59
73
|
function tagChips(tags){ return tags?.length ? html`<div class="tag-list">${tags.map(tag => html`<a class="tag-chip" href="/search?tag=${encodeURIComponent(tag)}">${tag}</a>`)}</div>` : ''; }
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
75
|
+
/* Loads the item and draws the page from it: the first time, and again after a save. */
|
|
76
|
+
async function show(){
|
|
63
77
|
const [error, data] = await getItem(id);
|
|
64
78
|
if(error){
|
|
65
79
|
render(html`<a href="/" class="btn mb"><k-icon name="arrow" direction="left"></k-icon> Home</a>
|
|
66
|
-
<h1>${error.code === 404 ? 'Item not found' : 'Something went wrong'}</h1><p>${error.msg}</p>`, $
|
|
80
|
+
<h1>${error.code === 404 ? 'Item not found' : 'Something went wrong'}</h1><p>${error.msg}</p>`, $header);
|
|
67
81
|
return;
|
|
68
82
|
}
|
|
69
|
-
|
|
70
|
-
document.title = `${item.name} · Inventory`;
|
|
83
|
+
current = data;
|
|
84
|
+
document.title = `${data.item.name} · Inventory`;
|
|
85
|
+
dirty = false;
|
|
86
|
+
mountForm();
|
|
87
|
+
drawHeader();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* The form is a fresh one each time: that is what Reset does, and what a save leaves behind. */
|
|
91
|
+
function mountForm(){
|
|
92
|
+
const { item, linked, media } = current;
|
|
93
|
+
form = createItemForm({ item, fields, readOnly: !canEdit, context: { linked, media, ownId: item.id } });
|
|
94
|
+
$body.replaceChildren(form.$root);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function drawHeader(){
|
|
98
|
+
if(!current) return;
|
|
99
|
+
const { item } = current;
|
|
71
100
|
const category = categoryOf(item);
|
|
72
101
|
const backHref = category ? categoryHref(category) : '/';
|
|
73
|
-
|
|
102
|
+
render(html`
|
|
103
|
+
<div class="d-f" style="align-items: center;">
|
|
104
|
+
<a href=${backHref} class="btn mb mrq" id="backBtn"><k-icon name="arrow" direction="left"></k-icon> ${category || 'Home'}</a>
|
|
105
|
+
<div class="flex"></div>
|
|
106
|
+
${canEdit ? html`
|
|
107
|
+
<button type="button" class="btn mb mrq" id="resetBtn" ?disabled=${!dirty || saving} @click=${reset}>Reset</button>
|
|
108
|
+
<button type="button" class="btn success mb mrq" id="saveBtn" ?disabled=${!dirty || saving} @click=${save}>${saving ? 'Saving…' : 'Save'}</button>` : ''}
|
|
109
|
+
${can('items:delete') ? html`<button type="button" class="btn danger mb" id="deleteBtn" @click=${remove}><k-icon name="delete"></k-icon> Delete</button>` : ''}
|
|
110
|
+
</div>
|
|
111
|
+
<h1 class="mb" id="itemName">${item.name}</h1>
|
|
112
|
+
`, $header);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reset(){
|
|
116
|
+
mountForm();
|
|
117
|
+
dirty = false;
|
|
118
|
+
drawHeader();
|
|
119
|
+
}
|
|
74
120
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
async function save(){
|
|
122
|
+
if(saving || !form) return;
|
|
123
|
+
saving = true;
|
|
124
|
+
drawHeader();
|
|
125
|
+
const saved = await form.save();
|
|
126
|
+
saving = false;
|
|
127
|
+
if(!saved){
|
|
128
|
+
drawHeader();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
Toast.success(`Saved ${saved.name}`, { position: 'bottom right' });
|
|
132
|
+
await show();
|
|
133
|
+
}
|
|
83
134
|
|
|
84
|
-
|
|
135
|
+
function remove(){
|
|
136
|
+
const { item } = current;
|
|
137
|
+
const category = categoryOf(item);
|
|
138
|
+
Dialog.confirm(`Delete "${item.name}" and its stock history? This action cannot be undone.`, async confirmed => {
|
|
85
139
|
if(!confirmed) return;
|
|
86
140
|
const [deleteError] = await deleteItem(item.id);
|
|
87
141
|
if(deleteError){
|
|
88
142
|
Toast.error(deleteError.msg || 'Failed to delete item');
|
|
89
143
|
return;
|
|
90
144
|
}
|
|
91
|
-
location.href =
|
|
145
|
+
location.href = category ? categoryHref(category) : '/';
|
|
92
146
|
});
|
|
147
|
+
}
|
|
93
148
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
${item.description ? html`<p style="white-space: pre-wrap;">${item.description}</p>` : ''}
|
|
114
|
-
|
|
115
|
-
${rows.length ? html`
|
|
116
|
-
<dl id="fields" style="display: grid; grid-template-columns: max-content 1fr; gap: var(--spacer_q) var(--spacer);">
|
|
117
|
-
${rows.map(f => html`<dt class="tc-muted">${f.label}</dt>
|
|
118
|
-
<dd class="m0">${f.type === 'rating'
|
|
119
|
-
? html`<k-rating disabled .value=${item.fields[f.key]} aria-label=${`${item.fields[f.key]} out of 5`}></k-rating>`
|
|
120
|
-
: f.type === 'color'
|
|
121
|
-
? html`<span style="display: inline-block; width: 4rem; height: 4rem; vertical-align: middle; margin-right: var(--spacer_h); border: 1px solid var(--c_border); border-radius: var(--radius); background: ${item.fields[f.key]};"></span>${item.fields[f.key]}`
|
|
122
|
-
: f.type === 'item' && linked[item.fields[f.key]]
|
|
123
|
-
? html`<a href="/items/${item.fields[f.key]}">${displayValue(f, item.fields[f.key], context)}</a>`
|
|
124
|
-
: f.type === 'url' && isWebLink(item.fields[f.key])
|
|
125
|
-
? html`<a href=${item.fields[f.key]} target="_blank" rel="noopener">${item.fields[f.key]}</a>`
|
|
126
|
-
: f.type === 'longtext'
|
|
127
|
-
? html`<span style="white-space: pre-wrap;">${item.fields[f.key]}</span>`
|
|
128
|
-
: displayValue(f, item.fields[f.key], context)}</dd>`)}
|
|
129
|
-
</dl>` : ''}
|
|
130
|
-
`, $detail);
|
|
149
|
+
/* Save and Reset wake up when anything in the form differs from how it opened. Some boxes keep their value inside their own shadow DOM, so this checks on a short timer too. */
|
|
150
|
+
function checkDirty(){
|
|
151
|
+
const next = Boolean(form?.isDirty());
|
|
152
|
+
if(next === dirty) return;
|
|
153
|
+
dirty = next;
|
|
154
|
+
drawHeader();
|
|
155
|
+
}
|
|
156
|
+
if(canEdit){
|
|
157
|
+
$body.addEventListener('input', () => setTimeout(checkDirty));
|
|
158
|
+
$body.addEventListener('change', () => setTimeout(checkDirty));
|
|
159
|
+
setInterval(checkDirty, 400);
|
|
160
|
+
/* The browser's own "Leave site?" prompt while there are unsaved changes. */
|
|
161
|
+
window.addEventListener('beforeunload', event => {
|
|
162
|
+
if(!dirty) return;
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
event.returnValue = '';
|
|
165
|
+
});
|
|
166
|
+
window.hasUnsavedInventoryWork = () => dirty;
|
|
131
167
|
}
|
|
132
168
|
</script>
|
|
133
169
|
</content>
|