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,229 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="Inventory Categories - Admin">
|
|
5
|
+
<content>
|
|
6
|
+
<script type="module" src="/kempo-ui/components/Table.js"></script>
|
|
7
|
+
<script type="module" src="/kempo-ui/components/controls/TcDeleteRecord.js"></script>
|
|
8
|
+
<script type="module" src="/kempo-ui/components/controls/TcSearch.js"></script>
|
|
9
|
+
<script type="module" src="/kempo-ui/components/controls/TcFirstPage.js"></script>
|
|
10
|
+
<script type="module" src="/kempo-ui/components/controls/TcPrevPage.js"></script>
|
|
11
|
+
<script type="module" src="/kempo-ui/components/controls/TcNextPage.js"></script>
|
|
12
|
+
<script type="module" src="/kempo-ui/components/controls/TcLastPage.js"></script>
|
|
13
|
+
<script type="module" src="/kempo-ui/components/controls/TcPageSelect.js"></script>
|
|
14
|
+
<script type="module" src="/kempo-ui/components/controls/TcPageSize.js"></script>
|
|
15
|
+
<script type="module" src="/kempo-ui/components/Icon.js"></script>
|
|
16
|
+
<script type="module" src="/kempo/components/Permission.js"></script>
|
|
17
|
+
<script type="module" src="/inventory/components/RowAction.js"></script>
|
|
18
|
+
|
|
19
|
+
<div class="d-f mb" style="align-items: center; gap: var(--spacer);">
|
|
20
|
+
<a href="/admin/extension/kempo-inventory/" class="btn"><k-icon name="arrow" direction="left"></k-icon> Inventory</a>
|
|
21
|
+
<h1 class="flex mb0">Categories</h1>
|
|
22
|
+
</div>
|
|
23
|
+
<p class="mb tc-muted">
|
|
24
|
+
An item's category is the text in its Category box, so a category appears here as soon as an item uses it.
|
|
25
|
+
Here you can give each one a picture and a description, set up fields that only items in it have (the gear button), rename it (which updates its items, and merges it
|
|
26
|
+
into another category if you pick a name that already exists), or add one before any item uses it.
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<k-table id="categoriesTable" enable-pages page-size="25" page-size-options="[10,25,50]" placeholder="No categories yet." filtered-placeholder="No categories match your search.">
|
|
30
|
+
<div slot="top" class="d-f full" style="align-items: center; gap: var(--spacer_h); flex-wrap: wrap;">
|
|
31
|
+
<kc-tc-search style="width: 16rem"></kc-tc-search>
|
|
32
|
+
<div class="flex"></div>
|
|
33
|
+
<k-permission has="kempo-inventory:items:update">
|
|
34
|
+
<button id="newCategoryBtn" class="primary m0"><k-icon name="add"></k-icon> New Category</button>
|
|
35
|
+
</k-permission>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="d-f full" style="align-items: center;">
|
|
38
|
+
<div>
|
|
39
|
+
<kc-tc-first-page></kc-tc-first-page>
|
|
40
|
+
<kc-tc-prev-page></kc-tc-prev-page>
|
|
41
|
+
<kc-tc-next-page></kc-tc-next-page>
|
|
42
|
+
<kc-tc-last-page></kc-tc-last-page>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="flex"></div>
|
|
45
|
+
<div><kc-tc-page-select></kc-tc-page-select></div>
|
|
46
|
+
<div class="flex"></div>
|
|
47
|
+
<div><kc-tc-page-size></kc-tc-page-size></div>
|
|
48
|
+
</div>
|
|
49
|
+
</k-table>
|
|
50
|
+
<p id="uncategorised" class="mt tc-muted"></p>
|
|
51
|
+
|
|
52
|
+
<script type="module">
|
|
53
|
+
import { getCategories, saveCategory, deleteCategory } from '/inventory/sdk.js';
|
|
54
|
+
import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
55
|
+
import { html } from '/kempo-ui/lit-all.min.js';
|
|
56
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
57
|
+
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
58
|
+
import { tagsValue, suggestTags, readTags, lowerTagInput, lowerTagChips } from '/inventory/components/ItemFields.js';
|
|
59
|
+
|
|
60
|
+
await window.customElements.whenDefined('k-table');
|
|
61
|
+
const $table = document.getElementById('categoriesTable');
|
|
62
|
+
|
|
63
|
+
const [, permData] = await getAllCurrentUserPermissions();
|
|
64
|
+
const canEdit = new Set(permData?.permissions ?? []).has('kempo-inventory:items:update');
|
|
65
|
+
|
|
66
|
+
const manageFields = document.createElement('inventory-row-action');
|
|
67
|
+
manageFields.setAttribute('slot', 'before');
|
|
68
|
+
manageFields.setAttribute('action', 'fields');
|
|
69
|
+
manageFields.setAttribute('icon', 'settings');
|
|
70
|
+
manageFields.setAttribute('title', 'Fields for this category');
|
|
71
|
+
$table.appendChild(manageFields);
|
|
72
|
+
|
|
73
|
+
if(canEdit){
|
|
74
|
+
const edit = document.createElement('inventory-row-action');
|
|
75
|
+
edit.setAttribute('slot', 'before');
|
|
76
|
+
edit.setAttribute('action', 'edit');
|
|
77
|
+
edit.setAttribute('icon', 'edit');
|
|
78
|
+
edit.setAttribute('title', 'Edit');
|
|
79
|
+
$table.appendChild(edit);
|
|
80
|
+
|
|
81
|
+
$table.setAttribute('request-delete', '');
|
|
82
|
+
const del = document.createElement('kc-tc-delete-record');
|
|
83
|
+
del.setAttribute('slot', 'after');
|
|
84
|
+
$table.appendChild(del);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const keyOf = name => String(name ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
88
|
+
let categories = [];
|
|
89
|
+
|
|
90
|
+
/* Plain strings only, so a name or description can never be read as markup. */
|
|
91
|
+
const decorate = c => ({
|
|
92
|
+
...c,
|
|
93
|
+
itemsText: String(c.count),
|
|
94
|
+
imageText: c.image ? 'Yes' : '',
|
|
95
|
+
fieldsText: String(c.fields),
|
|
96
|
+
tagsText: c.tags.join(', '),
|
|
97
|
+
ownerText: c.owner,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const load = async () => {
|
|
101
|
+
const [error, data] = await getCategories({ includeEmpty: true });
|
|
102
|
+
if(error){
|
|
103
|
+
Toast.error(error.msg || 'Failed to load the categories');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
categories = data.categories;
|
|
107
|
+
$table.setData({
|
|
108
|
+
records: categories.map(decorate),
|
|
109
|
+
fields: [
|
|
110
|
+
{ name: 'name', label: 'Name', size: 220, editable: false },
|
|
111
|
+
{ name: 'itemsText', label: 'Items', size: 80, editable: false },
|
|
112
|
+
{ name: 'fieldsText', label: 'Fields', size: 80, editable: false },
|
|
113
|
+
{ name: 'description', label: 'Description', size: 300, editable: false },
|
|
114
|
+
{ name: 'tagsText', label: 'Tags', size: 200, editable: false },
|
|
115
|
+
{ name: 'imageText', label: 'Image', size: 80, editable: false },
|
|
116
|
+
...(categories.some(c => c.owner) ? [{ name: 'ownerText', label: 'Managed by', size: 170, editable: false }] : []),
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
document.getElementById('uncategorised').textContent = data.uncategorised
|
|
120
|
+
? `${data.uncategorised} item${data.uncategorised === 1 ? ' has' : 's have'} no category.`
|
|
121
|
+
: '';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/* One dialog for new and edit. Saving always sends what the dialog shows, so it is the source of truth. */
|
|
125
|
+
const openDialog = category => {
|
|
126
|
+
const editing = Boolean(category);
|
|
127
|
+
const $dialog = Dialog.create(html`
|
|
128
|
+
<div class="p" style="max-height: calc(100vh - 14rem); overflow-y: auto;">
|
|
129
|
+
<div class="mb">
|
|
130
|
+
<label class="d-b mb-sm" for="cat-name">Name *</label>
|
|
131
|
+
<input type="text" id="cat-name" class="full" maxlength="100" ?disabled=${editing && category.owner} .value=${category?.name ?? ''}>
|
|
132
|
+
${editing && category.owner ? html`<small class="d-b tc-muted">Managed by the ${category.owner} extension, which is the only one that can rename it. You can still change everything else.</small>` : ''}
|
|
133
|
+
${editing && !category.owner && category.count ? html`<small class="d-b tc-muted">Renaming also changes the category on its ${category.count} item${category.count === 1 ? '' : 's'}.</small>` : ''}
|
|
134
|
+
</div>
|
|
135
|
+
<div class="mb">
|
|
136
|
+
<label class="d-b mb-sm" for="cat-description">Description</label>
|
|
137
|
+
<textarea id="cat-description" class="full" rows="3" maxlength="500" .value=${category?.description ?? ''}></textarea>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="mb">
|
|
140
|
+
<label class="d-b mb-sm" for="cat-tags">Tags</label>
|
|
141
|
+
<k-tags id="cat-tags" .value=${tagsValue(category?.tags)} .getSuggestions=${suggestTags} @input=${lowerTagInput} @change=${lowerTagChips}></k-tags>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="mb">
|
|
144
|
+
<label class="d-b mb-sm">Image</label>
|
|
145
|
+
<inventory-media-field id="cat-image" ratio-for="category" .max=${1} .value=${category?.image ? [category.image.id] : []} .assets=${category?.image ? { [category.image.id]: category.image } : {}}></inventory-media-field>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
`, {
|
|
149
|
+
title: editing ? `Edit ${category.name}` : 'New Category',
|
|
150
|
+
confirmText: editing ? 'Save' : 'Create',
|
|
151
|
+
cancelText: 'Cancel',
|
|
152
|
+
confirmAction: async event => {
|
|
153
|
+
event.keepDialogOpen = true;
|
|
154
|
+
const name = $dialog.querySelector('#cat-name').value.trim();
|
|
155
|
+
if(!name){
|
|
156
|
+
Toast.error('A name is required');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const changes = {
|
|
160
|
+
description: $dialog.querySelector('#cat-description').value,
|
|
161
|
+
tags: readTags($dialog.querySelector('#cat-tags')),
|
|
162
|
+
image: $dialog.querySelector('#cat-image').value[0] ?? null,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
let error, result;
|
|
166
|
+
if(editing){
|
|
167
|
+
if(name !== category.name && !category.owner) changes.newName = name;
|
|
168
|
+
[error, result] = await saveCategory(category.name, changes);
|
|
169
|
+
} else {
|
|
170
|
+
/* Creating must not silently overwrite one that is already there. */
|
|
171
|
+
if(categories.some(c => keyOf(c.name) === keyOf(name))){
|
|
172
|
+
Toast.error('That category already exists. Edit it instead');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
[error, result] = await saveCategory(name, changes);
|
|
176
|
+
}
|
|
177
|
+
if(error){
|
|
178
|
+
Toast.error(error.msg || 'Failed to save the category');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
$dialog.close();
|
|
182
|
+
const moved = result.category.moved;
|
|
183
|
+
Toast.success(!editing ? 'Category created' : moved ? `Saved. ${moved} item${moved === 1 ? '' : 's'} updated` : 'Category saved');
|
|
184
|
+
load();
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
$table.addEventListener('itemAction', ({ detail: { action, record } }) => {
|
|
190
|
+
if(action === 'edit') openDialog(categories.find(c => c.key === record.key));
|
|
191
|
+
if(action === 'fields') location.href = `/admin/extension/kempo-inventory/fields?category=${encodeURIComponent(record.name)}`;
|
|
192
|
+
});
|
|
193
|
+
document.getElementById('newCategoryBtn')?.addEventListener('click', () => openDialog(null));
|
|
194
|
+
|
|
195
|
+
$table.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
|
|
196
|
+
const [record] = records;
|
|
197
|
+
if(record.owner){
|
|
198
|
+
reject();
|
|
199
|
+
Toast.error(`"${record.name}" is managed by the ${record.owner} extension and cannot be deleted here`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if(record.fields){
|
|
203
|
+
reject();
|
|
204
|
+
Toast.error(`"${record.name}" still has ${record.fields} field${record.fields === 1 ? '' : 's'}. Delete ${record.fields === 1 ? 'it' : 'them'} first`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if(record.count){
|
|
208
|
+
reject();
|
|
209
|
+
Toast.error(`${record.count} item${record.count === 1 ? ' is' : 's are'} still in "${record.name}". Move or clear ${record.count === 1 ? 'it' : 'them'} first`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
Dialog.confirm(`Delete the category "${record.name}"? Its image and description are removed. This action cannot be undone.`, async confirmed => {
|
|
213
|
+
if(!confirmed){ reject(); return; }
|
|
214
|
+
const [error] = await deleteCategory(record.name);
|
|
215
|
+
if(error){
|
|
216
|
+
reject();
|
|
217
|
+
Toast.error(error.msg || 'Failed to delete the category');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
approve();
|
|
221
|
+
Toast.success('Category deleted');
|
|
222
|
+
load();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await load();
|
|
227
|
+
</script>
|
|
228
|
+
</content>
|
|
229
|
+
</page>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="Edit Item - Admin">
|
|
5
|
+
<content>
|
|
6
|
+
<script type="module" src="/kempo-ui/components/Icon.js"></script>
|
|
7
|
+
|
|
8
|
+
<div class="d-f mb" style="align-items: center; gap: var(--spacer);">
|
|
9
|
+
<a href="/admin/extension/kempo-inventory/" class="btn" id="backLink"><k-icon name="arrow" direction="left"></k-icon> Inventory</a>
|
|
10
|
+
<h1 class="flex mb0" id="title">Edit Item</h1>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div id="editItem" style="max-width: 44rem;"></div>
|
|
14
|
+
|
|
15
|
+
<script type="module">
|
|
16
|
+
import { getItem, getFields } from '/inventory/sdk.js';
|
|
17
|
+
import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
18
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
19
|
+
import { mountEditItemPage, safeNext } from '/inventory/components/NewItemPage.js';
|
|
20
|
+
import { showFlash } from '/inventory/components/flash.js';
|
|
21
|
+
|
|
22
|
+
/* ?id= is the item; ?next= (a path on this site) is where to go after saving or cancelling. */
|
|
23
|
+
const params = new URLSearchParams(location.search);
|
|
24
|
+
const next = safeNext(params.get('next'), '/admin/extension/kempo-inventory/');
|
|
25
|
+
document.getElementById('backLink').href = next;
|
|
26
|
+
const $container = document.getElementById('editItem');
|
|
27
|
+
|
|
28
|
+
const [, permData] = await getAllCurrentUserPermissions();
|
|
29
|
+
if(!new Set(permData?.permissions ?? []).has('kempo-inventory:items:update')){
|
|
30
|
+
$container.innerHTML = '<p class="tc-danger">Your account is not allowed to edit items.</p>';
|
|
31
|
+
} else {
|
|
32
|
+
const [[itemError, data], [fieldsError, fieldData]] = await Promise.all([getItem(params.get('id')), getFields()]);
|
|
33
|
+
if(itemError || fieldsError){
|
|
34
|
+
const error = itemError || fieldsError;
|
|
35
|
+
$container.innerHTML = `<p class="tc-danger">${error.code === 404 ? 'Item not found.' : 'Something went wrong loading the item.'}</p>`;
|
|
36
|
+
Toast.error(error.msg || 'Failed to load the item');
|
|
37
|
+
} else {
|
|
38
|
+
document.getElementById('title').textContent = `Edit ${data.item.name}`;
|
|
39
|
+
mountEditItemPage({
|
|
40
|
+
$container,
|
|
41
|
+
item: data.item,
|
|
42
|
+
fields: fieldData.fields,
|
|
43
|
+
context: { linked: data.linked, media: data.media, ownId: data.item.id },
|
|
44
|
+
next,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
showFlash();
|
|
49
|
+
</script>
|
|
50
|
+
</content>
|
|
51
|
+
</page>
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="Inventory Fields - Admin">
|
|
5
|
+
<content>
|
|
6
|
+
<script type="module" src="/kempo-ui/components/Table.js"></script>
|
|
7
|
+
<script type="module" src="/kempo-ui/components/controls/TcDeleteRecord.js"></script>
|
|
8
|
+
<script type="module" src="/kempo-ui/components/controls/TcSearch.js"></script>
|
|
9
|
+
<script type="module" src="/kempo-ui/components/Toggle.js"></script>
|
|
10
|
+
<script type="module" src="/kempo-ui/components/Icon.js"></script>
|
|
11
|
+
<script type="module" src="/kempo/components/Permission.js"></script>
|
|
12
|
+
<script type="module" src="/inventory/components/RowAction.js"></script>
|
|
13
|
+
|
|
14
|
+
<div class="d-f mb" style="align-items: center; gap: var(--spacer);">
|
|
15
|
+
<a href="/admin/extension/kempo-inventory/" class="btn"><k-icon name="arrow" direction="left"></k-icon> Inventory</a>
|
|
16
|
+
<h1 class="flex mb0">Item Fields</h1>
|
|
17
|
+
</div>
|
|
18
|
+
<p class="mb tc-muted">
|
|
19
|
+
Every item has a SKU, name, category, quantity and description. A field you add for <strong>every item</strong> appears on all of them;
|
|
20
|
+
a field you add for one category (a paint's "Finish", say) only appears on items in that category.
|
|
21
|
+
Fields added by other extensions are managed by those extensions.
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<k-table id="fieldsTable" placeholder="No custom fields yet." filtered-placeholder="No fields match your search.">
|
|
25
|
+
<div slot="top" class="d-f full" style="align-items: center; gap: var(--spacer_h); flex-wrap: wrap;">
|
|
26
|
+
<kc-tc-search style="width: 16rem"></kc-tc-search>
|
|
27
|
+
<label class="d-f m0" style="align-items: center; gap: var(--spacer_h);">Show fields for
|
|
28
|
+
<select id="scopeFilter" style="width: auto;" aria-label="Show fields for"></select>
|
|
29
|
+
</label>
|
|
30
|
+
<div class="flex"></div>
|
|
31
|
+
<k-permission has="kempo-inventory:fields:manage">
|
|
32
|
+
<button id="newFieldBtn" class="primary m0"><k-icon name="add"></k-icon> New Field</button>
|
|
33
|
+
</k-permission>
|
|
34
|
+
</div>
|
|
35
|
+
</k-table>
|
|
36
|
+
|
|
37
|
+
<script type="module">
|
|
38
|
+
import { getFields, createField, updateField, deleteField, getCategories } from '/inventory/sdk.js';
|
|
39
|
+
import { html } from '/kempo-ui/lit-all.min.js';
|
|
40
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
41
|
+
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
42
|
+
|
|
43
|
+
await window.customElements.whenDefined('k-table');
|
|
44
|
+
await window.customElements.whenDefined('k-toggle');
|
|
45
|
+
const $table = document.getElementById('fieldsTable');
|
|
46
|
+
|
|
47
|
+
const TYPE_LABELS = {
|
|
48
|
+
text: 'Text',
|
|
49
|
+
longtext: 'Long text',
|
|
50
|
+
number: 'Number',
|
|
51
|
+
boolean: 'Yes / No',
|
|
52
|
+
date: 'Date',
|
|
53
|
+
color: 'Color',
|
|
54
|
+
rating: 'Rating (stars)',
|
|
55
|
+
select: 'Choice list',
|
|
56
|
+
item: 'Link to another item',
|
|
57
|
+
media: 'Photos / files',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const addControl = (tag, slot, attributes = {}) => {
|
|
61
|
+
const $control = document.createElement(tag);
|
|
62
|
+
$control.setAttribute('slot', slot);
|
|
63
|
+
for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
|
|
64
|
+
$table.appendChild($control);
|
|
65
|
+
};
|
|
66
|
+
addControl('inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
|
|
67
|
+
$table.setAttribute('request-delete', '');
|
|
68
|
+
addControl('kc-tc-delete-record', 'after');
|
|
69
|
+
|
|
70
|
+
/* Same rule the server applies, so the key preview matches what gets saved. */
|
|
71
|
+
const keyFromLabel = label => {
|
|
72
|
+
const words = String(label ?? '').replace(/[^a-zA-Z0-9]+/g, ' ').trim().split(' ').filter(Boolean);
|
|
73
|
+
const key = words.map((w, i) => i === 0 ? w.charAt(0).toLowerCase() + w.slice(1) : w.charAt(0).toUpperCase() + w.slice(1)).join('');
|
|
74
|
+
return /^[a-z]/.test(key) ? key.slice(0, 40) : '';
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const decorate = field => ({
|
|
78
|
+
...field,
|
|
79
|
+
typeText: TYPE_LABELS[field.type] ?? field.type,
|
|
80
|
+
requiredText: field.required ? 'Yes' : 'No',
|
|
81
|
+
listedText: field.listed ? 'Yes' : 'No',
|
|
82
|
+
ownerText: field.owner || 'Custom',
|
|
83
|
+
scopeText: field.category ? field.categoryName : 'Every item',
|
|
84
|
+
optionsText: field.options.join(', '),
|
|
85
|
+
cropText: field.type === 'media' ? [field.imageRatio ? field.imageRatio.replace(' / ', ':') : '', field.imageMax ? `max ${field.imageMax}px` : ''].filter(Boolean).join(', ') : '',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/* The types that can be created right now; "media" is absent when kempo-media is not installed. */
|
|
89
|
+
let creatableTypes = Object.keys(TYPE_LABELS);
|
|
90
|
+
/* Which type changes are safe, from the server: { select: ['text'], text: ['longtext'] }. */
|
|
91
|
+
let conversions = {};
|
|
92
|
+
|
|
93
|
+
/* '' = every field, '*' = the default fields only, otherwise a category key. */
|
|
94
|
+
const $scope = document.getElementById('scopeFilter');
|
|
95
|
+
let categories = [];
|
|
96
|
+
let allFields = [];
|
|
97
|
+
const wanted = new URLSearchParams(location.search).get('category');
|
|
98
|
+
let scope = wanted ? wanted.trim().replace(/\s+/g, ' ').toLowerCase() : '';
|
|
99
|
+
|
|
100
|
+
const renderScopeOptions = () => {
|
|
101
|
+
$scope.replaceChildren(...[['', 'All fields'], ['*', 'Every item (default fields)'], ...categories.map(c => [c.key, c.name])].map(([value, label]) => {
|
|
102
|
+
const $option = document.createElement('option');
|
|
103
|
+
$option.value = value;
|
|
104
|
+
$option.textContent = label;
|
|
105
|
+
$option.selected = value === scope;
|
|
106
|
+
return $option;
|
|
107
|
+
}));
|
|
108
|
+
};
|
|
109
|
+
const renderTable = () => {
|
|
110
|
+
const visible = allFields.filter(f => scope === '' || (scope === '*' ? !f.category : f.category === scope));
|
|
111
|
+
$table.setData({
|
|
112
|
+
records: visible.map(decorate),
|
|
113
|
+
fields: [
|
|
114
|
+
{ name: 'label', label: 'Label', size: 200, editable: false },
|
|
115
|
+
{ name: 'key', label: 'Key', size: 150, editable: false },
|
|
116
|
+
{ name: 'scopeText', label: 'Applies to', size: 170, editable: false },
|
|
117
|
+
{ name: 'typeText', label: 'Type', size: 170, editable: false },
|
|
118
|
+
{ name: 'requiredText', label: 'Required', size: 90, editable: false },
|
|
119
|
+
{ name: 'listedText', label: 'In list', size: 80, editable: false },
|
|
120
|
+
{ name: 'optionsText', label: 'Choices', size: 200, editable: false },
|
|
121
|
+
{ name: 'cropText', label: 'Photo crop', size: 150, editable: false },
|
|
122
|
+
{ name: 'ownerText', label: 'Managed by', size: 170, editable: false },
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
$scope.addEventListener('change', () => { scope = $scope.value; renderTable(); });
|
|
127
|
+
|
|
128
|
+
const load = async () => {
|
|
129
|
+
const [categoriesError, categoryData] = await getCategories({ includeEmpty: true });
|
|
130
|
+
if(!categoriesError) categories = categoryData.categories;
|
|
131
|
+
const [error, data] = await getFields();
|
|
132
|
+
if(!error){
|
|
133
|
+
creatableTypes = data.types;
|
|
134
|
+
conversions = data.conversions ?? {};
|
|
135
|
+
}
|
|
136
|
+
if(error){
|
|
137
|
+
Toast.error(error.msg || 'Failed to load fields');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
allFields = data.fields;
|
|
141
|
+
renderScopeOptions();
|
|
142
|
+
renderTable();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
$table.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
|
|
146
|
+
const [record] = records;
|
|
147
|
+
if(record.owner){
|
|
148
|
+
reject();
|
|
149
|
+
Toast.error(`"${record.label}" is managed by the ${record.owner} extension and cannot be deleted here`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
Dialog.confirm(`Delete the field "${record.label}"? Its value is removed from ${record.category ? 'every item in that category' : 'every item'}. This action cannot be undone.`, async confirmed => {
|
|
153
|
+
if(!confirmed){ reject(); return; }
|
|
154
|
+
const [error] = await deleteField(record.key, { category: record.category });
|
|
155
|
+
if(error){
|
|
156
|
+
reject();
|
|
157
|
+
Toast.error(error.msg || 'Failed to delete field');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
approve();
|
|
161
|
+
Toast.success('Field deleted');
|
|
162
|
+
load();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const openFieldDialog = field => {
|
|
167
|
+
const editing = Boolean(field);
|
|
168
|
+
const managed = editing && Boolean(field.owner);
|
|
169
|
+
let keyTouched = editing;
|
|
170
|
+
|
|
171
|
+
const $dialog = Dialog.create(html`
|
|
172
|
+
<div class="p" style="max-height: calc(100vh - 14rem); overflow-y: auto;">
|
|
173
|
+
${managed ? html`<p class="mb tc-muted">Managed by the ${field.owner} extension. You can rename it, change its description and choose whether it shows in the list.</p>` : ''}
|
|
174
|
+
<div class="mb">
|
|
175
|
+
<label class="d-b mb-sm" for="fld-label">Label *</label>
|
|
176
|
+
<input type="text" id="fld-label" class="full" .value=${field?.label ?? ''}
|
|
177
|
+
@input=${e => {
|
|
178
|
+
if(keyTouched) return;
|
|
179
|
+
$dialog.querySelector('#fld-key').value = keyFromLabel(e.target.value);
|
|
180
|
+
}}>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="mb">
|
|
183
|
+
<label class="d-b mb-sm" for="fld-scope">Applies to</label>
|
|
184
|
+
<select id="fld-scope" class="full" ?disabled=${editing}>
|
|
185
|
+
<option value="" ?selected=${editing ? !field.category : scope === '' || scope === '*'}>Every item</option>
|
|
186
|
+
${categories.map(c => html`<option value=${c.name} ?selected=${editing ? c.key === field.category : c.key === scope}>Only ${c.name}</option>`)}
|
|
187
|
+
</select>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="d-f mb" style="gap: var(--spacer);">
|
|
190
|
+
<div class="flex">
|
|
191
|
+
<label class="d-b mb-sm" for="fld-key">Key *</label>
|
|
192
|
+
<input type="text" id="fld-key" class="full" ?disabled=${editing} .value=${field?.key ?? ''}
|
|
193
|
+
@input=${() => { keyTouched = true; }}>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="flex">
|
|
196
|
+
<label class="d-b mb-sm" for="fld-type">Type *</label>
|
|
197
|
+
<select id="fld-type" class="full" ?disabled=${editing && (managed || !conversions[field.type]?.length)}>
|
|
198
|
+
${Object.entries(TYPE_LABELS).filter(([value]) => editing
|
|
199
|
+
? value === field.type || (!managed && conversions[field.type]?.includes(value))
|
|
200
|
+
: creatableTypes.includes(value)).map(([value, label]) => html`<option value=${value} ?selected=${value === (field?.type ?? 'text')}>${label}</option>`)}
|
|
201
|
+
</select>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<div id="fld-options-row" class="mb" style="display: none;">
|
|
205
|
+
<label class="d-b mb-sm" for="fld-options">Choices (one per line)</label>
|
|
206
|
+
<textarea id="fld-options" class="full" rows="4" ?disabled=${managed} .value=${field?.options.join('\n') ?? ''}></textarea>
|
|
207
|
+
</div>
|
|
208
|
+
<div id="fld-crop-row" class="mb" style="display: none;">
|
|
209
|
+
<div class="d-f" style="gap: var(--spacer);">
|
|
210
|
+
<div class="flex">
|
|
211
|
+
<label class="d-b mb-sm" for="fld-ratio">Force photo shape</label>
|
|
212
|
+
<input type="text" id="fld-ratio" class="full" placeholder="e.g. 1:1, 4:3, 16:9" autocomplete="off" .value=${field?.imageRatio ? field.imageRatio.replace(' / ', ':') : ''}>
|
|
213
|
+
</div>
|
|
214
|
+
<div style="width: 9rem;">
|
|
215
|
+
<label class="d-b mb-sm" for="fld-max">Max size (px)</label>
|
|
216
|
+
<input type="number" id="fld-max" class="full" min="0" max="8000" step="1" placeholder="no limit" .value=${field?.imageMax || ''}>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<small class="d-b tc-muted">Photos are cropped to this shape as they are added (a cropper opens, locked to it) and kept within the maximum size. Leave the shape empty to allow any shape; with only a maximum, photos are just scaled down.</small>
|
|
220
|
+
</div>
|
|
221
|
+
<div id="fld-suggest-row" class="mb" style="display: none;">
|
|
222
|
+
<k-toggle id="fld-suggest" .value=${Boolean(field?.suggest)}>Suggest values already used</k-toggle>
|
|
223
|
+
<small class="d-b tc-muted">Offers values from other items as you type, so repeated entries such as a brand stay consistent. Anything new can still be typed.</small>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="mb">
|
|
226
|
+
<label class="d-b mb-sm" for="fld-description">Description</label>
|
|
227
|
+
<input type="text" id="fld-description" class="full" .value=${field?.description ?? ''}>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="d-f mb" style="gap: var(--spacer); align-items: center;">
|
|
230
|
+
${managed ? '' : html`<k-toggle id="fld-required" .value=${Boolean(field?.required)}>Required</k-toggle>`}
|
|
231
|
+
<k-toggle id="fld-listed" .value=${field ? Boolean(field.listed) : true}>Show in list</k-toggle>
|
|
232
|
+
<div class="flex"></div>
|
|
233
|
+
<div style="width: 7rem;">
|
|
234
|
+
<label class="d-b mb-sm" for="fld-position">Order</label>
|
|
235
|
+
<input type="number" id="fld-position" class="full" step="1" .value=${field?.position ?? 0}>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
`, {
|
|
240
|
+
title: editing ? `Edit ${field.label}` : 'New Field',
|
|
241
|
+
confirmText: editing ? 'Save' : 'Create',
|
|
242
|
+
cancelText: 'Cancel',
|
|
243
|
+
confirmAction: async event => {
|
|
244
|
+
event.keepDialogOpen = true;
|
|
245
|
+
const value = id => $dialog.querySelector(id).value;
|
|
246
|
+
const data = {
|
|
247
|
+
label: value('#fld-label').trim(),
|
|
248
|
+
description: value('#fld-description'),
|
|
249
|
+
listed: $dialog.querySelector('#fld-listed').value === true,
|
|
250
|
+
suggest: $dialog.querySelector('#fld-suggest').value === true,
|
|
251
|
+
position: Number(value('#fld-position') || 0),
|
|
252
|
+
};
|
|
253
|
+
if($type.value === 'media'){
|
|
254
|
+
data.imageRatio = value('#fld-ratio').trim();
|
|
255
|
+
data.imageMax = Number(value('#fld-max') || 0);
|
|
256
|
+
}
|
|
257
|
+
if(!managed){
|
|
258
|
+
data.required = $dialog.querySelector('#fld-required').value === true;
|
|
259
|
+
data.options = value('#fld-options').split('\n').map(o => o.trim()).filter(Boolean);
|
|
260
|
+
}
|
|
261
|
+
if(!data.label){
|
|
262
|
+
Toast.error('A label is required');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let error;
|
|
266
|
+
if(editing){
|
|
267
|
+
if(value('#fld-type') !== field.type) data.type = value('#fld-type');
|
|
268
|
+
[error] = await updateField(field.key, data, { category: field.category });
|
|
269
|
+
} else {
|
|
270
|
+
[error] = await createField({ ...data, key: value('#fld-key').trim(), type: value('#fld-type'), category: value('#fld-scope') });
|
|
271
|
+
}
|
|
272
|
+
if(error){
|
|
273
|
+
Toast.error(error.msg || 'Failed to save field');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
$dialog.close();
|
|
277
|
+
Toast.success(editing ? 'Field saved' : 'Field created');
|
|
278
|
+
load();
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const $type = $dialog.querySelector('#fld-type');
|
|
283
|
+
const syncOptions = () => {
|
|
284
|
+
$dialog.querySelector('#fld-options-row').style.display = $type.value === 'select' ? '' : 'none';
|
|
285
|
+
$dialog.querySelector('#fld-suggest-row').style.display = $type.value === 'text' ? '' : 'none';
|
|
286
|
+
$dialog.querySelector('#fld-crop-row').style.display = $type.value === 'media' ? '' : 'none';
|
|
287
|
+
};
|
|
288
|
+
$type.addEventListener('change', syncOptions);
|
|
289
|
+
syncOptions();
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
$table.addEventListener('itemAction', ({ detail: { action, record } }) => {
|
|
293
|
+
if(action === 'edit') openFieldDialog(record);
|
|
294
|
+
});
|
|
295
|
+
document.getElementById('newFieldBtn')?.addEventListener('click', () => openFieldDialog(null));
|
|
296
|
+
|
|
297
|
+
await load();
|
|
298
|
+
</script>
|
|
299
|
+
</content>
|
|
300
|
+
</page>
|