import { applyItemUpsert, getStockEntry, lookupItemByIdentifier, previewItemUpsert, searchItemDefinitions, } from '../../api/stock.js'; import { mapLookupItemToForm } from './identifier-lookup-mapper.js'; import { fetchLocations } from '../../api/locations.js'; import { formatPrintErrorMessage, previewLabel, printItemLabel, } from '../../api/labels.js'; 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 = [ { value: 'measured', label: 'Measured' }, { value: 'descriptive', label: 'Descriptive' }, { value: 'binary', label: 'Binary' }, ]; const STOCK_LEVEL_OPTIONS = [ { value: 'plenty', label: 'Plenty (> 75%)' }, { value: 'good', label: 'Good (> 50%)' }, { value: 'some', label: 'Some (> 25%)' }, { value: 'low', label: 'Low (> 10%)' }, { value: 'trace', label: 'Trace (<= 10%)' }, { value: 'gone', label: 'Gone (~= 0%)' }, ]; 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 `

Label Creation

Create a stock label and entry

Active kitchen:

Drafts are stored locally so small navigation changes do not wipe form input.
Optional. Scan with camera, use a hardware scanner, or enter manually.
Amount
Unit
Value
Unit
Days
Date
Enter either days or a date. The other field updates automatically.
Print
* Required field

Label preview

PNG and SVG responses are both supported.

${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', })}
`; } 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 addDaysToIsoDate(isoDate, days) { const [year, month, day] = isoDate.split('-').map(Number); const date = new Date(year, month - 1, day); date.setDate(date.getDate() + days); const nextYear = date.getFullYear(); const nextMonth = String(date.getMonth() + 1).padStart(2, '0'); const nextDay = String(date.getDate()).padStart(2, '0'); return `${nextYear}-${nextMonth}-${nextDay}`; } function diffDays(fromIsoDate, toIsoDate) { const [fromYear, fromMonth, fromDay] = fromIsoDate.split('-').map(Number); const [toYear, toMonth, toDay] = toIsoDate.split('-').map(Number); const fromDate = new Date(fromYear, fromMonth - 1, fromDay); const toDate = new Date(toYear, toMonth - 1, toDay); const millisecondsPerDay = 24 * 60 * 60 * 1000; return Math.round((toDate - fromDate) / millisecondsPerDay); } function createDefaultForm() { 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: '', }; } function normalizeLabelDraft(draft) { return { ...createDefaultForm(), ...draft, quantity: draft.quantity === 0 || draft.quantity === '0' || draft.quantity == null ? '' : draft.quantity, itemId: '', itemUuidB64: '', identifierCode: '', externalSource: '', externalId: '', search: '', }; } function buildDraftPayload(form) { return { ...form, itemId: '', itemUuidB64: '', identifierCode: '', externalSource: '', externalId: '', search: '', }; } function buildLabelDraftEnvelope(form) { return { form: buildDraftPayload(form), savedAt: Date.now(), }; } function saveLabelDraft(form) { saveStoredValue(STORAGE_KEYS.labelDraft, buildLabelDraftEnvelope(form)); } function loadLabelDraft() { const storedDraft = loadStoredValue(STORAGE_KEYS.labelDraft, null); if (!storedDraft || typeof storedDraft !== 'object' || Array.isArray(storedDraft)) { return createDefaultForm(); } const hasEnvelope = storedDraft.form && typeof storedDraft.form === 'object' && !Array.isArray(storedDraft.form); if (!hasEnvelope) { return normalizeLabelDraft(storedDraft); } const savedAt = Number(storedDraft.savedAt || 0); if (!savedAt || Date.now() - savedAt >= LABEL_DRAFT_STALE_MS) { return createDefaultForm(); } return normalizeLabelDraft(storedDraft.form); } export function labelCreatePageData(store) { return { 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, expirationDayOptions: EXPIRATION_DAY_OPTIONS, suggestions: [], locations: [], locationSearch: '', locationPickerOpen: false, quantityUnitPickerOpen: false, expireDaysPickerOpen: false, expireDaysFieldFocused: false, previewUrl: '', successMessage: '', submitError: '', fieldErrors: {}, upsertPreview: null, printLabelOnSave: true, printIssue: '', lastCreatedLabelUuidB64: '', lastCreatedLabelName: '', scannerReader: null, scannerControls: null, scannerState: { isOpen: false, isLoading: false, isProcessing: false, hasCamera: false, error: '', lastDetectedCode: '', }, form: { ...loadLabelDraft(), }, async init() { this.scannerState.hasCamera = this.canUseCameraScanner(); if (!store.isConnected) { return; } await this.loadLocations(); this.$watch('form', () => this.persistDraft(), { deep: true }); this.$watch('form.stockType', (value) => { this.syncStockTypeState(value); this.syncStockTypeSelect(); this.syncStockLevelSelect(); }); this.$watch('form.level', () => this.syncStockLevelSelect()); this.$watch('form.productionDate', () => { if (this.form.expireDays !== '') { this.syncExpireDateFromDays(); return; } if (this.form.expirationDate) { this.syncExpireDaysFromDate(); } }); this.syncStockTypeState(this.form.stockType); this.syncStockTypeSelect(); this.syncStockLevelSelect(); this.searchDebounced = debounce(async () => { if (this.form.search.trim().length <= 2) { this.suggestions = []; return; } this.suggestions = await searchItemDefinitions(store, this.form.search.trim()); }, 250); }, destroy() { this.stopScanner(); }, canUseCameraScanner() { return canUseCameraScanner(); }, normalizeIdentifierCode(value) { return normalizeIdentifierCode(value); }, hasLookupIdentifierCode() { return Boolean(this.normalizeIdentifierCode(this.form.identifierCode)); }, lookupStatusMessage(status, identifierCode) { const normalizedCode = this.normalizeIdentifierCode(identifierCode); if (!normalizedCode || status === 'missing_identifier') { return 'Provide an identifier code before lookup.'; } if (status === 'not_found') { return `No lookup result found for code ${normalizedCode}.`; } if (status === 'lookup_failed') { return 'Lookup failed on the server. You can still fill the form manually.'; } if (status === 'rate_limited') { return 'Lookup is temporarily rate-limited. Try again shortly.'; } return 'Lookup response could not be applied to this form.'; }, lookupStatusMessageWithDetails(response, identifierCode) { const base = this.lookupStatusMessage(response?.status, identifierCode); if (response?.status !== 'rate_limited') { return base; } if (!Number.isInteger(response?.retryAfterSeconds) || response.retryAfterSeconds <= 0) { return base; } return `${base} Retry in ${response.retryAfterSeconds}s.`; }, lookupSourceLabel(source) { if (!source) { return ''; } const labels = { item: 'existing item', cache: 'cache', openfoodfacts: 'OpenFoodFacts', }; return labels[source] || source; }, lookupSuccessMessage(response) { const parts = ['Lookup applied product details']; const metadata = []; if (response?.source) { metadata.push(`source: ${this.lookupSourceLabel(response.source)}`); } if (response?.cacheHit) { metadata.push('cache hit'); } if (response?.staleCache) { metadata.push('stale cache'); } if (response?.payloadFetchedAt) { const fetchedAt = new Date(response.payloadFetchedAt); metadata.push( `fetched: ${ Number.isNaN(fetchedAt.getTime()) ? response.payloadFetchedAt : fetchedAt.toLocaleString() }`, ); } if (metadata.length) { parts.push(`(${metadata.join(', ')})`); } return `${parts.join(' ')}.`; }, normalizeScannerError(error) { 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(); }, async startScanner() { this.scannerState.error = ''; this.scannerState.lastDetectedCode = ''; if (!this.canUseCameraScanner()) { this.scannerState.hasCamera = false; this.scannerState.error = 'Camera scanning is not supported in this browser. Enter the identifier 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.onBarcodeDetected(code), }); this.scannerReader = session.reader; this.scannerControls = session.controls; } catch (error) { this.scannerState.error = this.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 = ''; }, 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; } 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(); 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() { const identifierCode = this.normalizeIdentifierCode(this.form.identifierCode); this.form.identifierCode = identifierCode; this.lookupState.error = ''; if (!identifierCode) { this.lookupState.error = 'Provide an identifier code before lookup.'; return; } await runAsyncState(this.lookupState, async () => { await this.lookupIdentifierDetailsByCode(identifierCode, { announceSuccess: true }); }).catch((error) => { store.addAlert({ type: 'warning', message: `Could not complete lookup: ${error.message || 'Unknown lookup error.'}`, }); }); }, async loadLocations() { if (!store.isConnected) { return; } try { const { flat } = await fetchLocations(store); this.locations = flat; this.syncLocationSelection(); } catch (error) { store.addAlert({ type: 'warning', message: `Locations could not be loaded: ${error.message}`, }); } }, onSearchInput() { this.upsertPreview = null; if (this.form.itemUuidB64 || this.form.itemId) { this.form.itemId = ''; this.form.itemUuidB64 = ''; this.form.identifierCode = ''; this.form.externalSource = ''; this.form.externalId = ''; } this.persistDraft(); this.searchDebounced(); }, pickSuggestion(item) { const baseProductionDate = this.form.productionDate || todayIsoDate(); const expirationDays = typeof item.expiration_days === 'number' ? item.expiration_days : item.date && item.expire_date ? diffDays(item.date, item.expire_date) : null; this.form.itemId = item.id; this.form.itemUuidB64 = item.uuid_b64 || ''; this.form.identifierCode = item.identifier_code || ''; this.form.externalSource = item.external_source || ''; this.form.externalId = item.external_id || ''; this.form.search = item.name; this.form.name = item.name; this.form.description = item.description || this.form.description; this.form.uom = item.uom_symbol || this.form.uom; this.form.quantity = item.quantity_initial ? String(item.quantity_initial) : this.form.quantity; this.form.stockType = item.stock_type || this.form.stockType; this.form.level = item.level || this.form.level; if (expirationDays !== null && expirationDays >= 0) { this.form.expireDays = String(expirationDays); this.form.expirationDate = addDaysToIsoDate(baseProductionDate, expirationDays); } this.applyItemLocation(item.location_initial_uuid_b64); this.suggestions = []; this.persistDraft(); }, clearItemSearch() { this.form.itemId = ''; this.form.itemUuidB64 = ''; this.form.identifierCode = ''; this.form.externalSource = ''; this.form.externalId = ''; this.form.search = ''; this.upsertPreview = null; this.suggestions = []; this.persistDraft(); }, persistDraft() { saveLabelDraft(this.form); }, get filteredLocations() { const query = this.locationSearch.trim().toLowerCase(); const selectedLabel = this.selectedLocation ? this.selectedLocation.name.toLowerCase() : ''; if (selectedLabel && query === selectedLabel) { return this.locations; } if (!query) { return this.locations; } return this.locations.filter((location) => `${location.name} ${location.pathLabel}`.toLowerCase().includes(query), ); }, get filteredQuantityUnits() { const query = this.form.uom.trim().toLowerCase(); if (!query) { return this.quantityUnitOptions; } if (this.quantityUnitOptions.some((unit) => unit.toLowerCase() === query)) { return this.quantityUnitOptions; } const matches = this.quantityUnitOptions.filter((unit) => unit.toLowerCase().includes(query), ); if (matches.length) { return matches; } return this.quantityUnitOptions; }, get filteredExpireDayOptions() { const query = this.form.expireDays.trim(); if (!query) { return this.expirationDayOptions; } if (this.expirationDayOptions.includes(query)) { return this.expirationDayOptions; } const matches = this.expirationDayOptions.filter((days) => days.includes(query), ); if (matches.length) { return matches; } return this.expirationDayOptions; }, get selectedLocation() { return this.locations.find( (location) => String(location.id) === String(this.form.locationId), ) || null; }, get selectedLocationPath() { return this.selectedLocation?.pathLabel || ''; }, pickLocation(location) { this.form.locationId = String(location.id); this.locationSearch = location.name; this.locationPickerOpen = false; this.syncLocationValidity(); this.persistDraft(); }, clearLocation() { this.form.locationId = ''; this.locationSearch = ''; this.locationPickerOpen = true; this.syncLocationValidity(); this.persistDraft(); }, openLocationPicker() { this.locationPickerOpen = true; }, openQuantityUnitPicker() { this.quantityUnitPickerOpen = true; }, openExpireDaysPicker() { if (this.isCompactExpireDaysLayout()) { this.expireDaysPickerOpen = false; return; } this.expireDaysPickerOpen = true; }, onLocationInput() { this.locationPickerOpen = true; if (this.selectedLocation && this.locationSearch !== this.selectedLocation.name) { this.form.locationId = ''; } this.syncLocationValidity(); }, handleLocationFocusOut(event) { const nextTarget = event.relatedTarget; if (nextTarget && this.$refs.locationPicker?.contains(nextTarget)) { return; } this.locationPickerOpen = false; }, onQuantityUnitInput() { this.quantityUnitPickerOpen = true; }, handleQuantityUnitFocusOut(event) { const nextTarget = event.relatedTarget; if (nextTarget && this.$refs.quantityUnitPicker?.contains(nextTarget)) { return; } this.quantityUnitPickerOpen = false; }, onExpireDaysInput() { if (!this.isCompactExpireDaysLayout()) { this.expireDaysPickerOpen = true; } this.syncExpireDateFromDays(); }, handleExpireDaysFocusOut(event) { if (this.isCompactExpireDaysLayout()) { return; } const nextTarget = event.relatedTarget; if (nextTarget && this.$refs.expireDaysPicker?.contains(nextTarget)) { return; } this.expireDaysPickerOpen = false; }, isCompactExpireDaysLayout() { return window.matchMedia('(max-width: 575.98px)').matches; }, pickQuantityUnit(unit) { this.form.uom = unit; this.quantityUnitPickerOpen = false; }, pickExpireDays(days) { this.form.expireDays = days; this.expireDaysPickerOpen = false; this.syncExpireDateFromDays(); }, syncLocationSelection() { if (!this.form.locationId) { this.locationSearch = ''; return; } const selected = this.locations.find( (location) => String(location.id) === String(this.form.locationId), ); this.locationSearch = selected ? selected.name : ''; this.syncLocationValidity(); }, applyItemLocation(locationUuidB64) { if (!locationUuidB64) { return; } const location = this.locations.find( (entry) => entry.uuid_b64 === locationUuidB64, ); if (!location) { return; } this.form.locationId = String(location.id); this.locationSearch = location.name; this.syncLocationValidity(); }, syncLocationValidity() { const input = this.$refs.locationInput; if (!input) { return; } if (!this.locationSearch.trim()) { input.setCustomValidity('Please select a storage location.'); return; } if (!this.form.locationId) { input.setCustomValidity('Please choose a location from the list.'); return; } input.setCustomValidity(''); }, validateBeforeSubmit() { this.syncLocationValidity(); const form = this.$refs.labelForm; if (!form) { return true; } return form.reportValidity(); }, syncExpireDateFromDays() { if (this.form.expireDays === '') { return; } const days = Number(this.form.expireDays); if (Number.isNaN(days) || days < 0) { return; } const baseDate = this.form.productionDate || todayIsoDate(); this.form.expirationDate = addDaysToIsoDate(baseDate, days); }, syncExpireDaysFromDate() { if (!this.form.expirationDate) { this.form.expireDays = ''; return; } const baseDate = this.form.productionDate || todayIsoDate(); const days = diffDays(baseDate, this.form.expirationDate); this.form.expireDays = days >= 0 ? String(days) : ''; }, clearExpirationDate() { this.form.expirationDate = ''; this.form.expireDays = ''; }, clearEnergyFields() { this.form.energy = ''; this.form.energyUnit = 'kcal (100g/ml)'; }, clearQuantityFields() { this.form.quantity = ''; this.form.uom = 'g'; }, syncStockTypeState(stockType) { if (stockType === 'measured') { this.form.level = ''; if (this.form.uom === '') { this.form.uom = 'g'; } return; } if (stockType === 'binary' && this.form.level !== 'plenty') { this.form.level = 'plenty'; return; } if (stockType === 'descriptive' && (!this.form.level || this.form.level === '')) { this.form.level = 'plenty'; } }, syncStockTypeSelect() { if (this.$refs.stockTypeSelect) { this.$refs.stockTypeSelect.value = this.form.stockType; } }, syncStockLevelSelect() { if (this.$refs.stockLevelSelect) { this.$refs.stockLevelSelect.value = this.form.level || 'plenty'; } }, buildPayload() { const quantity = this.form.quantity === '' ? this.form.stockType === 'binary' ? 1 : null : Number(this.form.quantity); const selectedLocationUuidB64 = this.selectedLocation?.uuid_b64 || null; return { item_id: this.form.itemId || null, name: this.form.name.trim(), description: this.form.description.trim(), quantity_initial: quantity, uom_symbol: this.form.uom.trim() || null, calories: this.form.energy === '' ? null : Number(this.form.energy), calories_unit: this.form.energyUnit.trim() || null, stock_type: this.form.stockType, level: this.form.stockType === 'measured' ? null : this.form.level || null, date: this.form.productionDate || null, expire_date: this.form.expirationDate || null, location_initial: selectedLocationUuidB64, kitchen_id: store.activeKitchen?.id || null, }; }, buildUpsertPayload() { const basePayload = this.buildPayload(); const itemPayload = { name: basePayload.name, description: basePayload.description, quantity_initial: basePayload.quantity_initial, uom_symbol: basePayload.uom_symbol, calories: basePayload.calories, calories_unit: basePayload.calories_unit, stock_type: basePayload.stock_type, level: basePayload.level, date: basePayload.date, expire_date: basePayload.expire_date, location_initial: basePayload.location_initial, }; return { uuid_b64: this.form.itemUuidB64 || null, identifier_code: this.form.identifierCode || null, external_source: this.form.externalSource || null, external_id: this.form.externalId || null, item: itemPayload, }; }, upsertPreviewSummary() { if (!this.upsertPreview || this.upsertPreview.error) { return ''; } if (this.upsertPreview.mode !== 'preview') { return ''; } if (this.upsertPreview.operation === 'update') { const name = this.upsertPreview.matchedItem?.name || this.form.name; const matchType = this.upsertPreview.matchType ? ` (matched by ${this.upsertPreview.matchType})` : ''; return `Submit will update: ${name}${matchType}.`; } return 'Submit will create a new stock item.'; }, async preview() { this.submitError = ''; this.fieldErrors = {}; this.upsertPreview = null; this.printIssue = ''; if (!this.validateBeforeSubmit()) { this.previewState.error = 'Please fill out the required fields before previewing the label.'; return; } await runAsyncState(this.previewState, async () => { this.successMessage = ''; const result = await previewLabel(store, this.buildPayload()); if (this.previewUrl && this.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(this.previewUrl); } this.previewUrl = result.objectUrl; try { this.upsertPreview = await previewItemUpsert(store, this.buildUpsertPayload()); } catch (error) { this.upsertPreview = { error: error.message || 'Upsert preview failed.', }; } this.persistDraft(); }); }, async create() { this.submitError = ''; this.fieldErrors = {}; this.printIssue = ''; if (!this.validateBeforeSubmit()) { this.submitError = 'Please fill out the required fields before saving the stock entry.'; return; } await runAsyncState(this.createState, async () => { try { 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; if (this.printLabelOnSave && createdUuidB64) { try { await printItemLabel(store, createdUuidB64); } catch (printError) { const parsedPrintMessage = formatPrintErrorMessage(printError); this.printIssue = `${entryName} was ${operationVerb}, but printing failed: ${parsedPrintMessage}`; store.addAlert({ type: 'warning', message: this.printIssue, }); } } this.successMessage = `${entryName} was ${operationVerb} successfully.`; store.addAlert({ type: 'success', message: `${entryName} was ${operationVerb} successfully.`, }); this.upsertPreview = entry; saveLabelDraft(this.form); } catch (error) { this.fieldErrors = normalizeValidationError(error); this.submitError = error.message; throw error; } }).catch(() => {}); }, reset(revokePreview = true) { this.closeScanner(); this.form = createDefaultForm(); this.syncStockTypeState(this.form.stockType); this.suggestions = []; this.locationSearch = ''; this.locationPickerOpen = false; this.successMessage = ''; this.submitError = ''; this.lookupState.error = ''; this.fieldErrors = {}; this.upsertPreview = null; this.printIssue = ''; this.lastCreatedLabelUuidB64 = ''; this.lastCreatedLabelName = ''; saveLabelDraft(this.form); if (revokePreview && this.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(this.previewUrl); } this.previewUrl = ''; }, locationItemStyle(location) { const depth = location.depth || 0; return `padding-left: calc(1rem + ${depth} * 1rem);`; }, locationLevelLabel(location) { return `L${(location.depth || 0) + 1}`; }, locationPathHint(location) { if (!location.depth) { return location.type || 'Top-level location'; } const segments = location.pathLabel.split(' / '); return `Inside ${segments.slice(0, -1).join(' / ')}`; }, }; }