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, }; }