2026-04-10 15:43:39 +02:00
|
|
|
import {
|
|
|
|
|
applyItemUpsert,
|
|
|
|
|
previewItemUpsert,
|
|
|
|
|
searchItemDefinitions,
|
|
|
|
|
} from '../../api/stock.js';
|
2026-04-06 09:24:22 +02:00
|
|
|
import { fetchLocations } from '../../api/locations.js';
|
2026-04-10 22:08:01 +02:00
|
|
|
import {
|
|
|
|
|
formatPrintErrorMessage,
|
|
|
|
|
previewLabel,
|
|
|
|
|
printItemLabel,
|
|
|
|
|
} from '../../api/labels.js';
|
2026-04-06 09:24:22 +02:00
|
|
|
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%)' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-04-06 17:34:11 +02:00
|
|
|
const QUANTITY_UNIT_OPTIONS = ['g', 'ml', 'pc'];
|
2026-04-06 17:47:02 +02:00
|
|
|
const EXPIRATION_DAY_OPTIONS = ['3', '5', '8', '10', '15', '20', '25', '30', '45', '60', '90', '120', '150', '180'];
|
2026-04-06 17:34:11 +02:00
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
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">
|
2026-04-06 17:54:45 +02:00
|
|
|
<div class="card border-0 shadow-sm">
|
2026-04-06 09:24:22 +02:00
|
|
|
<div class="card-body p-4">
|
2026-04-06 17:54:45 +02:00
|
|
|
<form class="vstack gap-3" @submit.prevent="create()" autocomplete="off" x-ref="labelForm">
|
2026-04-06 09:24:22 +02:00
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
×
|
|
|
|
|
</button>
|
|
|
|
|
<template x-if="suggestions.length">
|
2026-04-06 21:34:02 +02:00
|
|
|
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1 search-suggestions-picker">
|
2026-04-06 09:24:22 +02:00
|
|
|
<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">
|
2026-04-06 17:34:11 +02:00
|
|
|
<label class="form-label">Title / name <span class="text-danger">*</span></label>
|
2026-04-06 09:24:22 +02:00
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
×
|
|
|
|
|
</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"
|
|
|
|
|
>
|
|
|
|
|
×
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-12 col-md-4">
|
2026-04-06 17:54:45 +02:00
|
|
|
<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>
|
2026-04-06 09:24:22 +02:00
|
|
|
<template x-for="option in stockTypeOptions" :key="option.value">
|
|
|
|
|
<option :value="option.value" x-text="option.label"></option>
|
|
|
|
|
</template>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-04-06 17:34:11 +02:00
|
|
|
<div class="col-12 col-md-6 grouped-field-with-clear">
|
2026-04-06 09:24:22 +02:00
|
|
|
<div class="grouped-field-footer mb-1">
|
2026-04-06 17:34:11 +02:00
|
|
|
<label class="form-label mb-0">
|
|
|
|
|
Quantity
|
|
|
|
|
<span class="text-danger" x-show="form.stockType === 'measured'">*</span>
|
|
|
|
|
</label>
|
2026-04-06 09:24:22 +02:00
|
|
|
<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">
|
2026-04-06 17:34:11 +02:00
|
|
|
<input
|
|
|
|
|
class="form-control"
|
|
|
|
|
type="number"
|
|
|
|
|
step="0.01"
|
|
|
|
|
min="0"
|
|
|
|
|
x-model="form.quantity"
|
|
|
|
|
:required="form.stockType === 'measured'"
|
|
|
|
|
/>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
|
|
|
|
<div class="col-5">
|
2026-04-06 17:34:11 +02:00
|
|
|
<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>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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">
|
2026-04-06 17:34:11 +02:00
|
|
|
<label class="form-label">Storage location <span class="text-danger">*</span></label>
|
2026-04-06 09:24:22 +02:00
|
|
|
<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"
|
2026-04-06 17:54:45 +02:00
|
|
|
x-ref="locationInput"
|
2026-04-06 09:24:22 +02:00
|
|
|
@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"
|
|
|
|
|
>
|
|
|
|
|
×
|
|
|
|
|
</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"
|
2026-04-06 21:41:10 +02:00
|
|
|
@mousedown.prevent
|
|
|
|
|
@touchstart.prevent="pickLocation(location)"
|
2026-04-06 09:24:22 +02:00
|
|
|
@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">
|
2026-04-06 17:54:45 +02:00
|
|
|
<label class="form-label">Production date <span class="text-danger">*</span></label>
|
|
|
|
|
<input class="form-control" type="date" x-model="form.productionDate" required />
|
2026-04-06 09:24:22 +02:00
|
|
|
</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">
|
2026-04-06 17:47:02 +02:00
|
|
|
<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"
|
2026-04-06 21:41:10 +02:00
|
|
|
@focus="expireDaysFieldFocused = true"
|
|
|
|
|
@blur="expireDaysFieldFocused = false"
|
2026-04-06 17:47:02 +02:00
|
|
|
@input="onExpireDaysInput()"
|
|
|
|
|
@click="openExpireDaysPicker()"
|
|
|
|
|
@keydown.escape="expireDaysPickerOpen = false"
|
|
|
|
|
placeholder="30"
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
/>
|
2026-04-06 21:34:02 +02:00
|
|
|
<template x-if="expireDaysPickerOpen && !isCompactExpireDaysLayout()">
|
2026-04-06 17:47:02 +02:00
|
|
|
<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"
|
2026-04-06 21:34:02 +02:00
|
|
|
@mousedown.prevent
|
|
|
|
|
@touchstart.prevent="pickExpireDays(days)"
|
2026-04-06 17:47:02 +02:00
|
|
|
@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>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
|
|
|
|
<div class="col-7">
|
|
|
|
|
<input
|
|
|
|
|
class="form-control"
|
|
|
|
|
type="date"
|
|
|
|
|
x-model="form.expirationDate"
|
|
|
|
|
@input="syncExpireDaysFromDate()"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-06 21:41:10 +02:00
|
|
|
<template x-if="isCompactExpireDaysLayout() && expireDaysFieldFocused">
|
2026-04-06 21:34:02 +02:00
|
|
|
<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>
|
2026-04-06 09:24:22 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-10 22:08:01 +02:00
|
|
|
<template x-if="upsertPreview?.mode === 'preview' && !upsertPreview.error">
|
2026-04-10 15:43:39 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-10 22:08:01 +02:00
|
|
|
<template x-if="printIssue">
|
|
|
|
|
<div class="alert alert-warning mb-0 py-2" x-text="printIssue"></div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 label-actions-row">
|
|
|
|
|
<div class="d-flex flex-wrap gap-2 label-actions-primary">
|
|
|
|
|
<button class="btn btn-outline-primary label-action-btn" type="button" @click="preview()" :disabled="previewState.isLoading">
|
2026-04-06 09:24:22 +02:00
|
|
|
<span x-show="!previewState.isLoading">Preview label</span>
|
|
|
|
|
<span x-show="previewState.isLoading">Rendering preview...</span>
|
|
|
|
|
</button>
|
2026-04-10 22:08:01 +02:00
|
|
|
<div class="input-group input-group-label-submit">
|
|
|
|
|
<span class="input-group-text">
|
|
|
|
|
<input class="form-check-input mt-0 me-2" type="checkbox" x-model="printLabelOnSave" aria-label="Print label on save" />
|
|
|
|
|
Print
|
|
|
|
|
</span>
|
|
|
|
|
<button class="btn btn-primary label-action-btn" type="submit" :disabled="createState.isLoading">
|
|
|
|
|
<span x-show="!createState.isLoading">Save stock entry</span>
|
|
|
|
|
<span x-show="createState.isLoading">Saving...</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
2026-04-10 22:08:01 +02:00
|
|
|
<button class="btn btn-outline-secondary label-action-btn" type="button" @click="reset()">Clear form</button>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
2026-04-06 17:34:11 +02:00
|
|
|
<div class="small text-body-secondary">
|
|
|
|
|
<span class="text-danger">*</span> Required field
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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: '',
|
2026-04-10 15:43:39 +02:00
|
|
|
itemUuidB64: '',
|
|
|
|
|
identifierCode: '',
|
|
|
|
|
externalSource: '',
|
|
|
|
|
externalId: '',
|
2026-04-06 09:24:22 +02:00
|
|
|
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,
|
2026-04-06 17:34:11 +02:00
|
|
|
quantity:
|
|
|
|
|
draft.quantity === 0 || draft.quantity === '0' || draft.quantity == null
|
|
|
|
|
? ''
|
|
|
|
|
: draft.quantity,
|
2026-04-06 09:24:22 +02:00
|
|
|
itemId: '',
|
2026-04-10 15:43:39 +02:00
|
|
|
itemUuidB64: '',
|
|
|
|
|
identifierCode: '',
|
|
|
|
|
externalSource: '',
|
|
|
|
|
externalId: '',
|
2026-04-06 09:24:22 +02:00
|
|
|
search: '',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildDraftPayload(form) {
|
|
|
|
|
return {
|
|
|
|
|
...form,
|
|
|
|
|
itemId: '',
|
2026-04-10 15:43:39 +02:00
|
|
|
itemUuidB64: '',
|
|
|
|
|
identifierCode: '',
|
|
|
|
|
externalSource: '',
|
|
|
|
|
externalId: '',
|
2026-04-06 09:24:22 +02:00
|
|
|
search: '',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function labelCreatePageData(store) {
|
|
|
|
|
return {
|
|
|
|
|
previewState: createAsyncState(),
|
|
|
|
|
createState: createAsyncState(),
|
|
|
|
|
stockTypeOptions: STOCK_TYPE_OPTIONS,
|
|
|
|
|
stockLevelOptions: STOCK_LEVEL_OPTIONS,
|
2026-04-06 17:34:11 +02:00
|
|
|
quantityUnitOptions: QUANTITY_UNIT_OPTIONS,
|
2026-04-06 17:47:02 +02:00
|
|
|
expirationDayOptions: EXPIRATION_DAY_OPTIONS,
|
2026-04-06 09:24:22 +02:00
|
|
|
suggestions: [],
|
|
|
|
|
locations: [],
|
|
|
|
|
locationSearch: '',
|
|
|
|
|
locationPickerOpen: false,
|
2026-04-06 17:34:11 +02:00
|
|
|
quantityUnitPickerOpen: false,
|
2026-04-06 17:47:02 +02:00
|
|
|
expireDaysPickerOpen: false,
|
2026-04-06 21:41:10 +02:00
|
|
|
expireDaysFieldFocused: false,
|
2026-04-06 09:24:22 +02:00
|
|
|
previewUrl: '',
|
|
|
|
|
successMessage: '',
|
|
|
|
|
submitError: '',
|
|
|
|
|
fieldErrors: {},
|
2026-04-10 15:43:39 +02:00
|
|
|
upsertPreview: null,
|
2026-04-10 22:08:01 +02:00
|
|
|
printLabelOnSave: true,
|
|
|
|
|
printIssue: '',
|
2026-04-06 09:24:22 +02:00
|
|
|
form: {
|
|
|
|
|
...loadLabelDraft(),
|
|
|
|
|
},
|
|
|
|
|
async init() {
|
2026-04-06 18:31:31 +02:00
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-06 09:24:22 +02:00
|
|
|
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());
|
2026-04-07 00:41:55 +02:00
|
|
|
this.$watch('form.productionDate', () => {
|
|
|
|
|
if (this.form.expireDays !== '') {
|
|
|
|
|
this.syncExpireDateFromDays();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.form.expirationDate) {
|
|
|
|
|
this.syncExpireDaysFromDate();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-06 09:24:22 +02:00
|
|
|
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() {
|
2026-04-06 18:31:31 +02:00
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
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() {
|
2026-04-10 15:43:39 +02:00
|
|
|
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 = '';
|
|
|
|
|
}
|
2026-04-06 09:24:22 +02:00
|
|
|
this.persistDraft();
|
|
|
|
|
this.searchDebounced();
|
|
|
|
|
},
|
|
|
|
|
pickSuggestion(item) {
|
2026-04-07 00:41:55 +02:00
|
|
|
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;
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
this.form.itemId = item.id;
|
2026-04-10 15:43:39 +02:00
|
|
|
this.form.itemUuidB64 = item.uuid_b64 || '';
|
|
|
|
|
this.form.identifierCode = item.identifier_code || '';
|
|
|
|
|
this.form.externalSource = item.external_source || '';
|
|
|
|
|
this.form.externalId = item.external_id || '';
|
2026-04-06 09:24:22 +02:00
|
|
|
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 =
|
2026-04-06 18:01:58 +02:00
|
|
|
item.quantity_initial
|
|
|
|
|
? String(item.quantity_initial)
|
2026-04-06 09:24:22 +02:00
|
|
|
: this.form.quantity;
|
|
|
|
|
this.form.stockType = item.stock_type || this.form.stockType;
|
|
|
|
|
this.form.level = item.level || this.form.level;
|
2026-04-07 00:41:55 +02:00
|
|
|
if (expirationDays !== null && expirationDays >= 0) {
|
|
|
|
|
this.form.expireDays = String(expirationDays);
|
|
|
|
|
this.form.expirationDate = addDaysToIsoDate(baseProductionDate, expirationDays);
|
|
|
|
|
}
|
2026-04-06 09:24:22 +02:00
|
|
|
this.applyItemLocation(item.location_initial_uuid_b64);
|
|
|
|
|
this.suggestions = [];
|
|
|
|
|
this.persistDraft();
|
|
|
|
|
},
|
|
|
|
|
clearItemSearch() {
|
|
|
|
|
this.form.itemId = '';
|
2026-04-10 15:43:39 +02:00
|
|
|
this.form.itemUuidB64 = '';
|
|
|
|
|
this.form.identifierCode = '';
|
|
|
|
|
this.form.externalSource = '';
|
|
|
|
|
this.form.externalId = '';
|
2026-04-06 09:24:22 +02:00
|
|
|
this.form.search = '';
|
2026-04-10 15:43:39 +02:00
|
|
|
this.upsertPreview = null;
|
2026-04-06 09:24:22 +02:00
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-04-06 17:34:11 +02:00
|
|
|
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;
|
|
|
|
|
},
|
2026-04-06 17:47:02 +02:00
|
|
|
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;
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
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;
|
2026-04-06 17:54:45 +02:00
|
|
|
this.syncLocationValidity();
|
2026-04-06 09:24:22 +02:00
|
|
|
this.persistDraft();
|
|
|
|
|
},
|
|
|
|
|
clearLocation() {
|
|
|
|
|
this.form.locationId = '';
|
|
|
|
|
this.locationSearch = '';
|
|
|
|
|
this.locationPickerOpen = true;
|
2026-04-06 17:54:45 +02:00
|
|
|
this.syncLocationValidity();
|
2026-04-06 09:24:22 +02:00
|
|
|
this.persistDraft();
|
|
|
|
|
},
|
|
|
|
|
openLocationPicker() {
|
|
|
|
|
this.locationPickerOpen = true;
|
|
|
|
|
},
|
2026-04-06 17:34:11 +02:00
|
|
|
openQuantityUnitPicker() {
|
|
|
|
|
this.quantityUnitPickerOpen = true;
|
|
|
|
|
},
|
2026-04-06 17:47:02 +02:00
|
|
|
openExpireDaysPicker() {
|
2026-04-06 21:34:02 +02:00
|
|
|
if (this.isCompactExpireDaysLayout()) {
|
|
|
|
|
this.expireDaysPickerOpen = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:47:02 +02:00
|
|
|
this.expireDaysPickerOpen = true;
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
onLocationInput() {
|
|
|
|
|
this.locationPickerOpen = true;
|
|
|
|
|
if (this.selectedLocation && this.locationSearch !== this.selectedLocation.name) {
|
|
|
|
|
this.form.locationId = '';
|
|
|
|
|
}
|
2026-04-06 17:54:45 +02:00
|
|
|
this.syncLocationValidity();
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
handleLocationFocusOut(event) {
|
|
|
|
|
const nextTarget = event.relatedTarget;
|
|
|
|
|
if (nextTarget && this.$refs.locationPicker?.contains(nextTarget)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.locationPickerOpen = false;
|
|
|
|
|
},
|
2026-04-06 17:34:11 +02:00
|
|
|
onQuantityUnitInput() {
|
|
|
|
|
this.quantityUnitPickerOpen = true;
|
|
|
|
|
},
|
|
|
|
|
handleQuantityUnitFocusOut(event) {
|
|
|
|
|
const nextTarget = event.relatedTarget;
|
|
|
|
|
if (nextTarget && this.$refs.quantityUnitPicker?.contains(nextTarget)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.quantityUnitPickerOpen = false;
|
|
|
|
|
},
|
2026-04-06 17:47:02 +02:00
|
|
|
onExpireDaysInput() {
|
2026-04-06 21:34:02 +02:00
|
|
|
if (!this.isCompactExpireDaysLayout()) {
|
|
|
|
|
this.expireDaysPickerOpen = true;
|
|
|
|
|
}
|
2026-04-06 17:47:02 +02:00
|
|
|
this.syncExpireDateFromDays();
|
|
|
|
|
},
|
|
|
|
|
handleExpireDaysFocusOut(event) {
|
2026-04-06 21:34:02 +02:00
|
|
|
if (this.isCompactExpireDaysLayout()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:47:02 +02:00
|
|
|
const nextTarget = event.relatedTarget;
|
|
|
|
|
if (nextTarget && this.$refs.expireDaysPicker?.contains(nextTarget)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.expireDaysPickerOpen = false;
|
|
|
|
|
},
|
2026-04-06 21:34:02 +02:00
|
|
|
isCompactExpireDaysLayout() {
|
|
|
|
|
return window.matchMedia('(max-width: 575.98px)').matches;
|
|
|
|
|
},
|
2026-04-06 17:34:11 +02:00
|
|
|
pickQuantityUnit(unit) {
|
|
|
|
|
this.form.uom = unit;
|
|
|
|
|
this.quantityUnitPickerOpen = false;
|
|
|
|
|
},
|
2026-04-06 17:47:02 +02:00
|
|
|
pickExpireDays(days) {
|
|
|
|
|
this.form.expireDays = days;
|
|
|
|
|
this.expireDaysPickerOpen = false;
|
|
|
|
|
this.syncExpireDateFromDays();
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
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 : '';
|
2026-04-06 17:54:45 +02:00
|
|
|
this.syncLocationValidity();
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
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;
|
2026-04-06 17:54:45 +02:00
|
|
|
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();
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
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 =
|
2026-04-06 17:34:11 +02:00
|
|
|
this.form.quantity === ''
|
|
|
|
|
? this.form.stockType === 'binary'
|
2026-04-06 09:24:22 +02:00
|
|
|
? 1
|
2026-04-06 17:34:11 +02:00
|
|
|
: null
|
|
|
|
|
: Number(this.form.quantity);
|
2026-04-06 09:24:22 +02:00
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
const selectedLocationUuidB64 = this.selectedLocation?.uuid_b64 || null;
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
return {
|
|
|
|
|
item_id: this.form.itemId || null,
|
|
|
|
|
name: this.form.name.trim(),
|
|
|
|
|
description: this.form.description.trim(),
|
|
|
|
|
quantity_initial: quantity,
|
2026-04-06 17:34:11 +02:00
|
|
|
uom_symbol: this.form.uom.trim() || null,
|
2026-04-06 09:24:22 +02:00
|
|
|
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,
|
2026-04-10 15:43:39 +02:00
|
|
|
location_initial: selectedLocationUuidB64,
|
2026-04-06 09:24:22 +02:00
|
|
|
kitchen_id: store.activeKitchen?.id || null,
|
|
|
|
|
};
|
|
|
|
|
},
|
2026-04-10 15:43:39 +02:00
|
|
|
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 '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 22:08:01 +02:00
|
|
|
if (this.upsertPreview.mode !== 'preview') {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
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.';
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
async preview() {
|
2026-04-06 17:54:45 +02:00
|
|
|
this.submitError = '';
|
|
|
|
|
this.fieldErrors = {};
|
2026-04-10 15:43:39 +02:00
|
|
|
this.upsertPreview = null;
|
2026-04-10 22:08:01 +02:00
|
|
|
this.printIssue = '';
|
2026-04-06 17:54:45 +02:00
|
|
|
|
|
|
|
|
if (!this.validateBeforeSubmit()) {
|
|
|
|
|
this.previewState.error = 'Please fill out the required fields before previewing the label.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
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;
|
2026-04-10 15:43:39 +02:00
|
|
|
try {
|
|
|
|
|
this.upsertPreview = await previewItemUpsert(store, this.buildUpsertPayload());
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.upsertPreview = {
|
|
|
|
|
error: error.message || 'Upsert preview failed.',
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-06 09:24:22 +02:00
|
|
|
this.persistDraft();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
async create() {
|
|
|
|
|
this.submitError = '';
|
|
|
|
|
this.fieldErrors = {};
|
2026-04-10 22:08:01 +02:00
|
|
|
this.printIssue = '';
|
2026-04-06 09:24:22 +02:00
|
|
|
|
2026-04-06 17:54:45 +02:00
|
|
|
if (!this.validateBeforeSubmit()) {
|
2026-04-10 15:43:39 +02:00
|
|
|
this.submitError = 'Please fill out the required fields before saving the stock entry.';
|
2026-04-06 17:54:45 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
await runAsyncState(this.createState, async () => {
|
|
|
|
|
try {
|
2026-04-10 15:43:39 +02:00
|
|
|
const entry = await applyItemUpsert(store, this.buildUpsertPayload());
|
|
|
|
|
const entryName = entry.item?.name || this.form.name;
|
|
|
|
|
const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
|
2026-04-10 22:08:01 +02:00
|
|
|
const createdUuidB64 = entry.item?.uuid_b64 || null;
|
|
|
|
|
|
|
|
|
|
if (this.printLabelOnSave && createdUuidB64) {
|
|
|
|
|
try {
|
|
|
|
|
await printItemLabel(store, createdUuidB64);
|
|
|
|
|
} catch (printError) {
|
|
|
|
|
const parsedPrintMessage = formatPrintErrorMessage(printError);
|
|
|
|
|
this.printIssue = parsedPrintMessage;
|
|
|
|
|
store.addAlert({
|
|
|
|
|
type: 'warning',
|
|
|
|
|
message: `${entryName} was ${operationVerb}, but printing has an issue: ${parsedPrintMessage}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:43:39 +02:00
|
|
|
this.successMessage = `${entryName} was ${operationVerb} successfully.`;
|
2026-04-06 09:24:22 +02:00
|
|
|
store.addAlert({
|
|
|
|
|
type: 'success',
|
2026-04-10 15:43:39 +02:00
|
|
|
message: `${entryName} was ${operationVerb} successfully.`,
|
2026-04-06 09:24:22 +02:00
|
|
|
});
|
2026-04-10 15:43:39 +02:00
|
|
|
this.upsertPreview = entry;
|
2026-04-06 09:24:22 +02:00
|
|
|
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
|
|
|
|
} catch (error) {
|
2026-04-06 10:30:37 +02:00
|
|
|
this.fieldErrors = normalizeValidationError(error);
|
2026-04-06 09:24:22 +02:00
|
|
|
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 = {};
|
2026-04-10 15:43:39 +02:00
|
|
|
this.upsertPreview = null;
|
2026-04-10 22:08:01 +02:00
|
|
|
this.printIssue = '';
|
2026-04-06 09:24:22 +02:00
|
|
|
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(' / ')}`;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|