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,30 @@
|
|
|
1
|
+
import authorize from '../../../../server/utils/authorize.js';
|
|
2
|
+
import { getSetting } from 'kempo/server/sdk.js';
|
|
3
|
+
import { previewImport } from '../../../../server/utils/importFile.js';
|
|
4
|
+
import { stage } from '../../../../server/utils/importStage.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Step one of an import: the request body IS the file (a .json or a .zip), sent as raw bytes. Checks it and
|
|
8
|
+
reports what importing it would do; changes nothing in the inventory. Keeps the upload for the second step
|
|
9
|
+
and returns its `importId`.
|
|
10
|
+
|
|
11
|
+
Top tier only (fields:manage, the kempo-inventory:admin group). The permission is checked before anything
|
|
12
|
+
is read from the upload: a caller without it gets 401/403 and the bytes are never looked at, parsed or
|
|
13
|
+
stored. (kempo-server itself has already received the body by the time any route runs, for every route on
|
|
14
|
+
the site, up to its `maxBodySize`; lower that in the site's kempo-server config to bound it.)
|
|
15
|
+
*/
|
|
16
|
+
export default async (request, response) => {
|
|
17
|
+
const [authError, auth] = await authorize(request, 'fields:manage');
|
|
18
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
19
|
+
|
|
20
|
+
const [, maxMb] = await getSetting('kempo-inventory', 'import_max_mb', 250);
|
|
21
|
+
const bytes = await request.buffer();
|
|
22
|
+
if(!bytes.length) return response.status(400).json({ error: 'No file was uploaded' });
|
|
23
|
+
if(bytes.length > Number(maxMb) * 1024 * 1024) return response.status(413).json({ error: `That file is larger than the ${maxMb} MB import limit` });
|
|
24
|
+
|
|
25
|
+
const [error, report] = await previewImport(bytes, { userId: auth.userId });
|
|
26
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
27
|
+
|
|
28
|
+
const importId = await stage(bytes, auth.userId, String(request.query.name ?? ''));
|
|
29
|
+
response.json({ ...report, importId });
|
|
30
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { authorizeRead, forCaller } from '../../../server/utils/access.js';
|
|
2
|
+
import { parseTagParam } from '../../../server/utils/tags.js';
|
|
3
|
+
import { getItems } from '../../../server/utils/items.js';
|
|
4
|
+
|
|
5
|
+
export default async (request, response) => {
|
|
6
|
+
const [authError, access] = await authorizeRead(request);
|
|
7
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
8
|
+
|
|
9
|
+
const { q, category, tag, uncategorised, filters, owner, limit, offset } = request.query;
|
|
10
|
+
|
|
11
|
+
let parsedFilters = {};
|
|
12
|
+
if(filters){
|
|
13
|
+
try {
|
|
14
|
+
parsedFilters = JSON.parse(filters);
|
|
15
|
+
} catch {
|
|
16
|
+
return response.status(400).json({ error: 'filters must be a JSON object' });
|
|
17
|
+
}
|
|
18
|
+
if(parsedFilters === null || typeof parsedFilters !== 'object' || Array.isArray(parsedFilters)){
|
|
19
|
+
return response.status(400).json({ error: 'filters must be a JSON object' });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const [error, data] = await getItems({
|
|
24
|
+
q,
|
|
25
|
+
category,
|
|
26
|
+
tag: parseTagParam(tag),
|
|
27
|
+
uncategorised: uncategorised === 'true',
|
|
28
|
+
filters: parsedFilters,
|
|
29
|
+
owner,
|
|
30
|
+
limit: Math.min(parseInt(limit) || 50, 200),
|
|
31
|
+
offset: parseInt(offset) || 0,
|
|
32
|
+
});
|
|
33
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
34
|
+
response.json({ ...data, items: data.items.map(item => forCaller(item, access)) });
|
|
35
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import authorize from '../../../server/utils/authorize.js';
|
|
2
|
+
import { createItem } from '../../../server/utils/items.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError, auth] = await authorize(request, 'items:create');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const { sku, name, description, category, tags, quantity, fields } = request.body || {};
|
|
9
|
+
const [error, item] = await createItem({ sku, name, description, category, tags, quantity, fields, userId: auth.userId });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.status(201).json({ item });
|
|
12
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import authorize from '../../../../server/utils/authorize.js';
|
|
2
|
+
import { deleteItem } from '../../../../server/utils/items.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError, auth] = await authorize(request, 'items:delete');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const [error, result] = await deleteItem(request.params.id, { userId: auth.userId });
|
|
9
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
10
|
+
response.json(result);
|
|
11
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { authorizeRead, forCaller } from '../../../../server/utils/access.js';
|
|
2
|
+
import { getItem, getMovements, getLinked, getMedia } from '../../../../server/utils/items.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError, access] = await authorizeRead(request);
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const [error, item] = await getItem(request.params.id);
|
|
9
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
10
|
+
/* The stock history is all counts, so it is only for people who manage stock. */
|
|
11
|
+
const [, movements] = access.canSeeStock ? await getMovements({ itemId: item.id, limit: 100 }) : [null, []];
|
|
12
|
+
const [, linked] = await getLinked(item);
|
|
13
|
+
const [, media] = await getMedia(item);
|
|
14
|
+
response.json({ item: forCaller(item, access), movements: movements || [], linked: linked || {}, media: media || {} });
|
|
15
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import authorize from '../../../../server/utils/authorize.js';
|
|
2
|
+
import { updateItem } from '../../../../server/utils/items.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 { sku, name, description, category, tags, fields } = request.body || {};
|
|
9
|
+
const [error, item] = await updateItem(request.params.id, { sku, name, description, category, tags, fields, userId: auth.userId });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.json({ item });
|
|
12
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import authorize from '../../../../../server/utils/authorize.js';
|
|
2
|
+
import { adjustStock } from '../../../../../server/utils/items.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError, auth] = await authorize(request, 'stock:adjust');
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
|
|
8
|
+
const { delta, reason, note } = request.body || {};
|
|
9
|
+
const [error, item] = await adjustStock(request.params.id, { delta, reason, note, userId: auth.userId });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.json({ item });
|
|
12
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { authorizeRead } from '../../../server/utils/access.js';
|
|
2
|
+
import { getMovements } from '../../../server/utils/items.js';
|
|
3
|
+
|
|
4
|
+
export default async (request, response) => {
|
|
5
|
+
const [authError, access] = await authorizeRead(request);
|
|
6
|
+
if(authError) return response.status(authError.code).json({ error: authError.msg });
|
|
7
|
+
if(!access.canSeeStock) return response.status(403).json({ error: 'Insufficient permissions' });
|
|
8
|
+
|
|
9
|
+
const { itemId, limit, offset } = request.query;
|
|
10
|
+
const [error, movements] = await getMovements({
|
|
11
|
+
itemId,
|
|
12
|
+
limit: Math.min(parseInt(limit) || 50, 200),
|
|
13
|
+
offset: parseInt(offset) || 0,
|
|
14
|
+
});
|
|
15
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
16
|
+
response.json({ movements });
|
|
17
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { authorizeRead } from '../../../server/utils/access.js';
|
|
2
|
+
import { getTags } from '../../../server/utils/tagList.js';
|
|
3
|
+
|
|
4
|
+
/* Every tag in use, for suggestions and filters: [{ tag, count }]. Readable like items are. */
|
|
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, tags] = await getTags({ q: request.query.q, limit: Math.min(parseInt(request.query.limit) || 100, 500) });
|
|
10
|
+
if(error) return response.status(error.code).json({ error: error.msg });
|
|
11
|
+
response.json({ tags });
|
|
12
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import Dialog from '/kempo-ui/components/Dialog.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Scan barcodes with the device's camera. Two ways in:
|
|
5
|
+
|
|
6
|
+
import { scanBarcode, startScanner } from '/inventory/components/BarcodeScanner.js';
|
|
7
|
+
|
|
8
|
+
const code = await scanBarcode(); // a dialog; the code's text, or null if cancelled
|
|
9
|
+
|
|
10
|
+
const scanner = await startScanner({ video, onCode: code => ... }); // your own <video>, keeps going
|
|
11
|
+
scanner.setPaused(true); // ignore codes for a while (e.g. a form is open)
|
|
12
|
+
scanner.stop(); // release the camera
|
|
13
|
+
|
|
14
|
+
Uses the browser's own BarcodeDetector where it exists (Chrome on Android), which is fast and
|
|
15
|
+
needs no code of ours. Everywhere else (iPhone, Firefox, most desktops) it falls back to ZXing,
|
|
16
|
+
bundled in /inventory/vendor/ so nothing is loaded from a CDN.
|
|
17
|
+
|
|
18
|
+
Cameras are only available to pages served over HTTPS (or from localhost), so on a plain-http
|
|
19
|
+
address this explains why rather than failing silently.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const FORMATS = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128', 'code_39', 'code_93', 'codabar', 'itf', 'qr_code', 'data_matrix', 'pdf417', 'aztec'];
|
|
23
|
+
|
|
24
|
+
const CONSTRAINTS = { video: { facingMode: { ideal: 'environment' } }, audio: false };
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
How long a code has to be out of view before it counts as a new scan. Long enough that a code
|
|
28
|
+
flickering in and out of focus is not read twice, short enough that picking up the next bottle
|
|
29
|
+
of the same paint is.
|
|
30
|
+
*/
|
|
31
|
+
const GAP_MS = 700;
|
|
32
|
+
|
|
33
|
+
const POLL_MS = 120;
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
Why the camera cannot be used here, as a sentence for the user, or null when it can.
|
|
37
|
+
*/
|
|
38
|
+
export const cameraProblem = () => {
|
|
39
|
+
if(!window.isSecureContext){
|
|
40
|
+
return 'The camera only works on secure (https) pages. Open this site over https, or from localhost.';
|
|
41
|
+
}
|
|
42
|
+
if(!navigator.mediaDevices?.getUserMedia) return 'This browser cannot access a camera.';
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const explainCameraError = error => {
|
|
47
|
+
if(error?.name === 'NotAllowedError') return 'Camera access was blocked. Allow the camera for this site in the browser settings, then try again.';
|
|
48
|
+
if(error?.name === 'NotFoundError') return 'No camera was found on this device.';
|
|
49
|
+
if(error?.name === 'NotReadableError') return 'The camera is in use by another app.';
|
|
50
|
+
return error?.message ? `Could not start the camera: ${error.message}` : 'Could not start the camera.';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
The native detector, or null when this browser has none or supports none of our formats.
|
|
55
|
+
*/
|
|
56
|
+
const nativeDetector = async () => {
|
|
57
|
+
if(!('BarcodeDetector' in window)) return null;
|
|
58
|
+
try {
|
|
59
|
+
const supported = await window.BarcodeDetector.getSupportedFormats();
|
|
60
|
+
const formats = FORMATS.filter(format => supported.includes(format));
|
|
61
|
+
return formats.length ? new window.BarcodeDetector({ formats }) : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
Starts the camera in `video` and calls `onCode(text)` once each time a code comes into view. The
|
|
69
|
+
same code is not reported again until it has been out of view for a moment, so holding a bottle
|
|
70
|
+
still gives one scan, and scanning the next bottle of the same paint gives another.
|
|
71
|
+
|
|
72
|
+
Rejects (with the underlying camera error, or an Error carrying a sentence for the user) if the
|
|
73
|
+
camera cannot be started. Resolves to { stop, setPaused }.
|
|
74
|
+
*/
|
|
75
|
+
export const startScanner = async ({ video, onCode }) => {
|
|
76
|
+
const problem = cameraProblem();
|
|
77
|
+
if(problem) throw new Error(problem);
|
|
78
|
+
|
|
79
|
+
video.setAttribute('playsinline', '');
|
|
80
|
+
video.muted = true;
|
|
81
|
+
video.autoplay = true;
|
|
82
|
+
|
|
83
|
+
let stopped = false;
|
|
84
|
+
let paused = false;
|
|
85
|
+
let armed = true;
|
|
86
|
+
let lastSeen = 0;
|
|
87
|
+
let stopCamera = () => {};
|
|
88
|
+
|
|
89
|
+
/* `text` is the code in view this frame, or null when there is none. */
|
|
90
|
+
const frame = text => {
|
|
91
|
+
if(stopped) return;
|
|
92
|
+
const now = performance.now();
|
|
93
|
+
if(text){
|
|
94
|
+
lastSeen = now;
|
|
95
|
+
if(armed && !paused){
|
|
96
|
+
armed = false;
|
|
97
|
+
onCode(text);
|
|
98
|
+
}
|
|
99
|
+
} else if(now - lastSeen > GAP_MS){
|
|
100
|
+
armed = true;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const detector = await nativeDetector();
|
|
105
|
+
|
|
106
|
+
if(detector){
|
|
107
|
+
const stream = await navigator.mediaDevices.getUserMedia(CONSTRAINTS);
|
|
108
|
+
stopCamera = () => stream.getTracks().forEach(track => track.stop());
|
|
109
|
+
video.srcObject = stream;
|
|
110
|
+
await video.play();
|
|
111
|
+
const look = async () => {
|
|
112
|
+
if(stopped) return;
|
|
113
|
+
try {
|
|
114
|
+
const [found] = await detector.detect(video);
|
|
115
|
+
frame(found?.rawValue ?? null);
|
|
116
|
+
} catch {
|
|
117
|
+
frame(null); // a frame that cannot be read is just a frame without a code in it
|
|
118
|
+
}
|
|
119
|
+
setTimeout(look, POLL_MS);
|
|
120
|
+
};
|
|
121
|
+
look();
|
|
122
|
+
} else {
|
|
123
|
+
const { BrowserMultiFormatReader } = await import('/inventory/vendor/zxing-browser.js');
|
|
124
|
+
const controls = await new BrowserMultiFormatReader().decodeFromConstraints(CONSTRAINTS, video, result => {
|
|
125
|
+
frame(result ? result.getText() : null);
|
|
126
|
+
});
|
|
127
|
+
stopCamera = () => controls.stop();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
stop(){
|
|
132
|
+
stopped = true;
|
|
133
|
+
stopCamera();
|
|
134
|
+
},
|
|
135
|
+
/*
|
|
136
|
+
Resuming does not re-fire a code that is still in view: it has to leave and come back, which
|
|
137
|
+
is what stops a result being dismissed and then immediately scanned again.
|
|
138
|
+
*/
|
|
139
|
+
setPaused(value){
|
|
140
|
+
paused = value;
|
|
141
|
+
if(!value) armed = false;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
A one-shot scan in a dialog: resolves to the code's text, or null if cancelled.
|
|
148
|
+
*/
|
|
149
|
+
export const scanBarcode = ({ title = 'Scan a barcode' } = {}) => new Promise(resolve => {
|
|
150
|
+
let finished = false;
|
|
151
|
+
let scanner = null;
|
|
152
|
+
|
|
153
|
+
const $video = document.createElement('video');
|
|
154
|
+
$video.style.cssText = 'width: 100%; max-height: 55vh; background: #000; border-radius: var(--radius);';
|
|
155
|
+
|
|
156
|
+
const $status = document.createElement('p');
|
|
157
|
+
$status.className = 'tc-muted mt';
|
|
158
|
+
$status.textContent = 'Starting the camera…';
|
|
159
|
+
|
|
160
|
+
const $content = document.createElement('div');
|
|
161
|
+
$content.className = 'p';
|
|
162
|
+
$content.append($video, $status);
|
|
163
|
+
|
|
164
|
+
const finish = value => {
|
|
165
|
+
if(finished) return;
|
|
166
|
+
finished = true;
|
|
167
|
+
scanner?.stop();
|
|
168
|
+
if(value) navigator.vibrate?.(60);
|
|
169
|
+
$dialog.close();
|
|
170
|
+
resolve(value);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/*
|
|
174
|
+
closeExisting: false, because this is usually opened from inside another dialog (the item
|
|
175
|
+
form), which Dialog.create would otherwise close.
|
|
176
|
+
*/
|
|
177
|
+
const $dialog = Dialog.create($content, {
|
|
178
|
+
title,
|
|
179
|
+
cancelText: 'Cancel',
|
|
180
|
+
closeExisting: false,
|
|
181
|
+
closeCallback: () => finish(null),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
startScanner({ video: $video, onCode: finish })
|
|
185
|
+
.then(started => {
|
|
186
|
+
if(finished){
|
|
187
|
+
started.stop(); // closed before the camera finished starting
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
scanner = started;
|
|
191
|
+
$status.textContent = 'Point the camera at a barcode';
|
|
192
|
+
})
|
|
193
|
+
.catch(error => {
|
|
194
|
+
if(finished) return;
|
|
195
|
+
$status.textContent = cameraProblem() ?? explainCameraError(error);
|
|
196
|
+
$video.style.display = 'none';
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
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/ImageCrop.js';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Forces the shape and size of photos as they are added, with Kempo UI's <k-image-crop>.
|
|
8
|
+
|
|
9
|
+
import { prepareImages } from '/inventory/components/ImageCropDialog.js';
|
|
10
|
+
const files = await prepareImages(chosenFiles, { ratio: '1 / 1', max: 1600 }); // what to upload
|
|
11
|
+
|
|
12
|
+
- With a `ratio` each photo opens in a cropper locked to that shape, so only that shape can be
|
|
13
|
+
saved. "Skip" leaves the photo out. The result is also kept within `max` pixels.
|
|
14
|
+
- With only a `max`, photos are scaled down to fit, silently.
|
|
15
|
+
- With neither, the files are returned untouched.
|
|
16
|
+
|
|
17
|
+
Files that are not a still image the browser can edit (GIF, SVG, HEIC, video...) are returned as they
|
|
18
|
+
are: nothing can be cropped there.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const EDITABLE = /^image\/(jpeg|png|webp|bmp)$/;
|
|
22
|
+
const OUTPUT_TYPES = { 'image/jpeg': 'image/jpeg', 'image/webp': 'image/webp' }; // anything else is saved as PNG
|
|
23
|
+
const EXTENSIONS = { 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/png': '.png' };
|
|
24
|
+
|
|
25
|
+
/* Scales a photo down so neither side is bigger than `max` (it is never scaled up). */
|
|
26
|
+
const shrink = async (file, max) => {
|
|
27
|
+
let bitmap;
|
|
28
|
+
try {
|
|
29
|
+
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
|
30
|
+
} catch {
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
const scale = Math.min(1, max / Math.max(bitmap.width, bitmap.height));
|
|
34
|
+
if(scale === 1){
|
|
35
|
+
bitmap.close?.();
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
const canvas = document.createElement('canvas');
|
|
39
|
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
|
40
|
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
|
41
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
42
|
+
bitmap.close?.();
|
|
43
|
+
const type = OUTPUT_TYPES[file.type] ?? 'image/png';
|
|
44
|
+
const blob = await new Promise(done => canvas.toBlob(done, type, 0.92));
|
|
45
|
+
return blob ? new File([blob], file.name.replace(/\.[^.]+$/, '') + EXTENSIONS[type], { type }) : file;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/* Opens one photo in a cropper locked to `ratio`. Resolves to the cropped File, the original if the browser cannot read it, or null if skipped. */
|
|
49
|
+
const crop = (file, ratio, max, { index, total }) => new Promise(resolve => {
|
|
50
|
+
let settled = false;
|
|
51
|
+
const finish = value => {
|
|
52
|
+
if(settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
resolve(value);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const $dialog = Dialog.create(html`
|
|
58
|
+
<div class="p" style="max-height: calc(100vh - 14rem); overflow-y: auto;">
|
|
59
|
+
<p class="tc-muted mb">Drag the box to choose what to keep. It is locked to the shape this photo is saved in.</p>
|
|
60
|
+
<k-image-crop id="inventory-crop" aspect-ratio=${ratio} max-width=${max || 0} max-height=${max || 0}
|
|
61
|
+
type=${OUTPUT_TYPES[file.type] ?? 'image/png'} quality="0.92" label="Choose another photo"
|
|
62
|
+
@error=${() => { Toast.error(`${file.name} could not be opened, so it is uploaded as it is`); finish(file); $dialog.close(); }}></k-image-crop>
|
|
63
|
+
</div>
|
|
64
|
+
`, {
|
|
65
|
+
title: total > 1 ? `Crop photo ${index + 1} of ${total}` : 'Crop photo',
|
|
66
|
+
confirmText: 'Use photo',
|
|
67
|
+
cancelText: 'Skip',
|
|
68
|
+
closeExisting: false, // the form this photo is being added to stays open underneath
|
|
69
|
+
confirmAction: async event => {
|
|
70
|
+
event.keepDialogOpen = true;
|
|
71
|
+
const $crop = $dialog.querySelector('#inventory-crop');
|
|
72
|
+
finish(await $crop.toFile() ?? null);
|
|
73
|
+
$dialog.close();
|
|
74
|
+
},
|
|
75
|
+
closeCallback: () => finish(null),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
customElements.whenDefined('k-image-crop').then(() => $dialog.querySelector('#inventory-crop')?.loadImage(file));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const prepareImages = async (files, { ratio = '', max = 0 } = {}) => {
|
|
82
|
+
const prepared = [];
|
|
83
|
+
for(const [index, file] of files.entries()){
|
|
84
|
+
if(!EDITABLE.test(file.type) || (!ratio && !max)){
|
|
85
|
+
prepared.push(file);
|
|
86
|
+
} else if(!ratio){
|
|
87
|
+
prepared.push(await shrink(file, max));
|
|
88
|
+
} else {
|
|
89
|
+
const result = await crop(file, ratio, max, { index, total: files.length });
|
|
90
|
+
if(result) prepared.push(result); // null: skipped
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return prepared;
|
|
94
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { html, render } 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/Spinner.js';
|
|
5
|
+
import { exportUrl, previewImport, applyImport } from '/inventory/sdk.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The admin's import and export dialogs (top tier only: the server enforces it, the buttons only follow).
|
|
9
|
+
|
|
10
|
+
import { openExportDialog, openImportDialog } from '/inventory/components/ImportExport.js';
|
|
11
|
+
openExportDialog();
|
|
12
|
+
openImportDialog({ onDone: () => reload() });
|
|
13
|
+
|
|
14
|
+
Two kinds of export, both read by the import:
|
|
15
|
+
- Data only (.json): items, categories and tags, without any photos.
|
|
16
|
+
- With media (.zip): the same data (inventory.json) plus every photo and file (media/).
|
|
17
|
+
See server/utils/importExport.js for exactly what an export holds.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/* ---------- export ---------- */
|
|
21
|
+
export const openExportDialog = () => {
|
|
22
|
+
let kind = 'json';
|
|
23
|
+
const draw = () => render(html`
|
|
24
|
+
<p class="mb">Download the whole inventory: every item and category with its tags and field values.</p>
|
|
25
|
+
<label class="d-f mb" style="gap: var(--spacer_h); align-items: flex-start; cursor: pointer;">
|
|
26
|
+
<input type="radio" name="exportKind" value="json" ?checked=${kind === 'json'} @change=${() => { kind = 'json'; }}>
|
|
27
|
+
<span><strong>Data only</strong> (.json)<br><small class="tc-muted">Items, categories, tags and field values. No photos, so it is small and easy to read.</small></span>
|
|
28
|
+
</label>
|
|
29
|
+
<label class="d-f mb" style="gap: var(--spacer_h); align-items: flex-start; cursor: pointer;">
|
|
30
|
+
<input type="radio" name="exportKind" value="zip" ?checked=${kind === 'zip'} @change=${() => { kind = 'zip'; }}>
|
|
31
|
+
<span><strong>With media</strong> (.zip)<br><small class="tc-muted">The same data plus every photo and file, in one zip. Importing it brings the photos back.</small></span>
|
|
32
|
+
</label>`, $body);
|
|
33
|
+
|
|
34
|
+
const $body = document.createElement('div');
|
|
35
|
+
$body.className = 'p';
|
|
36
|
+
draw();
|
|
37
|
+
|
|
38
|
+
const $dialog = Dialog.create($body, {
|
|
39
|
+
title: 'Export inventory',
|
|
40
|
+
confirmText: 'Download',
|
|
41
|
+
cancelText: 'Cancel',
|
|
42
|
+
confirmAction: async event => {
|
|
43
|
+
event.keepDialogOpen = true;
|
|
44
|
+
const response = await fetch(exportUrl(kind), { credentials: 'same-origin' }).catch(() => null);
|
|
45
|
+
if(!response?.ok){
|
|
46
|
+
const body = await response?.json().catch(() => ({}));
|
|
47
|
+
Toast.error(body?.error || 'The export failed');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const blob = await response.blob();
|
|
51
|
+
const name = /filename="([^"]+)"/.exec(response.headers.get('Content-Disposition') ?? '')?.[1] ?? `inventory.${kind}`;
|
|
52
|
+
const link = document.createElement('a');
|
|
53
|
+
link.href = URL.createObjectURL(blob);
|
|
54
|
+
link.download = name;
|
|
55
|
+
link.click();
|
|
56
|
+
setTimeout(() => URL.revokeObjectURL(link.href), 10000);
|
|
57
|
+
const missing = Number(response.headers.get('X-Missing-Media') ?? 0);
|
|
58
|
+
if(missing) Toast.warning(`${missing} photo${missing === 1 ? ' was' : 's were'} missing from the library and left out`);
|
|
59
|
+
$dialog.close();
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return $dialog;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/* ---------- import ---------- */
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
The file is uploaded as it is to the server, which checks it (preview), keeps it, and later does the import
|
|
69
|
+
(apply). The server opens zips and stores their photos; this page never reads a zip.
|
|
70
|
+
*/
|
|
71
|
+
export const openImportDialog = ({ onDone = () => {} } = {}) => {
|
|
72
|
+
const state = {
|
|
73
|
+
phase: 'choose', // choose -> checking -> review -> importing -> done
|
|
74
|
+
fileName: '',
|
|
75
|
+
preview: null, // the server's report on the file, with its importId
|
|
76
|
+
onMatch: 'skip',
|
|
77
|
+
report: null,
|
|
78
|
+
error: '',
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const $body = document.createElement('div');
|
|
82
|
+
$body.className = 'p';
|
|
83
|
+
$body.style.cssText = 'max-height: calc(100vh - 14rem); overflow-y: auto;';
|
|
84
|
+
|
|
85
|
+
const problemList = results => {
|
|
86
|
+
const list = results.filter(r => r.action === 'error');
|
|
87
|
+
return list.length ? html`
|
|
88
|
+
<p class="mt mb-sm tc-danger"><strong>${list.length} problem${list.length === 1 ? '' : 's'}</strong>: these rows will not be imported.</p>
|
|
89
|
+
<ul class="mb" style="max-height: 9rem; overflow-y: auto; padding-left: 1.2rem;">
|
|
90
|
+
${list.slice(0, 50).map(r => html`<li><small>Row ${r.row}${r.sku || r.name ? html` (${r.sku || r.name})` : ''}: ${r.message}</small></li>`)}
|
|
91
|
+
${list.length > 50 ? html`<li><small>…and ${list.length - 50} more</small></li>` : ''}
|
|
92
|
+
</ul>` : '';
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const draw = () => {
|
|
96
|
+
const preview = state.preview;
|
|
97
|
+
const summary = preview?.summary;
|
|
98
|
+
render(html`
|
|
99
|
+
${state.error ? html`<p class="tc-danger mb">${state.error}</p>` : ''}
|
|
100
|
+
${state.phase === 'choose' || state.phase === 'checking' ? html`
|
|
101
|
+
<p class="mb">Choose an export: a <strong>.json</strong> file (data only) or a <strong>.zip</strong> (data with photos), both made by <strong>Export</strong>.</p>
|
|
102
|
+
<input type="file" id="importFile" accept=".json,.zip,application/json,application/zip" class="full" @change=${choose} ?disabled=${state.phase === 'checking'}>
|
|
103
|
+
${state.phase === 'checking' ? html`<p class="mt tc-muted"><k-spinner size="sm"></k-spinner> Uploading and checking ${state.fileName}…</p>` : ''}
|
|
104
|
+
<p class="mt tc-muted"><small>The file is checked on the server, which only accepts it from the top permission tier. Fields are not created or changed by an import: a value only comes across when a field with the same key exists here. Photos come across only from a zip, and only real photo, video, audio and PDF files.</small></p>` : ''}
|
|
105
|
+
${state.phase === 'review' ? html`
|
|
106
|
+
<p class="mb"><strong>${state.fileName}</strong>: ${preview.counts.items} item${preview.counts.items === 1 ? '' : 's'}${preview.counts.categories ? html` and ${preview.counts.categories} categor${preview.counts.categories === 1 ? 'y' : 'ies'}` : ''}${preview.withMedia ? html` with ${preview.mediaFiles} photo${preview.mediaFiles === 1 ? '' : 's'}` : ', no photos'}.</p>
|
|
107
|
+
<ul class="mb">
|
|
108
|
+
<li><strong>${summary.created}</strong> new item${summary.created === 1 ? '' : 's'}</li>
|
|
109
|
+
<li><strong>${summary.updated}</strong> already ${summary.updated === 1 ? 'exists' : 'exist'} (same SKU)</li>
|
|
110
|
+
${preview.counts.categories ? html`<li><strong>${summary.categoriesCreated}</strong> new categor${summary.categoriesCreated === 1 ? 'y' : 'ies'}, <strong>${summary.categoriesUpdated}</strong> already ${summary.categoriesUpdated === 1 ? 'exists' : 'exist'}</li>` : ''}
|
|
111
|
+
${summary.ignoredValues ? html`<li class="tc-muted">${summary.ignoredValues} value${summary.ignoredValues === 1 ? '' : 's'} for fields that do not exist here (or do not apply to the item's category) will be ignored</li>` : ''}
|
|
112
|
+
</ul>
|
|
113
|
+
${summary.updated + summary.categoriesUpdated > 0 ? html`
|
|
114
|
+
<p class="mb-sm"><strong>For the ones that already exist:</strong></p>
|
|
115
|
+
<label class="d-f mb-sm" style="gap: var(--spacer_h); align-items: flex-start; cursor: pointer;">
|
|
116
|
+
<input type="radio" name="onMatch" value="skip" ?checked=${state.onMatch === 'skip'} @change=${() => { state.onMatch = 'skip'; draw(); }}>
|
|
117
|
+
<span><strong>Ignore them</strong><br><small class="tc-muted">Leave what is already here exactly as it is. Only the new ones are added.</small></span>
|
|
118
|
+
</label>
|
|
119
|
+
<label class="d-f mb" style="gap: var(--spacer_h); align-items: flex-start; cursor: pointer;">
|
|
120
|
+
<input type="radio" name="onMatch" value="update" ?checked=${state.onMatch === 'update'} @change=${() => { state.onMatch = 'update'; draw(); }}>
|
|
121
|
+
<span><strong>Update them</strong> with the values in the file<br><small class="tc-muted">A value the file leaves empty never clears anything. A changed quantity is recorded as a stock adjustment.</small></span>
|
|
122
|
+
</label>` : ''}
|
|
123
|
+
${problemList(preview.results)}` : ''}
|
|
124
|
+
${state.phase === 'importing' ? html`<p><k-spinner size="sm"></k-spinner> Importing. With photos this can take a while; keep this window open.</p>` : ''}
|
|
125
|
+
${state.phase === 'done' ? html`
|
|
126
|
+
<p class="mb"><strong>Import finished.</strong></p>
|
|
127
|
+
<ul class="mb">
|
|
128
|
+
<li><strong>${state.report.summary.created}</strong> added</li>
|
|
129
|
+
<li><strong>${state.report.summary.updated}</strong> updated</li>
|
|
130
|
+
<li><strong>${state.report.summary.skipped}</strong> ignored (already existed)</li>
|
|
131
|
+
${state.report.summary.categoriesCreated + state.report.summary.categoriesUpdated ? html`<li><strong>${state.report.summary.categoriesCreated}</strong> categories added, <strong>${state.report.summary.categoriesUpdated}</strong> updated</li>` : ''}
|
|
132
|
+
${state.report.uploaded ? html`<li><strong>${state.report.uploaded}</strong> photo${state.report.uploaded === 1 ? '' : 's'} uploaded</li>` : ''}
|
|
133
|
+
${state.report.skippedFiles?.length ? html`<li class="tc-danger">${state.report.skippedFiles.length} file${state.report.skippedFiles.length === 1 ? '' : 's'} not uploaded: <small>${state.report.skippedFiles.slice(0, 5).map(file => `${file.name} (${file.reason})`).join(', ')}</small></li>` : ''}
|
|
134
|
+
</ul>
|
|
135
|
+
${problemList(state.report.results)}` : ''}
|
|
136
|
+
`, $body);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const choose = async event => {
|
|
140
|
+
const [file] = event.target.files;
|
|
141
|
+
if(!file) return;
|
|
142
|
+
state.error = '';
|
|
143
|
+
state.fileName = file.name;
|
|
144
|
+
state.phase = 'checking';
|
|
145
|
+
draw();
|
|
146
|
+
const [error, preview] = await previewImport(file);
|
|
147
|
+
if(error){
|
|
148
|
+
state.error = error.msg || 'The file could not be checked';
|
|
149
|
+
state.phase = 'choose';
|
|
150
|
+
draw();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
state.preview = preview;
|
|
154
|
+
state.onMatch = 'skip';
|
|
155
|
+
state.phase = 'review';
|
|
156
|
+
draw();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const run = async () => {
|
|
160
|
+
state.phase = 'importing';
|
|
161
|
+
draw();
|
|
162
|
+
$dialog.confirmText = '…';
|
|
163
|
+
const [error, report] = await applyImport(state.preview.importId, state.onMatch);
|
|
164
|
+
if(error){
|
|
165
|
+
state.error = error.msg || 'The import failed';
|
|
166
|
+
state.phase = error.code === 404 ? 'choose' : 'review';
|
|
167
|
+
$dialog.confirmText = 'Import';
|
|
168
|
+
draw();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
state.report = report;
|
|
172
|
+
state.phase = 'done';
|
|
173
|
+
$dialog.confirmText = 'Close';
|
|
174
|
+
draw();
|
|
175
|
+
onDone(report);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
draw();
|
|
179
|
+
const $dialog = Dialog.create($body, {
|
|
180
|
+
title: 'Import inventory',
|
|
181
|
+
confirmText: 'Import',
|
|
182
|
+
cancelText: 'Cancel',
|
|
183
|
+
confirmAction: async event => {
|
|
184
|
+
event.keepDialogOpen = true;
|
|
185
|
+
if(state.phase === 'done'){ $dialog.close(); return; }
|
|
186
|
+
if(state.phase !== 'review'){
|
|
187
|
+
Toast.error(state.phase === 'choose' ? 'Choose a file first' : 'Please wait');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await run();
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
return $dialog;
|
|
194
|
+
};
|