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) { + 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() {

Create label + Scan item Browse stock
@@ -70,10 +71,10 @@ export function renderDashboardPage() {
- - Adjustments - Fast quantity updates - Apply increments, decrements, or exact counts with clear feedback. + + Scanning + Use, spoil, or inspect + Scan a label or barcode and act on the matching item.
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.
@@ -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} + +
+ +
+ + +
+
${manualHelp}
+
+ +
+ +
+ +
+
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... - + @@ -336,7 +350,7 @@ export function renderStockDetailPage() { + + + +
+
+
+
+

History

+

Recent stock events

+
+ +
+ + +
@@ -366,38 +420,15 @@ export function renderStockDetailPage() {
-
-
-
-
-
-

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.', + 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() {
- + +
@@ -796,7 +802,8 @@ export function renderStockListPage() { > Details - + +
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. +

+
+
+ + Browse stock +
+
+
+
+ +
+
+
+
+

Choose scan action

+
+ +
+ + + +
+ +
+ + +
+
Works with keyboard-style barcode scanners and manual paste.
+
+ + +
+
+
+ +
+
+
+
+
+

Scan result

+

Last scanned code and the action outcome.

+
+ +
+ + + + + + + + + + +
+
+
+
+ + ${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