import { applyItemUpsert, previewItemUpsert, searchItemDefinitions, } from '../../api/stock.js'; import { fetchLocations } from '../../api/locations.js'; import { previewLabel } 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 { 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']; export function renderLabelCreatePage() { return `

Label Creation

Create a stock label and entry

Active kitchen:

Drafts are stored locally so small navigation changes do not wipe form input.
Amount
Unit
Value
Unit
Days
Date
Enter either days or a date. The other field updates automatically.
* Required field

Label preview

PNG and SVG responses are both supported.

`; } 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 loadLabelDraft() { const draft = loadStoredValue(STORAGE_KEYS.labelDraft, createDefaultForm()); 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: '', }; } export function labelCreatePageData(store) { return { previewState: createAsyncState(), createState: createAsyncState(), 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, form: { ...loadLabelDraft(), }, async init() { 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); }, 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() { saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(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.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; 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 = {}; 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()); if (this.previewUrl && this.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(this.previewUrl); } this.previewUrl = ''; const entryName = entry.item?.name || this.form.name; const operationVerb = entry.operation === 'update' ? 'updated' : 'created'; this.successMessage = `${entryName} was ${operationVerb} successfully.`; store.addAlert({ type: 'success', message: `${entryName} was ${operationVerb} successfully.`, }); this.upsertPreview = entry; saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form)); } catch (error) { this.fieldErrors = normalizeValidationError(error); this.submitError = error.message; throw error; } }).catch(() => {}); }, reset(revokePreview = true) { this.form = createDefaultForm(); this.syncStockTypeState(this.form.stockType); this.suggestions = []; this.locationSearch = ''; this.locationPickerOpen = false; this.successMessage = ''; this.submitError = ''; this.fieldErrors = {}; this.upsertPreview = null; saveStoredValue(STORAGE_KEYS.labelDraft, 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(' / ')}`; }, }; }