Files
lonc/src/features/labels/label-create-page.js
T

1100 lines
42 KiB
JavaScript
Raw Normal View History

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 `
<section class="container-xxl py-4 py-lg-5" x-data="labelCreatePage()" x-init="init()">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end mb-4">
<div>
<p class="eyebrow mb-2">Label Creation</p>
<h1 class="h3 mb-1">Create a stock label and entry</h1>
<p class="text-body-secondary mb-0">
Active kitchen:
<span class="fw-semibold text-body" x-text="$store.app.activeKitchen?.name"></span>
</p>
</div>
<div class="small text-body-secondary">
Drafts are stored locally so small navigation changes do not wipe form input.
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-7">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<form class="vstack gap-3" @submit.prevent="create()" autocomplete="off" x-ref="labelForm">
<div class="position-relative search-field-with-clear">
<label class="form-label">Search item definitions</label>
<input class="form-control pe-5" type="text" x-model="form.search" @input="onSearchInput()" placeholder="Search by item name" autocomplete="off" />
<button
type="button"
class="btn btn-sm btn-link text-body-secondary clear-field-button search-clear-button"
x-show="form.search || form.itemId"
@click="clearItemSearch()"
aria-label="Clear item search"
>
&times;
</button>
<template x-if="suggestions.length">
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1 search-suggestions-picker">
<template x-for="item in suggestions" :key="item.id">
<button class="list-group-item list-group-item-action" type="button" @click="pickSuggestion(item)">
<div class="fw-semibold" x-text="item.name"></div>
<div class="small text-body-secondary" x-text="item.description || 'Existing item definition'"></div>
</button>
</template>
</div>
</template>
</div>
<div class="row g-3">
<div class="col-12 col-md-8 position-relative text-field-with-clear">
<label class="form-label">Title / name <span class="text-danger">*</span></label>
<input class="form-control pe-5" type="text" x-model="form.name" required />
<button
type="button"
class="btn btn-sm btn-link text-body-secondary clear-field-button inline-clear-button"
x-show="form.name"
@click="form.name = ''"
aria-label="Clear title"
>
&times;
</button>
</div>
<div class="col-12 position-relative text-field-with-clear">
<label class="form-label">Description</label>
<textarea class="form-control pe-5" rows="2" x-model="form.description"></textarea>
<button
type="button"
class="btn btn-sm btn-link text-body-secondary clear-field-button textarea-clear-button"
x-show="form.description"
@click="form.description = ''"
aria-label="Clear description"
>
&times;
</button>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Stock type <span class="text-danger">*</span></label>
<select class="form-select" x-model="form.stockType" x-ref="stockTypeSelect" autocomplete="off" required>
<template x-for="option in stockTypeOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
</div>
<div class="col-12 col-md-6 grouped-field-with-clear">
<div class="grouped-field-footer mb-1">
<label class="form-label mb-0">
Quantity
<span class="text-danger" x-show="form.stockType === 'measured'">*</span>
</label>
<button
type="button"
class="btn btn-sm btn-link text-body-secondary p-0"
x-show="form.quantity || form.uom"
@click="clearQuantityFields()"
>
Clear quantity
</button>
</div>
<div class="row g-2">
<div class="col-7">
<input
class="form-control"
type="number"
step="0.01"
min="0"
x-model="form.quantity"
:required="form.stockType === 'measured'"
/>
</div>
<div class="col-5">
<div
class="position-relative"
x-ref="quantityUnitPicker"
@focusin="quantityUnitPickerOpen = true"
@focusout="handleQuantityUnitFocusOut($event)"
>
<input
class="form-control"
type="text"
x-model="form.uom"
@input="onQuantityUnitInput()"
@click="openQuantityUnitPicker()"
@keydown.escape="quantityUnitPickerOpen = false"
placeholder="g"
autocomplete="off"
/>
<template x-if="quantityUnitPickerOpen">
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1 quantity-unit-picker">
<template x-if="filteredQuantityUnits.length">
<div>
<template x-for="unit in filteredQuantityUnits" :key="unit">
<button
class="list-group-item list-group-item-action"
type="button"
@click="pickQuantityUnit(unit)"
>
<span class="fw-semibold" x-text="unit"></span>
</button>
</template>
</div>
</template>
<template x-if="!filteredQuantityUnits.length">
<div class="list-group-item text-body-secondary">
No matching units found.
</div>
</template>
</div>
</template>
</div>
</div>
</div>
<div class="row g-2 mt-1">
<div class="col-7">
<div class="small text-body-secondary">Amount</div>
</div>
<div class="col-5">
<div class="small text-body-secondary">Unit</div>
</div>
</div>
</div>
<div class="col-12 col-md-6" x-show="form.stockType === 'descriptive'">
<label class="form-label">Stock level</label>
<select class="form-select" x-model="form.level" x-ref="stockLevelSelect">
<option value="">Select level</option>
<template x-for="option in stockLevelOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
</div>
<div class="col-12 col-md-6 grouped-field-with-clear">
<div class="grouped-field-footer mb-1">
<label class="form-label mb-0">Energy</label>
<button
type="button"
class="btn btn-sm btn-link text-body-secondary p-0"
x-show="form.energy !== '' || form.energyUnit"
@click="clearEnergyFields()"
>
Clear energy
</button>
</div>
<div class="row g-2">
<div class="col-5">
<input class="form-control" type="number" step="0.01" min="0" x-model="form.energy" />
</div>
<div class="col-7">
<input class="form-control" type="text" x-model="form.energyUnit" placeholder="kcal (100g/ml)" />
</div>
</div>
<div class="row g-2 mt-1">
<div class="col-5">
<div class="small text-body-secondary">Value</div>
</div>
<div class="col-7">
<div class="small text-body-secondary">Unit</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Storage location <span class="text-danger">*</span></label>
<div
class="position-relative location-field-with-clear"
x-ref="locationPicker"
@focusin="locationPickerOpen = true"
@focusout="handleLocationFocusOut($event)"
>
<input
class="form-control pe-5"
type="text"
x-model="locationSearch"
x-ref="locationInput"
@input="onLocationInput()"
@keydown.escape="locationPickerOpen = false"
@click="openLocationPicker()"
placeholder="Search location"
autocomplete="off"
required
/>
<button
type="button"
class="btn btn-sm btn-link text-body-secondary clear-field-button location-clear-button"
x-show="locationSearch || form.locationId"
@click="clearLocation()"
aria-label="Clear selected location"
>
&times;
</button>
<input type="hidden" x-model="form.locationId" />
<template x-if="locationPickerOpen">
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1 location-picker">
<template x-if="filteredLocations.length">
<div>
<template x-for="location in filteredLocations" :key="location.id">
<button
class="list-group-item list-group-item-action"
type="button"
@mousedown.prevent
@touchstart.prevent="pickLocation(location)"
@click="pickLocation(location)"
:style="locationItemStyle(location)"
>
<div class="d-flex align-items-center gap-2">
<span
class="location-level-badge"
x-show="location.depth"
x-text="locationLevelLabel(location)"
></span>
<div class="fw-semibold" x-text="location.name"></div>
</div>
<div class="small text-body-secondary" x-text="locationPathHint(location)"></div>
</button>
</template>
</div>
</template>
<template x-if="!filteredLocations.length">
<div class="list-group-item text-body-secondary">
No matching locations found.
</div>
</template>
</div>
</template>
</div>
<div class="form-text" x-show="selectedLocationPath" x-text="selectedLocationPath"></div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Production date <span class="text-danger">*</span></label>
<input class="form-control" type="date" x-model="form.productionDate" required />
</div>
<div class="col-12 col-md-6 grouped-field-with-clear">
<div class="grouped-field-footer mb-1">
<label class="form-label mb-0">Expiration</label>
<button
type="button"
class="btn btn-sm btn-link text-body-secondary p-0"
x-show="form.expirationDate || form.expireDays"
@click="clearExpirationDate()"
>
Clear expiration date
</button>
</div>
<div class="row g-2">
<div class="col-5">
<div
class="position-relative"
x-ref="expireDaysPicker"
@focusin="expireDaysPickerOpen = true"
@focusout="handleExpireDaysFocusOut($event)"
>
<input
class="form-control"
type="text"
inputmode="numeric"
x-model="form.expireDays"
@focus="expireDaysFieldFocused = true"
@blur="expireDaysFieldFocused = false"
@input="onExpireDaysInput()"
@click="openExpireDaysPicker()"
@keydown.escape="expireDaysPickerOpen = false"
placeholder="30"
autocomplete="off"
/>
<template x-if="expireDaysPickerOpen && !isCompactExpireDaysLayout()">
<div class="shadow-sm position-absolute start-0 end-0 z-3 mt-1 quantity-unit-picker expiration-days-picker">
<template x-if="filteredExpireDayOptions.length">
<div class="expiration-days-grid">
<template x-for="days in filteredExpireDayOptions" :key="days">
<button
class="btn btn-outline-secondary btn-sm expiration-days-option"
type="button"
@mousedown.prevent
@touchstart.prevent="pickExpireDays(days)"
@click="pickExpireDays(days)"
:class="{ 'active': form.expireDays === days }"
>
<span class="fw-semibold" x-text="days"></span>
</button>
</template>
</div>
</template>
<template x-if="!filteredExpireDayOptions.length">
<div class="text-body-secondary small p-3">
No matching day values found.
</div>
</template>
</div>
</template>
</div>
</div>
<div class="col-7">
<input
class="form-control"
type="date"
x-model="form.expirationDate"
@input="syncExpireDaysFromDate()"
/>
</div>
</div>
<template x-if="isCompactExpireDaysLayout() && expireDaysFieldFocused">
<div class="expiration-days-inline mt-2">
<div class="small text-body-secondary mb-2">Quick picks</div>
<div class="expiration-days-grid expiration-days-grid-inline">
<template x-for="days in filteredExpireDayOptions" :key="days">
<button
class="btn btn-outline-secondary btn-sm expiration-days-option"
type="button"
@touchstart.prevent="pickExpireDays(days)"
@click="pickExpireDays(days)"
:class="{ 'active': form.expireDays === days }"
>
<span class="fw-semibold" x-text="days"></span>
</button>
</template>
</div>
</div>
</template>
<div class="row g-2 mt-1">
<div class="col-5">
<div class="small text-body-secondary">Days</div>
</div>
<div class="col-7">
<div class="small text-body-secondary">Date</div>
</div>
</div>
<div class="form-text">Enter either days or a date. The other field updates automatically.</div>
</div>
</div>
<template x-if="submitError">
<div class="alert alert-danger mb-0">
<div class="fw-semibold mb-1">Could not save this stock entry.</div>
<div x-text="submitError"></div>
</div>
</template>
<template x-if="successMessage">
<div class="alert alert-success mb-0" x-text="successMessage"></div>
</template>
<template x-if="upsertPreview && !upsertPreview.error">
<div class="alert alert-info mb-0 py-2" x-text="upsertPreviewSummary()"></div>
</template>
<template x-if="upsertPreview?.error">
<div class="alert alert-warning mb-0 py-2" x-text="upsertPreview.error"></div>
</template>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-outline-primary" type="button" @click="preview()" :disabled="previewState.isLoading">
<span x-show="!previewState.isLoading">Preview label</span>
<span x-show="previewState.isLoading">Rendering preview...</span>
</button>
<button class="btn btn-primary" type="submit" :disabled="createState.isLoading">
<span x-show="!createState.isLoading">Save stock entry</span>
<span x-show="createState.isLoading">Saving...</span>
</button>
</div>
<button class="btn btn-outline-secondary" type="button" @click="reset()">Clear form</button>
</div>
<div class="small text-body-secondary">
<span class="text-danger">*</span> Required field
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-xl-5" x-show="previewUrl">
<div class="card border-0 shadow-sm h-100">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="h5 mb-1">Label preview</h2>
<p class="text-body-secondary small mb-0">PNG and SVG responses are both supported.</p>
</div>
</div>
<template x-if="previewState.error">
<div class="alert alert-warning" x-text="previewState.error"></div>
</template>
<template x-if="previewUrl">
<div class="preview-frame">
<img class="img-fluid rounded-3 border" :src="previewUrl" alt="Generated label preview" />
</div>
</template>
</div>
</div>
</div>
</div>
</section>
`;
}
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(' / ')}`;
},
};
}