From 47434db5b5e4a1187651d3fb38ec87db0910580b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bla=C5=BE=20Bregar?=
Date: Fri, 1 May 2026 23:32:13 +0200
Subject: [PATCH 1/2] Add scanner utility, modal, and stock scan page
implementation
---
package.json | 2 +-
src/api/stock.js | 53 +-
src/app/config.js | 3 +-
src/app/router.js | 20 +-
src/components/nav-bar.js | 3 +
src/features/dashboard/dashboard-page.js | 9 +-
src/features/labels/label-create-page.js | 566 +++++++++++-----
src/features/register.js | 2 +
src/features/shared/scanner-modal.js | 65 ++
src/features/shared/scanner.js | 151 +++++
src/features/shared/stock-actions.js | 77 +++
src/features/stock/stock-detail-page.js | 377 +++++++----
src/features/stock/stock-list-page.js | 49 +-
src/features/stock/stock-scan-page.js | 825 +++++++++++++++++++++++
src/styles/app.css | 103 +++
15 files changed, 1952 insertions(+), 353 deletions(-)
create mode 100644 src/features/shared/scanner-modal.js
create mode 100644 src/features/shared/scanner.js
create mode 100644 src/features/shared/stock-actions.js
create mode 100644 src/features/stock/stock-scan-page.js
diff --git a/package.json b/package.json
index c8feee0..1100bd1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "lonc-web",
- "version": "0.2.4",
+ "version": "0.2.5",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/api/stock.js b/src/api/stock.js
index a92fc9e..7f39dae 100644
--- a/src/api/stock.js
+++ b/src/api/stock.js
@@ -230,7 +230,58 @@ export async function updateStockItem(store, uuidB64, body) {
method: 'POST',
body,
});
- return getStockEntry(store, uuidB64);
+ return getStockEntry(store, uuidB64, {
+ allowInactive: body?.level === 'gone' || Number(body?.quantity) <= 0,
+ });
+}
+
+export async function createStockEvent(store, uuidB64, body) {
+ const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
+ method: 'POST',
+ body,
+ });
+ return payload?.stock || payload;
+}
+
+export async function listStockEvents(store, uuidB64, options = {}) {
+ const query = {};
+ if (options.allowInactive) {
+ query.allow_inactive = 1;
+ }
+ if (options.limit !== undefined && options.limit !== null) {
+ query.limit = options.limit;
+ }
+ if (options.offset !== undefined && options.offset !== null) {
+ query.offset = options.offset;
+ }
+ if (options.orderBy || options.order_by) {
+ query.order_by = options.orderBy || options.order_by;
+ }
+ if (options.orderDir || options.order_dir) {
+ query.order_dir = options.orderDir || options.order_dir;
+ }
+
+ const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
+ query,
+ });
+ return unwrapListPayload(payload);
+}
+
+export async function markStockGone(store, uuidB64, reason = 'consumed') {
+ try {
+ await createStockEvent(store, uuidB64, {
+ level: 'gone',
+ gone_reason: reason,
+ });
+ return { status: 'gone', reason };
+ } catch (error) {
+ const status = error?.status || error?.cause?.status;
+ if (status === 409 || status === 404) {
+ return { status: 'already_gone', reason };
+ }
+
+ throw error;
+ }
}
export async function deleteStockItem(store, uuidB64) {
diff --git a/src/app/config.js b/src/app/config.js
index 2cabde1..94008eb 100644
--- a/src/app/config.js
+++ b/src/app/config.js
@@ -1,5 +1,5 @@
export const APP_NAME = 'Lonc';
-export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.4';
+export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.5';
export const TRYTON_APPLICATION = 'kitchen';
export const CONNECTION_STATES = {
@@ -33,6 +33,7 @@ export const API_PATHS = {
export const ROUTES = {
login: '/login',
home: '/',
+ scan: '/scan',
stock: '/stock',
stockNew: '/stock/new',
stockDetail: '/stock/:id',
diff --git a/src/app/router.js b/src/app/router.js
index dc11b80..a0ef68c 100644
--- a/src/app/router.js
+++ b/src/app/router.js
@@ -6,10 +6,12 @@ import { renderLabelCreatePage } from '../features/labels/label-create-page.js';
import { renderSettingsPage } from '../features/auth/settings-page.js';
import { renderStockDetailPage } from '../features/stock/stock-detail-page.js';
import { renderStockListPage } from '../features/stock/stock-list-page.js';
+import { renderStockScanPage } from '../features/stock/stock-scan-page.js';
const routeDefinitions = [
{ path: ROUTES.login, render: renderLoginPage, protected: false },
{ path: ROUTES.home, render: renderDashboardPage, protected: true },
+ { path: ROUTES.scan, render: renderStockScanPage, protected: true },
{ path: ROUTES.stock, render: renderStockListPage, protected: true },
{ path: ROUTES.stockNew, render: renderLabelCreatePage, protected: true },
{ path: ROUTES.stockDetail, render: renderStockDetailPage, protected: true },
@@ -17,9 +19,13 @@ const routeDefinitions = [
{ path: ROUTES.settings, render: renderSettingsPage, protected: false },
];
-function normalizeHashRoute() {
+function parseHashRoute() {
const route = window.location.hash.replace(/^#/, '') || ROUTES.home;
- return route.startsWith('/') ? route : `/${route}`;
+ const normalized = route.startsWith('/') ? route : `/${route}`;
+ const [pathnameRaw, search = ''] = normalized.split('?');
+ const pathname = pathnameRaw || ROUTES.home;
+ const query = Object.fromEntries(new URLSearchParams(search).entries());
+ return { pathname, query };
}
function matchRoute(pathname) {
@@ -52,12 +58,12 @@ export function navigate(path) {
}
export function getRouteContext() {
- return window.__loncRouteContext || { path: ROUTES.home, params: {} };
+ return window.__loncRouteContext || { path: ROUTES.home, params: {}, query: {} };
}
export function createRouter({ Alpine, store, outlet }) {
const render = async () => {
- const pathname = normalizeHashRoute();
+ const { pathname, query } = parseHashRoute();
const match = matchRoute(pathname);
if (!match) {
@@ -81,7 +87,11 @@ export function createRouter({ Alpine, store, outlet }) {
return;
}
- window.__loncRouteContext = { path: pathname, params: match.params };
+ window.__loncRouteContext = {
+ path: pathname,
+ params: match.params,
+ query,
+ };
outlet.innerHTML = match.render();
Alpine.initTree(outlet);
};
diff --git a/src/components/nav-bar.js b/src/components/nav-bar.js
index 1103535..c6f7f49 100644
--- a/src/components/nav-bar.js
+++ b/src/components/nav-bar.js
@@ -17,6 +17,9 @@ export function navBar(appName) {
New Label
+
+ Scan
+
Stock
diff --git a/src/features/dashboard/dashboard-page.js b/src/features/dashboard/dashboard-page.js
index 715facf..11e6282 100644
--- a/src/features/dashboard/dashboard-page.js
+++ b/src/features/dashboard/dashboard-page.js
@@ -15,6 +15,7 @@ export function renderDashboardPage() {
@@ -70,10 +71,10 @@ export function renderDashboardPage() {
diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js
index e7d62ab..f7e0abc 100644
--- a/src/features/labels/label-create-page.js
+++ b/src/features/labels/label-create-page.js
@@ -1,10 +1,10 @@
import {
applyItemUpsert,
+ getStockEntry,
lookupItemByIdentifier,
previewItemUpsert,
searchItemDefinitions,
} from '../../api/stock.js';
-import { BrowserMultiFormatReader } from '@zxing/browser';
import { mapLookupItemToForm } from './identifier-lookup-mapper.js';
import { fetchLocations } from '../../api/locations.js';
import {
@@ -15,6 +15,16 @@ import {
import { STORAGE_KEYS } from '../../app/config.js';
import { debounce, normalizeValidationError } from '../shared/form-utils.js';
import { loadStoredValue, saveStoredValue } from '../shared/storage.js';
+import { renderScannerModal } from '../shared/scanner-modal.js';
+import {
+ canUseCameraScanner,
+ createScannerReader,
+ normalizeIdentifierCode,
+ parseKitchenScanPayload,
+ normalizeScannerError,
+ startCameraScanner,
+ stopCameraScanner,
+} from '../shared/scanner.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
const STOCK_TYPE_OPTIONS = [
@@ -35,8 +45,41 @@ const STOCK_LEVEL_OPTIONS = [
const QUANTITY_UNIT_OPTIONS = ['g', 'ml', 'pc'];
const EXPIRATION_DAY_OPTIONS = ['3', '5', '8', '10', '15', '20', '25', '30', '45', '60', '90', '120', '150', '180'];
const LABEL_DRAFT_STALE_MS = 30 * 60 * 1000;
+const SCANNER_ACTION_OPTIONS = [
+ {
+ key: 'lookup',
+ label: 'Lookup',
+ description: 'Lookup identifier data and prefill the label form.',
+ },
+ {
+ key: 'create',
+ label: 'Create',
+ description: 'Lookup data and create label immediately when required fields are complete.',
+ },
+ {
+ key: 'create_print',
+ label: 'Create & print',
+ description: 'Lookup data, create label, and print when required fields are complete.',
+ },
+];
export function renderLabelCreatePage() {
+ const scannerOptionsMarkup = `
+
+
+
+
+
+
+ `;
+
return `
@@ -121,7 +164,7 @@ export function renderLabelCreatePage() {
- Optional. Scan with camera or enter manually.
+ Optional. Scan with camera, use a hardware scanner, or enter manually.
@@ -468,7 +511,19 @@ export function renderLabelCreatePage() {
-
+
+
+
+
@@ -522,38 +577,16 @@ export function renderLabelCreatePage() {
-
-
-
-
-
-
Scan barcode
-
Point your camera at the barcode to fill the identifier field.
-
-
-
-
-
-
-
-
-
-
Starting camera...
-
-
-
-
-
-
-
-
-
-
+ ${renderScannerModal({
+ title: 'Scan barcode',
+ subtitle: 'Point your camera at a product barcode or kitchen DataMatrix label.',
+ optionsMarkup: scannerOptionsMarkup,
+ manualCodeModel: 'scannerManualCode',
+ manualSubmitAction: 'processScannerManualCode()',
+ manualPlaceholder: 'Scan with hardware reader or paste code',
+ manualHelp: 'Use this with keyboard-style barcode scanners or for manual paste.',
+ manualDisabledExpression: 'lookupState.isLoading || createState.isLoading || scannerState.isLoading || scannerState.isProcessing',
+ })}
`;
}
@@ -677,6 +710,10 @@ export function labelCreatePageData(store) {
previewState: createAsyncState(),
createState: createAsyncState(),
lookupState: createAsyncState(),
+ printState: createAsyncState(),
+ scannerActionOptions: SCANNER_ACTION_OPTIONS,
+ scannerAction: 'lookup',
+ scannerManualCode: '',
stockTypeOptions: STOCK_TYPE_OPTIONS,
stockLevelOptions: STOCK_LEVEL_OPTIONS,
quantityUnitOptions: QUANTITY_UNIT_OPTIONS,
@@ -695,11 +732,14 @@ export function labelCreatePageData(store) {
upsertPreview: null,
printLabelOnSave: true,
printIssue: '',
+ lastCreatedLabelUuidB64: '',
+ lastCreatedLabelName: '',
scannerReader: null,
scannerControls: null,
scannerState: {
isOpen: false,
isLoading: false,
+ isProcessing: false,
hasCamera: false,
error: '',
lastDetectedCode: '',
@@ -746,14 +786,10 @@ export function labelCreatePageData(store) {
this.stopScanner();
},
canUseCameraScanner() {
- return Boolean(
- typeof navigator !== 'undefined'
- && navigator.mediaDevices
- && typeof navigator.mediaDevices.getUserMedia === 'function',
- );
+ return canUseCameraScanner();
},
normalizeIdentifierCode(value) {
- return String(value || '').replace(/\s+/g, '').trim();
+ return normalizeIdentifierCode(value);
},
hasLookupIdentifierCode() {
return Boolean(this.normalizeIdentifierCode(this.form.identifierCode));
@@ -835,26 +871,16 @@ export function labelCreatePageData(store) {
return `${parts.join(' ')}.`;
},
normalizeScannerError(error) {
- const message = String(error?.message || '');
- const normalized = message.toLowerCase();
-
- if (error?.name === 'NotAllowedError' || normalized.includes('permission')) {
- return 'Camera access was denied. Allow access to scan, or enter the code manually.';
- }
-
- if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) {
- return 'No camera was found on this device. Enter the identifier code manually.';
- }
-
- if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) {
- return 'Camera is busy in another app. Close it there and try scanning again.';
- }
-
- return 'Could not start barcode scanning. Enter the identifier code manually.';
+ return normalizeScannerError(error);
+ },
+ activeScannerActionDescription() {
+ return this.scannerActionOptions.find((action) => action.key === this.scannerAction)?.description || '';
},
async openScanner() {
this.scannerState.error = '';
this.scannerState.lastDetectedCode = '';
+ this.scannerState.isProcessing = false;
+ this.scannerManualCode = this.form.identifierCode || '';
this.scannerState.isOpen = true;
await this.$nextTick();
await this.startScanner();
@@ -877,45 +903,19 @@ export function labelCreatePageData(store) {
this.stopScanner();
this.scannerState.isLoading = true;
- const shouldLogDecodeErrors = import.meta.env.DEV;
- let lastDecodeErrorName = '';
- let lastDecodeErrorAt = 0;
try {
if (!this.scannerReader) {
- this.scannerReader = new BrowserMultiFormatReader();
+ this.scannerReader = createScannerReader();
}
- this.scannerControls = await this.scannerReader.decodeFromConstraints(
- {
- audio: false,
- video: {
- facingMode: { ideal: 'environment' },
- },
- },
+ const session = await startCameraScanner({
+ reader: this.scannerReader,
videoElement,
- (result, error) => {
- if (result) {
- this.onBarcodeDetected(result.getText?.() || '');
- return;
- }
-
- if (error) {
- // Continuous decode emits expected per-frame misses/errors before a valid barcode is found.
- // Keep the modal quiet and only surface startup failures from the outer catch block.
- if (shouldLogDecodeErrors) {
- const errorName = String(error?.name || 'UnknownError');
- const now = Date.now();
- if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) {
- console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || '');
- lastDecodeErrorName = errorName;
- lastDecodeErrorAt = now;
- }
- }
- return;
- }
- },
- );
+ onDetected: (code) => this.onBarcodeDetected(code),
+ });
+ this.scannerReader = session.reader;
+ this.scannerControls = session.controls;
} catch (error) {
this.scannerState.error = this.normalizeScannerError(error);
} finally {
@@ -923,27 +923,12 @@ export function labelCreatePageData(store) {
}
},
stopScanner() {
- try {
- this.scannerControls?.stop?.();
- } catch {
- // Ignore cleanup errors when scanner is already stopped.
- }
+ stopCameraScanner({
+ reader: this.scannerReader,
+ controls: this.scannerControls,
+ videoElement: this.$refs.scannerVideo,
+ });
this.scannerControls = null;
-
- try {
- this.scannerReader?.reset?.();
- } catch {
- // Ignore cleanup errors from stale reader state.
- }
-
- const videoElement = this.$refs.scannerVideo;
- const stream = videoElement?.srcObject;
- if (stream && typeof stream.getTracks === 'function') {
- stream.getTracks().forEach((track) => track.stop());
- }
- if (videoElement) {
- videoElement.srcObject = null;
- }
},
closeScanner() {
this.stopScanner();
@@ -951,18 +936,293 @@ export function labelCreatePageData(store) {
this.scannerState.isLoading = false;
this.scannerState.error = '';
},
- onBarcodeDetected(rawCode) {
- const code = this.normalizeIdentifierCode(rawCode);
- if (!code || !this.scannerState.isOpen) {
+ async resolveIdentifierCodeFromScan(rawCode) {
+ const parsed = parseKitchenScanPayload(rawCode);
+ if (parsed.type === 'empty' || parsed.type === 'unknown') {
+ return {
+ identifierCode: '',
+ message: 'Scanned code could not be interpreted.',
+ level: 'warning',
+ };
+ }
+
+ if (parsed.type === 'item') {
+ const item = await getStockEntry(store, parsed.uuidB64, { allowInactive: true });
+ const itemIdentifierCode = this.normalizeIdentifierCode(item?.identifier_code);
+ if (!itemIdentifierCode) {
+ return {
+ identifierCode: '',
+ message: `${item?.name || 'Scanned item'} has no identifier code. Add one first, then scan again.`,
+ level: 'info',
+ };
+ }
+
+ return {
+ identifierCode: itemIdentifierCode,
+ message: `Resolved identifier ${itemIdentifierCode} from ${item?.name || 'scanned item'}.`,
+ level: 'success',
+ };
+ }
+
+ return {
+ identifierCode: this.normalizeIdentifierCode(parsed.identifierCode),
+ message: '',
+ level: 'success',
+ };
+ },
+ applyLookupResult(response, identifierCode, { announceSuccess = true } = {}) {
+ if (response.status !== 'ok') {
+ const message = this.lookupStatusMessageWithDetails(response, identifierCode);
+ this.lookupState.error = message;
+ store.addAlert({
+ type: response.status === 'not_found' ? 'info' : 'warning',
+ message,
+ });
+ return false;
+ }
+
+ if (!response.item || typeof response.item !== 'object') {
+ const message = 'Lookup returned no item payload to apply.';
+ this.lookupState.error = message;
+ store.addAlert({
+ type: 'warning',
+ message,
+ });
+ return false;
+ }
+
+ const mapped = mapLookupItemToForm({
+ form: this.form,
+ lookupItem: response.item,
+ locations: this.locations,
+ });
+
+ if (!mapped.didUpdate) {
+ const message = 'Lookup finished, but no compatible fields were returned.';
+ this.lookupState.error = message;
+ store.addAlert({
+ type: 'info',
+ message,
+ });
+ return false;
+ }
+
+ this.form = {
+ ...mapped.form,
+ itemId: '',
+ itemUuidB64: '',
+ };
+ if (mapped.locationSearch !== null) {
+ this.locationSearch = mapped.locationSearch;
+ }
+ this.syncStockTypeState(this.form.stockType);
+ this.syncStockTypeSelect();
+ this.syncStockLevelSelect();
+ this.syncLocationValidity();
+ this.upsertPreview = null;
+ this.previewState.error = '';
+ this.submitError = '';
+ if (this.previewUrl.startsWith('blob:')) {
+ URL.revokeObjectURL(this.previewUrl);
+ }
+ this.previewUrl = '';
+ this.suggestions = [];
+ this.persistDraft();
+
+ if (announceSuccess) {
+ store.addAlert({
+ type: 'success',
+ message: this.lookupSuccessMessage(response),
+ });
+ }
+
+ return true;
+ },
+ async lookupIdentifierDetailsByCode(identifierCode, { announceSuccess = true } = {}) {
+ const normalizedCode = this.normalizeIdentifierCode(identifierCode);
+ this.form.identifierCode = normalizedCode;
+ this.lookupState.error = '';
+
+ if (!normalizedCode) {
+ this.lookupState.error = 'Provide an identifier code before lookup.';
+ return false;
+ }
+
+ const response = await lookupItemByIdentifier(store, normalizedCode);
+ return this.applyLookupResult(response, normalizedCode, { announceSuccess });
+ },
+ canAutoCreateFromForm() {
+ if (!String(this.form.name || '').trim()) {
+ return false;
+ }
+ if (!this.form.productionDate) {
+ return false;
+ }
+ if (!this.selectedLocation?.uuid_b64) {
+ return false;
+ }
+ if (this.form.stockType === 'measured') {
+ const quantity = Number(this.form.quantity);
+ if (Number.isNaN(quantity) || quantity <= 0) {
+ return false;
+ }
+ }
+ return true;
+ },
+ async createFromScannerAction({ shouldPrint = false } = {}) {
+ const entry = await applyItemUpsert(store, this.buildUpsertPayload());
+ const entryName = entry.item?.name || this.form.name;
+ const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
+ const createdUuidB64 = entry.item?.uuid_b64 || null;
+ this.lastCreatedLabelUuidB64 = createdUuidB64 || '';
+ this.lastCreatedLabelName = entryName;
+
+ this.successMessage = `${entryName} was ${operationVerb} successfully.`;
+ this.upsertPreview = entry;
+ saveLabelDraft(this.form);
+
+ if (shouldPrint) {
+ if (!createdUuidB64) {
+ const message = `${entryName} was ${operationVerb}, but label printing is unavailable for this entry.`;
+ this.printIssue = message;
+ store.addAlert({
+ type: 'warning',
+ message,
+ });
+ return true;
+ }
+
+ try {
+ await printItemLabel(store, createdUuidB64);
+ const message = `${entryName} was ${operationVerb} and printed. Ready for next scan.`;
+ store.addAlert({
+ type: 'success',
+ message,
+ });
+ void this.openScanner().catch(() => {});
+ } catch (printError) {
+ const parsedPrintMessage = formatPrintErrorMessage(printError);
+ this.printIssue = `${entryName} was ${operationVerb}, but printing failed: ${parsedPrintMessage}`;
+ store.addAlert({
+ type: 'warning',
+ message: this.printIssue,
+ });
+ }
+ return true;
+ }
+
+ const message = `${entryName} was ${operationVerb}. Ready for next scan.`;
+ store.addAlert({
+ type: 'success',
+ message,
+ });
+ void this.openScanner().catch(() => {});
+ return true;
+ },
+ canRetryLastLabelPrint() {
+ return Boolean(this.lastCreatedLabelUuidB64);
+ },
+ async retryLastLabelPrint() {
+ if (!this.canRetryLastLabelPrint()) {
return;
}
- this.form.identifierCode = code;
+ await runAsyncState(this.printState, async () => {
+ await printItemLabel(store, this.lastCreatedLabelUuidB64);
+ this.printIssue = '';
+ store.addAlert({
+ type: 'success',
+ message: `${this.lastCreatedLabelName || 'Label'} sent to printer.`,
+ });
+ }).catch((printError) => {
+ const parsedPrintMessage = formatPrintErrorMessage(printError);
+ const itemName = this.lastCreatedLabelName || 'Label';
+ this.printIssue = `Could not print ${itemName}: ${parsedPrintMessage}`;
+ });
+ },
+ async runScannerActionForCode(rawCode) {
+ try {
+ const resolved = await this.resolveIdentifierCodeFromScan(rawCode);
+ if (!resolved.identifierCode) {
+ store.addAlert({
+ type: resolved.level || 'warning',
+ message: resolved.message || 'Scanned code could not be used for lookup.',
+ });
+ return;
+ }
+
+ this.form.identifierCode = resolved.identifierCode;
+ if (resolved.message) {
+ store.addAlert({
+ type: resolved.level || 'info',
+ message: resolved.message,
+ });
+ }
+
+ const didLookupApply = await this.lookupIdentifierDetailsByCode(resolved.identifierCode, {
+ announceSuccess: this.scannerAction === 'lookup',
+ });
+ if (!didLookupApply) {
+ return;
+ }
+
+ if (this.scannerAction === 'lookup') {
+ return;
+ }
+
+ if (!this.canAutoCreateFromForm()) {
+ const message = 'Lookup filled part of the form. Complete required fields, then save or print.';
+ this.lookupState.error = message;
+ store.addAlert({
+ type: 'info',
+ message,
+ });
+ return;
+ }
+
+ this.submitError = '';
+ this.fieldErrors = {};
+ this.printIssue = '';
+ await runAsyncState(this.createState, async () => {
+ await this.createFromScannerAction({
+ shouldPrint: this.scannerAction === 'create_print',
+ });
+ }).catch((error) => {
+ this.fieldErrors = normalizeValidationError(error);
+ this.submitError = error.message || 'Could not create from scanned lookup.';
+ });
+ } catch (error) {
+ store.addAlert({
+ type: 'warning',
+ message: `Scanner action failed: ${error.message || 'Unknown error.'}`,
+ });
+ }
+ },
+ processScannerManualCode() {
+ const code = this.normalizeIdentifierCode(this.scannerManualCode);
+ if (!code) {
+ this.scannerState.error = 'Scan or enter a code first.';
+ return;
+ }
this.scannerState.lastDetectedCode = code;
+ this.scannerState.isProcessing = true;
this.closeScanner();
- store.addAlert({
- type: 'success',
- message: `Scanned identifier code: ${code}`,
+ this.runScannerActionForCode(code).finally(() => {
+ this.scannerState.isProcessing = false;
+ });
+ },
+ onBarcodeDetected(rawCode) {
+ const code = this.normalizeIdentifierCode(rawCode);
+ if (!code || !this.scannerState.isOpen || this.scannerState.isProcessing) {
+ return;
+ }
+
+ this.scannerState.lastDetectedCode = code;
+ this.scannerManualCode = code;
+ this.scannerState.isProcessing = true;
+ this.closeScanner();
+ this.runScannerActionForCode(rawCode).finally(() => {
+ this.scannerState.isProcessing = false;
});
},
async lookupIdentifierDetails() {
@@ -976,69 +1236,7 @@ export function labelCreatePageData(store) {
}
await runAsyncState(this.lookupState, async () => {
- const response = await lookupItemByIdentifier(store, identifierCode);
- if (response.status !== 'ok') {
- const message = this.lookupStatusMessageWithDetails(response, identifierCode);
- this.lookupState.error = message;
- store.addAlert({
- type: response.status === 'not_found' ? 'info' : 'warning',
- message,
- });
- return;
- }
-
- if (!response.item || typeof response.item !== 'object') {
- const message = 'Lookup returned no item payload to apply.';
- this.lookupState.error = message;
- store.addAlert({
- type: 'warning',
- message,
- });
- return;
- }
-
- const mapped = mapLookupItemToForm({
- form: this.form,
- lookupItem: response.item,
- locations: this.locations,
- });
-
- if (!mapped.didUpdate) {
- const message = 'Lookup finished, but no compatible fields were returned.';
- this.lookupState.error = message;
- store.addAlert({
- type: 'info',
- message,
- });
- return;
- }
-
- this.form = {
- ...mapped.form,
- itemId: '',
- itemUuidB64: '',
- };
- if (mapped.locationSearch !== null) {
- this.locationSearch = mapped.locationSearch;
- }
- this.syncStockTypeState(this.form.stockType);
- this.syncStockTypeSelect();
- this.syncStockLevelSelect();
- this.syncLocationValidity();
- this.upsertPreview = null;
- this.previewState.error = '';
- this.submitError = '';
- if (this.previewUrl.startsWith('blob:')) {
- URL.revokeObjectURL(this.previewUrl);
- }
- this.previewUrl = '';
- this.suggestions = [];
- this.persistDraft();
-
- store.addAlert({
- type: 'success',
- message: this.lookupSuccessMessage(response),
- });
+ await this.lookupIdentifierDetailsByCode(identifierCode, { announceSuccess: true });
}).catch((error) => {
store.addAlert({
type: 'warning',
@@ -1501,16 +1699,18 @@ export function labelCreatePageData(store) {
const entryName = entry.item?.name || this.form.name;
const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
const createdUuidB64 = entry.item?.uuid_b64 || null;
+ this.lastCreatedLabelUuidB64 = createdUuidB64 || '';
+ this.lastCreatedLabelName = entryName;
if (this.printLabelOnSave && createdUuidB64) {
try {
await printItemLabel(store, createdUuidB64);
} catch (printError) {
const parsedPrintMessage = formatPrintErrorMessage(printError);
- this.printIssue = parsedPrintMessage;
+ this.printIssue = `${entryName} was ${operationVerb}, but printing failed: ${parsedPrintMessage}`;
store.addAlert({
type: 'warning',
- message: `${entryName} was ${operationVerb}, but printing has an issue: ${parsedPrintMessage}`,
+ message: this.printIssue,
});
}
}
@@ -1542,6 +1742,8 @@ export function labelCreatePageData(store) {
this.fieldErrors = {};
this.upsertPreview = null;
this.printIssue = '';
+ this.lastCreatedLabelUuidB64 = '';
+ this.lastCreatedLabelName = '';
saveLabelDraft(this.form);
if (revokePreview && this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);
diff --git a/src/features/register.js b/src/features/register.js
index 128b58c..c60dcb4 100644
--- a/src/features/register.js
+++ b/src/features/register.js
@@ -6,6 +6,7 @@ import { kitchenSelectorData } from './kitchens/kitchen-selector.js';
import { labelCreatePageData } from './labels/label-create-page.js';
import { stockDetailPageData } from './stock/stock-detail-page.js';
import { stockListPageData } from './stock/stock-list-page.js';
+import { stockScanPageData } from './stock/stock-scan-page.js';
export function registerFeatureData(Alpine, store) {
Alpine.data('alertsData', () => alertsData(store));
@@ -14,6 +15,7 @@ export function registerFeatureData(Alpine, store) {
Alpine.data('dashboardPage', () => dashboardPageData(store));
Alpine.data('kitchenSelector', () => kitchenSelectorData(store));
Alpine.data('labelCreatePage', () => labelCreatePageData(store));
+ Alpine.data('stockScanPage', () => stockScanPageData(store));
Alpine.data('stockListPage', () => stockListPageData(store));
Alpine.data('stockDetailPage', () => stockDetailPageData(store));
}
diff --git a/src/features/shared/scanner-modal.js b/src/features/shared/scanner-modal.js
new file mode 100644
index 0000000..f5de026
--- /dev/null
+++ b/src/features/shared/scanner-modal.js
@@ -0,0 +1,65 @@
+export function renderScannerModal({
+ title = 'Scan barcode',
+ subtitle = 'Point your camera at the barcode.',
+ optionsMarkup = '',
+ manualCodeModel = 'scannerManualCode',
+ manualSubmitAction = 'processScannerManualCode()',
+ manualPlaceholder = 'Scan with hardware reader or paste code',
+ manualHelp = 'Manual entry works with keyboard-style barcode scanners.',
+ manualButtonLabel = 'Run',
+ manualDisabledExpression = 'scannerState.isLoading',
+}) {
+ return `
+
+
+
+
+
+
${title}
+
${subtitle}
+
+
+
+
+ ${optionsMarkup}
+
+
+
+
+
+
+
+
+
Starting camera...
+
+
+
+
+
+
+
+
+
+
+ `;
+}
diff --git a/src/features/shared/scanner.js b/src/features/shared/scanner.js
new file mode 100644
index 0000000..bf40876
--- /dev/null
+++ b/src/features/shared/scanner.js
@@ -0,0 +1,151 @@
+import {
+ BarcodeFormat,
+ BrowserMultiFormatReader,
+} from '@zxing/browser';
+import { DecodeHintType } from '@zxing/library';
+
+const KITCHEN_ITEM_PREFIX = 'kitchen:item::';
+const UUID_B64_PATTERN = /^[A-Za-z0-9_-]{22}$/;
+const UUID_B64_WITH_PADDING_PATTERN = /^[A-Za-z0-9_-]{22}={0,2}$/;
+
+const SCANNER_FORMATS = [
+ BarcodeFormat.DATA_MATRIX,
+ BarcodeFormat.EAN_13,
+ BarcodeFormat.EAN_8,
+ BarcodeFormat.UPC_A,
+ BarcodeFormat.UPC_E,
+ BarcodeFormat.CODE_128,
+ BarcodeFormat.CODE_39,
+ BarcodeFormat.QR_CODE,
+];
+
+export function normalizeIdentifierCode(value) {
+ return String(value || '').replace(/\s+/g, '').trim();
+}
+
+export function parseKitchenScanPayload(rawValue) {
+ const value = normalizeIdentifierCode(rawValue);
+ if (!value) {
+ return { type: 'empty', raw: '' };
+ }
+
+ if (value.toLowerCase().startsWith(KITCHEN_ITEM_PREFIX)) {
+ const uuidB64 = value.slice(KITCHEN_ITEM_PREFIX.length).trim();
+ return uuidB64
+ ? { type: 'item', uuidB64, raw: value }
+ : { type: 'unknown', raw: value };
+ }
+
+ // Backward compatibility: some labels/scanners provide the raw base64 UUID only.
+ if (UUID_B64_PATTERN.test(value) || UUID_B64_WITH_PADDING_PATTERN.test(value)) {
+ return {
+ type: 'item',
+ uuidB64: value.replace(/=+$/g, ''),
+ raw: value,
+ };
+ }
+
+ return { type: 'identifier', identifierCode: value, raw: value };
+}
+
+export function canUseCameraScanner() {
+ return Boolean(
+ typeof navigator !== 'undefined'
+ && navigator.mediaDevices
+ && typeof navigator.mediaDevices.getUserMedia === 'function',
+ );
+}
+
+export function createScannerReader() {
+ const hints = new Map();
+ hints.set(DecodeHintType.POSSIBLE_FORMATS, SCANNER_FORMATS);
+ return new BrowserMultiFormatReader(hints);
+}
+
+export function normalizeScannerError(error) {
+ const message = String(error?.message || '');
+ const normalized = message.toLowerCase();
+
+ if (error?.name === 'NotAllowedError' || normalized.includes('permission')) {
+ return 'Camera access was denied. Allow access to scan, or enter the code manually.';
+ }
+
+ if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) {
+ return 'No camera was found on this device. Enter the code manually.';
+ }
+
+ if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) {
+ return 'Camera is busy in another app. Close it there and try scanning again.';
+ }
+
+ return 'Could not start scanning. Enter the code manually.';
+}
+
+export async function startCameraScanner({
+ reader,
+ videoElement,
+ onDetected,
+ onDecodeError,
+}) {
+ const activeReader = reader || createScannerReader();
+ const shouldLogDecodeErrors = import.meta.env.DEV;
+ let lastDecodeErrorName = '';
+ let lastDecodeErrorAt = 0;
+
+ const controls = await activeReader.decodeFromConstraints(
+ {
+ audio: false,
+ video: {
+ facingMode: { ideal: 'environment' },
+ },
+ },
+ videoElement,
+ (result, error) => {
+ if (result) {
+ onDetected?.(result.getText?.() || '');
+ return;
+ }
+
+ if (!error) {
+ return;
+ }
+
+ onDecodeError?.(error);
+ if (!shouldLogDecodeErrors) {
+ return;
+ }
+
+ const errorName = String(error?.name || 'UnknownError');
+ const now = Date.now();
+ if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) {
+ console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || '');
+ lastDecodeErrorName = errorName;
+ lastDecodeErrorAt = now;
+ }
+ },
+ );
+
+ return { reader: activeReader, controls };
+}
+
+export function stopCameraScanner({ reader, controls, videoElement }) {
+ try {
+ controls?.stop?.();
+ } catch {
+ // Ignore cleanup errors when scanner is already stopped.
+ }
+
+ try {
+ reader?.reset?.();
+ } catch {
+ // Ignore cleanup errors from stale reader state.
+ }
+
+ const stream = videoElement?.srcObject;
+ if (stream && typeof stream.getTracks === 'function') {
+ stream.getTracks().forEach((track) => track.stop());
+ }
+ if (videoElement) {
+ videoElement.srcObject = null;
+ }
+}
diff --git a/src/features/shared/stock-actions.js b/src/features/shared/stock-actions.js
new file mode 100644
index 0000000..de5885e
--- /dev/null
+++ b/src/features/shared/stock-actions.js
@@ -0,0 +1,77 @@
+const LEVEL_TO_FACTOR = {
+ plenty: 0.75,
+ good: 0.50,
+ some: 0.25,
+ low: 0.10,
+ trace: 0.05,
+ gone: 0,
+};
+
+function levelFromRatio(ratio) {
+ if (ratio <= 0) {
+ return 'gone';
+ }
+ if (ratio >= 0.75) {
+ return 'plenty';
+ }
+ if (ratio >= 0.50) {
+ return 'good';
+ }
+ if (ratio >= 0.25) {
+ return 'some';
+ }
+ if (ratio >= 0.10) {
+ return 'low';
+ }
+ return 'trace';
+}
+
+export function buildGoneStockPayload(reason = 'consumed') {
+ return {
+ level: 'gone',
+ gone_reason: reason,
+ };
+}
+
+export function buildConsumeOneStockPayload(item) {
+ if (!item || item.active === false) {
+ return buildGoneStockPayload('consumed');
+ }
+
+ if (item.stock_type === 'binary') {
+ return buildGoneStockPayload('consumed');
+ }
+
+ const currentQuantity = Number(item.quantity || 0);
+ const nextQuantity = Math.max(currentQuantity - 1, 0);
+ if (item.stock_type === 'measured') {
+ return nextQuantity <= 0
+ ? { quantity: 0, level: 'gone', gone_reason: 'consumed' }
+ : { quantity: nextQuantity };
+ }
+
+ if (item.stock_type === 'descriptive') {
+ const initialQuantity = Number(item.quantity_initial || 0);
+ if (!initialQuantity) {
+ return buildGoneStockPayload('consumed');
+ }
+
+ const nextLevel = levelFromRatio(nextQuantity / initialQuantity);
+ return nextLevel === 'gone'
+ ? buildGoneStockPayload('consumed')
+ : { level: nextLevel };
+ }
+
+ const currentLevel = item.level || 'plenty';
+ const currentFactor = LEVEL_TO_FACTOR[currentLevel] ?? 1;
+ const initialQuantity = Number(item.quantity_initial || item.quantity || 1);
+ const estimatedQuantity = currentQuantity || currentFactor * initialQuantity;
+ const nextLevel = levelFromRatio(Math.max(estimatedQuantity - 1, 0) / initialQuantity);
+ return nextLevel === 'gone'
+ ? buildGoneStockPayload('consumed')
+ : { level: nextLevel };
+}
+
+export function isGonePayload(payload) {
+ return payload?.level === 'gone' || Number(payload?.quantity) <= 0;
+}
diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js
index f6541d4..356ead2 100644
--- a/src/features/stock/stock-detail-page.js
+++ b/src/features/stock/stock-detail-page.js
@@ -1,14 +1,24 @@
import {
adjustStockEntry,
getStockEntry,
+ listStockEvents,
lookupItemDetails,
+ markStockGone,
patchStockItem,
- useStockItem,
} from '../../api/stock.js';
-import { BrowserMultiFormatReader } from '@zxing/browser';
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
import { fetchLocations } from '../../api/locations.js';
import { getRouteContext } from '../../app/router.js';
+import { renderScannerModal } from '../shared/scanner-modal.js';
+import {
+ canUseCameraScanner,
+ createScannerReader,
+ normalizeIdentifierCode,
+ normalizeScannerError,
+ parseKitchenScanPayload,
+ startCameraScanner,
+ stopCameraScanner,
+} from '../shared/scanner.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
import { formatDate } from '../shared/date-utils.js';
@@ -30,10 +40,6 @@ function parseDateValue(value) {
return new Date(year, month - 1, day);
}
-function normalizeIdentifierCode(value) {
- return String(value || '').replace(/\s+/g, '').trim();
-}
-
function expirationInfo(entry) {
if (!entry?.expire_date) {
return {
@@ -95,7 +101,7 @@ export function renderStockDetailPage() {
-
← Back to stock
+
←
Stock detail
Inspect the entry and update its quantity without leaving the workflow.
@@ -283,8 +289,12 @@ export function renderStockDetailPage() {
Print label
Printing...
-
@@ -325,8 +335,12 @@ export function renderStockDetailPage() {
Print label
Printing...
-
- Mark gone
+
+ Mark used
+ Removing...
+
+
+ Spoilt
Removing...
@@ -336,7 +350,7 @@ export function renderStockDetailPage() {
- Binary stock items can be marked gone from this screen.
+ Binary stock items can be marked used or spoilt from this screen.
@@ -350,14 +364,54 @@ export function renderStockDetailPage() {
>
-
- Mark gone
- Removing...
-
-
- Print label
- Printing...
-
+
+
+ Mark used
+ Removing...
+
+
+ Spoilt
+ Removing...
+
+
+ Print label
+ Printing...
+
+
+
+
+
+
+
+
+
+
+
+
History
+
Recent stock events
+
+
+ Refresh
+ Refreshing...
+
+
+
+
+
+
+ No stock events yet.
+
+
+
@@ -366,38 +420,15 @@ export function renderStockDetailPage() {
-
-
-
-
-
-
Scan barcode
-
Point your camera at the barcode to fill the identifier field.
-
-
Close
-
-
-
-
-
-
-
-
Starting camera...
-
-
Retry
-
-
-
-
-
-
-
-
+ ${renderScannerModal({
+ title: 'Scan barcode',
+ subtitle: 'Point your camera at a product barcode or kitchen DataMatrix label.',
+ manualCodeModel: 'scannerManualCode',
+ manualSubmitAction: 'processScannerManualCode()',
+ manualPlaceholder: 'Scan with hardware reader or paste code',
+ manualHelp: 'Use this with keyboard-style barcode scanners or manual entry.',
+ manualDisabledExpression: 'identifierState.isLoading || scannerState.isLoading || scannerState.isProcessing',
+ })}
`;
}
@@ -408,11 +439,13 @@ export function stockDetailPageData(store) {
adjustmentState: createAsyncState(),
printState: createAsyncState(),
identifierState: createAsyncState(),
+ stockHistoryState: createAsyncState(),
scannerReader: null,
scannerControls: null,
scannerState: {
isOpen: false,
isLoading: false,
+ isProcessing: false,
hasCamera: false,
error: '',
lastDetectedCode: '',
@@ -426,9 +459,13 @@ export function stockDetailPageData(store) {
type: '',
message: '',
},
+ backHref: '#/stock',
+ backLabel: 'Back to stock',
entry: null,
+ stockEvents: [],
locationPathByUuid: {},
identifierDraft: '',
+ scannerManualCode: '',
adjustment: {
mode: 'increment',
quantity: '1',
@@ -436,6 +473,11 @@ export function stockDetailPageData(store) {
},
async init() {
this.scannerState.hasCamera = this.canUseCameraScanner();
+ const routeContext = getRouteContext();
+ if (routeContext?.query?.from === 'scan') {
+ this.backHref = '#/scan';
+ this.backLabel = 'Back to scan';
+ }
if (!store.isConnected) {
return;
}
@@ -455,38 +497,22 @@ export function stockDetailPageData(store) {
);
this.adjustment.level = this.entry?.level || 'plenty';
}).catch(() => {});
+ this.loadStockHistory().catch(() => {});
},
destroy() {
this.stopScanner();
},
canUseCameraScanner() {
- return Boolean(
- typeof navigator !== 'undefined'
- && navigator.mediaDevices
- && typeof navigator.mediaDevices.getUserMedia === 'function',
- );
+ return canUseCameraScanner();
},
normalizeScannerError(error) {
- const message = String(error?.message || '');
- const normalized = message.toLowerCase();
-
- if (error?.name === 'NotAllowedError' || normalized.includes('permission')) {
- return 'Camera access was denied. Allow access to scan, or enter the code manually.';
- }
-
- if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) {
- return 'No camera was found on this device. Enter the identifier code manually.';
- }
-
- if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) {
- return 'Camera is busy in another app. Close it there and try scanning again.';
- }
-
- return 'Could not start barcode scanning. Enter the identifier code manually.';
+ return normalizeScannerError(error);
},
async openScanner() {
this.scannerState.error = '';
this.scannerState.lastDetectedCode = '';
+ this.scannerState.isProcessing = false;
+ this.scannerManualCode = normalizeIdentifierCode(this.identifierDraft);
this.scannerState.isOpen = true;
await this.$nextTick();
await this.startScanner();
@@ -509,45 +535,19 @@ export function stockDetailPageData(store) {
this.stopScanner();
this.scannerState.isLoading = true;
- const shouldLogDecodeErrors = import.meta.env.DEV;
- let lastDecodeErrorName = '';
- let lastDecodeErrorAt = 0;
try {
if (!this.scannerReader) {
- this.scannerReader = new BrowserMultiFormatReader();
+ this.scannerReader = createScannerReader();
}
- this.scannerControls = await this.scannerReader.decodeFromConstraints(
- {
- audio: false,
- video: {
- facingMode: { ideal: 'environment' },
- },
- },
+ const session = await startCameraScanner({
+ reader: this.scannerReader,
videoElement,
- (result, error) => {
- if (result) {
- this.onBarcodeDetected(result.getText?.() || '');
- return;
- }
-
- if (error) {
- // Continuous decode emits expected per-frame misses/errors before a valid barcode is found.
- // Keep the modal quiet and only surface startup failures from the outer catch block.
- if (shouldLogDecodeErrors) {
- const errorName = String(error?.name || 'UnknownError');
- const now = Date.now();
- if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) {
- console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || '');
- lastDecodeErrorName = errorName;
- lastDecodeErrorAt = now;
- }
- }
- return;
- }
- },
- );
+ onDetected: (code) => this.onBarcodeDetected(code),
+ });
+ this.scannerReader = session.reader;
+ this.scannerControls = session.controls;
} catch (error) {
this.scannerState.error = this.normalizeScannerError(error);
} finally {
@@ -555,27 +555,12 @@ export function stockDetailPageData(store) {
}
},
stopScanner() {
- try {
- this.scannerControls?.stop?.();
- } catch {
- // Ignore cleanup errors when scanner is already stopped.
- }
+ stopCameraScanner({
+ reader: this.scannerReader,
+ controls: this.scannerControls,
+ videoElement: this.$refs.scannerVideo,
+ });
this.scannerControls = null;
-
- try {
- this.scannerReader?.reset?.();
- } catch {
- // Ignore cleanup errors from stale reader state.
- }
-
- const videoElement = this.$refs.scannerVideo;
- const stream = videoElement?.srcObject;
- if (stream && typeof stream.getTracks === 'function') {
- stream.getTracks().forEach((track) => track.stop());
- }
- if (videoElement) {
- videoElement.srcObject = null;
- }
},
closeScanner() {
this.stopScanner();
@@ -583,18 +568,75 @@ export function stockDetailPageData(store) {
this.scannerState.isLoading = false;
this.scannerState.error = '';
},
- onBarcodeDetected(rawCode) {
- const code = normalizeIdentifierCode(rawCode);
- if (!code || !this.scannerState.isOpen) {
+ async applyScannedCode(rawCode) {
+ const parsed = parseKitchenScanPayload(rawCode);
+ if (parsed.type === 'empty' || parsed.type === 'unknown') {
+ store.addAlert({
+ type: 'warning',
+ message: 'Scanned code could not be interpreted.',
+ });
return;
}
- this.identifierDraft = code;
- this.scannerState.lastDetectedCode = code;
- this.closeScanner();
+ let identifierCode = '';
+ if (parsed.type === 'item') {
+ const scannedItem = await getStockEntry(store, parsed.uuidB64, { allowInactive: true });
+ identifierCode = normalizeIdentifierCode(scannedItem?.identifier_code);
+ if (!identifierCode) {
+ store.addAlert({
+ type: 'info',
+ message: `${scannedItem?.name || 'Scanned item'} has no identifier code saved.`,
+ });
+ return;
+ }
+ store.addAlert({
+ type: 'success',
+ message: `Loaded identifier ${identifierCode} from ${scannedItem?.name || 'scanned item'}.`,
+ });
+ } else {
+ identifierCode = normalizeIdentifierCode(parsed.identifierCode);
+ }
+
+ if (!identifierCode) {
+ store.addAlert({
+ type: 'warning',
+ message: 'No identifier code found in scan.',
+ });
+ return;
+ }
+
+ this.identifierDraft = identifierCode;
+ this.scannerManualCode = identifierCode;
store.addAlert({
type: 'success',
- message: `Scanned identifier code: ${code}`,
+ message: `Scanned identifier code: ${identifierCode}`,
+ });
+ },
+ processScannerManualCode() {
+ const code = normalizeIdentifierCode(this.scannerManualCode);
+ if (!code) {
+ this.scannerState.error = 'Scan or enter a code first.';
+ return;
+ }
+ this.scannerState.lastDetectedCode = code;
+ this.scannerState.isProcessing = true;
+ this.closeScanner();
+ this.applyScannedCode(code).finally(() => {
+ this.scannerState.isProcessing = false;
+ });
+ },
+ onBarcodeDetected(rawCode) {
+ const code = normalizeIdentifierCode(rawCode);
+ if (!code || !this.scannerState.isOpen || this.scannerState.isProcessing) {
+ return;
+ }
+
+ this.scannerState.lastDetectedCode = code;
+ this.scannerManualCode = code;
+ this.scannerState.isProcessing = true;
+ this.closeScanner();
+ this.applyScannedCode(rawCode).finally(() => {
+ this.scannerState.isProcessing = false;
});
},
async saveIdentifierCode() {
@@ -761,6 +803,7 @@ export function stockDetailPageData(store) {
});
this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
+ this.loadStockHistory().catch(() => {});
}).catch(() => {});
},
async submitLevelAdjustment() {
@@ -771,8 +814,13 @@ export function stockDetailPageData(store) {
await runAsyncState(this.adjustmentState, async () => {
if (this.adjustment.level === 'gone') {
const entryName = this.entry.name;
- await useStockItem(store, this.entry.uuid_b64);
- store.addAlert({ type: 'success', message: `${entryName} was marked gone.` });
+ const result = await markStockGone(store, this.entry.uuid_b64, 'consumed');
+ store.addAlert({
+ type: result.status === 'already_gone' ? 'info' : 'success',
+ message: result.status === 'already_gone'
+ ? `${entryName} was already out of stock.`
+ : `${entryName} was marked used.`,
+ });
window.__loncApp.navigate('/stock');
return;
}
@@ -782,25 +830,76 @@ export function stockDetailPageData(store) {
});
this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
store.addAlert({ type: 'success', message: 'Stock level updated.' });
+ this.loadStockHistory().catch(() => {});
}).catch(() => {});
},
- async markGone() {
+ async markGone(reason = 'consumed') {
if (!this.entry) {
return;
}
await runAsyncState(this.adjustmentState, async () => {
- const result = await useStockItem(store, this.entry.uuid_b64);
+ const entryName = this.entry.name;
+ const result = await markStockGone(store, this.entry.uuid_b64, reason);
const alreadyGone = result.status === 'already_gone';
+ const actionLabel = reason === 'spoiled' ? 'marked spoilt' : 'marked used';
store.addAlert({
type: alreadyGone ? 'info' : 'success',
message: alreadyGone
- ? `${this.entry.name} was already out of stock.`
- : `${this.entry.name} was marked gone.`,
+ ? `${entryName} was already out of stock.`
+ : `${entryName} was ${actionLabel}.`,
});
window.__loncApp.navigate('/stock');
}).catch(() => {});
},
+ async loadStockHistory() {
+ if (!this.entry?.uuid_b64) {
+ return;
+ }
+
+ await runAsyncState(this.stockHistoryState, async () => {
+ this.stockEvents = await listStockEvents(store, this.entry.uuid_b64, {
+ allowInactive: true,
+ limit: 8,
+ orderBy: 'date',
+ orderDir: 'desc',
+ });
+ }).catch(() => {});
+ },
+ goneReasonLabel(reason) {
+ if (reason === 'consumed') {
+ return 'Consumed';
+ }
+ if (reason === 'spoiled') {
+ return 'Spoilt';
+ }
+ if (reason === 'other') {
+ return 'Other removal';
+ }
+ return '';
+ },
+ stockEventLabel(event) {
+ if (event?.level === 'gone') {
+ return this.goneReasonLabel(event.gone_reason) || 'Gone';
+ }
+ if (event?.level) {
+ return `Level: ${event.level}`;
+ }
+ return 'Quantity updated';
+ },
+ stockEventDetail(event) {
+ const parts = [];
+ if (event?.quantity !== null && event?.quantity !== undefined) {
+ parts.push(`Quantity: ${event.quantity}${event.uom_symbol ? ` ${event.uom_symbol}` : ''}`);
+ }
+ if (event?.level && event.level !== 'gone') {
+ parts.push(`Level: ${event.level}`);
+ }
+ if (event?.gone_reason) {
+ parts.push(`Reason: ${this.goneReasonLabel(event.gone_reason).toLowerCase()}`);
+ }
+ return parts.join(' · ') || 'Stock event saved.';
+ },
async printLabel() {
if (!this.entry?.uuid_b64) {
return;
diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js
index a45ac93..88d2045 100644
--- a/src/features/stock/stock-list-page.js
+++ b/src/features/stock/stock-list-page.js
@@ -3,8 +3,8 @@ import {
listGroupedStockEntries,
listKitchenChanges,
listStockEntries,
+ markStockGone,
updateStockItem,
- useStockItem,
} from '../../api/stock.js';
import { fetchLocations } from '../../api/locations.js';
import { STORAGE_KEYS } from '../../app/config.js';
@@ -578,7 +578,8 @@ export function renderStockListPage() {
- Mark gone
+ Mark used
+ Spoilt
@@ -589,14 +590,16 @@ export function renderStockListPage() {
Save
-
Mark gone
+
Mark used
+
Spoilt
Save qty
- Mark gone
+ Mark used
+ Spoilt
@@ -660,7 +663,8 @@ export function renderStockListPage() {
- Mark gone
+ Mark used
+ Spoilt
@@ -672,7 +676,8 @@ export function renderStockListPage() {
Save stock level
- Mark gone
+ Mark used
+ Spoilt
@@ -681,7 +686,8 @@ export function renderStockListPage() {
Save quantity
- Mark gone
+ Mark used
+ Spoilt
@@ -796,7 +802,8 @@ export function renderStockListPage() {
>
Details
- Mark gone
+ Mark used
+ Spoilt
Refreshing...
@@ -2202,12 +2209,12 @@ export function stockListPageData(store) {
return;
}
- await this.useEntry(entry);
+ await this.useEntry(entry, 'consumed');
},
async saveLevel(entry) {
const level = this.editForms[entry.id]?.level || 'plenty';
if (level === 'gone') {
- await this.useEntry(entry);
+ await this.useEntry(entry, 'consumed');
return;
}
@@ -2234,14 +2241,14 @@ export function stockListPageData(store) {
{ quantity },
);
},
- async markGone(entry) {
+ async markGone(entry, reason = 'consumed') {
if (this.isItemRefreshing(entry)) {
return;
}
- await this.useEntry(entry);
+ await this.useEntry(entry, reason);
},
- async markGoneFromGroup(item, group) {
+ async markGoneFromGroup(item, group, reason = 'consumed') {
if (this.isItemRefreshing(item)) {
return;
}
@@ -2249,8 +2256,9 @@ export function stockListPageData(store) {
this.editErrors[item.id] = '';
try {
- const result = await useStockItem(store, item.uuid_b64);
+ const result = await markStockGone(store, item.uuid_b64, reason);
const alreadyGone = result.status === 'already_gone';
+ const actionLabel = reason === 'spoiled' ? 'marked spoilt' : 'marked used';
this.removeGroupedItem(group.id, item.id);
this.removeEntryLocally(item.id);
delete this.editForms[item.id];
@@ -2259,11 +2267,11 @@ export function stockListPageData(store) {
type: alreadyGone ? 'info' : 'success',
message: alreadyGone
? `${item.name} was already out of stock and removed from the group.`
- : `${item.name} was marked gone and removed from the group.`,
+ : `${item.name} was ${actionLabel} and removed from the group.`,
});
this.loadGroupedEntries({ expanded: 0, background: true }).catch(() => {});
} catch (error) {
- this.editErrors[item.id] = error.message || 'Mark gone failed.';
+ this.editErrors[item.id] = error.message || 'Removal failed.';
}
},
async saveEntryUpdate(entry, payload, localPatch) {
@@ -2281,7 +2289,7 @@ export function stockListPageData(store) {
this.editErrors[entry.id] = error.message || 'Update failed.';
}
},
- async useEntry(entry) {
+ async useEntry(entry, reason = 'consumed') {
if (this.isItemRefreshing(entry)) {
return;
}
@@ -2289,8 +2297,9 @@ export function stockListPageData(store) {
this.editErrors[entry.id] = '';
try {
- const result = await useStockItem(store, entry.uuid_b64);
+ const result = await markStockGone(store, entry.uuid_b64, reason);
const alreadyGone = result.status === 'already_gone';
+ const actionLabel = reason === 'spoiled' ? 'marked spoilt' : 'marked used';
this.removeEntryLocally(entry.id);
delete this.editForms[entry.id];
delete this.editErrors[entry.id];
@@ -2298,11 +2307,11 @@ export function stockListPageData(store) {
type: alreadyGone ? 'info' : 'success',
message: alreadyGone
? `${entry.name} was already out of stock and removed from the list.`
- : `${entry.name} was marked gone and removed from the list.`,
+ : `${entry.name} was ${actionLabel} and removed from the list.`,
});
this.refreshLoadedViewsInBackground().catch(() => {});
} catch (error) {
- this.editErrors[entry.id] = error.message || 'Mark gone failed.';
+ this.editErrors[entry.id] = error.message || 'Removal failed.';
}
},
removeEntryLocally(entryId) {
diff --git a/src/features/stock/stock-scan-page.js b/src/features/stock/stock-scan-page.js
new file mode 100644
index 0000000..076097e
--- /dev/null
+++ b/src/features/stock/stock-scan-page.js
@@ -0,0 +1,825 @@
+import {
+ applyItemUpsert,
+ createStockEvent,
+ getStockEntry,
+ listStockEntries,
+ lookupItemByIdentifier,
+ markStockGone,
+} from '../../api/stock.js';
+import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
+import { fetchLocations } from '../../api/locations.js';
+import { STORAGE_KEYS } from '../../app/config.js';
+import { mapLookupItemToForm } from '../labels/identifier-lookup-mapper.js';
+import { saveStoredValue } from '../shared/storage.js';
+import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
+import { formatDate } from '../shared/date-utils.js';
+import { renderScannerModal } from '../shared/scanner-modal.js';
+import {
+ canUseCameraScanner,
+ createScannerReader,
+ normalizeIdentifierCode,
+ normalizeScannerError,
+ parseKitchenScanPayload,
+ startCameraScanner,
+ stopCameraScanner,
+} from '../shared/scanner.js';
+import {
+ buildConsumeOneStockPayload,
+ isGonePayload,
+} from '../shared/stock-actions.js';
+
+const SCAN_MODES = [
+ {
+ key: 'details',
+ label: 'Open details',
+ description: 'Scan and inspect the exact item.',
+ },
+ {
+ key: 'consume',
+ label: 'Consume standard unit',
+ description: 'Reduce stock by one standard unit.',
+ },
+ {
+ key: 'used',
+ label: 'Mark used',
+ description: 'Mark the item gone because it was consumed.',
+ },
+ {
+ key: 'spoiled',
+ label: 'Mark spoiled',
+ description: 'Mark the item gone because it spoiled.',
+ },
+ {
+ key: 'label',
+ label: 'Label workflow',
+ description: 'Lookup product data and continue to label creation.',
+ },
+];
+
+const LABEL_SCAN_ACTIONS = [
+ {
+ key: 'lookup',
+ label: 'Lookup',
+ description: 'Open label form with prefilled lookup data.',
+ },
+ {
+ key: 'create',
+ label: 'Create',
+ description: 'Create stock label directly when lookup has enough data.',
+ },
+ {
+ key: 'create_print',
+ label: 'Create & print',
+ description: 'Create and print when data is sufficient.',
+ },
+];
+
+function parseDateValue(value) {
+ if (!value) {
+ return null;
+ }
+ const [year, month, day] = String(value).split('-').map(Number);
+ if (!year || !month || !day) {
+ return null;
+ }
+ return new Date(year, month - 1, day);
+}
+
+function sortByOperationalPriority(entries, locationMap) {
+ return [...entries].sort((left, right) => {
+ const leftDate = parseDateValue(left.expire_date)?.getTime() ?? Number.MAX_SAFE_INTEGER;
+ const rightDate = parseDateValue(right.expire_date)?.getTime() ?? Number.MAX_SAFE_INTEGER;
+ if (leftDate !== rightDate) {
+ return leftDate - rightDate;
+ }
+
+ const leftLocation = locationMap[left.location_initial_uuid_b64] || '';
+ const rightLocation = locationMap[right.location_initial_uuid_b64] || '';
+ if (leftLocation !== rightLocation) {
+ return leftLocation.localeCompare(rightLocation);
+ }
+
+ return (left.name || '').localeCompare(right.name || '');
+ });
+}
+
+function quantityLabel(entry) {
+ if (!entry) {
+ return '';
+ }
+ if (entry.stock_type === 'binary') {
+ return entry.level === 'gone' ? 'Gone' : 'Available';
+ }
+ const quantity = entry.quantity ?? null;
+ const uom = entry.uom_symbol || '';
+ const measured = quantity !== null && quantity !== undefined ? `${quantity} ${uom}`.trim() : '';
+ return measured || entry.level || 'No quantity';
+}
+
+function todayIsoDate() {
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = String(now.getMonth() + 1).padStart(2, '0');
+ const day = String(now.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+}
+
+function createLabelDraftBase() {
+ return {
+ itemId: '',
+ itemUuidB64: '',
+ identifierCode: '',
+ externalSource: '',
+ externalId: '',
+ search: '',
+ name: '',
+ description: '',
+ quantity: '',
+ uom: 'g',
+ stockType: 'binary',
+ level: 'plenty',
+ energy: '',
+ energyUnit: 'kcal (100g/ml)',
+ productionDate: todayIsoDate(),
+ expireDays: '',
+ expirationDate: '',
+ locationId: '',
+ };
+}
+
+export function renderStockScanPage() {
+ const scannerOptionsMarkup = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ return `
+
+
+
+
+
+
Scan operations
+
Scan a label or barcode and act immediately.
+
+ DataMatrix labels open the exact stock item. Product barcodes resolve matching active stock and ask when there is ambiguity.
+
+
+
+
+
+
+
+
+
+
+
+
Choose scan action
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Run
+
+
Works with keyboard-style barcode scanners and manual paste.
+
+
+
+
+ Camera scanning is not available in this browser. Manual entry still works.
+
+
+
+
+
+
+
+
+
+
+
+
Scan result
+
Last scanned code and the action outcome.
+
+
Clear
+
+
+
+
+
+
+
+
+
+
+
+
+
Choose matching stock item
+
+
+
+
+
+
+
+
+
+
View item
+
+ Print label
+ Printing...
+
+
Scan another
+
+
+
+
+
+
+
+ Pick an action, scan a kitchen label or barcode, and the result will appear here.
+
+
+
+
+
+
+
+ ${renderScannerModal({
+ title: 'Scan for ',
+ subtitle: 'Point your camera at a DataMatrix label or product barcode.',
+ optionsMarkup: scannerOptionsMarkup,
+ manualCodeModel: 'scannerManualCode',
+ manualSubmitAction: 'processScannerManualCode()',
+ manualPlaceholder: 'Scan with hardware reader or paste code',
+ manualHelp: 'Works with keyboard-style barcode scanners and manual paste.',
+ manualDisabledExpression: 'actionState.isLoading || scannerState.isLoading',
+ })}
+
+ `;
+}
+
+export function stockScanPageData(store) {
+ return {
+ scanModes: SCAN_MODES,
+ labelScanActions: LABEL_SCAN_ACTIONS,
+ scanMode: 'details',
+ labelScanAction: 'lookup',
+ manualCode: '',
+ scannerManualCode: '',
+ scannerReader: null,
+ scannerControls: null,
+ scannerState: {
+ isOpen: false,
+ isLoading: false,
+ hasCamera: false,
+ error: '',
+ lastDetectedCode: '',
+ },
+ actionState: createAsyncState(),
+ printState: createAsyncState(),
+ result: {
+ type: '',
+ message: '',
+ item: null,
+ },
+ candidateItems: [],
+ locations: [],
+ locationPathByUuid: {},
+ async init() {
+ this.scannerState.hasCamera = canUseCameraScanner();
+ if (!store.isConnected) {
+ return;
+ }
+ const locations = await fetchLocations(store).catch(() => ({ flat: [] }));
+ this.locations = locations.flat || [];
+ this.locationPathByUuid = Object.fromEntries(
+ this.locations
+ .filter((location) => location.uuid_b64)
+ .map((location) => [location.uuid_b64, location.pathLabel || location.name]),
+ );
+ },
+ destroy() {
+ this.stopScanner();
+ },
+ activeModeLabel() {
+ return this.scanModes.find((mode) => mode.key === this.scanMode)?.label || 'scan';
+ },
+ activeLabelActionDescription() {
+ return this.labelScanActions.find((action) => action.key === this.labelScanAction)?.description || '';
+ },
+ async openScanner() {
+ this.scannerState.error = '';
+ this.scannerState.lastDetectedCode = '';
+ this.scannerManualCode = this.manualCode;
+ this.scannerState.isOpen = true;
+ await this.$nextTick();
+ await this.startScanner();
+ },
+ async startScanner() {
+ this.scannerState.error = '';
+ this.scannerState.lastDetectedCode = '';
+
+ if (!canUseCameraScanner()) {
+ this.scannerState.hasCamera = false;
+ this.scannerState.error = 'Camera scanning is not supported in this browser. Enter the code manually.';
+ return;
+ }
+
+ const videoElement = this.$refs.scannerVideo;
+ if (!videoElement) {
+ this.scannerState.error = 'Scanner video element is unavailable. Close and reopen scanner.';
+ return;
+ }
+
+ this.stopScanner();
+ this.scannerState.isLoading = true;
+ try {
+ if (!this.scannerReader) {
+ this.scannerReader = createScannerReader();
+ }
+ const session = await startCameraScanner({
+ reader: this.scannerReader,
+ videoElement,
+ onDetected: (code) => this.onScanDetected(code),
+ });
+ this.scannerReader = session.reader;
+ this.scannerControls = session.controls;
+ } catch (error) {
+ this.scannerState.error = normalizeScannerError(error);
+ } finally {
+ this.scannerState.isLoading = false;
+ }
+ },
+ stopScanner() {
+ stopCameraScanner({
+ reader: this.scannerReader,
+ controls: this.scannerControls,
+ videoElement: this.$refs.scannerVideo,
+ });
+ this.scannerControls = null;
+ },
+ closeScanner() {
+ this.stopScanner();
+ this.scannerState.isOpen = false;
+ this.scannerState.isLoading = false;
+ this.scannerState.error = '';
+ },
+ onScanDetected(rawCode) {
+ const code = normalizeIdentifierCode(rawCode);
+ if (!code || !this.scannerState.isOpen) {
+ return;
+ }
+ this.scannerState.lastDetectedCode = code;
+ this.scannerManualCode = code;
+ this.closeScanner();
+ this.processScannedCode(code);
+ },
+ processScannerManualCode() {
+ const code = normalizeIdentifierCode(this.scannerManualCode);
+ if (!code) {
+ this.scannerState.error = 'Scan or enter a code first.';
+ return;
+ }
+ this.scannerState.lastDetectedCode = code;
+ this.manualCode = code;
+ this.closeScanner();
+ this.processScannedCode(code);
+ },
+ processManualCode() {
+ this.processScannedCode(this.manualCode);
+ },
+ async processScannedCode(rawCode) {
+ const parsed = parseKitchenScanPayload(rawCode);
+ this.clearResult();
+ if (parsed.type === 'empty') {
+ this.actionState.error = 'Scan or enter a code first.';
+ return;
+ }
+
+ if (this.scanMode === 'label') {
+ await runAsyncState(this.actionState, async () => {
+ await this.processLabelScan(parsed);
+ }).catch(() => {});
+ return;
+ }
+
+ await runAsyncState(this.actionState, async () => {
+ if (parsed.type === 'item') {
+ const item = await getStockEntry(store, parsed.uuidB64, { allowInactive: true });
+ await this.executeAction(item);
+ return;
+ }
+
+ const matches = await this.resolveIdentifierMatches(parsed.identifierCode);
+ if (!matches.length) {
+ this.result = {
+ type: 'info',
+ message: `No active stock item matched ${parsed.identifierCode}.`,
+ item: null,
+ };
+ return;
+ }
+
+ if (matches.length > 1) {
+ this.candidateItems = matches;
+ this.result = {
+ type: 'info',
+ message: `${matches.length} stock items match this barcode. Choose the one to ${this.activeModeLabel().toLowerCase()}.`,
+ item: null,
+ };
+ return;
+ }
+
+ await this.executeAction(matches[0]);
+ }).catch(() => {});
+ },
+ buildLabelDraftFromLookup(lookupItem, identifierCode) {
+ const mapped = mapLookupItemToForm({
+ form: {
+ ...createLabelDraftBase(),
+ identifierCode: identifierCode || '',
+ },
+ lookupItem,
+ locations: this.locations,
+ });
+
+ const stockType = ['measured', 'descriptive', 'binary'].includes(lookupItem?.stock_type)
+ ? lookupItem.stock_type
+ : mapped.form.stockType || 'binary';
+ const level = stockType === 'measured'
+ ? ''
+ : (mapped.form.level || 'plenty');
+
+ return {
+ form: {
+ ...mapped.form,
+ stockType,
+ level,
+ identifierCode: normalizeIdentifierCode(identifierCode || mapped.form.identifierCode),
+ },
+ locationSearch: mapped.locationSearch || '',
+ };
+ },
+ locationUuidFromDraft(form) {
+ const location = this.locations.find((entry) => String(entry.id) === String(form.locationId));
+ return location?.uuid_b64 || null;
+ },
+ canAutoCreateLabel(form) {
+ if (!String(form.name || '').trim()) {
+ return false;
+ }
+ if (!this.locationUuidFromDraft(form)) {
+ return false;
+ }
+ if (!form.productionDate) {
+ return false;
+ }
+ if (form.stockType === 'measured') {
+ const quantity = Number(form.quantity);
+ if (Number.isNaN(quantity) || quantity <= 0) {
+ return false;
+ }
+ }
+ return true;
+ },
+ buildUpsertPayloadFromDraft(form) {
+ const locationUuidB64 = this.locationUuidFromDraft(form);
+ const quantity = form.quantity === ''
+ ? form.stockType === 'binary'
+ ? 1
+ : null
+ : Number(form.quantity);
+
+ return {
+ uuid_b64: form.itemUuidB64 || null,
+ identifier_code: form.identifierCode || null,
+ external_source: form.externalSource || null,
+ external_id: form.externalId || null,
+ item: {
+ name: String(form.name || '').trim(),
+ description: String(form.description || '').trim(),
+ quantity_initial: Number.isNaN(quantity) ? null : quantity,
+ uom_symbol: String(form.uom || '').trim() || null,
+ calories: form.energy === '' ? null : Number(form.energy),
+ calories_unit: String(form.energyUnit || '').trim() || null,
+ stock_type: form.stockType,
+ level: form.stockType === 'measured' ? null : (form.level || null),
+ date: form.productionDate || null,
+ expire_date: form.expirationDate || null,
+ location_initial: locationUuidB64,
+ },
+ };
+ },
+ saveLabelDraftAndOpenForm(form) {
+ saveStoredValue(STORAGE_KEYS.labelDraft, {
+ form,
+ savedAt: Date.now(),
+ });
+ window.__loncApp.navigate('/labels/new');
+ },
+ async processLabelScan(parsed) {
+ let identifierCode = '';
+ if (parsed.type === 'item') {
+ const item = await getStockEntry(store, parsed.uuidB64, { allowInactive: true });
+ identifierCode = normalizeIdentifierCode(item?.identifier_code);
+ if (!identifierCode) {
+ this.result = {
+ type: 'info',
+ message: `${item?.name || 'Item'} has no identifier code. Open label form to complete it first.`,
+ item: item || null,
+ };
+ return;
+ }
+ } else {
+ identifierCode = normalizeIdentifierCode(parsed.identifierCode);
+ }
+
+ if (!identifierCode) {
+ throw new Error('No identifier code found in scan.');
+ }
+
+ const lookup = await lookupItemByIdentifier(store, identifierCode);
+ if (lookup.status !== 'ok' || !lookup.item) {
+ this.result = {
+ type: 'info',
+ message: `Lookup did not return a usable item for ${identifierCode}.`,
+ item: null,
+ };
+ return;
+ }
+
+ const draft = this.buildLabelDraftFromLookup(lookup.item, identifierCode);
+ if (this.labelScanAction === 'lookup') {
+ this.saveLabelDraftAndOpenForm(draft.form);
+ store.addAlert({
+ type: 'success',
+ message: `Lookup loaded for ${identifierCode}. Continue in label form.`,
+ });
+ return;
+ }
+
+ if (!this.canAutoCreateLabel(draft.form)) {
+ this.saveLabelDraftAndOpenForm(draft.form);
+ store.addAlert({
+ type: 'info',
+ message: 'Lookup data is incomplete for direct create. Please complete required fields in label form.',
+ });
+ return;
+ }
+
+ const upsertPayload = this.buildUpsertPayloadFromDraft(draft.form);
+ const entry = await applyItemUpsert(store, upsertPayload);
+ const entryName = entry.item?.name || draft.form.name;
+ const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
+
+ if (this.labelScanAction === 'create_print') {
+ const createdUuidB64 = entry.item?.uuid_b64 || '';
+ if (!createdUuidB64) {
+ const message = `${entryName} was ${operationVerb}, but label printing is unavailable for this entry.`;
+ this.result = {
+ type: 'info',
+ message,
+ item: entry.item || null,
+ };
+ store.addAlert({
+ type: 'warning',
+ message,
+ });
+ return;
+ }
+
+ try {
+ await printItemLabel(store, createdUuidB64);
+ const message = `${entryName} was ${operationVerb} and printed. Ready for next scan.`;
+ this.result = {
+ type: 'success',
+ message,
+ item: entry.item || null,
+ };
+ store.addAlert({
+ type: 'success',
+ message,
+ });
+ void this.openScanner().catch(() => {});
+ } catch (error) {
+ const parsed = formatPrintErrorMessage(error);
+ const message = `${entryName} was ${operationVerb}, but printing failed: ${parsed} Use "Print label" to retry.`;
+ this.result = {
+ type: 'info',
+ message,
+ item: entry.item || null,
+ };
+ store.addAlert({
+ type: 'warning',
+ message: `${entryName} was ${operationVerb}, but printing failed: ${parsed}`,
+ });
+ }
+ return;
+ }
+
+ const message = `${entryName} was ${operationVerb}. Ready for next scan.`;
+ this.result = {
+ type: 'success',
+ message,
+ item: entry.item || null,
+ };
+ store.addAlert({
+ type: 'success',
+ message,
+ });
+ void this.openScanner().catch(() => {});
+ },
+ async resolveIdentifierMatches(identifierCode) {
+ const normalizedCode = normalizeIdentifierCode(identifierCode);
+ if (!normalizedCode) {
+ return [];
+ }
+
+ const entries = await listStockEntries(store).catch(() => []);
+ const matches = entries.filter((entry) =>
+ normalizeIdentifierCode(entry.identifier_code) === normalizedCode,
+ );
+ return sortByOperationalPriority(matches, this.locationPathByUuid);
+ },
+ async selectCandidate(item) {
+ this.candidateItems = [];
+ await runAsyncState(this.actionState, async () => {
+ await this.executeAction(item);
+ }).catch(() => {});
+ },
+ async executeAction(item) {
+ if (!item?.uuid_b64) {
+ throw new Error('Scanned item could not be resolved.');
+ }
+
+ if (this.scanMode === 'details') {
+ window.__loncApp.navigate(`/stock/${item.uuid_b64}?from=scan`);
+ return;
+ }
+
+ if (item.active === false) {
+ this.result = {
+ type: 'info',
+ message: `${item.name} is already out of stock.`,
+ item,
+ };
+ return;
+ }
+
+ if (this.scanMode === 'used' || this.scanMode === 'spoiled') {
+ const reason = this.scanMode === 'spoiled' ? 'spoiled' : 'consumed';
+ const result = await markStockGone(store, item.uuid_b64, reason);
+ const label = reason === 'spoiled' ? 'marked spoilt' : 'marked used';
+ this.result = {
+ type: result.status === 'already_gone' ? 'info' : 'success',
+ message: result.status === 'already_gone'
+ ? `${item.name} was already out of stock.`
+ : `${item.name} was ${label}.`,
+ item: { ...item, active: false, level: 'gone', gone_reason: reason },
+ };
+ return;
+ }
+
+ const payload = buildConsumeOneStockPayload(item);
+ await createStockEvent(store, item.uuid_b64, payload);
+ const refreshed = await getStockEntry(store, item.uuid_b64, {
+ allowInactive: isGonePayload(payload),
+ }).catch(() => ({
+ ...item,
+ active: false,
+ level: 'gone',
+ }));
+ this.result = {
+ type: 'success',
+ message: `${item.name} stock was reduced by one standard unit.`,
+ item: refreshed,
+ };
+ },
+ async printResultLabel() {
+ if (!this.result.item?.uuid_b64) {
+ return;
+ }
+
+ await runAsyncState(this.printState, async () => {
+ try {
+ await printItemLabel(store, this.result.item.uuid_b64);
+ store.addAlert({
+ type: 'success',
+ message: `${this.result.item.name} label sent to printer.`,
+ });
+ } catch (error) {
+ const parsed = formatPrintErrorMessage(error);
+ store.addAlert({
+ type: 'warning',
+ message: `Could not print ${this.result.item.name} label: ${parsed}`,
+ });
+ }
+ }).catch(() => {});
+ },
+ clearResult() {
+ this.actionState.error = '';
+ this.result = {
+ type: '',
+ message: '',
+ item: null,
+ };
+ this.candidateItems = [];
+ },
+ detailHref(item) {
+ return `#/stock/${item.uuid_b64}?from=scan`;
+ },
+ locationLabel(item) {
+ return this.locationPathByUuid[item?.location_initial_uuid_b64] || 'No location assigned';
+ },
+ quantityLabel,
+ formatDate,
+ };
+}
diff --git a/src/styles/app.css b/src/styles/app.css
index 82c939d..51c7540 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -1099,6 +1099,109 @@ button.legend-card:focus-visible {
object-fit: cover;
}
+.scan-hero {
+ background:
+ radial-gradient(circle at 15% 20%, rgba(80, 180, 140, 0.18), transparent 28rem),
+ linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(245, 250, 255, 0.92));
+}
+
+.scan-mode-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.8rem;
+}
+
+.scan-mode-card {
+ display: grid;
+ gap: 0.25rem;
+ min-height: 6rem;
+ padding: 1rem;
+ text-align: left;
+ color: inherit;
+ background: rgba(255, 255, 255, 0.74);
+ border: 1px solid var(--lonc-border);
+ border-radius: 1rem;
+ transition:
+ transform 160ms ease,
+ box-shadow 160ms ease,
+ border-color 160ms ease,
+ background-color 160ms ease;
+}
+
+.scan-mode-card:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 12px 24px rgba(24, 42, 79, 0.08);
+}
+
+.scan-mode-card-active {
+ border-color: rgba(31, 75, 153, 0.42);
+ background: rgba(31, 75, 153, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(31, 75, 153, 0.18);
+}
+
+.scan-candidate-list {
+ max-height: 24rem;
+ overflow-y: auto;
+}
+
+.scan-modal-mode-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.scan-modal-mode-btn {
+ border-radius: 999px;
+}
+
+.scan-modal-mode-btn-active {
+ color: #fff;
+ background: var(--lonc-primary);
+ border-color: var(--lonc-primary);
+}
+
+.scan-label-mode-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+.scan-label-mode-btn {
+ border-radius: 999px;
+}
+
+.scan-label-mode-btn-active {
+ color: #fff;
+ background: var(--lonc-primary-dark);
+ border-color: var(--lonc-primary-dark);
+}
+
+.scan-result-card {
+ padding: 1.25rem;
+ border-radius: 1.25rem;
+ border: 1px solid var(--lonc-border);
+ background:
+ linear-gradient(135deg, rgba(235, 243, 255, 0.78), rgba(255, 255, 255, 0.92));
+}
+
+.empty-state-inline {
+ display: grid;
+ min-height: 12rem;
+ place-items: center;
+ padding: 2rem;
+ color: var(--lonc-muted);
+ text-align: center;
+ border: 1px dashed rgba(31, 75, 153, 0.22);
+ border-radius: 1.25rem;
+ background: rgba(255, 255, 255, 0.58);
+}
+
+@media (max-width: 575.98px) {
+ .scan-mode-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
@media (max-width: 991.98px) {
.navbar {
backdrop-filter: blur(10px);
--
2.52.0
From e63c8a27702981eddddedf50bd3b81954b0a8eca Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bla=C5=BE=20Bregar?=
Date: Fri, 1 May 2026 23:35:39 +0200
Subject: [PATCH 2/2] Refactor stock mark-gone tests to use `markStockGoneMock`
instead of `useStockItemMock` and update alert messaging
---
tests/features/labels/upsert-submit.test.js | 4 ++--
tests/features/stock/mark-gone.test.js | 24 ++++++++++-----------
2 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/tests/features/labels/upsert-submit.test.js b/tests/features/labels/upsert-submit.test.js
index 9a6e141..c2a3ca0 100644
--- a/tests/features/labels/upsert-submit.test.js
+++ b/tests/features/labels/upsert-submit.test.js
@@ -299,10 +299,10 @@ describe('label create upsert-first submit', () => {
await data.create();
- expect(data.printIssue).toBe('Printer is unavailable.');
+ expect(data.printIssue).toBe('Beans was created, but printing failed: Printer is unavailable.');
expect(addAlert).toHaveBeenCalledWith({
type: 'warning',
- message: 'Beans was created, but printing has an issue: Printer is unavailable.',
+ message: 'Beans was created, but printing failed: Printer is unavailable.',
});
expect(localStorageMock.setItem).toHaveBeenCalled();
});
diff --git a/tests/features/stock/mark-gone.test.js b/tests/features/stock/mark-gone.test.js
index 3674b9d..25cf90c 100644
--- a/tests/features/stock/mark-gone.test.js
+++ b/tests/features/stock/mark-gone.test.js
@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-const useStockItemMock = vi.fn();
+const markStockGoneMock = vi.fn();
const getStockEntryMock = vi.fn();
const listGroupedStockEntriesMock = vi.fn();
vi.mock('../../../src/api/stock.js', () => ({
- useStockItem: (...args) => useStockItemMock(...args),
+ markStockGone: (...args) => markStockGoneMock(...args),
getStockEntry: (...args) => getStockEntryMock(...args),
adjustStockEntry: vi.fn(),
lookupItemDetails: vi.fn(),
@@ -24,7 +24,7 @@ const { stockListPageData } = await import('../../../src/features/stock/stock-li
describe('stock mark-gone behavior', () => {
beforeEach(() => {
- useStockItemMock.mockReset();
+ markStockGoneMock.mockReset();
getStockEntryMock.mockReset();
listGroupedStockEntriesMock.mockReset();
globalThis.window = {
@@ -39,15 +39,15 @@ describe('stock mark-gone behavior', () => {
delete globalThis.window;
});
- it('stock detail markGone uses /use and shows info for already gone', async () => {
- useStockItemMock.mockResolvedValueOnce({ status: 'already_gone' });
+ it('stock detail markGone posts gone event and shows info for already gone', async () => {
+ markStockGoneMock.mockResolvedValueOnce({ status: 'already_gone' });
const addAlert = vi.fn();
const data = stockDetailPageData({ addAlert });
data.entry = { uuid_b64: 'item-1', name: 'Rice' };
await data.markGone();
- expect(useStockItemMock).toHaveBeenCalledWith({ addAlert }, 'item-1');
+ expect(markStockGoneMock).toHaveBeenCalledWith({ addAlert }, 'item-1', 'consumed');
expect(addAlert).toHaveBeenCalledWith({
type: 'info',
message: 'Rice was already out of stock.',
@@ -55,8 +55,8 @@ describe('stock mark-gone behavior', () => {
expect(globalThis.window.__loncApp.navigate).toHaveBeenCalledWith('/stock');
});
- it('stock list markGone removes entry and uses /use path', async () => {
- useStockItemMock.mockResolvedValueOnce({ status: 'used' });
+ it('stock list markGone removes entry and posts gone event', async () => {
+ markStockGoneMock.mockResolvedValueOnce({ status: 'ok' });
const addAlert = vi.fn();
const data = stockListPageData({ addAlert, isConnected: false });
data.entries = [{ id: 1, uuid_b64: 'item-1', name: 'Flour' }];
@@ -65,16 +65,16 @@ describe('stock mark-gone behavior', () => {
await data.markGone(data.entries[0]);
- expect(useStockItemMock).toHaveBeenCalledWith({ addAlert, isConnected: false }, 'item-1');
+ expect(markStockGoneMock).toHaveBeenCalledWith({ addAlert, isConnected: false }, 'item-1', 'consumed');
expect(data.entries).toEqual([]);
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
- message: 'Flour was marked gone and removed from the list.',
+ message: 'Flour was marked used and removed from the list.',
});
});
it('stock list grouped markGone removes item from grouped and flat collections', async () => {
- useStockItemMock.mockResolvedValueOnce({ status: 'used' });
+ markStockGoneMock.mockResolvedValueOnce({ status: 'ok' });
listGroupedStockEntriesMock.mockResolvedValueOnce([]);
const addAlert = vi.fn();
const store = { addAlert, isConnected: true };
@@ -127,7 +127,7 @@ describe('stock mark-gone behavior', () => {
expect(data.groupedEntries).toEqual([]);
expect(addAlert).toHaveBeenCalledWith({
type: 'success',
- message: 'Beans was marked gone and removed from the group.',
+ message: 'Beans was marked used and removed from the group.',
});
expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1);
expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: 0 });
--
2.52.0