Add label printing functionality and error handling in stock and label flows
This commit is contained in:
@@ -94,7 +94,8 @@ These are current project assumptions and should not be casually changed.
|
||||
|
||||
- Preview uses label-preview flags
|
||||
- Submit/create flow uses upsert apply (`/kitchen/items/upsert?mode=apply`)
|
||||
- Auto-print is deferred and should not be assumed in current UI submit flow
|
||||
- UI exposes a `Print` checkbox next to save (default on for current page session)
|
||||
- If `Print` is enabled and save succeeds, label printing uses `/kitchen/items/{uuid_b64}/print-label`
|
||||
|
||||
### Item-definition search for label creation
|
||||
|
||||
|
||||
@@ -177,6 +177,8 @@ Expected shapes today:
|
||||
Updates measured or descriptive stock state using `{ quantity }` or `{ level }`.
|
||||
- `POST /{database}/kitchen/items/{uuid_b64}/use`
|
||||
Marks an item used up (`gone`) via stock-event semantics.
|
||||
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
|
||||
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
||||
- `DELETE /{database}/kitchen/items/{uuid_b64}`
|
||||
Compatibility fallback when `/use` is not available on the backend.
|
||||
- `GET /{database}/kitchen/locations`
|
||||
@@ -188,4 +190,4 @@ Expected shapes today:
|
||||
- Local storage only keeps non-sensitive app config, session payload, active kitchen, and label draft state.
|
||||
- Kitchen context now lives in the URL path instead of a custom header.
|
||||
- The API client now builds database-scoped kitchen routes by default; it always keeps bearer authentication handling separate from URL shaping.
|
||||
- Label submit now uses upsert-first apply semantics; auto-print is intentionally deferred.
|
||||
- Label submit uses upsert-first apply semantics and an optional `Print` checkbox (default on for the current page session).
|
||||
|
||||
@@ -52,3 +52,57 @@ export async function previewLabel(store, body) {
|
||||
|
||||
throw new Error('Label preview response did not include an image.');
|
||||
}
|
||||
|
||||
export async function printItemLabel(store, uuidB64) {
|
||||
return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
function flattenDetails(details) {
|
||||
if (!details) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof details === 'string') {
|
||||
return details;
|
||||
}
|
||||
|
||||
if (Array.isArray(details)) {
|
||||
return details
|
||||
.map((entry) => (typeof entry === 'string' ? entry : JSON.stringify(entry)))
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
if (typeof details === 'object') {
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
return String(details);
|
||||
}
|
||||
|
||||
export function formatPrintErrorMessage(error) {
|
||||
const status = error?.status || error?.cause?.status;
|
||||
const payload = error?.payload || error?.cause?.payload || {};
|
||||
const code = String(payload?.code || '').toLowerCase();
|
||||
const detailsText = flattenDetails(payload?.details || error?.details || error?.cause?.details);
|
||||
|
||||
let message;
|
||||
if (code === 'printer_unavailable') {
|
||||
message = 'Printer is unavailable.';
|
||||
} else if (code === 'print_failed') {
|
||||
message = 'Label printing failed.';
|
||||
} else if (status === 503) {
|
||||
message = 'Printer service is unavailable.';
|
||||
} else if (status === 404) {
|
||||
message = 'Saved item could not be found for printing.';
|
||||
} else if (status === 400) {
|
||||
message = 'Print request was invalid.';
|
||||
} else {
|
||||
message = error?.message || 'Printing failed.';
|
||||
}
|
||||
|
||||
return detailsText ? `${message} (${detailsText})` : message;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
searchItemDefinitions,
|
||||
} from '../../api/stock.js';
|
||||
import { fetchLocations } from '../../api/locations.js';
|
||||
import { previewLabel } from '../../api/labels.js';
|
||||
import {
|
||||
formatPrintErrorMessage,
|
||||
previewLabel,
|
||||
printItemLabel,
|
||||
} 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';
|
||||
@@ -404,7 +408,7 @@ export function renderLabelCreatePage() {
|
||||
<div class="alert alert-success mb-0" x-text="successMessage"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="upsertPreview && !upsertPreview.error">
|
||||
<template x-if="upsertPreview?.mode === 'preview' && !upsertPreview.error">
|
||||
<div class="alert alert-info mb-0 py-2" x-text="upsertPreviewSummary()"></div>
|
||||
</template>
|
||||
|
||||
@@ -412,18 +416,28 @@ export function renderLabelCreatePage() {
|
||||
<div class="alert alert-warning mb-0 py-2" x-text="upsertPreview.error"></div>
|
||||
</template>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-outline-primary" type="button" @click="preview()" :disabled="previewState.isLoading">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
<button class="btn btn-outline-secondary" type="button" @click="reset()">Clear form</button>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary label-action-btn" type="button" @click="reset()">Clear form</button>
|
||||
</div>
|
||||
<div class="small text-body-secondary">
|
||||
<span class="text-danger">*</span> Required field
|
||||
@@ -561,6 +575,8 @@ export function labelCreatePageData(store) {
|
||||
submitError: '',
|
||||
fieldErrors: {},
|
||||
upsertPreview: null,
|
||||
printLabelOnSave: true,
|
||||
printIssue: '',
|
||||
form: {
|
||||
...loadLabelDraft(),
|
||||
},
|
||||
@@ -997,6 +1013,10 @@ export function labelCreatePageData(store) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.upsertPreview.mode !== 'preview') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (this.upsertPreview.operation === 'update') {
|
||||
const name = this.upsertPreview.matchedItem?.name || this.form.name;
|
||||
const matchType = this.upsertPreview.matchType ? ` (matched by ${this.upsertPreview.matchType})` : '';
|
||||
@@ -1009,6 +1029,7 @@ export function labelCreatePageData(store) {
|
||||
this.submitError = '';
|
||||
this.fieldErrors = {};
|
||||
this.upsertPreview = null;
|
||||
this.printIssue = '';
|
||||
|
||||
if (!this.validateBeforeSubmit()) {
|
||||
this.previewState.error = 'Please fill out the required fields before previewing the label.';
|
||||
@@ -1035,6 +1056,7 @@ export function labelCreatePageData(store) {
|
||||
async create() {
|
||||
this.submitError = '';
|
||||
this.fieldErrors = {};
|
||||
this.printIssue = '';
|
||||
|
||||
if (!this.validateBeforeSubmit()) {
|
||||
this.submitError = 'Please fill out the required fields before saving the stock entry.';
|
||||
@@ -1044,12 +1066,23 @@ export function labelCreatePageData(store) {
|
||||
await runAsyncState(this.createState, async () => {
|
||||
try {
|
||||
const entry = await applyItemUpsert(store, this.buildUpsertPayload());
|
||||
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
}
|
||||
this.previewUrl = '';
|
||||
const entryName = entry.item?.name || this.form.name;
|
||||
const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.successMessage = `${entryName} was ${operationVerb} successfully.`;
|
||||
store.addAlert({
|
||||
type: 'success',
|
||||
@@ -1074,6 +1107,7 @@ export function labelCreatePageData(store) {
|
||||
this.submitError = '';
|
||||
this.fieldErrors = {};
|
||||
this.upsertPreview = null;
|
||||
this.printIssue = '';
|
||||
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
|
||||
if (revokePreview && this.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
|
||||
@@ -3,10 +3,86 @@ import {
|
||||
getStockEntry,
|
||||
useStockItem,
|
||||
} from '../../api/stock.js';
|
||||
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
|
||||
import { fetchLocations } from '../../api/locations.js';
|
||||
import { getRouteContext } from '../../app/router.js';
|
||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||
import { formatDate } from '../shared/date-utils.js';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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`,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderStockDetailPage() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5" x-data="stockDetailPage()" x-init="init()">
|
||||
@@ -42,14 +118,45 @@ export function renderStockDetailPage() {
|
||||
<dt class="col-5">Quantity</dt>
|
||||
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
|
||||
<dt class="col-5">Location</dt>
|
||||
<dd class="col-7" x-text="entry.location_initial_uuid_b64 || 'Unassigned'"></dd>
|
||||
<dd class="col-7" x-text="locationLabel(entry)"></dd>
|
||||
<dt class="col-5">Production date</dt>
|
||||
<dd class="col-7" x-text="formatDate(entry.date)"></dd>
|
||||
<dt class="col-5">Expiration date</dt>
|
||||
<dd class="col-7" x-text="formatDate(entry.expire_date)"></dd>
|
||||
<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>
|
||||
<dt class="col-5">Stock type</dt>
|
||||
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
|
||||
</dl>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,12 +190,23 @@ export function renderStockDetailPage() {
|
||||
<template x-if="adjustmentState.error">
|
||||
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||
</template>
|
||||
<template x-if="printFeedback.message">
|
||||
<div
|
||||
class="alert mb-0"
|
||||
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
||||
x-text="printFeedback.message"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -114,12 +232,23 @@ export function renderStockDetailPage() {
|
||||
<template x-if="adjustmentState.error">
|
||||
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||
</template>
|
||||
<template x-if="printFeedback.message">
|
||||
<div
|
||||
class="alert mb-0"
|
||||
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
||||
x-text="printFeedback.message"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -137,11 +266,22 @@ export function renderStockDetailPage() {
|
||||
<template x-if="adjustmentState.error">
|
||||
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||
</template>
|
||||
<template x-if="printFeedback.message">
|
||||
<div
|
||||
class="alert mb-0"
|
||||
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
||||
x-text="printFeedback.message"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -157,7 +297,13 @@ export function stockDetailPageData(store) {
|
||||
return {
|
||||
state: createAsyncState(),
|
||||
adjustmentState: createAsyncState(),
|
||||
printState: createAsyncState(),
|
||||
printFeedback: {
|
||||
type: '',
|
||||
message: '',
|
||||
},
|
||||
entry: null,
|
||||
locationPathByUuid: {},
|
||||
adjustment: {
|
||||
mode: 'increment',
|
||||
quantity: '1',
|
||||
@@ -170,7 +316,16 @@ export function stockDetailPageData(store) {
|
||||
|
||||
const { params } = getRouteContext();
|
||||
await runAsyncState(this.state, async () => {
|
||||
this.entry = await getStockEntry(store, params.id);
|
||||
const [entry, locations] = await Promise.all([
|
||||
getStockEntry(store, params.id),
|
||||
fetchLocations(store).catch(() => ({ flat: [] })),
|
||||
]);
|
||||
this.entry = entry;
|
||||
this.locationPathByUuid = Object.fromEntries(
|
||||
(locations.flat || [])
|
||||
.filter((location) => location.uuid_b64)
|
||||
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
|
||||
);
|
||||
this.adjustment.level = this.entry?.level || 'plenty';
|
||||
}).catch(() => {});
|
||||
},
|
||||
@@ -236,11 +391,144 @@ export function stockDetailPageData(store) {
|
||||
window.__loncApp.navigate('/stock');
|
||||
}).catch(() => {});
|
||||
},
|
||||
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(() => {});
|
||||
},
|
||||
quickAdjust(step) {
|
||||
const current = Number(this.adjustment.quantity || 0);
|
||||
this.adjustment.quantity = String(Math.max(current + step, 0));
|
||||
},
|
||||
formatDate,
|
||||
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);
|
||||
},
|
||||
formatQuantity(entry) {
|
||||
return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
|
||||
},
|
||||
|
||||
@@ -660,7 +660,7 @@ export function renderStockListPage() {
|
||||
|
||||
<div class="grouped-stock-items">
|
||||
<template x-for="item in group.items" :key="item.id">
|
||||
<a class="grouped-stock-item text-decoration-none" :class="groupedItemClass(item)" :href="detailHref(item)">
|
||||
<div class="grouped-stock-item" :class="groupedItemClass(item)">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-2">
|
||||
<div>
|
||||
<div class="fw-semibold" x-text="item.name"></div>
|
||||
@@ -669,6 +669,7 @@ export function renderStockListPage() {
|
||||
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
|
||||
<span x-text="shortDescription(item.description)"></span>
|
||||
</div>
|
||||
<a class="small text-decoration-none fw-semibold" :href="detailHref(item)">View item</a>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3 small text-body-secondary grouped-stock-item-meta">
|
||||
<span x-text="quantityLabel(item)"></span>
|
||||
@@ -678,9 +679,13 @@ export function renderStockListPage() {
|
||||
<span x-text="formatDate(item.expire_date)"></span>
|
||||
</span>
|
||||
<span class="font-monospace" x-text="shortId(item)"></span>
|
||||
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" @click="markGoneFromGroup(item, group)">Mark gone</button>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<template x-if="editErrors[item.id]">
|
||||
<div class="small text-danger mt-2" x-text="editErrors[item.id]"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1231,6 +1236,26 @@ export function stockListPageData(store) {
|
||||
async markGone(entry) {
|
||||
await this.useEntry(entry);
|
||||
},
|
||||
async markGoneFromGroup(item, group) {
|
||||
this.editErrors[item.id] = '';
|
||||
|
||||
try {
|
||||
const result = await useStockItem(store, item.uuid_b64);
|
||||
const alreadyGone = result.status === 'already_gone';
|
||||
this.removeGroupedItem(group.id, item.id);
|
||||
this.entries = this.entries.filter((candidate) => candidate.id !== item.id);
|
||||
delete this.editForms[item.id];
|
||||
delete this.editErrors[item.id];
|
||||
store.addAlert({
|
||||
type: alreadyGone ? 'info' : 'success',
|
||||
message: alreadyGone
|
||||
? `${item.name} was already out of stock and removed from the group.`
|
||||
: `${item.name} was marked gone and removed from the group.`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.editErrors[item.id] = error.message || 'Mark gone failed.';
|
||||
}
|
||||
},
|
||||
async saveEntryUpdate(entry, payload, localPatch) {
|
||||
this.editErrors[entry.id] = '';
|
||||
|
||||
@@ -1273,5 +1298,24 @@ export function stockListPageData(store) {
|
||||
quantity: nextEntry.quantity ?? '',
|
||||
};
|
||||
},
|
||||
removeGroupedItem(groupId, itemId) {
|
||||
this.groupedEntries = this.groupedEntries
|
||||
.map((group) => {
|
||||
if (group.id !== groupId) {
|
||||
return group;
|
||||
}
|
||||
|
||||
const nextItems = (group.items || []).filter((candidate) => candidate.id !== itemId);
|
||||
if (!nextItems.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
items: nextItems,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,40 @@ body {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.input-group-label-submit .input-group-text {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label-actions-row .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-group-label-submit {
|
||||
width: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.input-group-label-submit .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label-action-btn {
|
||||
white-space: nowrap !important;
|
||||
flex: 0 0 auto;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.label-actions-row {
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.label-actions-primary {
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
@@ -647,6 +681,11 @@ button.legend-card:focus-visible {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.grouped-stock-mark-gone {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grouped-stock-item-subline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatPrintErrorMessage } from '../../src/api/labels.js';
|
||||
|
||||
describe('api/labels formatPrintErrorMessage', () => {
|
||||
it('maps printer_unavailable payload to user-friendly message', () => {
|
||||
const message = formatPrintErrorMessage({
|
||||
status: 503,
|
||||
payload: {
|
||||
code: 'printer_unavailable',
|
||||
message: 'Backend says unavailable',
|
||||
details: { printer: 'Office Zebra' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(message).toBe('Printer is unavailable. (printer: Office Zebra)');
|
||||
});
|
||||
|
||||
it('falls back to generic message when payload is missing', () => {
|
||||
const message = formatPrintErrorMessage(new Error('Something failed'));
|
||||
expect(message).toBe('Something failed');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const applyItemUpsertMock = vi.fn();
|
||||
const previewItemUpsertMock = vi.fn();
|
||||
const printItemLabelMock = vi.fn();
|
||||
|
||||
vi.mock('../../../src/api/stock.js', () => ({
|
||||
applyItemUpsert: (...args) => applyItemUpsertMock(...args),
|
||||
@@ -11,6 +12,8 @@ vi.mock('../../../src/api/stock.js', () => ({
|
||||
|
||||
vi.mock('../../../src/api/labels.js', () => ({
|
||||
previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })),
|
||||
printItemLabel: (...args) => printItemLabelMock(...args),
|
||||
formatPrintErrorMessage: (error) => error?.message || 'Printing failed.',
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/api/locations.js', () => ({
|
||||
@@ -20,6 +23,15 @@ vi.mock('../../../src/api/locations.js', () => ({
|
||||
const { labelCreatePageData } = await import('../../../src/features/labels/label-create-page.js');
|
||||
|
||||
describe('label create upsert-first submit', () => {
|
||||
it('defaults print checkbox to enabled', () => {
|
||||
const data = labelCreatePageData({
|
||||
isConnected: false,
|
||||
activeKitchen: { id: 1 },
|
||||
addAlert: vi.fn(),
|
||||
});
|
||||
expect(data.printLabelOnSave).toBe(true);
|
||||
});
|
||||
|
||||
it('builds upsert payload with selected template uuid', () => {
|
||||
const store = {
|
||||
isConnected: false,
|
||||
@@ -53,8 +65,9 @@ describe('label create upsert-first submit', () => {
|
||||
it('create uses applyItemUpsert and sets operation-aware success message', async () => {
|
||||
applyItemUpsertMock.mockResolvedValueOnce({
|
||||
operation: 'update',
|
||||
item: { name: 'Rice' },
|
||||
item: { name: 'Rice', uuid_b64: 'uuid-rice-1' },
|
||||
});
|
||||
printItemLabelMock.mockResolvedValueOnce(null);
|
||||
|
||||
const addAlert = vi.fn();
|
||||
const store = {
|
||||
@@ -77,10 +90,44 @@ describe('label create upsert-first submit', () => {
|
||||
|
||||
expect(applyItemUpsertMock).toHaveBeenCalledTimes(1);
|
||||
expect(applyItemUpsertMock.mock.calls[0][1].uuid_b64).toBe('uuid-rice-1');
|
||||
expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-rice-1');
|
||||
expect(data.successMessage).toBe('Rice was updated successfully.');
|
||||
expect(addAlert).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Rice was updated successfully.',
|
||||
});
|
||||
});
|
||||
|
||||
it('create shows parsed print issue warning when printing fails', async () => {
|
||||
applyItemUpsertMock.mockResolvedValueOnce({
|
||||
operation: 'create',
|
||||
item: { name: 'Beans', uuid_b64: 'uuid-beans-1' },
|
||||
});
|
||||
printItemLabelMock.mockRejectedValueOnce(new Error('Printer is unavailable.'));
|
||||
|
||||
const addAlert = vi.fn();
|
||||
const store = {
|
||||
isConnected: false,
|
||||
activeKitchen: { id: 3 },
|
||||
addAlert,
|
||||
};
|
||||
const data = labelCreatePageData(store);
|
||||
data.validateBeforeSubmit = () => true;
|
||||
data.form = {
|
||||
...data.form,
|
||||
name: 'Beans',
|
||||
stockType: 'binary',
|
||||
locationId: '',
|
||||
productionDate: '2026-04-10',
|
||||
itemUuidB64: '',
|
||||
};
|
||||
|
||||
await data.create();
|
||||
|
||||
expect(data.printIssue).toBe('Printer is unavailable.');
|
||||
expect(addAlert).toHaveBeenCalledWith({
|
||||
type: 'warning',
|
||||
message: 'Beans was created, but printing has an issue: Printer is unavailable.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const printItemLabelMock = vi.fn();
|
||||
const formatPrintErrorMessageMock = vi.fn();
|
||||
|
||||
vi.mock('../../../src/api/labels.js', () => ({
|
||||
printItemLabel: (...args) => printItemLabelMock(...args),
|
||||
formatPrintErrorMessage: (...args) => formatPrintErrorMessageMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/api/stock.js', () => ({
|
||||
getStockEntry: vi.fn(),
|
||||
adjustStockEntry: vi.fn(),
|
||||
useStockItem: vi.fn(),
|
||||
listStockEntries: vi.fn(async () => []),
|
||||
listGroupedStockEntries: vi.fn(async () => []),
|
||||
updateStockItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/api/locations.js', () => ({
|
||||
fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
|
||||
}));
|
||||
|
||||
const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js');
|
||||
|
||||
describe('stock print label actions', () => {
|
||||
beforeEach(() => {
|
||||
printItemLabelMock.mockReset();
|
||||
formatPrintErrorMessageMock.mockReset();
|
||||
globalThis.window = {
|
||||
__loncApp: {
|
||||
navigate: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
it('prints from stock detail and shows success alert', async () => {
|
||||
printItemLabelMock.mockResolvedValueOnce(null);
|
||||
const addAlert = vi.fn();
|
||||
const store = { addAlert };
|
||||
const data = stockDetailPageData(store);
|
||||
data.entry = { uuid_b64: 'uuid-1', name: 'Rice' };
|
||||
|
||||
await data.printLabel();
|
||||
|
||||
expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-1');
|
||||
expect(addAlert).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Rice label sent to printer.',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows parsed warning when detail printing fails', async () => {
|
||||
printItemLabelMock.mockRejectedValueOnce(new Error('boom'));
|
||||
formatPrintErrorMessageMock.mockReturnValueOnce('Printer unavailable.');
|
||||
const addAlert = vi.fn();
|
||||
const store = { addAlert };
|
||||
const data = stockDetailPageData(store);
|
||||
data.entry = { uuid_b64: 'uuid-1', name: 'Rice' };
|
||||
|
||||
await data.printLabel();
|
||||
|
||||
expect(addAlert).toHaveBeenCalledWith({
|
||||
type: 'warning',
|
||||
message: 'Could not print Rice label: Printer unavailable.',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user