kempo-inventory 0.2.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 +429 -0
- package/admin/categories/index.page.html +229 -0
- package/admin/edit/index.page.html +51 -0
- package/admin/fields/index.page.html +300 -0
- package/admin/index.page.html +203 -0
- package/admin/nav.global.html +3 -0
- package/admin/new/index.page.html +43 -0
- package/install.js +19 -0
- package/kempo-config.json +103 -0
- package/package.json +59 -0
- package/public/api/categories/DELETE.js +12 -0
- package/public/api/categories/GET.js +12 -0
- package/public/api/categories/PUT.js +20 -0
- package/public/api/export/GET.js +31 -0
- package/public/api/fields/GET.js +23 -0
- package/public/api/fields/POST.js +12 -0
- package/public/api/fields/[key]/DELETE.js +11 -0
- package/public/api/fields/[key]/PATCH.js +16 -0
- package/public/api/fields/[key]/suggestions/GET.js +12 -0
- package/public/api/import/apply/POST.js +25 -0
- package/public/api/import/preview/POST.js +30 -0
- package/public/api/items/GET.js +35 -0
- package/public/api/items/POST.js +12 -0
- package/public/api/items/[id]/DELETE.js +11 -0
- package/public/api/items/[id]/GET.js +15 -0
- package/public/api/items/[id]/PATCH.js +12 -0
- package/public/api/items/[id]/adjust/POST.js +12 -0
- package/public/api/movements/GET.js +17 -0
- package/public/api/tags/GET.js +12 -0
- package/public/components/BarcodeScanner.js +198 -0
- package/public/components/ImageCropDialog.js +94 -0
- package/public/components/ImportExport.js +194 -0
- package/public/components/ItemDialog.js +135 -0
- package/public/components/ItemFields.js +441 -0
- package/public/components/ItemForm.js +186 -0
- package/public/components/NewItemPage.js +92 -0
- package/public/components/RowAction.js +34 -0
- package/public/components/flash.js +20 -0
- package/public/sdk.js +96 -0
- package/public/vendor/LICENSES.md +12 -0
- package/public/vendor/ts-custom-error.js +8 -0
- package/public/vendor/zxing-browser.js +8 -0
- package/public/vendor/zxing-library.js +27 -0
- package/sdk.js +65 -0
- package/server/db/schema.js +74 -0
- package/server/utils/access.js +43 -0
- package/server/utils/authorize.js +13 -0
- package/server/utils/categories.js +267 -0
- package/server/utils/categoryLogic.js +54 -0
- package/server/utils/events.js +31 -0
- package/server/utils/fieldTypes.js +227 -0
- package/server/utils/fields.js +238 -0
- package/server/utils/hooks.js +30 -0
- package/server/utils/importExport.js +239 -0
- package/server/utils/importFile.js +148 -0
- package/server/utils/importStage.js +76 -0
- package/server/utils/items.js +412 -0
- package/server/utils/media.js +245 -0
- package/server/utils/mediaSafety.js +51 -0
- package/server/utils/ownership.js +44 -0
- package/server/utils/rank.js +31 -0
- package/server/utils/ratio.js +31 -0
- package/server/utils/suggestions.js +36 -0
- package/server/utils/tagList.js +25 -0
- package/server/utils/tags.js +47 -0
- package/server/utils/zip.js +88 -0
- package/server/utils/zipRead.js +99 -0
- package/update.js +126 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { html } from '/kempo-ui/lit-all.min.js';
|
|
2
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
3
|
+
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
4
|
+
import '/kempo-ui/components/Table.js';
|
|
5
|
+
import { getItem, adjustStock } from '/inventory/sdk.js';
|
|
6
|
+
import { createItemForm } from '/inventory/components/ItemForm.js';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
The two dialogs every inventory screen needs, shared by the extension's admin page and by any
|
|
10
|
+
site built on it:
|
|
11
|
+
|
|
12
|
+
import { openItemDialog, openAdjustDialog } from '/inventory/components/ItemDialog.js';
|
|
13
|
+
|
|
14
|
+
openItemDialog({ fields, onSaved: item => reload() }); // new item
|
|
15
|
+
openItemDialog({ fields, defaults: { sku, name, quantity: 1 }, onSaved: ... }); // new item, SKU, name and quantity filled in
|
|
16
|
+
openItemDialog({ fields, defaults: { category: 'Paint' }, onSaved: ... }); // a category filled in
|
|
17
|
+
openItemDialog({ fields, defaults: { fields: { brand: 'Vallejo' } }, onSaved: ... }); // custom fields filled in
|
|
18
|
+
openItemDialog({ item, fields, onSaved: item => reload() }); // edit an item
|
|
19
|
+
openItemDialog({ fields, askQuantity: false, defaults: { quantity: 1 }, onSaved }); // no quantity box: the item just exists
|
|
20
|
+
openAdjustDialog({ item, onSaved: item => reload() }); // add or remove stock
|
|
21
|
+
|
|
22
|
+
`fields` is the full field list from getFields(). The form shows the default fields plus those of the
|
|
23
|
+
item's category, and swaps them as the Category box changes. `onSaved` runs after the change is stored.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const SCROLL = 'max-height: calc(100vh - 14rem); overflow-y: auto;';
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
Edit an item (or add one) in a dialog. The form itself is createItemForm (ItemForm.js). Adding an item
|
|
30
|
+
is better done on a page (see NewItemPage.js): a dialog is too easy to dismiss with a stray tap, and
|
|
31
|
+
everything entered would be lost. So this dialog does not close when you click outside it; only its own
|
|
32
|
+
Cancel button (or the close button) does.
|
|
33
|
+
*/
|
|
34
|
+
export const openItemDialog = async ({ item = null, fields = [], defaults = {}, askQuantity = true, onSaved = () => {} } = {}) => {
|
|
35
|
+
let context = { linked: {}, media: {} };
|
|
36
|
+
if(item){
|
|
37
|
+
const [error, data] = await getItem(item.id);
|
|
38
|
+
if(error){
|
|
39
|
+
Toast.error(error.msg || 'Failed to load item');
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
item = data.item;
|
|
43
|
+
context = { linked: data.linked, media: data.media, ownId: item.id };
|
|
44
|
+
}
|
|
45
|
+
const form = createItemForm({ item, fields, defaults, askQuantity, context, inDialog: true });
|
|
46
|
+
|
|
47
|
+
const $dialog = Dialog.create(form.$root, {
|
|
48
|
+
title: form.editing ? `Edit ${item.name}` : 'New Item',
|
|
49
|
+
confirmText: form.editing ? 'Save' : 'Create',
|
|
50
|
+
cancelText: 'Cancel',
|
|
51
|
+
overlayClose: false, // a stray tap outside must not throw away what was entered
|
|
52
|
+
confirmAction: async event => {
|
|
53
|
+
event.keepDialogOpen = true;
|
|
54
|
+
const saved = await form.save();
|
|
55
|
+
if(!saved) return;
|
|
56
|
+
$dialog.close();
|
|
57
|
+
Toast.success(form.editing ? 'Item saved' : 'Item created');
|
|
58
|
+
onSaved(saved);
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return $dialog;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const openAdjustDialog = async ({ item, onSaved = () => {} } = {}) => {
|
|
65
|
+
const [error, data] = await getItem(item.id);
|
|
66
|
+
if(error){
|
|
67
|
+
Toast.error(error.msg || 'Failed to load item');
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const $dialog = Dialog.create(html`
|
|
71
|
+
<div class="p" style=${SCROLL}>
|
|
72
|
+
<p class="mb">Current quantity: <strong>${data.item.quantity}</strong></p>
|
|
73
|
+
<div class="mb">
|
|
74
|
+
<label class="d-b mb-sm" for="dlg-delta">Change (negative removes stock)</label>
|
|
75
|
+
<input type="number" id="dlg-delta" class="full" step="1" required>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="mb">
|
|
78
|
+
<label class="d-b mb-sm" for="dlg-reason">Reason</label>
|
|
79
|
+
<select id="dlg-reason" class="full">
|
|
80
|
+
<option value="received">Received</option>
|
|
81
|
+
<option value="sold">Sold / used</option>
|
|
82
|
+
<option value="damaged">Damaged / lost</option>
|
|
83
|
+
<option value="correction">Count correction</option>
|
|
84
|
+
<option value="adjustment">Other</option>
|
|
85
|
+
</select>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="mb">
|
|
88
|
+
<label class="d-b mb-sm" for="dlg-note">Note</label>
|
|
89
|
+
<input type="text" id="dlg-note" class="full">
|
|
90
|
+
</div>
|
|
91
|
+
<h6 class="mb-sm">History</h6>
|
|
92
|
+
<k-table id="historyTable" placeholder="No movements yet."></k-table>
|
|
93
|
+
</div>
|
|
94
|
+
`, {
|
|
95
|
+
title: `Adjust stock: ${data.item.name}`,
|
|
96
|
+
confirmText: 'Apply',
|
|
97
|
+
cancelText: 'Close',
|
|
98
|
+
confirmAction: async event => {
|
|
99
|
+
event.keepDialogOpen = true;
|
|
100
|
+
const delta = Number($dialog.querySelector('#dlg-delta').value);
|
|
101
|
+
if(!Number.isInteger(delta) || delta === 0){
|
|
102
|
+
Toast.error('Enter a non-zero whole number');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const [adjustError, adjusted] = await adjustStock(item.id, {
|
|
106
|
+
delta,
|
|
107
|
+
reason: $dialog.querySelector('#dlg-reason').value,
|
|
108
|
+
note: $dialog.querySelector('#dlg-note').value,
|
|
109
|
+
});
|
|
110
|
+
if(adjustError){
|
|
111
|
+
Toast.error(adjustError.msg || 'Failed to adjust stock');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
$dialog.close();
|
|
115
|
+
Toast.success('Stock updated');
|
|
116
|
+
onSaved(adjusted.item);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
$dialog.querySelector('#historyTable').setData({
|
|
120
|
+
records: data.movements.map(m => ({
|
|
121
|
+
...m,
|
|
122
|
+
when: new Date(m.created).toLocaleString(),
|
|
123
|
+
change: `${m.delta > 0 ? '+' : ''}${m.delta}`,
|
|
124
|
+
why: m.note ? `${m.reason} - ${m.note}` : m.reason,
|
|
125
|
+
balance: String(m.quantityAfter),
|
|
126
|
+
})),
|
|
127
|
+
fields: [
|
|
128
|
+
{ name: 'when', label: 'When', size: 190 },
|
|
129
|
+
{ name: 'change', label: 'Change', size: 80 },
|
|
130
|
+
{ name: 'why', label: 'Reason', size: 200 },
|
|
131
|
+
{ name: 'balance', label: 'Balance', size: 80 },
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
return $dialog;
|
|
135
|
+
};
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { LitElement, html } from '/kempo-ui/lit-all.min.js';
|
|
2
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
3
|
+
import '/kempo-ui/components/Toggle.js';
|
|
4
|
+
import '/kempo-ui/components/Combobox.js';
|
|
5
|
+
import '/kempo-ui/components/Icon.js';
|
|
6
|
+
import '/kempo-ui/components/ColorPicker.js';
|
|
7
|
+
import '/kempo-ui/components/Rating.js';
|
|
8
|
+
import { getItems, getFields, getSuggestions, getTags } from '/inventory/sdk.js';
|
|
9
|
+
import { prepareImages } from '/inventory/components/ImageCropDialog.js';
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Everything needed to render and read the form inputs for an item's custom fields, shared by the
|
|
13
|
+
extension's own admin page and by any site built on it. A site is expected to compose these
|
|
14
|
+
rather than copy them:
|
|
15
|
+
|
|
16
|
+
import { fieldTemplate, readField } from '/inventory/components/ItemFields.js';
|
|
17
|
+
|
|
18
|
+
html`${fields.map(f => fieldTemplate(f, item.fields[f.key], { linked, media, ownId: item.id }))}`
|
|
19
|
+
const value = readField($dialog, field); // throws an Error with a user-facing message
|
|
20
|
+
|
|
21
|
+
`ctx` is { linked, media, ownId }, the maps the API returns alongside items.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const inputId = field => `item-field-${field.key}`;
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
A category's key: the name in lower case with its spacing tidied, so "Paint" and " paint " match.
|
|
28
|
+
A field's `category` holds this, or '' when it belongs to every item.
|
|
29
|
+
*/
|
|
30
|
+
export const categoryKeyOf = name => String(name ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
The fields an item in the given category shows: the default ones plus that category's own. `fields`
|
|
34
|
+
is the full list from getFields(); an uncategorised item gets only the default fields.
|
|
35
|
+
*/
|
|
36
|
+
export const applicableFields = (fields, category) => {
|
|
37
|
+
const key = categoryKeyOf(category);
|
|
38
|
+
return fields.filter(f => !f.category || f.category === key);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const MEDIA_ACCEPT = 'image/*,video/*,audio/*,.glb,.gltf,.obj,.fbx,.zip';
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
Which library new uploads go to ('media', 'files' or null), decided by the server from what is
|
|
45
|
+
installed and the media_provider setting. Asked once per page.
|
|
46
|
+
*/
|
|
47
|
+
let infoPromise = null;
|
|
48
|
+
const fieldInfo = () => infoPromise ??= getFields().then(([error, data]) => {
|
|
49
|
+
if(error) infoPromise = null;
|
|
50
|
+
return error ? null : data;
|
|
51
|
+
});
|
|
52
|
+
const uploadProvider = () => fieldInfo().then(data => data?.capabilities.upload ?? null);
|
|
53
|
+
/* Whether files uploaded to kempo-files are made public (the public_photos setting). */
|
|
54
|
+
const uploadsArePublic = () => fieldInfo().then(data => data?.capabilities.publicFiles !== false);
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
The aspect ratios images are shown at, from the category_image_ratio and item_image_ratio settings:
|
|
58
|
+
{ category, item } as CSS values such as "4 / 3". Asked once per page.
|
|
59
|
+
*/
|
|
60
|
+
export const imageRatios = () => fieldInfo().then(data => data?.imageRatios ?? {});
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
Each uploader resolves to [error, { id, asset }]: the value to store, and a description of the
|
|
64
|
+
file in the shape the inventory API reports it. Their libraries are only ever imported here, when
|
|
65
|
+
an upload actually happens, because both are optional.
|
|
66
|
+
*/
|
|
67
|
+
const uploadToMedia = async file => {
|
|
68
|
+
const { uploadMedia } = await import('/kempo-media/sdk.js');
|
|
69
|
+
const [error, data] = await uploadMedia(file);
|
|
70
|
+
if(error) return [error, null];
|
|
71
|
+
const asset = data.asset;
|
|
72
|
+
return [null, { id: asset.id, asset: {
|
|
73
|
+
id: asset.id,
|
|
74
|
+
kind: asset.kind,
|
|
75
|
+
name: asset.originalName,
|
|
76
|
+
alt: asset.altText,
|
|
77
|
+
path: `/${asset.path}`,
|
|
78
|
+
thumbnail: asset.thumbnailPath ? `/${asset.thumbnailPath}` : null,
|
|
79
|
+
} }];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/* kempo-files keeps real filenames and refuses a duplicate, and photos are often all "IMG_0001.jpg". */
|
|
83
|
+
const withSuffix = file => {
|
|
84
|
+
const dot = file.name.lastIndexOf('.');
|
|
85
|
+
const stem = dot > 0 ? file.name.slice(0, dot) : file.name;
|
|
86
|
+
const extension = dot > 0 ? file.name.slice(dot) : '';
|
|
87
|
+
return new File([file], `${stem}-${Math.random().toString(36).slice(2, 7)}${extension}`, { type: file.type });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
Thumbnails are made after the upload, so a new file has none yet: the preview falls back to the
|
|
92
|
+
image itself, and later loads pick the thumbnail up.
|
|
93
|
+
*/
|
|
94
|
+
const uploadToFiles = async file => {
|
|
95
|
+
const { uploadFile, urlForFile } = await import('/kempo-files/sdk.js');
|
|
96
|
+
const options = { public: await uploadsArePublic() };
|
|
97
|
+
let [error, data] = await uploadFile(file, options);
|
|
98
|
+
if(error?.code === 409) [error, data] = await uploadFile(withSuffix(file), options);
|
|
99
|
+
if(error) return [error, null];
|
|
100
|
+
const stored = data.file;
|
|
101
|
+
const id = `files:${stored.id}`;
|
|
102
|
+
return [null, { id, asset: { id, kind: stored.kind, name: stored.name, alt: stored.altText ?? '', path: urlForFile(stored), thumbnail: null } }];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
A gallery of files with an upload button, bound to an array of kempo-media asset ids.
|
|
107
|
+
`value` is that array; it fires `change` whenever it changes.
|
|
108
|
+
*/
|
|
109
|
+
export class InventoryMediaField extends LitElement {
|
|
110
|
+
static properties = {
|
|
111
|
+
value: { type: Array },
|
|
112
|
+
assets: { type: Object },
|
|
113
|
+
uploading: { type: Number, state: true },
|
|
114
|
+
max: { type: Number },
|
|
115
|
+
ratioFor: { type: String, attribute: 'ratio-for' }, // 'item' (default) or 'category': whose aspect ratio the previews use
|
|
116
|
+
ratio: { type: String, state: true },
|
|
117
|
+
cropRatio: { type: String, attribute: 'crop-ratio' }, // force photos to this shape as they are added, e.g. '1 / 1'
|
|
118
|
+
cropMax: { type: Number, attribute: 'crop-max' }, // and keep them within this many pixels on a side
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
constructor(){
|
|
122
|
+
super();
|
|
123
|
+
this.value = [];
|
|
124
|
+
this.assets = {};
|
|
125
|
+
this.uploading = 0;
|
|
126
|
+
this.max = 20; // how many files it holds; adding more replaces the oldest
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
createRenderRoot(){ return this; }
|
|
130
|
+
|
|
131
|
+
changed(){
|
|
132
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* The first photo is the primary one: the picture shown on cards and lists. */
|
|
136
|
+
makePrimary = id => {
|
|
137
|
+
this.value = [id, ...this.value.filter(v => v !== id)];
|
|
138
|
+
this.changed();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
remove = id => {
|
|
142
|
+
this.value = this.value.filter(v => v !== id);
|
|
143
|
+
this.changed();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
addFiles = async files => {
|
|
147
|
+
const provider = await uploadProvider();
|
|
148
|
+
if(!provider){
|
|
149
|
+
Toast.error('Uploading needs the kempo-media or kempo-files extension');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
/*
|
|
153
|
+
A category's picture is always cropped to the category image shape (a setting). A photo field
|
|
154
|
+
crops only when the field says so: its own shape and maximum size.
|
|
155
|
+
*/
|
|
156
|
+
const forced = this.ratioFor === 'category' ? ((await imageRatios()).category ?? '') : (this.cropRatio ?? '');
|
|
157
|
+
const prepared = await prepareImages(files, { ratio: forced, max: this.cropMax || 0 });
|
|
158
|
+
for(const file of prepared){
|
|
159
|
+
this.uploading++;
|
|
160
|
+
const [error, uploaded] = await (provider === 'files' ? uploadToFiles : uploadToMedia)(file);
|
|
161
|
+
this.uploading--;
|
|
162
|
+
if(error){
|
|
163
|
+
Toast.error(`${file.name}: ${error.msg}`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
this.assets = { ...this.assets, [uploaded.id]: uploaded.asset };
|
|
167
|
+
this.value = [...this.value, uploaded.id].slice(-this.max);
|
|
168
|
+
this.changed();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
async connectedCallback(){
|
|
173
|
+
super.connectedCallback();
|
|
174
|
+
const ratios = await imageRatios();
|
|
175
|
+
this.ratio = ratios[this.ratioFor || 'item'] ?? '4 / 3';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
render(){
|
|
179
|
+
return html`
|
|
180
|
+
<div class="d-f" style="flex-wrap: wrap; gap: var(--spacer_h); align-items: center;">
|
|
181
|
+
${this.value.map((id, index) => {
|
|
182
|
+
const asset = this.assets[id];
|
|
183
|
+
const ordered = this.max > 1 && this.value.length > 1;
|
|
184
|
+
return html`
|
|
185
|
+
<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
|
+
${asset?.thumbnail || asset?.kind === 'image'
|
|
187
|
+
? html`<img src=${asset.thumbnail ?? asset.path} alt=${asset.alt || asset.name} style="width: 100%; height: 100%; object-fit: cover;">`
|
|
188
|
+
: 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>
|
|
190
|
+
${ordered ? (index === 0
|
|
191
|
+
? 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
|
+
: 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
|
+
</div>`;
|
|
194
|
+
})}
|
|
195
|
+
<label class="btn" style="margin: 0; cursor: pointer;">
|
|
196
|
+
<k-icon name="add"></k-icon> ${this.uploading ? `Uploading ${this.uploading}…` : 'Add files'}
|
|
197
|
+
<input type="file" multiple accept=${MEDIA_ACCEPT} style="display: none;"
|
|
198
|
+
@change=${e => { const files = [...e.target.files]; e.target.value = ''; this.addFiles(files); }}>
|
|
199
|
+
</label>
|
|
200
|
+
</div>`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if(!customElements.get('inventory-media-field')) customElements.define('inventory-media-field', InventoryMediaField);
|
|
205
|
+
|
|
206
|
+
/*
|
|
207
|
+
A colour field's control. Colour is optional, so with nothing chosen it offers a button, because a
|
|
208
|
+
colour picker on its own always has *some* colour. `value` is a hex string, or '' for none.
|
|
209
|
+
*/
|
|
210
|
+
/*
|
|
211
|
+
Tags are always lower case. Put these two on a <k-tags>: what is typed is converted as it goes in (the
|
|
212
|
+
caret stays where it was), and the chips are made lower case, including ones pasted in. Leaving the box
|
|
213
|
+
does not add what is in it as a tag (see below).
|
|
214
|
+
|
|
215
|
+
<k-tags @input=${lowerTagInput} @change=${lowerTagChips} ...>
|
|
216
|
+
*/
|
|
217
|
+
export const lowerTagInput = event => {
|
|
218
|
+
const input = event.composedPath().find(element => element.id === 'tagsInput');
|
|
219
|
+
if(!input) return;
|
|
220
|
+
/*
|
|
221
|
+
k-tags turns whatever is left in its box into a tag when the box loses focus. Tapping Save with a tag
|
|
222
|
+
half typed would then add a chip, the form would grow a little, and the Save button would move out from
|
|
223
|
+
under the finger. So leaving the box adds nothing; Enter, Tab or a comma add the tag, and readTags()
|
|
224
|
+
picks up what is still in the box when the form is saved.
|
|
225
|
+
*/
|
|
226
|
+
if(!input.dataset.keepOnBlur){
|
|
227
|
+
input.dataset.keepOnBlur = 'true';
|
|
228
|
+
input.addEventListener('change', changeEvent => changeEvent.stopImmediatePropagation(), true);
|
|
229
|
+
}
|
|
230
|
+
if(input.value === input.value.toLowerCase()) return;
|
|
231
|
+
const { selectionStart, selectionEnd } = input;
|
|
232
|
+
input.value = input.value.toLowerCase();
|
|
233
|
+
input.setSelectionRange(selectionStart, selectionEnd);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export const lowerTagChips = event => {
|
|
237
|
+
const $tags = event.currentTarget;
|
|
238
|
+
const lower = $tags.value.toLowerCase();
|
|
239
|
+
if($tags.value !== lower) $tags.value = lower;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/*
|
|
243
|
+
Any CSS colour (the picker can show rgb, hsl, lab...) as the hex the field stores: "#rrggbb", or
|
|
244
|
+
"#rrggbbaa" when it is see-through. The browser does the conversion.
|
|
245
|
+
*/
|
|
246
|
+
const toHex = css => {
|
|
247
|
+
const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
248
|
+
context.clearRect(0, 0, 1, 1);
|
|
249
|
+
context.fillStyle = '#000000';
|
|
250
|
+
context.fillStyle = css;
|
|
251
|
+
context.fillRect(0, 0, 1, 1);
|
|
252
|
+
const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
|
|
253
|
+
const hex = n => n.toString(16).padStart(2, '0');
|
|
254
|
+
return `#${hex(r)}${hex(g)}${hex(b)}${a < 255 ? hex(a) : ''}`;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/*
|
|
258
|
+
Tags use Kempo UI's <k-tags>: the tags as chips (click one to remove it), Enter, Tab or a comma adds
|
|
259
|
+
what is typed, and tags already used on other items and categories are offered as you type.
|
|
260
|
+
|
|
261
|
+
html`<k-tags id="tags" .value=${tagsValue(item.tags)} .getSuggestions=${suggestTags}></k-tags>`
|
|
262
|
+
const tags = readTags($dialog.querySelector('#tags')); // a list, including text typed but not yet added
|
|
263
|
+
*/
|
|
264
|
+
import '/kempo-ui/components/Tags.js';
|
|
265
|
+
|
|
266
|
+
/* k-tags holds its value as comma-separated text. */
|
|
267
|
+
export const tagsValue = tags => (tags ?? []).join(',');
|
|
268
|
+
|
|
269
|
+
/* The tags already in use that contain what has been typed, for k-tags' ghost-text completion. */
|
|
270
|
+
export const suggestTags = async query => {
|
|
271
|
+
const [error, data] = await getTags({ q: query });
|
|
272
|
+
return error ? [] : data.tags.map(entry => entry.tag);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/* The tags in a k-tags as a list. Text still in its box is included, so saving never loses a tag the person typed. */
|
|
276
|
+
export const readTags = $tags => {
|
|
277
|
+
const pending = $tags.shadowRoot?.getElementById('tagsInput')?.value ?? '';
|
|
278
|
+
return [...new Set(`${$tags.value},${pending}`.split(',').map(tag => tag.trim().replace(/\s+/g, ' ').toLowerCase()).filter(Boolean))];
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
class InventoryColorField extends LitElement {
|
|
282
|
+
static properties = {
|
|
283
|
+
value: { type: String },
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
constructor(){
|
|
287
|
+
super();
|
|
288
|
+
this.value = '';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
createRenderRoot(){ return this; }
|
|
292
|
+
|
|
293
|
+
set(value){
|
|
294
|
+
this.value = value;
|
|
295
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true }));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
render(){
|
|
299
|
+
return this.value
|
|
300
|
+
? html`<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
301
|
+
<k-color-picker format="hex" .value=${this.value} @change=${e => {
|
|
302
|
+
e.stopPropagation();
|
|
303
|
+
/* It is a hex field, so a pick made in another format (rgb, hsl...) is converted and shown as hex. */
|
|
304
|
+
const hex = toHex(e.target.value);
|
|
305
|
+
if(e.target.value !== hex){ e.target.format = 'hex'; e.target.value = hex; }
|
|
306
|
+
this.set(hex);
|
|
307
|
+
}}></k-color-picker>
|
|
308
|
+
<span class="tc-muted">${this.value}</span>
|
|
309
|
+
<button type="button" class="btn" @click=${() => this.set('')}>Clear</button>
|
|
310
|
+
</div>`
|
|
311
|
+
: html`<button type="button" class="btn" @click=${() => this.set('#000000')}><k-icon name="add"></k-icon> Choose a color</button>`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if(!customElements.get('inventory-color-field')) customElements.define('inventory-color-field', InventoryColorField);
|
|
316
|
+
|
|
317
|
+
const searchItems = async (event, ownId) => {
|
|
318
|
+
const $combobox = event.currentTarget;
|
|
319
|
+
const [error, data] = await getItems({ q: event.detail.value, limit: 10 });
|
|
320
|
+
if(error) return;
|
|
321
|
+
$combobox.setOptions(data.items.filter(i => i.id !== ownId).map(i => ({ label: `${i.name} (${i.sku})`, value: i.id })));
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/*
|
|
325
|
+
Suggestions for a text field that has "suggest" switched on: the values other items already use.
|
|
326
|
+
Free text is still allowed, so a brand seen for the first time is just typed in.
|
|
327
|
+
*/
|
|
328
|
+
const loadSuggestions = async ($combobox, field, q) => {
|
|
329
|
+
const [error, data] = await getSuggestions(field.key, q, 8, field.category);
|
|
330
|
+
if(!error) $combobox.setOptions(data.suggestions.map(s => s.value));
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/*
|
|
334
|
+
Offers the most used values the moment the field is focused, before anything is typed. The field
|
|
335
|
+
is also scrolled to the middle of the form: the suggestions open below it, and on a phone a field
|
|
336
|
+
near the bottom of a dialog would leave them hidden behind the buttons.
|
|
337
|
+
*/
|
|
338
|
+
const preloadSuggestions = ($combobox, field) => {
|
|
339
|
+
setTimeout(() => $combobox.scrollIntoView({ block: 'center', behavior: 'smooth' }), 120);
|
|
340
|
+
if($combobox.dataset.preloaded) return;
|
|
341
|
+
$combobox.dataset.preloaded = 'true';
|
|
342
|
+
loadSuggestions($combobox, field, '');
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/*
|
|
346
|
+
The form control for one field, with its label, a required marker and its description.
|
|
347
|
+
*/
|
|
348
|
+
export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = null } = {}) => {
|
|
349
|
+
const id = inputId(field);
|
|
350
|
+
const link = field.type === 'item' && value ? linked[value] : null;
|
|
351
|
+
const linkLabel = link ? `${link.name} (${link.sku})` : '';
|
|
352
|
+
let control;
|
|
353
|
+
switch(field.type){
|
|
354
|
+
case 'longtext':
|
|
355
|
+
control = html`<textarea id=${id} class="full" rows="3" .value=${value ?? ''}></textarea>`;
|
|
356
|
+
break;
|
|
357
|
+
case 'number':
|
|
358
|
+
control = html`<input type="number" step="any" id=${id} class="full" .value=${value ?? ''}>`;
|
|
359
|
+
break;
|
|
360
|
+
case 'date':
|
|
361
|
+
control = html`<input type="date" id=${id} class="full" .value=${value ?? ''}>`;
|
|
362
|
+
break;
|
|
363
|
+
case 'color':
|
|
364
|
+
control = html`<inventory-color-field id=${id} .value=${value ?? ''}></inventory-color-field>`;
|
|
365
|
+
break;
|
|
366
|
+
case 'rating':
|
|
367
|
+
control = html`<div class="d-f" style="align-items: center; gap: var(--spacer_h);">
|
|
368
|
+
<k-rating id=${id} .value=${Number(value) || 0}></k-rating>
|
|
369
|
+
<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>
|
|
370
|
+
</div>`;
|
|
371
|
+
break;
|
|
372
|
+
case 'boolean':
|
|
373
|
+
control = html`<k-toggle id=${id} .value=${Boolean(value)}></k-toggle>`;
|
|
374
|
+
break;
|
|
375
|
+
case 'select':
|
|
376
|
+
control = html`<select id=${id} class="full">
|
|
377
|
+
<option value="">${field.required ? 'Choose…' : 'None'}</option>
|
|
378
|
+
${field.options.map(o => html`<option value=${o} ?selected=${o === value}>${o}</option>`)}
|
|
379
|
+
</select>`;
|
|
380
|
+
break;
|
|
381
|
+
case 'item':
|
|
382
|
+
control = html`<k-combobox id=${id} class="full" placeholder="Search items…"
|
|
383
|
+
.value=${linkLabel} data-item-id=${value ?? ''} data-label=${linkLabel}
|
|
384
|
+
@search=${e => searchItems(e, ownId)}
|
|
385
|
+
@select=${e => { e.currentTarget.dataset.itemId = e.detail.value; e.currentTarget.dataset.label = e.detail.label; }}></k-combobox>`;
|
|
386
|
+
break;
|
|
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>`;
|
|
389
|
+
break;
|
|
390
|
+
default:
|
|
391
|
+
control = field.type === 'text' && field.suggest
|
|
392
|
+
? html`<k-combobox id=${id} class="full" debounce-ms="150" .value=${value ?? ''}
|
|
393
|
+
empty-message="Nothing saved yet. Type to add one." no-results-message="New value"
|
|
394
|
+
@focusin=${e => preloadSuggestions(e.currentTarget, field)}
|
|
395
|
+
@search=${e => loadSuggestions(e.currentTarget, field, e.detail.value)}></k-combobox>`
|
|
396
|
+
: html`<input type="text" id=${id} class="full" .value=${value ?? ''}>`;
|
|
397
|
+
}
|
|
398
|
+
return html`<div class="mb">
|
|
399
|
+
<label class="d-b mb-sm" for=${id}>${field.label}${field.required ? ' *' : ''}</label>
|
|
400
|
+
${control}
|
|
401
|
+
${field.description ? html`<small class="d-b tc-muted">${field.description}</small>` : ''}
|
|
402
|
+
</div>`;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/*
|
|
406
|
+
Reads the current value of a field's control. An empty string means "no value".
|
|
407
|
+
*/
|
|
408
|
+
export const readField = ($root, field) => {
|
|
409
|
+
const $el = $root.querySelector(`#${inputId(field)}`);
|
|
410
|
+
switch(field.type){
|
|
411
|
+
case 'rating':
|
|
412
|
+
return $el.value || ''; // no stars is no rating
|
|
413
|
+
case 'boolean':
|
|
414
|
+
return $el.value === true || $el.value === 'true';
|
|
415
|
+
case 'media':
|
|
416
|
+
return [...$el.value];
|
|
417
|
+
case 'item': {
|
|
418
|
+
const text = ($el.value || '').trim();
|
|
419
|
+
if(!text) return '';
|
|
420
|
+
if($el.dataset.itemId && text === $el.dataset.label) return $el.dataset.itemId;
|
|
421
|
+
throw new Error(`${field.label}: choose an item from the list`);
|
|
422
|
+
}
|
|
423
|
+
default:
|
|
424
|
+
return typeof $el.value === 'string' ? $el.value.trim() : $el.value;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/*
|
|
429
|
+
A plain-text rendering of a value, for table cells and summaries. Never returns markup, so it is
|
|
430
|
+
safe to hand to anything that might treat a string as HTML.
|
|
431
|
+
*/
|
|
432
|
+
export const displayValue = (field, value, { linked = {} } = {}) => {
|
|
433
|
+
if(value === undefined || value === null) return '';
|
|
434
|
+
switch(field.type){
|
|
435
|
+
case 'boolean': return value ? 'Yes' : 'No';
|
|
436
|
+
case 'rating': return '★'.repeat(Number(value)) + '☆'.repeat(5 - Number(value));
|
|
437
|
+
case 'item': return linked[value] ? `${linked[value].name} (${linked[value].sku})` : '(deleted item)';
|
|
438
|
+
case 'media': return value.length === 1 ? '1 file' : `${value.length} files`;
|
|
439
|
+
default: return String(value);
|
|
440
|
+
}
|
|
441
|
+
};
|