kempo-inventory 0.5.8 → 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 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
- **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.
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). They never see quantity counts. Off by default: reading otherwise needs the items:read permission."
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.5.8",
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.0.0",
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, forCaller } from '../../../../server/utils/access.js';
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 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, []];
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: forCaller(item, access), movements: movements || [], linked: linked || {}, media: media || {} });
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
- <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>
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
- <label class="btn" style="margin: 0; cursor: pointer;">
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="https://" .value=${value ?? ''}>`;
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
- <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>
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
- <button type="button" class="btn" id="dlg-scan" ?disabled=${locked} @click=${scan}>Scan</button>
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
- <k-combobox id="dlg-category" class="full" debounce-ms="150" ?disabled=${locked} .value=${item?.category ?? defaults.category ?? ''}
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><k-tags id="dlg-tags" .value=${tagsValue(item?.tags ?? defaults.tags)} .getSuggestions=${suggestTags} @input=${lowerTagInput} @change=${lowerTagChips}></k-tags></div>
125
- <div class="mb"><label class="d-b mb-sm" for="dlg-description">Description</label><textarea id="dlg-description" class="full" rows="2" .value=${item?.description ?? ''}></textarea></div>
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" style="align-items: flex-end;">
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: 16rem;">
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,40 +8,63 @@
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="detail"></div>
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> <script type="module">
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 { displayValue, applicableFields } from '/inventory/components/ItemFields.js';
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 $detail = document.getElementById('detail');
40
+ const $header = document.getElementById('itemHeader');
41
+ const $body = document.getElementById('itemBody');
31
42
 
32
43
  const [, session] = await getSession();
33
- const signedIn = Boolean(session?.user);
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
- await draw(fieldData);
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. */
@@ -49,76 +72,98 @@
49
72
  function categoryHref(name){ return name ? `/category?name=${encodeURIComponent(name)}` : '/category?uncategorised=1'; }
50
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>` : ''; }
51
74
 
52
- async function draw(fieldData){
53
- const fields = fieldData.fields;
75
+ /* Loads the item and draws the page from it: the first time, and again after a save. */
76
+ async function show(){
54
77
  const [error, data] = await getItem(id);
55
78
  if(error){
56
79
  render(html`<a href="/" class="btn mb"><k-icon name="arrow" direction="left"></k-icon> Home</a>
57
- <h1>${error.code === 404 ? 'Item not found' : 'Something went wrong'}</h1><p>${error.msg}</p>`, $detail);
80
+ <h1>${error.code === 404 ? 'Item not found' : 'Something went wrong'}</h1><p>${error.msg}</p>`, $header);
58
81
  return;
59
82
  }
60
- const { item, linked, media } = data;
61
- 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;
62
100
  const category = categoryOf(item);
63
101
  const backHref = category ? categoryHref(category) : '/';
64
- const context = { linked, media };
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
+ }
65
120
 
66
- /* Clicking a photo opens Kempo UI's photo viewer over the page (arrows and keys move between the item's photos). */
67
- const openPhoto = photo => {
68
- const images = photos.filter(p => p.kind === 'image');
69
- PhotoViewer.open(images.map(p => ({ src: p.path, caption: p.alt || p.name })), images.indexOf(photo));
70
- };
71
- const shown = applicableFields(fields, item.category);
72
- const photos = shown.filter(f => f.type === 'media').flatMap(f => (item.fields[f.key] ?? []).map(mediaId => media[mediaId]).filter(Boolean));
73
- const rows = shown.filter(f => f.type !== 'media' && item.fields[f.key] !== undefined && item.fields[f.key] !== null);
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
+ }
74
134
 
75
- const remove = () => Dialog.confirm(`Delete "${item.name}" and its stock history? This action cannot be undone.`, async confirmed => {
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 => {
76
139
  if(!confirmed) return;
77
140
  const [deleteError] = await deleteItem(item.id);
78
141
  if(deleteError){
79
142
  Toast.error(deleteError.msg || 'Failed to delete item');
80
143
  return;
81
144
  }
82
- location.href = backHref;
145
+ location.href = category ? categoryHref(category) : '/';
83
146
  });
147
+ }
84
148
 
85
- render(html`
86
- <div class="d-f mb" style="align-items: center; gap: var(--spacer); flex-wrap: wrap;">
87
- <a href=${backHref} class="btn" id="backBtn"><k-icon name="arrow" direction="left"></k-icon> ${category || 'Home'}</a>
88
- <div class="flex"></div>
89
- ${can('items:update') ? html`<a class="btn" id="editBtn" href=${`/items/${item.id}/edit?next=${encodeURIComponent(`/items/${item.id}`)}`}><k-icon name="edit"></k-icon> Edit</a>` : ''}
90
- ${can('items:delete') ? html`<button class="btn danger" id="deleteBtn" @click=${remove}><k-icon name="delete"></k-icon> Delete</button>` : ''}
91
- </div>
92
-
93
- <h1 class="mb0" id="itemName">${item.name}</h1>
94
- <p class="tc-muted mb-sm">${item.sku}</p>
95
- ${tagChips(item.tags)}
96
-
97
- ${photos.length ? html`
98
- <div class="gallery mb-lg" id="gallery">
99
- ${photos.map(p => p.kind === 'image'
100
- ? html`<a href=${p.path} @click=${e => { e.preventDefault(); openPhoto(p); }}><img src=${p.thumbnail ?? p.path} alt=${p.alt || p.name}></a>`
101
- : html`<a href=${p.path} target="_blank" rel="noopener" class="btn">${p.name}</a>`)}
102
- </div>` : ''}
103
-
104
- ${item.description ? html`<p style="white-space: pre-wrap;">${item.description}</p>` : ''}
105
-
106
- ${rows.length ? html`
107
- <dl id="fields" style="display: grid; grid-template-columns: max-content 1fr; gap: var(--spacer_q) var(--spacer);">
108
- ${rows.map(f => html`<dt class="tc-muted">${f.label}</dt>
109
- <dd class="m0">${f.type === 'rating'
110
- ? html`<k-rating disabled .value=${item.fields[f.key]} aria-label=${`${item.fields[f.key]} out of 5`}></k-rating>`
111
- : f.type === 'color'
112
- ? 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]}`
113
- : f.type === 'item' && linked[item.fields[f.key]]
114
- ? html`<a href="/items/${item.fields[f.key]}">${displayValue(f, item.fields[f.key], context)}</a>`
115
- : f.type === 'url'
116
- ? html`<a href=${item.fields[f.key]} target="_blank" rel="noopener">${item.fields[f.key]}</a>`
117
- : f.type === 'longtext'
118
- ? html`<span style="white-space: pre-wrap;">${item.fields[f.key]}</span>`
119
- : displayValue(f, item.fields[f.key], context)}</dd>`)}
120
- </dl>` : ''}
121
- `, $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;
122
167
  }
