2026-04-06 10:30:37 +02:00
|
|
|
import {
|
|
|
|
|
adjustStockEntry,
|
|
|
|
|
getStockEntry,
|
2026-04-11 10:14:49 +02:00
|
|
|
lookupItemDetails,
|
|
|
|
|
patchStockItem,
|
2026-04-10 15:43:39 +02:00
|
|
|
useStockItem,
|
2026-04-06 10:30:37 +02:00
|
|
|
} from '../../api/stock.js';
|
2026-04-12 00:18:25 +02:00
|
|
|
import { BrowserMultiFormatReader } from '@zxing/browser';
|
2026-04-10 22:08:01 +02:00
|
|
|
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
|
|
|
|
|
import { fetchLocations } from '../../api/locations.js';
|
2026-04-06 09:24:22 +02:00
|
|
|
import { getRouteContext } from '../../app/router.js';
|
|
|
|
|
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
|
|
|
|
import { formatDate } from '../shared/date-utils.js';
|
|
|
|
|
|
2026-04-10 22:08:01 +02:00
|
|
|
function todayAtMidnight() {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseDateValue(value) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [year, month, day] = String(value).split('-').map(Number);
|
|
|
|
|
if (!year || !month || !day) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Date(year, month - 1, day);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 10:14:49 +02:00
|
|
|
function normalizeIdentifierCode(value) {
|
|
|
|
|
return String(value || '').replace(/\s+/g, '').trim();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 22:08:01 +02:00
|
|
|
function expirationInfo(entry) {
|
|
|
|
|
if (!entry?.expire_date) {
|
|
|
|
|
return {
|
|
|
|
|
key: 'none',
|
|
|
|
|
label: 'No expiration date',
|
|
|
|
|
detail: 'No expiration date',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const expireDate = parseDateValue(entry.expire_date);
|
|
|
|
|
const expireIn =
|
|
|
|
|
typeof entry.expire_in === 'number'
|
|
|
|
|
? entry.expire_in
|
|
|
|
|
: expireDate
|
|
|
|
|
? Math.round((expireDate - todayAtMidnight()) / (24 * 60 * 60 * 1000))
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (expireIn === null) {
|
|
|
|
|
return {
|
|
|
|
|
key: 'none',
|
|
|
|
|
label: 'No expiration date',
|
|
|
|
|
detail: 'No expiration date',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (expireIn < 0) {
|
|
|
|
|
return {
|
|
|
|
|
key: 'expired',
|
|
|
|
|
label: 'Expired',
|
|
|
|
|
detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (expireIn <= 2) {
|
|
|
|
|
return {
|
|
|
|
|
key: 'use-first',
|
|
|
|
|
label: expireIn === 0 ? 'Use today' : 'Use first',
|
|
|
|
|
detail: expireIn === 0 ? 'Expires today' : `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (expireIn <= 7) {
|
|
|
|
|
return {
|
|
|
|
|
key: 'upcoming',
|
|
|
|
|
label: 'Upcoming expiration',
|
|
|
|
|
detail: `Expires in ${expireIn} days`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
key: 'within-date',
|
|
|
|
|
label: 'Within date',
|
|
|
|
|
detail: `Expires in ${expireIn} days`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
export function renderStockDetailPage() {
|
|
|
|
|
return `
|
|
|
|
|
<section class="container-xxl py-4 py-lg-5" x-data="stockDetailPage()" x-init="init()">
|
|
|
|
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center mb-4">
|
|
|
|
|
<div>
|
|
|
|
|
<a href="#/stock" class="link-secondary text-decoration-none small">← Back to stock</a>
|
|
|
|
|
<h1 class="h3 mb-1 mt-2">Stock detail</h1>
|
|
|
|
|
<p class="text-body-secondary mb-0">Inspect the entry and update its quantity without leaving the workflow.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template x-if="state.error">
|
|
|
|
|
<div class="alert alert-danger" x-text="state.error"></div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-if="entry">
|
|
|
|
|
<div class="row g-4">
|
|
|
|
|
<div class="col-12 col-lg-7">
|
|
|
|
|
<div class="card border-0 shadow-sm">
|
|
|
|
|
<div class="card-body p-4">
|
|
|
|
|
<div class="d-flex flex-wrap justify-content-between gap-3 mb-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="eyebrow mb-2">Entry</p>
|
|
|
|
|
<h2 class="h4 mb-1" x-text="entry.name"></h2>
|
|
|
|
|
<p class="text-body-secondary mb-0" x-text="entry.description || 'No description'"></p>
|
|
|
|
|
</div>
|
|
|
|
|
<span class="badge rounded-pill text-bg-light border align-self-start" x-text="entry.status || 'ok'"></span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<dl class="row mb-0 detail-grid">
|
|
|
|
|
<dt class="col-5">Kitchen</dt>
|
|
|
|
|
<dd class="col-7" x-text="$store.app.activeKitchen?.name || 'Unknown'"></dd>
|
|
|
|
|
<dt class="col-5">Quantity</dt>
|
|
|
|
|
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
|
|
|
|
|
<dt class="col-5">Location</dt>
|
2026-04-10 22:08:01 +02:00
|
|
|
<dd class="col-7" x-text="locationLabel(entry)"></dd>
|
2026-04-06 09:24:22 +02:00
|
|
|
<dt class="col-5">Production date</dt>
|
2026-04-06 10:30:37 +02:00
|
|
|
<dd class="col-7" x-text="formatDate(entry.date)"></dd>
|
2026-04-06 09:24:22 +02:00
|
|
|
<dt class="col-5">Expiration date</dt>
|
2026-04-06 10:30:37 +02:00
|
|
|
<dd class="col-7" x-text="formatDate(entry.expire_date)"></dd>
|
2026-04-10 22:08:01 +02:00
|
|
|
<dt class="col-5">Expiration status</dt>
|
|
|
|
|
<dd class="col-7">
|
|
|
|
|
<div class="d-flex align-items-center gap-2 mb-1">
|
|
|
|
|
<span class="badge rounded-pill" :class="expirationBadgeClass(entry)" x-text="expirationFor(entry).label"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="small text-body-secondary" x-text="expirationFor(entry).detail"></div>
|
|
|
|
|
</dd>
|
2026-04-06 09:24:22 +02:00
|
|
|
<dt class="col-5">Stock type</dt>
|
|
|
|
|
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
|
|
|
|
|
</dl>
|
2026-04-10 22:08:01 +02:00
|
|
|
|
2026-04-11 10:14:49 +02:00
|
|
|
<div class="mt-4">
|
|
|
|
|
<h3 class="h6 mb-3">Identifier</h3>
|
|
|
|
|
<div class="input-group">
|
|
|
|
|
<input
|
|
|
|
|
class="form-control"
|
|
|
|
|
type="text"
|
|
|
|
|
x-model="identifierDraft"
|
2026-04-12 00:18:25 +02:00
|
|
|
@input="identifierState.error = ''"
|
2026-04-11 10:14:49 +02:00
|
|
|
inputmode="numeric"
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
placeholder="EAN / UPC / GTIN"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-outline-primary"
|
|
|
|
|
type="button"
|
|
|
|
|
@click="saveIdentifierCode()"
|
|
|
|
|
:disabled="identifierState.isLoading"
|
|
|
|
|
>
|
|
|
|
|
<span x-show="!identifierState.isLoading">Save identifier</span>
|
|
|
|
|
<span x-show="identifierState.isLoading">Saving...</span>
|
|
|
|
|
</button>
|
2026-04-12 00:18:25 +02:00
|
|
|
<button
|
|
|
|
|
class="btn btn-outline-secondary"
|
|
|
|
|
type="button"
|
|
|
|
|
@click="openScanner()"
|
|
|
|
|
x-show="scannerState.hasCamera"
|
|
|
|
|
>
|
|
|
|
|
Camera
|
|
|
|
|
</button>
|
2026-04-11 10:14:49 +02:00
|
|
|
</div>
|
2026-04-12 00:18:25 +02:00
|
|
|
<div class="form-text">Used for product identifier tracking and metadata lookups.</div>
|
2026-04-11 10:14:49 +02:00
|
|
|
<template x-if="identifierState.error">
|
|
|
|
|
<div class="small text-danger mt-1" x-text="identifierState.error"></div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="mt-4">
|
|
|
|
|
<h3 class="h6 mb-2">OpenFoodFacts</h3>
|
|
|
|
|
<div class="d-flex flex-wrap gap-2">
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-outline-secondary"
|
|
|
|
|
type="button"
|
|
|
|
|
@click="runItemLookup(false)"
|
|
|
|
|
:disabled="lookupDetailsState.isLoading || !hasIdentifierCode()"
|
|
|
|
|
>
|
|
|
|
|
<span x-show="!lookupDetailsState.isLoading">Refresh details</span>
|
|
|
|
|
<span x-show="lookupDetailsState.isLoading">Refreshing...</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-outline-primary"
|
|
|
|
|
type="button"
|
|
|
|
|
@click="runItemLookup(true)"
|
|
|
|
|
:disabled="lookupDetailsState.isLoading || !hasIdentifierCode()"
|
|
|
|
|
>
|
|
|
|
|
<span x-show="!lookupDetailsState.isLoading">Apply missing fields</span>
|
|
|
|
|
<span x-show="lookupDetailsState.isLoading">Applying...</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<template x-if="!hasIdentifierCode()">
|
|
|
|
|
<div class="small text-body-secondary mt-2">Save an identifier code first to enable lookup refresh.</div>
|
|
|
|
|
</template>
|
|
|
|
|
<template x-if="offLookupFeedback.message">
|
|
|
|
|
<div
|
|
|
|
|
class="alert mt-3 mb-0"
|
|
|
|
|
:class="offLookupFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
|
|
|
|
x-text="offLookupFeedback.message"
|
|
|
|
|
></div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
2026-04-10 22:08:01 +02:00
|
|
|
<div class="mt-4">
|
|
|
|
|
<h3 class="h6 mb-3">Nutrition</h3>
|
|
|
|
|
<dl class="row mb-0 detail-grid">
|
|
|
|
|
<dt class="col-5">Nutri-Score</dt>
|
|
|
|
|
<dd class="col-7" x-text="nutriScoreLabel(entry)"></dd>
|
|
|
|
|
<dt class="col-5">Nutriments</dt>
|
|
|
|
|
<dd class="col-7">
|
|
|
|
|
<template x-if="nutritionFactsRows(entry).length">
|
|
|
|
|
<ul class="list-unstyled mb-0 small d-grid gap-1">
|
|
|
|
|
<template x-for="fact in nutritionFactsRows(entry)" :key="fact.key">
|
|
|
|
|
<li>
|
|
|
|
|
<span class="text-body-secondary" x-text="fact.label + ':'"></span>
|
|
|
|
|
<span class="fw-semibold" x-text="fact.value"></span>
|
|
|
|
|
</li>
|
|
|
|
|
</template>
|
|
|
|
|
</ul>
|
|
|
|
|
</template>
|
|
|
|
|
<template x-if="!nutritionFactsRows(entry).length">
|
|
|
|
|
<span class="text-body-secondary">Not available</span>
|
|
|
|
|
</template>
|
|
|
|
|
</dd>
|
|
|
|
|
</dl>
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="col-12 col-lg-5">
|
|
|
|
|
<div class="card border-0 shadow-sm">
|
|
|
|
|
<div class="card-body p-4">
|
|
|
|
|
<p class="eyebrow mb-2">Adjustment</p>
|
|
|
|
|
<h2 class="h5 mb-3">Update current stock level</h2>
|
|
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
<template x-if="entry.stock_type === 'measured'">
|
|
|
|
|
<form class="vstack gap-3" @submit.prevent="submitMeasuredAdjustment()">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="form-label">Adjustment mode</label>
|
|
|
|
|
<select class="form-select" x-model="adjustment.mode">
|
|
|
|
|
<option value="increment">Add quantity</option>
|
|
|
|
|
<option value="decrement">Subtract quantity</option>
|
|
|
|
|
<option value="set">Set exact quantity</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label class="form-label">Quantity</label>
|
|
|
|
|
<input class="form-control" type="number" min="0" step="0.01" x-model="adjustment.quantity" required />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="d-flex flex-wrap gap-2">
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(1)">+1</button>
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(-1)">-1</button>
|
|
|
|
|
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(0.5)">+0.5</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template x-if="adjustmentState.error">
|
|
|
|
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
|
|
|
|
</template>
|
2026-04-10 22:08:01 +02:00
|
|
|
<template x-if="printFeedback.message">
|
|
|
|
|
<div
|
|
|
|
|
class="alert mb-0"
|
|
|
|
|
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
|
|
|
|
x-text="printFeedback.message"
|
|
|
|
|
></div>
|
|
|
|
|
</template>
|
2026-04-06 10:30:37 +02:00
|
|
|
|
2026-04-06 21:34:02 +02:00
|
|
|
<div class="d-flex flex-wrap gap-2">
|
|
|
|
|
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
|
|
|
|
<span x-show="!adjustmentState.isLoading">Save quantity</span>
|
|
|
|
|
<span x-show="adjustmentState.isLoading">Saving...</span>
|
|
|
|
|
</button>
|
2026-04-10 22:08:01 +02:00
|
|
|
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
|
|
|
|
|
<span x-show="!printState.isLoading">Print label</span>
|
|
|
|
|
<span x-show="printState.isLoading">Printing...</span>
|
|
|
|
|
</button>
|
2026-04-06 21:34:02 +02:00
|
|
|
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
|
|
|
|
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
|
|
|
|
<span x-show="adjustmentState.isLoading">Removing...</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-04-06 10:30:37 +02:00
|
|
|
</form>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-if="entry.stock_type === 'descriptive'">
|
|
|
|
|
<form class="vstack gap-3" @submit.prevent="submitLevelAdjustment()">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="form-label">Stock level</label>
|
|
|
|
|
<select class="form-select" x-model="adjustment.level">
|
|
|
|
|
<option value="plenty">Plenty</option>
|
|
|
|
|
<option value="good">Good</option>
|
|
|
|
|
<option value="some">Some</option>
|
|
|
|
|
<option value="low">Low</option>
|
|
|
|
|
<option value="trace">Trace</option>
|
|
|
|
|
<option value="gone">Gone</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
<template x-if="adjustmentState.error">
|
|
|
|
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
|
|
|
|
</template>
|
2026-04-10 22:08:01 +02:00
|
|
|
<template x-if="printFeedback.message">
|
|
|
|
|
<div
|
|
|
|
|
class="alert mb-0"
|
|
|
|
|
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
|
|
|
|
x-text="printFeedback.message"
|
|
|
|
|
></div>
|
|
|
|
|
</template>
|
2026-04-06 09:24:22 +02:00
|
|
|
|
2026-04-06 21:34:02 +02:00
|
|
|
<div class="d-flex flex-wrap gap-2">
|
|
|
|
|
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
|
|
|
|
<span x-show="!adjustmentState.isLoading">Save stock level</span>
|
|
|
|
|
<span x-show="adjustmentState.isLoading">Saving...</span>
|
|
|
|
|
</button>
|
2026-04-10 22:08:01 +02:00
|
|
|
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
|
|
|
|
|
<span x-show="!printState.isLoading">Print label</span>
|
|
|
|
|
<span x-show="printState.isLoading">Printing...</span>
|
|
|
|
|
</button>
|
2026-04-06 21:34:02 +02:00
|
|
|
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
|
|
|
|
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
|
|
|
|
<span x-show="adjustmentState.isLoading">Removing...</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-04-06 10:30:37 +02:00
|
|
|
</form>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-if="entry.stock_type === 'binary'">
|
|
|
|
|
<div class="vstack gap-3">
|
|
|
|
|
<p class="text-body-secondary mb-0">
|
|
|
|
|
Binary stock items can be marked gone from this screen.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<template x-if="adjustmentState.error">
|
|
|
|
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
|
|
|
|
</template>
|
2026-04-10 22:08:01 +02:00
|
|
|
<template x-if="printFeedback.message">
|
|
|
|
|
<div
|
|
|
|
|
class="alert mb-0"
|
|
|
|
|
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
|
|
|
|
x-text="printFeedback.message"
|
|
|
|
|
></div>
|
|
|
|
|
</template>
|
2026-04-06 10:30:37 +02:00
|
|
|
|
|
|
|
|
<button class="btn btn-outline-danger align-self-start" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
|
|
|
|
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
|
|
|
|
<span x-show="adjustmentState.isLoading">Removing...</span>
|
|
|
|
|
</button>
|
2026-04-10 22:08:01 +02:00
|
|
|
<button class="btn btn-outline-secondary align-self-start" type="button" @click="printLabel()" :disabled="printState.isLoading">
|
|
|
|
|
<span x-show="!printState.isLoading">Print label</span>
|
|
|
|
|
<span x-show="printState.isLoading">Printing...</span>
|
|
|
|
|
</button>
|
2026-04-06 10:30:37 +02:00
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-04-12 00:18:25 +02:00
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
class="scanner-modal-backdrop"
|
|
|
|
|
x-show="scannerState.isOpen"
|
|
|
|
|
@click.self="closeScanner()"
|
|
|
|
|
@keydown.escape.window="closeScanner()"
|
|
|
|
|
>
|
|
|
|
|
<div class="scanner-modal card border-0 shadow-lg">
|
|
|
|
|
<div class="card-body p-3 p-md-4">
|
|
|
|
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="h5 mb-1">Scan barcode</h2>
|
|
|
|
|
<p class="text-body-secondary small mb-0">Point your camera at the barcode to fill the identifier field.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" @click="closeScanner()">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="scanner-video-shell mb-3">
|
|
|
|
|
<video class="scanner-video" x-ref="scannerVideo" autoplay muted playsinline></video>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
|
|
|
|
<div class="small text-body-secondary" x-show="scannerState.isLoading">Starting camera...</div>
|
|
|
|
|
<div class="small text-success" x-show="scannerState.lastDetectedCode" x-text="'Detected: ' + scannerState.lastDetectedCode"></div>
|
|
|
|
|
<button class="btn btn-outline-secondary btn-sm" type="button" @click="startScanner()" :disabled="scannerState.isLoading">Retry</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template x-if="scannerState.error">
|
|
|
|
|
<div class="alert alert-warning py-2 mt-3 mb-0" x-text="scannerState.error"></div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
</section>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stockDetailPageData(store) {
|
|
|
|
|
return {
|
|
|
|
|
state: createAsyncState(),
|
|
|
|
|
adjustmentState: createAsyncState(),
|
2026-04-10 22:08:01 +02:00
|
|
|
printState: createAsyncState(),
|
2026-04-11 10:14:49 +02:00
|
|
|
identifierState: createAsyncState(),
|
2026-04-12 00:18:25 +02:00
|
|
|
scannerReader: null,
|
|
|
|
|
scannerControls: null,
|
|
|
|
|
scannerState: {
|
|
|
|
|
isOpen: false,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
hasCamera: false,
|
|
|
|
|
error: '',
|
|
|
|
|
lastDetectedCode: '',
|
|
|
|
|
},
|
2026-04-11 10:14:49 +02:00
|
|
|
lookupDetailsState: createAsyncState(),
|
2026-04-10 22:08:01 +02:00
|
|
|
printFeedback: {
|
|
|
|
|
type: '',
|
|
|
|
|
message: '',
|
|
|
|
|
},
|
2026-04-11 10:14:49 +02:00
|
|
|
offLookupFeedback: {
|
|
|
|
|
type: '',
|
|
|
|
|
message: '',
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
entry: null,
|
2026-04-10 22:08:01 +02:00
|
|
|
locationPathByUuid: {},
|
2026-04-11 10:14:49 +02:00
|
|
|
identifierDraft: '',
|
2026-04-06 09:24:22 +02:00
|
|
|
adjustment: {
|
|
|
|
|
mode: 'increment',
|
|
|
|
|
quantity: '1',
|
2026-04-06 10:30:37 +02:00
|
|
|
level: 'plenty',
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
async init() {
|
2026-04-12 00:18:25 +02:00
|
|
|
this.scannerState.hasCamera = this.canUseCameraScanner();
|
2026-04-06 18:31:31 +02:00
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
const { params } = getRouteContext();
|
|
|
|
|
await runAsyncState(this.state, async () => {
|
2026-04-10 22:08:01 +02:00
|
|
|
const [entry, locations] = await Promise.all([
|
|
|
|
|
getStockEntry(store, params.id),
|
|
|
|
|
fetchLocations(store).catch(() => ({ flat: [] })),
|
|
|
|
|
]);
|
|
|
|
|
this.entry = entry;
|
2026-04-11 10:14:49 +02:00
|
|
|
this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code);
|
2026-04-10 22:08:01 +02:00
|
|
|
this.locationPathByUuid = Object.fromEntries(
|
|
|
|
|
(locations.flat || [])
|
|
|
|
|
.filter((location) => location.uuid_b64)
|
|
|
|
|
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
|
|
|
|
|
);
|
2026-04-06 10:30:37 +02:00
|
|
|
this.adjustment.level = this.entry?.level || 'plenty';
|
2026-04-06 09:24:22 +02:00
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
2026-04-12 00:18:25 +02:00
|
|
|
destroy() {
|
|
|
|
|
this.stopScanner();
|
|
|
|
|
},
|
|
|
|
|
canUseCameraScanner() {
|
|
|
|
|
return Boolean(
|
|
|
|
|
typeof navigator !== 'undefined'
|
|
|
|
|
&& navigator.mediaDevices
|
|
|
|
|
&& typeof navigator.mediaDevices.getUserMedia === 'function',
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
normalizeScannerError(error) {
|
|
|
|
|
const message = String(error?.message || '');
|
|
|
|
|
const normalized = message.toLowerCase();
|
|
|
|
|
|
|
|
|
|
if (error?.name === 'NotAllowedError' || normalized.includes('permission')) {
|
|
|
|
|
return 'Camera access was denied. Allow access to scan, or enter the code manually.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) {
|
|
|
|
|
return 'No camera was found on this device. Enter the identifier code manually.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) {
|
|
|
|
|
return 'Camera is busy in another app. Close it there and try scanning again.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'Could not start barcode scanning. Enter the identifier code manually.';
|
|
|
|
|
},
|
|
|
|
|
async openScanner() {
|
|
|
|
|
this.scannerState.error = '';
|
|
|
|
|
this.scannerState.lastDetectedCode = '';
|
|
|
|
|
this.scannerState.isOpen = true;
|
|
|
|
|
await this.$nextTick();
|
|
|
|
|
await this.startScanner();
|
|
|
|
|
},
|
|
|
|
|
async startScanner() {
|
|
|
|
|
this.scannerState.error = '';
|
|
|
|
|
this.scannerState.lastDetectedCode = '';
|
|
|
|
|
|
|
|
|
|
if (!this.canUseCameraScanner()) {
|
|
|
|
|
this.scannerState.hasCamera = false;
|
|
|
|
|
this.scannerState.error = 'Camera scanning is not supported in this browser. Enter the identifier code manually.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videoElement = this.$refs.scannerVideo;
|
|
|
|
|
if (!videoElement) {
|
|
|
|
|
this.scannerState.error = 'Scanner video element is unavailable. Close and reopen scanner.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.stopScanner();
|
|
|
|
|
this.scannerState.isLoading = true;
|
2026-04-12 00:24:41 +02:00
|
|
|
const shouldLogDecodeErrors = import.meta.env.DEV;
|
|
|
|
|
let lastDecodeErrorName = '';
|
|
|
|
|
let lastDecodeErrorAt = 0;
|
2026-04-12 00:18:25 +02:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (!this.scannerReader) {
|
|
|
|
|
this.scannerReader = new BrowserMultiFormatReader();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.scannerControls = await this.scannerReader.decodeFromConstraints(
|
|
|
|
|
{
|
|
|
|
|
audio: false,
|
|
|
|
|
video: {
|
|
|
|
|
facingMode: { ideal: 'environment' },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
videoElement,
|
|
|
|
|
(result, error) => {
|
|
|
|
|
if (result) {
|
|
|
|
|
this.onBarcodeDetected(result.getText?.() || '');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 00:24:41 +02:00
|
|
|
if (error) {
|
|
|
|
|
// Continuous decode emits expected per-frame misses/errors before a valid barcode is found.
|
|
|
|
|
// Keep the modal quiet and only surface startup failures from the outer catch block.
|
|
|
|
|
if (shouldLogDecodeErrors) {
|
|
|
|
|
const errorName = String(error?.name || 'UnknownError');
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) {
|
|
|
|
|
console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || '');
|
|
|
|
|
lastDecodeErrorName = errorName;
|
|
|
|
|
lastDecodeErrorAt = now;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-12 00:18:25 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.scannerState.error = this.normalizeScannerError(error);
|
|
|
|
|
} finally {
|
|
|
|
|
this.scannerState.isLoading = false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
stopScanner() {
|
|
|
|
|
try {
|
|
|
|
|
this.scannerControls?.stop?.();
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore cleanup errors when scanner is already stopped.
|
|
|
|
|
}
|
|
|
|
|
this.scannerControls = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.scannerReader?.reset?.();
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore cleanup errors from stale reader state.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const videoElement = this.$refs.scannerVideo;
|
|
|
|
|
const stream = videoElement?.srcObject;
|
|
|
|
|
if (stream && typeof stream.getTracks === 'function') {
|
|
|
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
|
|
|
}
|
|
|
|
|
if (videoElement) {
|
|
|
|
|
videoElement.srcObject = null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
closeScanner() {
|
|
|
|
|
this.stopScanner();
|
|
|
|
|
this.scannerState.isOpen = false;
|
|
|
|
|
this.scannerState.isLoading = false;
|
|
|
|
|
this.scannerState.error = '';
|
|
|
|
|
},
|
|
|
|
|
onBarcodeDetected(rawCode) {
|
|
|
|
|
const code = normalizeIdentifierCode(rawCode);
|
|
|
|
|
if (!code || !this.scannerState.isOpen) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.identifierDraft = code;
|
|
|
|
|
this.scannerState.lastDetectedCode = code;
|
|
|
|
|
this.closeScanner();
|
|
|
|
|
store.addAlert({
|
|
|
|
|
type: 'success',
|
|
|
|
|
message: `Scanned identifier code: ${code}`,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
async saveIdentifierCode() {
|
|
|
|
|
if (!this.entry?.uuid_b64) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.identifierState.error = '';
|
|
|
|
|
await runAsyncState(this.identifierState, async () => {
|
|
|
|
|
const identifierCode = normalizeIdentifierCode(this.identifierDraft);
|
|
|
|
|
const updated = await updateStockItem(store, this.entry.uuid_b64, {
|
|
|
|
|
identifier_code: identifierCode || null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.entry = updated;
|
|
|
|
|
this.identifierDraft = normalizeIdentifierCode(updated?.identifier_code || identifierCode);
|
|
|
|
|
store.addAlert({
|
|
|
|
|
type: 'success',
|
|
|
|
|
message: identifierCode
|
|
|
|
|
? `Identifier code saved for ${this.entry.name}.`
|
|
|
|
|
: `Identifier code cleared for ${this.entry.name}.`,
|
|
|
|
|
});
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
|
2026-04-11 10:14:49 +02:00
|
|
|
normalizedIdentifierDraft() {
|
|
|
|
|
return normalizeIdentifierCode(this.identifierDraft);
|
|
|
|
|
},
|
|
|
|
|
hasIdentifierCode() {
|
|
|
|
|
return Boolean(this.normalizedIdentifierDraft());
|
|
|
|
|
},
|
|
|
|
|
async reloadEntry(uuidB64) {
|
|
|
|
|
const refreshed = await getStockEntry(store, uuidB64);
|
|
|
|
|
this.entry = refreshed;
|
|
|
|
|
this.identifierDraft = normalizeIdentifierCode(refreshed?.identifier_code);
|
|
|
|
|
this.adjustment.level = this.entry?.level || 'plenty';
|
|
|
|
|
},
|
|
|
|
|
itemLookupStatusMessage(response) {
|
|
|
|
|
const retryAfter = Number.isInteger(response?.retryAfterSeconds) && response.retryAfterSeconds > 0
|
|
|
|
|
? ` Retry in ${response.retryAfterSeconds}s.`
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
if (response?.status === 'missing_identifier') {
|
|
|
|
|
return 'Save an identifier code before running lookup.';
|
|
|
|
|
}
|
|
|
|
|
if (response?.status === 'not_found') {
|
|
|
|
|
return `No OpenFoodFacts result found for code ${this.normalizedIdentifierDraft() || 'unknown'}.`;
|
|
|
|
|
}
|
|
|
|
|
if (response?.status === 'rate_limited') {
|
|
|
|
|
return `OpenFoodFacts lookup is temporarily rate-limited.${retryAfter}`;
|
|
|
|
|
}
|
|
|
|
|
if (response?.status === 'lookup_failed') {
|
|
|
|
|
return 'OpenFoodFacts lookup failed. Try again shortly or continue manually.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'Lookup response could not be applied.';
|
|
|
|
|
},
|
|
|
|
|
itemLookupSuccessMessage(response) {
|
|
|
|
|
const parts = [
|
|
|
|
|
response?.update
|
|
|
|
|
? 'Applied missing fields from OpenFoodFacts.'
|
|
|
|
|
: 'Fetched OpenFoodFacts details preview.',
|
|
|
|
|
];
|
|
|
|
|
const source = response?.item?.external_source || this.entry?.external_source;
|
|
|
|
|
|
|
|
|
|
if (source) {
|
|
|
|
|
parts.push(`Source: ${source}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(response?.updatedFields) && response.updatedFields.length) {
|
|
|
|
|
parts.push(`Updated: ${response.updatedFields.join(', ')}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response?.staleCache) {
|
|
|
|
|
parts.push('Using stale cache data.');
|
|
|
|
|
} else {
|
|
|
|
|
parts.push('Cache freshness: current.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response?.offPayloadFetchedAt) {
|
|
|
|
|
const fetchedAt = new Date(response.offPayloadFetchedAt);
|
|
|
|
|
parts.push(
|
|
|
|
|
`Fetched at: ${
|
|
|
|
|
Number.isNaN(fetchedAt.getTime())
|
|
|
|
|
? response.offPayloadFetchedAt
|
|
|
|
|
: fetchedAt.toLocaleString()
|
|
|
|
|
}.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parts.join(' ');
|
|
|
|
|
},
|
|
|
|
|
async runItemLookup(update) {
|
|
|
|
|
if (!this.entry?.uuid_b64) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const identifierCode = this.normalizedIdentifierDraft();
|
|
|
|
|
if (!identifierCode) {
|
|
|
|
|
this.offLookupFeedback = {
|
|
|
|
|
type: 'warning',
|
|
|
|
|
message: 'Save an identifier code before running lookup refresh.',
|
|
|
|
|
};
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.lookupDetailsState.error = '';
|
|
|
|
|
await runAsyncState(this.lookupDetailsState, async () => {
|
|
|
|
|
const response = await lookupItemDetails(store, this.entry.uuid_b64, { update });
|
|
|
|
|
if (response.status !== 'ok') {
|
|
|
|
|
const message = this.itemLookupStatusMessage(response);
|
|
|
|
|
this.offLookupFeedback = {
|
|
|
|
|
type: 'warning',
|
|
|
|
|
message,
|
|
|
|
|
};
|
|
|
|
|
store.addAlert({ type: 'warning', message });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (update) {
|
|
|
|
|
await this.reloadEntry(this.entry.uuid_b64);
|
|
|
|
|
} else if (response.item) {
|
|
|
|
|
this.entry = response.item;
|
|
|
|
|
this.identifierDraft = normalizeIdentifierCode(response.item.identifier_code || identifierCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const message = this.itemLookupSuccessMessage(response);
|
|
|
|
|
this.offLookupFeedback = {
|
|
|
|
|
type: 'success',
|
|
|
|
|
message,
|
|
|
|
|
};
|
|
|
|
|
store.addAlert({
|
|
|
|
|
type: 'success',
|
|
|
|
|
message,
|
|
|
|
|
});
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
this.offLookupFeedback = {
|
|
|
|
|
type: 'warning',
|
|
|
|
|
message: error?.message || 'OpenFoodFacts lookup failed.',
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-04-06 10:30:37 +02:00
|
|
|
async submitMeasuredAdjustment() {
|
2026-04-06 09:24:22 +02:00
|
|
|
if (!this.entry) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await runAsyncState(this.adjustmentState, async () => {
|
2026-04-06 10:30:37 +02:00
|
|
|
const requestedQuantity = Number(this.adjustment.quantity);
|
|
|
|
|
if (Number.isNaN(requestedQuantity) || requestedQuantity < 0) {
|
|
|
|
|
throw new Error('Enter a valid quantity first.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentQuantity = Number(this.entry.quantity || 0);
|
|
|
|
|
const exactQuantity =
|
|
|
|
|
this.adjustment.mode === 'increment'
|
|
|
|
|
? currentQuantity + requestedQuantity
|
|
|
|
|
: this.adjustment.mode === 'decrement'
|
|
|
|
|
? Math.max(currentQuantity - requestedQuantity, 0)
|
|
|
|
|
: requestedQuantity;
|
|
|
|
|
|
|
|
|
|
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
|
|
|
|
|
quantity: exactQuantity,
|
2026-04-06 09:24:22 +02:00
|
|
|
});
|
2026-04-11 10:14:49 +02:00
|
|
|
this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
|
2026-04-06 09:24:22 +02:00
|
|
|
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
2026-04-06 10:30:37 +02:00
|
|
|
async submitLevelAdjustment() {
|
|
|
|
|
if (!this.entry) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await runAsyncState(this.adjustmentState, async () => {
|
2026-04-06 21:34:02 +02:00
|
|
|
if (this.adjustment.level === 'gone') {
|
|
|
|
|
const entryName = this.entry.name;
|
2026-04-10 15:43:39 +02:00
|
|
|
await useStockItem(store, this.entry.uuid_b64);
|
2026-04-06 21:34:02 +02:00
|
|
|
store.addAlert({ type: 'success', message: `${entryName} was marked gone.` });
|
|
|
|
|
window.__loncApp.navigate('/stock');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 10:30:37 +02:00
|
|
|
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
|
|
|
|
|
level: this.adjustment.level,
|
|
|
|
|
});
|
2026-04-11 10:14:49 +02:00
|
|
|
this.identifierDraft = normalizeIdentifierCode(this.entry?.identifier_code);
|
2026-04-06 10:30:37 +02:00
|
|
|
store.addAlert({ type: 'success', message: 'Stock level updated.' });
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
async markGone() {
|
|
|
|
|
if (!this.entry) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await runAsyncState(this.adjustmentState, async () => {
|
2026-04-10 15:43:39 +02:00
|
|
|
const result = await useStockItem(store, this.entry.uuid_b64);
|
|
|
|
|
const alreadyGone = result.status === 'already_gone';
|
|
|
|
|
store.addAlert({
|
|
|
|
|
type: alreadyGone ? 'info' : 'success',
|
|
|
|
|
message: alreadyGone
|
|
|
|
|
? `${this.entry.name} was already out of stock.`
|
|
|
|
|
: `${this.entry.name} was marked gone.`,
|
|
|
|
|
});
|
2026-04-06 10:30:37 +02:00
|
|
|
window.__loncApp.navigate('/stock');
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
2026-04-10 22:08:01 +02:00
|
|
|
async printLabel() {
|
|
|
|
|
if (!this.entry?.uuid_b64) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.printFeedback = {
|
|
|
|
|
type: '',
|
|
|
|
|
message: '',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await runAsyncState(this.printState, async () => {
|
|
|
|
|
try {
|
|
|
|
|
await printItemLabel(store, this.entry.uuid_b64);
|
|
|
|
|
this.printFeedback = {
|
|
|
|
|
type: 'success',
|
|
|
|
|
message: 'Label printed successfully.',
|
|
|
|
|
};
|
|
|
|
|
store.addAlert({
|
|
|
|
|
type: 'success',
|
|
|
|
|
message: `${this.entry.name} label sent to printer.`,
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const parsed = formatPrintErrorMessage(error);
|
|
|
|
|
this.printFeedback = {
|
|
|
|
|
type: 'warning',
|
|
|
|
|
message: parsed,
|
|
|
|
|
};
|
|
|
|
|
store.addAlert({
|
|
|
|
|
type: 'warning',
|
|
|
|
|
message: `Could not print ${this.entry.name} label: ${parsed}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
quickAdjust(step) {
|
|
|
|
|
const current = Number(this.adjustment.quantity || 0);
|
|
|
|
|
this.adjustment.quantity = String(Math.max(current + step, 0));
|
|
|
|
|
},
|
|
|
|
|
formatDate,
|
2026-04-10 22:08:01 +02:00
|
|
|
expirationFor(entry) {
|
|
|
|
|
return expirationInfo(entry);
|
|
|
|
|
},
|
|
|
|
|
expirationBadgeClass(entry) {
|
|
|
|
|
const key = this.expirationFor(entry).key;
|
|
|
|
|
if (key === 'expired') {
|
|
|
|
|
return 'text-bg-danger';
|
|
|
|
|
}
|
|
|
|
|
if (key === 'use-first') {
|
|
|
|
|
return 'text-bg-warning';
|
|
|
|
|
}
|
|
|
|
|
if (key === 'upcoming') {
|
|
|
|
|
return 'text-bg-secondary';
|
|
|
|
|
}
|
|
|
|
|
if (key === 'within-date') {
|
|
|
|
|
return 'text-bg-success';
|
|
|
|
|
}
|
|
|
|
|
return 'text-bg-light border';
|
|
|
|
|
},
|
|
|
|
|
locationLabel(entry) {
|
|
|
|
|
const locationUuid = entry?.location_initial_uuid_b64;
|
|
|
|
|
if (!locationUuid) {
|
|
|
|
|
return 'Unassigned';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.locationPathByUuid[locationUuid] || 'Location not resolved';
|
|
|
|
|
},
|
|
|
|
|
nutriScoreLabel(entry) {
|
|
|
|
|
const value = entry?.nutriscore_grade;
|
|
|
|
|
if (!value) {
|
|
|
|
|
return 'Not available';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return String(value).toUpperCase();
|
|
|
|
|
},
|
|
|
|
|
nutritionFactsRows(entry) {
|
|
|
|
|
const facts = entry?.nutrition_facts;
|
|
|
|
|
if (!facts || typeof facts !== 'object' || Array.isArray(facts)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const preferredOrder = [
|
|
|
|
|
'per',
|
|
|
|
|
'serving_size',
|
|
|
|
|
'energy_kj',
|
|
|
|
|
'energy_kcal',
|
|
|
|
|
'fat',
|
|
|
|
|
'saturated_fat',
|
|
|
|
|
'carbohydrates',
|
|
|
|
|
'sugars',
|
|
|
|
|
'fibers',
|
|
|
|
|
'proteins',
|
|
|
|
|
'salt',
|
|
|
|
|
'sodium',
|
|
|
|
|
];
|
|
|
|
|
const rankByKey = new Map(preferredOrder.map((key, index) => [key, index]));
|
|
|
|
|
|
|
|
|
|
return Object.entries(facts)
|
|
|
|
|
.sort(([leftKey], [rightKey]) => {
|
|
|
|
|
const leftRank = rankByKey.has(leftKey) ? rankByKey.get(leftKey) : Number.POSITIVE_INFINITY;
|
|
|
|
|
const rightRank = rankByKey.has(rightKey) ? rankByKey.get(rightKey) : Number.POSITIVE_INFINITY;
|
|
|
|
|
|
|
|
|
|
if (leftRank !== rightRank) {
|
|
|
|
|
return leftRank - rightRank;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return leftKey.localeCompare(rightKey);
|
|
|
|
|
})
|
|
|
|
|
.map(([key, value]) => ({
|
|
|
|
|
key,
|
|
|
|
|
label: this.nutritionLabel(key),
|
|
|
|
|
value: this.formatNutritionValue(value),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
nutritionLabel(key) {
|
|
|
|
|
const labels = {
|
|
|
|
|
per: 'Per',
|
|
|
|
|
serving_size: 'Serving size',
|
|
|
|
|
energy_kj: 'Energy (kJ)',
|
|
|
|
|
energy_kcal: 'Energy (kcal)',
|
|
|
|
|
fat: 'Fat',
|
|
|
|
|
saturated_fat: 'Saturated fat',
|
|
|
|
|
carbohydrates: 'Carbohydrates',
|
|
|
|
|
sugars: 'Sugars',
|
|
|
|
|
fibers: 'Fibers',
|
|
|
|
|
proteins: 'Proteins',
|
|
|
|
|
salt: 'Salt',
|
|
|
|
|
sodium: 'Sodium',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return labels[key] || key.replace(/_/g, ' ');
|
|
|
|
|
},
|
|
|
|
|
formatNutritionValue(value) {
|
|
|
|
|
if (value === null || value === undefined || value === '') {
|
|
|
|
|
return 'n/a';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return String(value);
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
formatQuantity(entry) {
|
2026-04-06 10:30:37 +02:00
|
|
|
return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|