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,203 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="Inventory - Admin">
|
|
5
|
+
<content>
|
|
6
|
+
<script type="module" src="/kempo-ui/components/Table.js"></script>
|
|
7
|
+
<script type="module" src="/kempo-ui/components/controls/TcSearch.js"></script>
|
|
8
|
+
<script type="module" src="/kempo-ui/components/controls/TcFilters.js"></script>
|
|
9
|
+
<script type="module" src="/kempo-ui/components/controls/TcFieldSortHide.js"></script>
|
|
10
|
+
<script type="module" src="/kempo-ui/components/controls/TcFirstPage.js"></script>
|
|
11
|
+
<script type="module" src="/kempo-ui/components/controls/TcPrevPage.js"></script>
|
|
12
|
+
<script type="module" src="/kempo-ui/components/controls/TcNextPage.js"></script>
|
|
13
|
+
<script type="module" src="/kempo-ui/components/controls/TcLastPage.js"></script>
|
|
14
|
+
<script type="module" src="/kempo-ui/components/controls/TcPageSelect.js"></script>
|
|
15
|
+
<script type="module" src="/kempo-ui/components/controls/TcPageSize.js"></script>
|
|
16
|
+
<script type="module" src="/kempo-ui/components/controls/TcDeleteRecord.js"></script>
|
|
17
|
+
<script type="module" src="/kempo-ui/components/Toggle.js"></script>
|
|
18
|
+
<script type="module" src="/kempo-ui/components/Combobox.js"></script>
|
|
19
|
+
<script type="module" src="/kempo-ui/components/Icon.js"></script>
|
|
20
|
+
<script type="module" src="/kempo/components/Permission.js"></script>
|
|
21
|
+
<script type="module" src="/inventory/components/RowAction.js"></script>
|
|
22
|
+
|
|
23
|
+
<h1 class="mb">Inventory</h1>
|
|
24
|
+
|
|
25
|
+
<!--
|
|
26
|
+
Everything that acts on the table lives in the table: search, filters and the buttons above it,
|
|
27
|
+
paging below it, and the row actions beside each record.
|
|
28
|
+
-->
|
|
29
|
+
<k-table
|
|
30
|
+
id="itemsTable"
|
|
31
|
+
enable-pages
|
|
32
|
+
page-size="25"
|
|
33
|
+
page-size-options="[10,25,50,100]"
|
|
34
|
+
placeholder="No items yet."
|
|
35
|
+
filtered-placeholder="No items match your search."
|
|
36
|
+
>
|
|
37
|
+
<div slot="top" class="d-f full" style="align-items: center; gap: var(--spacer_h); flex-wrap: wrap;">
|
|
38
|
+
<kc-tc-search style="width: 16rem"></kc-tc-search>
|
|
39
|
+
<kc-tc-filters></kc-tc-filters>
|
|
40
|
+
<kc-tc-field-sort-hide></kc-tc-field-sort-hide>
|
|
41
|
+
<div class="flex"></div>
|
|
42
|
+
<k-permission has="kempo-inventory:fields:manage">
|
|
43
|
+
<button id="importBtn" class="btn m0"><k-icon name="arrow" direction="up"></k-icon> Import</button>
|
|
44
|
+
</k-permission>
|
|
45
|
+
<k-permission has="kempo-inventory:fields:manage">
|
|
46
|
+
<button id="exportBtn" class="btn m0"><k-icon name="download"></k-icon> Export</button>
|
|
47
|
+
</k-permission>
|
|
48
|
+
<a href="/admin/extension/kempo-inventory/categories" class="btn"><k-icon name="folder"></k-icon> Categories</a>
|
|
49
|
+
<k-permission has="kempo-inventory:fields:manage">
|
|
50
|
+
<a href="/admin/extension/kempo-inventory/fields" class="btn"><k-icon name="settings"></k-icon> Fields</a>
|
|
51
|
+
</k-permission>
|
|
52
|
+
<k-permission has="kempo-inventory:items:create">
|
|
53
|
+
<button id="newItemBtn" class="primary m0"><k-icon name="add"></k-icon> New Item</button>
|
|
54
|
+
</k-permission>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="d-f full" style="align-items: center;">
|
|
57
|
+
<div>
|
|
58
|
+
<kc-tc-first-page></kc-tc-first-page>
|
|
59
|
+
<kc-tc-prev-page></kc-tc-prev-page>
|
|
60
|
+
<kc-tc-next-page></kc-tc-next-page>
|
|
61
|
+
<kc-tc-last-page></kc-tc-last-page>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="flex"></div>
|
|
64
|
+
<div><kc-tc-page-select></kc-tc-page-select></div>
|
|
65
|
+
<div class="flex"></div>
|
|
66
|
+
<div><kc-tc-page-size></kc-tc-page-size></div>
|
|
67
|
+
</div>
|
|
68
|
+
</k-table>
|
|
69
|
+
|
|
70
|
+
<script type="module">
|
|
71
|
+
import { getItems, deleteItem, getFields } from '/inventory/sdk.js';
|
|
72
|
+
import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
73
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
74
|
+
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
75
|
+
import { displayValue, applicableFields } from '/inventory/components/ItemFields.js';
|
|
76
|
+
import { openAdjustDialog } from '/inventory/components/ItemDialog.js';
|
|
77
|
+
import { openImportDialog, openExportDialog } from '/inventory/components/ImportExport.js';
|
|
78
|
+
import { showFlash } from '/inventory/components/flash.js';
|
|
79
|
+
|
|
80
|
+
await window.customElements.whenDefined('k-table');
|
|
81
|
+
await window.customElements.whenDefined('k-toggle');
|
|
82
|
+
const $table = document.getElementById('itemsTable');
|
|
83
|
+
|
|
84
|
+
const [, permData] = await getAllCurrentUserPermissions();
|
|
85
|
+
const myPerms = new Set(permData?.permissions ?? []);
|
|
86
|
+
const can = name => myPerms.has(`kempo-inventory:${name}`);
|
|
87
|
+
|
|
88
|
+
const addControl = (tag, slot, attributes = {}) => {
|
|
89
|
+
const $control = document.createElement(tag);
|
|
90
|
+
$control.setAttribute('slot', slot);
|
|
91
|
+
for(const [name, value] of Object.entries(attributes)) $control.setAttribute(name, value);
|
|
92
|
+
$table.appendChild($control);
|
|
93
|
+
};
|
|
94
|
+
if(can('items:update')) addControl('inventory-row-action', 'before', { action: 'edit', icon: 'edit', title: 'Edit' });
|
|
95
|
+
if(can('stock:adjust')) addControl('inventory-row-action', 'before', { action: 'adjust', icon: 'repeat', title: 'Adjust stock' });
|
|
96
|
+
if(can('items:delete')){
|
|
97
|
+
$table.setAttribute('request-delete', '');
|
|
98
|
+
addControl('kc-tc-delete-record', 'after');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/*
|
|
102
|
+
The custom fields are data, not code: the columns and the form below are all built from
|
|
103
|
+
whatever the field list says right now.
|
|
104
|
+
*/
|
|
105
|
+
let fields = [];
|
|
106
|
+
/* The Managed by column only appears once an extension owns an item. */
|
|
107
|
+
let owners = false;
|
|
108
|
+
|
|
109
|
+
/*
|
|
110
|
+
Display-only values are precomputed as plain strings so the table never formats
|
|
111
|
+
user-supplied text itself.
|
|
112
|
+
*/
|
|
113
|
+
const decorate = (item, ctx) => {
|
|
114
|
+
const record = { ...item, qtyText: String(item.quantity), tagsText: item.tags.join(', '), ownerText: item.owner };
|
|
115
|
+
/* Only the fields that apply to this item's category; a column another category's items fill stays blank here. */
|
|
116
|
+
for(const field of applicableFields(fields, item.category).filter(f => f.listed)) record[`f_${field.key}`] = displayValue(field, item.fields[field.key], ctx);
|
|
117
|
+
return record;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const columns = () => [
|
|
121
|
+
{ name: 'sku', label: 'SKU', size: 130, editable: false },
|
|
122
|
+
{ name: 'name', label: 'Name', size: 240, editable: false },
|
|
123
|
+
{ name: 'category', label: 'Category', size: 140, editable: false },
|
|
124
|
+
{ name: 'qtyText', label: 'Quantity', size: 100, editable: false },
|
|
125
|
+
{ name: 'tagsText', label: 'Tags', size: 160, editable: false },
|
|
126
|
+
...(owners ? [{ name: 'ownerText', label: 'Managed by', size: 170, editable: false }] : []),
|
|
127
|
+
/* One column per key: a key two categories both use is one column. */
|
|
128
|
+
...fields.filter((f, i) => f.listed && fields.findIndex(o => o.listed && o.key === f.key) === i).map(f => ({ name: `f_${f.key}`, label: f.label, size: 130, editable: false })),
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/*
|
|
132
|
+
The table searches, filters and pages the records itself, so every item is loaded (the API
|
|
133
|
+
hands them over 200 at a time). A personal inventory is a few hundred items at most.
|
|
134
|
+
*/
|
|
135
|
+
const loadEveryItem = async () => {
|
|
136
|
+
const items = [];
|
|
137
|
+
let context = { linked: {}, media: {} };
|
|
138
|
+
for(let offset = 0; ; offset += 200){
|
|
139
|
+
const [error, page] = await getItems({ limit: 200, offset });
|
|
140
|
+
if(error) return [error, null];
|
|
141
|
+
items.push(...page.items);
|
|
142
|
+
context = { linked: { ...context.linked, ...page.linked }, media: { ...context.media, ...page.media } };
|
|
143
|
+
if(!page.items.length || items.length >= page.total) return [null, { items, context }];
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const load = async () => {
|
|
148
|
+
const [error, data] = await loadEveryItem();
|
|
149
|
+
if(error){
|
|
150
|
+
Toast.error(error.msg || 'Failed to load inventory');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
owners = data.items.some(i => i.owner);
|
|
154
|
+
$table.setData({ records: data.items.map(i => decorate(i, data.context)), fields: columns() });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const loadFields = async () => {
|
|
158
|
+
const [error, data] = await getFields();
|
|
159
|
+
if(error){
|
|
160
|
+
Toast.error(error.msg || 'Failed to load fields');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
fields = data.fields;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
$table.addEventListener('requestDelete', ({ detail: { records, approve, reject } }) => {
|
|
167
|
+
const [record] = records;
|
|
168
|
+
if(record.owner){
|
|
169
|
+
reject();
|
|
170
|
+
Toast.error(`"${record.name}" is managed by the ${record.owner} extension and cannot be deleted here`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
Dialog.confirm(`Delete "${record.name}" and its stock history? This action cannot be undone.`, async confirmed => {
|
|
174
|
+
if(!confirmed){ reject(); return; }
|
|
175
|
+
const [error] = await deleteItem(record.id);
|
|
176
|
+
if(error){
|
|
177
|
+
reject();
|
|
178
|
+
Toast.error(error.msg || 'Failed to delete item');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
approve();
|
|
182
|
+
Toast.success('Item deleted');
|
|
183
|
+
load();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
$table.addEventListener('itemAction', ({ detail: { action, record } }) => {
|
|
188
|
+
/* Editing is a page of its own, like adding: a dialog is too easy to dismiss and loses what was entered. */
|
|
189
|
+
if(action === 'edit') location.href = `/admin/extension/kempo-inventory/edit?id=${encodeURIComponent(record.id)}`;
|
|
190
|
+
if(action === 'adjust') openAdjustDialog({ item: record, onSaved: load });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
document.getElementById('importBtn')?.addEventListener('click', () => openImportDialog({ onDone: load }));
|
|
194
|
+
document.getElementById('exportBtn')?.addEventListener('click', () => openExportDialog());
|
|
195
|
+
/* Adding an item is a page of its own: a dialog is too easy to dismiss and loses everything entered. */
|
|
196
|
+
document.getElementById('newItemBtn')?.addEventListener('click', () => { location.href = '/admin/extension/kempo-inventory/new'; });
|
|
197
|
+
|
|
198
|
+
await loadFields();
|
|
199
|
+
await load();
|
|
200
|
+
showFlash();
|
|
201
|
+
</script>
|
|
202
|
+
</content>
|
|
203
|
+
</page>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="New 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">New Item</h1>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div id="newItem" style="max-width: 44rem;"></div>
|
|
14
|
+
|
|
15
|
+
<script type="module">
|
|
16
|
+
import { getFields } from '/inventory/sdk.js';
|
|
17
|
+
import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
|
18
|
+
import Toast from '/kempo-ui/components/Toast.js';
|
|
19
|
+
import { mountNewItemPage, safeNext } from '/inventory/components/NewItemPage.js';
|
|
20
|
+
import { showFlash } from '/inventory/components/flash.js';
|
|
21
|
+
|
|
22
|
+
const [, permData] = await getAllCurrentUserPermissions();
|
|
23
|
+
const $container = document.getElementById('newItem');
|
|
24
|
+
if(!new Set(permData?.permissions ?? []).has('kempo-inventory:items:create')){
|
|
25
|
+
$container.innerHTML = '<p class="tc-danger">Your account is not allowed to add items.</p>';
|
|
26
|
+
} else {
|
|
27
|
+
const [error, data] = await getFields();
|
|
28
|
+
if(error){
|
|
29
|
+
Toast.error(error.msg || 'Failed to load the item form');
|
|
30
|
+
} else {
|
|
31
|
+
/* ?category= starts the item in a category; ?next= is where to go after saving (a path on this site). */
|
|
32
|
+
const params = new URLSearchParams(location.search);
|
|
33
|
+
const defaults = {};
|
|
34
|
+
for(const key of ['category', 'sku', 'name']) if(params.get(key)) defaults[key] = params.get(key);
|
|
35
|
+
const next = safeNext(params.get('next'), '/admin/extension/kempo-inventory/');
|
|
36
|
+
document.getElementById('backLink').href = next;
|
|
37
|
+
mountNewItemPage({ $container, fields: data.fields, defaults, next });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
showFlash();
|
|
41
|
+
</script>
|
|
42
|
+
</content>
|
|
43
|
+
</page>
|
package/install.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import db from 'kempo/server/db/index.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
kempo's installer builds CREATE TABLE from the Drizzle columns only; indexes and unique
|
|
6
|
+
constraints declared in server/db/schema.js are not carried across. The unique SKU and field-key
|
|
7
|
+
indexes are correctness guarantees, so they are created here, matching the schema.
|
|
8
|
+
*/
|
|
9
|
+
const INDEXES = [
|
|
10
|
+
sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoInventoryItemSkuIdx" ON "kempoInventoryItem" ("sku")`,
|
|
11
|
+
sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoInventoryFieldKeyIdx" ON "kempoInventoryField" ("category", "key")`,
|
|
12
|
+
sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoInventoryCategoryKeyIdx" ON "kempoInventoryCategory" ("key")`,
|
|
13
|
+
sql`CREATE INDEX IF NOT EXISTS "kempoInventoryMovementItemIdx" ON "kempoInventoryMovement" ("itemId")`,
|
|
14
|
+
sql`CREATE INDEX IF NOT EXISTS "kempoInventoryItemOwnerIdx" ON "kempoInventoryItem" ("owner")`,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export default async () => {
|
|
18
|
+
for(const statement of INDEXES) await db.execute(statement);
|
|
19
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"public-scope": "inventory",
|
|
3
|
+
"schema": "./server/db/schema.js",
|
|
4
|
+
"permissions": [
|
|
5
|
+
{
|
|
6
|
+
"name": "kempo-inventory:items:read",
|
|
7
|
+
"description": "View inventory items, fields and stock movements"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "kempo-inventory:items:create",
|
|
11
|
+
"description": "Add new inventory items"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "kempo-inventory:items:update",
|
|
15
|
+
"description": "Edit inventory items"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "kempo-inventory:items:delete",
|
|
19
|
+
"description": "Delete inventory items"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "kempo-inventory:stock:adjust",
|
|
23
|
+
"description": "Add or remove stock on inventory items"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "kempo-inventory:fields:manage",
|
|
27
|
+
"description": "Create, edit and delete custom item fields"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"groups": [
|
|
31
|
+
{
|
|
32
|
+
"name": "kempo-inventory:viewer",
|
|
33
|
+
"description": "Can view inventory",
|
|
34
|
+
"permissions": [
|
|
35
|
+
"system:admin:access",
|
|
36
|
+
"kempo-inventory:items:read"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "kempo-inventory:manager",
|
|
41
|
+
"description": "Can view and manage items and stock levels",
|
|
42
|
+
"permissions": [
|
|
43
|
+
"system:admin:access",
|
|
44
|
+
"kempo-inventory:items:read",
|
|
45
|
+
"kempo-inventory:items:create",
|
|
46
|
+
"kempo-inventory:items:update",
|
|
47
|
+
"kempo-inventory:items:delete",
|
|
48
|
+
"kempo-inventory:stock:adjust"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"name": "kempo-inventory:admin",
|
|
53
|
+
"description": "Can manage items and stock levels, and set up the custom fields items have",
|
|
54
|
+
"permissions": [
|
|
55
|
+
"system:admin:access",
|
|
56
|
+
"kempo-inventory:items:read",
|
|
57
|
+
"kempo-inventory:items:create",
|
|
58
|
+
"kempo-inventory:items:update",
|
|
59
|
+
"kempo-inventory:items:delete",
|
|
60
|
+
"kempo-inventory:stock:adjust",
|
|
61
|
+
"kempo-inventory:fields:manage"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"settings": [
|
|
66
|
+
{
|
|
67
|
+
"name": "media_provider",
|
|
68
|
+
"value": "auto",
|
|
69
|
+
"type": "string",
|
|
70
|
+
"description": "Where new photos and files are uploaded: auto, kempo-media or kempo-files. Auto uses kempo-files when kempo-thumbs is installed to thumbnail it, otherwise kempo-media."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"name": "import_max_mb",
|
|
74
|
+
"value": 250,
|
|
75
|
+
"type": "number",
|
|
76
|
+
"description": "The largest import file (a .json or .zip made by Export) the admin Import accepts, in MB."
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "public_read",
|
|
80
|
+
"value": false,
|
|
81
|
+
"type": "boolean",
|
|
82
|
+
"description": "Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see stock counts. Off by default: reading otherwise needs the items:read permission."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "public_photos",
|
|
86
|
+
"value": true,
|
|
87
|
+
"type": "boolean",
|
|
88
|
+
"description": "Photos uploaded to kempo-files are viewable by anyone with the link, so every person who can read the inventory sees them (kempo-files otherwise needs each of them to have its files:download permission). Turn off to keep photos behind that permission."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "category_image_ratio",
|
|
92
|
+
"value": "4:3",
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "The aspect ratio category images are shown at, as width:height (4:3, 1:1, 16:9). Images are cropped to fit, never stretched."
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "item_image_ratio",
|
|
98
|
+
"value": "4:3",
|
|
99
|
+
"type": "string",
|
|
100
|
+
"description": "The aspect ratio item photos are shown at, as width:height (4:3, 1:1, 16:9). Images are cropped to fit, never stretched."
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kempo-inventory",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A basic inventory management system for kempo cms",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dustin Poissant",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dustinpoissant/kempo-inventory.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/dustinpoissant/kempo-inventory/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/dustinpoissant/kempo-inventory#readme",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "sdk.js",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test",
|
|
19
|
+
"link:local": "npm install ../kempo --no-save"
|
|
20
|
+
},
|
|
21
|
+
"kempo": {
|
|
22
|
+
"public-scope": "inventory"
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
"./install": "./install.js",
|
|
26
|
+
"./update": "./update.js",
|
|
27
|
+
"./sdk": "./sdk.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"admin",
|
|
31
|
+
"public",
|
|
32
|
+
"server",
|
|
33
|
+
"install.js",
|
|
34
|
+
"update.js",
|
|
35
|
+
"sdk.js",
|
|
36
|
+
"kempo-config.json"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"drizzle-orm": "^0.45.2"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"kempo": ">=4.3.0",
|
|
43
|
+
"kempo-ui": ">=0.5.0",
|
|
44
|
+
"kempo-media": ">=0.2.3",
|
|
45
|
+
"kempo-files": ">=0.1.3",
|
|
46
|
+
"kempo-thumbs": ">=0.1.3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"kempo-media": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"kempo-files": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"kempo-thumbs": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import authorize from '../../../server/utils/authorize.js';
|
|
2
|
+
import { deleteCategory } from '../../../server/utils/categories.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError, auth] = await authorize(request, 'items:update');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const name = request.body?.name ?? request.query.name;
|
|
9
|
+
const [error, result] = await deleteCategory(name, { userId: auth.userId });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.json(result);
|
|
12
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { authorizeRead } from '../../../server/utils/access.js';
|
|
2
|
+
import { parseTagParam } from '../../../server/utils/tags.js';
|
|
3
|
+
import { getCategories } from '../../../server/utils/categories.js';
|
|
4
|
+
|
|
5
|
+
export default async (request, response) => {
|
|
6
|
+
const [authError] = await authorizeRead(request);
|
|
7
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
8
|
+
|
|
9
|
+
const [error, data] = await getCategories({ includeEmpty: request.query.includeEmpty === 'true', q: request.query.q, tag: parseTagParam(request.query.tag) });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.json(data);
|
|
12
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import authorize from '../../../server/utils/authorize.js';
|
|
2
|
+
import { saveCategory } from '../../../server/utils/categories.js';
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Create or update a category by name. Only the properties present in the body change, so
|
|
6
|
+
{ name, description } leaves the image alone and { name, image: null } clears it. `newName` renames.
|
|
7
|
+
*/
|
|
8
|
+
export default async (request, response) => {
|
|
9
|
+
const [authError, auth] = await authorize(request, 'items:update');
|
|
10
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
11
|
+
|
|
12
|
+
const body = request.body || {};
|
|
13
|
+
const changes = {};
|
|
14
|
+
for(const property of ['newName', 'image', 'description', 'tags']){
|
|
15
|
+
if(property in body) changes[property] = body[property];
|
|
16
|
+
}
|
|
17
|
+
const [error, category] = await saveCategory(body.name, changes, { userId: auth.userId });
|
|
18
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
19
|
+
response.json({ category });
|
|
20
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import authorize from '../../../server/utils/authorize.js';
|
|
2
|
+
import { buildExport } from '../../../server/utils/importExport.js';
|
|
3
|
+
import { buildZip } from '../../../server/utils/zip.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Downloads the inventory: ?format=json is the data on its own, ?format=zip is the data (inventory.json)
|
|
7
|
+
together with every photo and file it uses (media/). The whole inventory leaves in one file, so it is
|
|
8
|
+
limited to the top tier, the people who set the inventory up (the fields:manage permission, held by
|
|
9
|
+
the kempo-inventory:admin group).
|
|
10
|
+
*/
|
|
11
|
+
export default async (request, response) => {
|
|
12
|
+
const [authError] = await authorize(request, 'fields:manage');
|
|
13
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
14
|
+
|
|
15
|
+
const format = request.query.format === 'zip' ? 'zip' : 'json';
|
|
16
|
+
const [error, built] = await buildExport({ withMedia: format === 'zip' });
|
|
17
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
18
|
+
|
|
19
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
20
|
+
const json = Buffer.from(JSON.stringify(built.data, null, 2), 'utf8');
|
|
21
|
+
const body = format === 'zip'
|
|
22
|
+
? buildZip([{ name: 'inventory.json', data: json }, ...built.files.map(file => ({ name: file.path, data: file.bytes }))])
|
|
23
|
+
: json;
|
|
24
|
+
|
|
25
|
+
response.setHeader('Content-Type', format === 'zip' ? 'application/zip' : 'application/json; charset=utf-8');
|
|
26
|
+
response.setHeader('Content-Disposition', `attachment; filename="inventory-${stamp}.${format}"`);
|
|
27
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
28
|
+
if(built.missingMedia) response.setHeader('X-Missing-Media', String(built.missingMedia));
|
|
29
|
+
response.status(200);
|
|
30
|
+
response.end(body);
|
|
31
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { authorizeRead } from '../../../server/utils/access.js';
|
|
2
|
+
import { getFields } from '../../../server/utils/fields.js';
|
|
3
|
+
import { FIELD_TYPES, OPTIONAL_TYPES, CONVERSIONS } from '../../../server/utils/fieldTypes.js';
|
|
4
|
+
import { capabilities as mediaCapabilities, getImageRatios } from '../../../server/utils/media.js';
|
|
5
|
+
|
|
6
|
+
export default async (request, response) => {
|
|
7
|
+
const [authError] = await authorizeRead(request);
|
|
8
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
9
|
+
|
|
10
|
+
/* ?category=Paint returns just what an item in that category shows (default fields plus its own). */
|
|
11
|
+
const scoped = request.query.category !== undefined;
|
|
12
|
+
const [error, fields] = await getFields(scoped ? { category: request.query.category } : {});
|
|
13
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
`types` is what can be created right now: a type backed by an extension that is not installed
|
|
17
|
+
is left out, so a client never offers something that would be refused.
|
|
18
|
+
*/
|
|
19
|
+
const capabilities = await mediaCapabilities();
|
|
20
|
+
const types = FIELD_TYPES.filter(type => !OPTIONAL_TYPES[type] || capabilities[type]);
|
|
21
|
+
/* `imageRatios` is the aspect ratio (CSS) category and item images are shown at. */
|
|
22
|
+
response.json({ fields, types, capabilities, conversions: CONVERSIONS, imageRatios: await getImageRatios() });
|
|
23
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import authorize from '../../../server/utils/authorize.js';
|
|
2
|
+
import { createField } from '../../../server/utils/fields.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError] = await authorize(request, 'fields:manage');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const { key, label, type, description, required, listed, suggest, options, position, category, imageRatio, imageMax } = request.body || {};
|
|
9
|
+
const [error, field] = await createField({ key, label, type, description, required, listed, suggest, options, position, category, imageRatio, imageMax });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.status(201).json({ field });
|
|
12
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import authorize from '../../../../server/utils/authorize.js';
|
|
2
|
+
import { deleteField } from '../../../../server/utils/fields.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError] = await authorize(request, 'fields:manage');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const [error, result] = await deleteField(request.params.key, { category: request.query.category ?? '' });
|
|
9
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
10
|
+
response.json(result);
|
|
11
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import authorize from '../../../../server/utils/authorize.js';
|
|
2
|
+
import { updateField } from '../../../../server/utils/fields.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError] = await authorize(request, 'fields:manage');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const body = request.body || {};
|
|
9
|
+
const changes = {};
|
|
10
|
+
for(const property of ['label', 'description', 'required', 'listed', 'suggest', 'options', 'position', 'type', 'imageRatio', 'imageMax']){
|
|
11
|
+
if(body[property] !== undefined) changes[property] = body[property];
|
|
12
|
+
}
|
|
13
|
+
const [error, field] = await updateField(request.params.key, changes, { category: request.query.category ?? '' });
|
|
14
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
15
|
+
response.json({ field });
|
|
16
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import authorize from '../../../../../server/utils/authorize.js';
|
|
2
|
+
import { getSuggestions } from '../../../../../server/utils/suggestions.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError] = await authorize(request, 'items:read');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const { q, limit, category } = request.query;
|
|
9
|
+
const [error, suggestions] = await getSuggestions(request.params.key, { q, limit: parseInt(limit) || 8, category: category ?? '' });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.json({ suggestions });
|
|
12
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import authorize from '../../../../server/utils/authorize.js';
|
|
2
|
+
import { applyImport } from '../../../../server/utils/importFile.js';
|
|
3
|
+
import { readStaged, discardStaged } from '../../../../server/utils/importStage.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Step two: { importId, onMatch: 'skip' | 'update' } runs the import of the file uploaded in step one. An
|
|
7
|
+
item or category that already exists is left alone ('skip') or updated with the file's values ('update').
|
|
8
|
+
|
|
9
|
+
Top tier only, checked first, and the file must be the caller's own staged upload.
|
|
10
|
+
*/
|
|
11
|
+
export default async (request, response) => {
|
|
12
|
+
const [authError, auth] = await authorize(request, 'fields:manage');
|
|
13
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
14
|
+
|
|
15
|
+
const { importId, onMatch } = request.body || {};
|
|
16
|
+
if(!['skip', 'update'].includes(onMatch)) return response.status(400).json({ error: 'onMatch must be "skip" or "update"' });
|
|
17
|
+
|
|
18
|
+
const staged = await readStaged(importId, auth.userId);
|
|
19
|
+
if(!staged) return response.status(404).json({ error: 'That upload has expired. Choose the file again.' });
|
|
20
|
+
|
|
21
|
+
const [error, report] = await applyImport(staged.bytes, { onMatch, userId: auth.userId });
|
|
22
|
+
await discardStaged(importId);
|
|
23
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
24
|
+
response.json(report);
|
|
25
|
+
};
|