123
168
  </script>
124
169
  </content>
@@ -28,6 +28,11 @@ export const CONVERSIONS = { select: ['text'], text: ['longtext', 'url'] };
28
28
 
29
29
  export const canConvert = (from, to) => from === to || (CONVERSIONS[from] ?? []).includes(to);
30
30
 
31
+ /* A javascript: link runs code when clicked. Browsers ignore spaces and control characters inside the scheme, so they are allowed for. */
32
+ const SCRIPT_SCHEME = /^[\s\x00-\x1f\x7f]*j[\s\x00-\x1f\x7f]*a[\s\x00-\x1f\x7f]*v[\s\x00-\x1f\x7f]*a[\s\x00-\x1f\x7f]*s[\s\x00-\x1f\x7f]*c[\s\x00-\x1f\x7f]*r[\s\x00-\x1f\x7f]*i[\s\x00-\x1f\x7f]*p[\s\x00-\x1f\x7f]*t[\s\x00-\x1f\x7f]*:/i;
33
+ export const hasScriptScheme = value => SCRIPT_SCHEME.test(String(value));
34
+ export const stripScriptScheme = value => String(value).replace(SCRIPT_SCHEME, '');
35
+
31
36
  /*
32
37
  A `media` value is a list of ids, each saying where the file lives. A bare id is a kempo-media
33
38
  asset; "files:<id>" is a kempo-files file.
@@ -3,7 +3,7 @@ import { eq, and, asc, sql } from 'drizzle-orm';
3
3
  import crypto from 'crypto';
4
4
  import { kempoInventoryField, kempoInventoryItem, kempoInventoryCategory } from '../db/schema.js';
5
5
  import { categoryKey, tidyCategory, MAX_CATEGORY } from './categoryLogic.js';
6
- import { normalizeFieldDefinition, OPTIONAL_TYPES, canConvert, coerceValue } from './fieldTypes.js';
6
+ import { normalizeFieldDefinition, OPTIONAL_TYPES, canConvert, coerceValue, hasScriptScheme, stripScriptScheme } from './fieldTypes.js';
7
7
  import { mediaAvailable } from './media.js';
8
8
  import { EVENTS, guard, notify } from './hooks.js';
9
9
 
@@ -20,10 +20,12 @@ const addressesOf = async (key, label) => {
20
20
  .where(sql`${kempoInventoryItem.data}->>${key} IS NOT NULL`);
21
21
  const rewrites = [];
22
22
  for(const row of rows){
23
- const value = row.data[key];
24
- if(value === '' || value === null) continue;
25
- const [invalid, address] = coerceValue({ type: 'url', label }, value);
26
- if(!invalid && address && address !== value) rewrites.push({ id: row.id, data: { ...row.data, [key]: address } });
23
+ const original = row.data[key];
24
+ if(original === '' || original === null) continue;
25
+ const cleaned = hasScriptScheme(original) ? stripScriptScheme(original) : original;
26
+ const [invalid, address] = coerceValue({ type: 'url', label }, cleaned);
27
+ const value = !invalid && address ? address : cleaned;
28
+ if(value !== original) rewrites.push({ id: row.id, data: { ...row.data, [key]: value } });
27
29
  }
28
30
  return rewrites;
29
31
  };