728 lines
28 KiB
JavaScript
728 lines
28 KiB
JavaScript
|
|
import { createStockEntry, 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%)' },
|
||
|
|
];
|
||
|
|
|
||
|
|
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">
|
||
|
|
<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">
|
||
|
|
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1">
|
||
|
|
<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</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"
|
||
|
|
>
|
||
|
|
×
|
||
|
|
</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">
|
||
|
|
<label class="form-label">Stock type</label>
|
||
|
|
<select class="form-select" x-model="form.stockType" x-ref="stockTypeSelect" autocomplete="off">
|
||
|
|
<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" x-show="form.stockType === 'measured'">
|
||
|
|
<div class="grouped-field-footer mb-1">
|
||
|
|
<label class="form-label mb-0">Quantity</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" />
|
||
|
|
</div>
|
||
|
|
<div class="col-5">
|
||
|
|
<input class="form-control" type="text" x-model="form.uom" placeholder="g" />
|
||
|
|
</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</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"
|
||
|
|
@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"
|
||
|
|
@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</label>
|
||
|
|
<input class="form-control" type="date" x-model="form.productionDate" />
|
||
|
|
</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">
|
||
|
|
<input
|
||
|
|
class="form-control"
|
||
|
|
type="number"
|
||
|
|
min="0"
|
||
|
|
step="1"
|
||
|
|
x-model="form.expireDays"
|
||
|
|
@input="syncExpireDateFromDays()"
|
||
|
|
placeholder="30"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div class="col-7">
|
||
|
|
<input
|
||
|
|
class="form-control"
|
||
|
|
type="date"
|
||
|
|
x-model="form.expirationDate"
|
||
|
|
@input="syncExpireDaysFromDate()"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<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">Create 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>
|
||
|
|
</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: '',
|
||
|
|
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,
|
||
|
|
itemId: '',
|
||
|
|
search: '',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildDraftPayload(form) {
|
||
|
|
return {
|
||
|
|
...form,
|
||
|
|
itemId: '',
|
||
|
|
search: '',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function labelCreatePageData(store) {
|
||
|
|
return {
|
||
|
|
previewState: createAsyncState(),
|
||
|
|
createState: createAsyncState(),
|
||
|
|
stockTypeOptions: STOCK_TYPE_OPTIONS,
|
||
|
|
stockLevelOptions: STOCK_LEVEL_OPTIONS,
|
||
|
|
suggestions: [],
|
||
|
|
locations: [],
|
||
|
|
locationSearch: '',
|
||
|
|
locationPickerOpen: false,
|
||
|
|
previewUrl: '',
|
||
|
|
successMessage: '',
|
||
|
|
submitError: '',
|
||
|
|
fieldErrors: {},
|
||
|
|
form: {
|
||
|
|
...loadLabelDraft(),
|
||
|
|
},
|
||
|
|
async init() {
|
||
|
|
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.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() {
|
||
|
|
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.persistDraft();
|
||
|
|
this.searchDebounced();
|
||
|
|
},
|
||
|
|
pickSuggestion(item) {
|
||
|
|
this.form.itemId = item.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 === 0 || item.quantity
|
||
|
|
? String(item.quantity)
|
||
|
|
: this.form.quantity;
|
||
|
|
this.form.stockType = item.stock_type || this.form.stockType;
|
||
|
|
this.form.level = item.level || this.form.level;
|
||
|
|
this.form.expirationDate = item.expire_date || this.form.expirationDate;
|
||
|
|
this.applyItemLocation(item.location_initial_uuid_b64);
|
||
|
|
this.syncExpireDaysFromDate();
|
||
|
|
this.suggestions = [];
|
||
|
|
this.persistDraft();
|
||
|
|
},
|
||
|
|
clearItemSearch() {
|
||
|
|
this.form.itemId = '';
|
||
|
|
this.form.search = '';
|
||
|
|
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 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.persistDraft();
|
||
|
|
},
|
||
|
|
clearLocation() {
|
||
|
|
this.form.locationId = '';
|
||
|
|
this.locationSearch = '';
|
||
|
|
this.locationPickerOpen = true;
|
||
|
|
this.persistDraft();
|
||
|
|
},
|
||
|
|
openLocationPicker() {
|
||
|
|
this.locationPickerOpen = true;
|
||
|
|
},
|
||
|
|
onLocationInput() {
|
||
|
|
this.locationPickerOpen = true;
|
||
|
|
if (this.selectedLocation && this.locationSearch !== this.selectedLocation.name) {
|
||
|
|
this.form.locationId = '';
|
||
|
|
}
|
||
|
|
},
|
||
|
|
handleLocationFocusOut(event) {
|
||
|
|
const nextTarget = event.relatedTarget;
|
||
|
|
if (nextTarget && this.$refs.locationPicker?.contains(nextTarget)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.locationPickerOpen = false;
|
||
|
|
},
|
||
|
|
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 : '';
|
||
|
|
},
|
||
|
|
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;
|
||
|
|
},
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
this.form.quantity = '';
|
||
|
|
this.form.uom = 'g';
|
||
|
|
|
||
|
|
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.stockType === 'measured'
|
||
|
|
? this.form.quantity === ''
|
||
|
|
? null
|
||
|
|
: Number(this.form.quantity)
|
||
|
|
: this.form.stockType === 'binary'
|
||
|
|
? 1
|
||
|
|
: 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.stockType === 'measured' ? 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: this.form.locationId || null,
|
||
|
|
kitchen_id: store.activeKitchen?.id || null,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async preview() {
|
||
|
|
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;
|
||
|
|
this.persistDraft();
|
||
|
|
});
|
||
|
|
},
|
||
|
|
async create() {
|
||
|
|
this.submitError = '';
|
||
|
|
this.fieldErrors = {};
|
||
|
|
|
||
|
|
await runAsyncState(this.createState, async () => {
|
||
|
|
try {
|
||
|
|
const entry = await createStockEntry(store, this.buildPayload());
|
||
|
|
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
|
||
|
|
URL.revokeObjectURL(this.previewUrl);
|
||
|
|
}
|
||
|
|
this.previewUrl = '';
|
||
|
|
this.successMessage = `${entry.name || this.form.name} was created successfully.`;
|
||
|
|
store.addAlert({
|
||
|
|
type: 'success',
|
||
|
|
message: `${entry.name || this.form.name} was created successfully.`,
|
||
|
|
});
|
||
|
|
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
||
|
|
} catch (error) {
|
||
|
|
this.fieldErrors = normalizeValidationError(error.cause);
|
||
|
|
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 = {};
|
||
|
|
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(' / ')}`;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|