From 1dc1bb49125f7cf96cb2fd2d63564e32d3d6f679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Fri, 10 Apr 2026 15:43:39 +0200 Subject: [PATCH 1/2] Implement upsert label flow and use-based mark gone handling --- AGENTS.md | 7 +- README.md | 17 +- package-lock.json | 4 +- package.json | 2 +- src/api/auth.js | 2 - src/api/client.js | 23 +- src/api/kitchens.js | 4 +- src/api/labels.js | 1 - src/api/locations.js | 4 +- src/api/stock.js | 82 +++++- src/app/bootstrap.js | 8 +- src/app/config.js | 3 +- src/components/app-shell.js | 12 +- src/features/dashboard/dashboard-page.js | 233 +++++++++++++++++- src/features/labels/label-create-page.js | 107 +++++++- src/features/stock/stock-detail-page.js | 14 +- src/features/stock/stock-list-page.js | 21 +- src/styles/app.css | 5 + tests/api/client.test.js | 10 +- tests/api/stock.test.js | 161 ++++++++++++ .../features/dashboard/dashboard-page.test.js | 143 +++++++++++ tests/features/labels/upsert-submit.test.js | 86 +++++++ tests/features/stock/mark-gone.test.js | 71 ++++++ vite.config.js | 4 + 24 files changed, 948 insertions(+), 76 deletions(-) create mode 100644 tests/api/stock.test.js create mode 100644 tests/features/dashboard/dashboard-page.test.js create mode 100644 tests/features/labels/upsert-submit.test.js create mode 100644 tests/features/stock/mark-gone.test.js diff --git a/AGENTS.md b/AGENTS.md index 3a45206..268a14c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,16 +82,19 @@ These are current project assumptions and should not be casually changed. - `/{database}/user/application/` - `/{database}/kitchen/kitchens` - `/{database}/kitchen/items` +- `/{database}/kitchen/items/upsert` - `/{database}/kitchen/items/grouped` - `/{database}/kitchen/items/{uuid_b64}` - `/{database}/kitchen/items/{uuid_b64}/stock` +- `/{database}/kitchen/items/{uuid_b64}/use` +- `/{database}/kitchen/changes` - `/{database}/kitchen/locations` ### Labels - Preview uses label-preview flags -- Create uses label-generation flags -- Actual create currently also needs `print=1` +- 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 ### Item-definition search for label creation diff --git a/README.md b/README.md index 3b36bd6..e61e87b 100644 --- a/README.md +++ b/README.md @@ -147,8 +147,8 @@ Default endpoint placeholders live in [`src/app/config.js`](/Users/blaz/PycharmP Expected shapes today: -- Kitchen-scoped application resources use: - `/{database}/kitchen/{kitchen_id}/{resource}` +- Kitchen application resources use database-scoped routes: + `/{database}/kitchen/{resource}` - User application key management uses: `/{database}/user/application/` @@ -165,14 +165,20 @@ Expected shapes today: Returns the current stock review list. - `GET /{database}/kitchen/items/{uuid_b64}` Returns one item detail payload. +- `GET /{database}/kitchen/changes` + Returns `{ since, next_cursor, changes }` feed payload for item/stock updates. +- `POST /{database}/kitchen/items/upsert?mode=preview|apply` + Used by label submit flow for create-or-update behavior and conflict-safe matching. - `POST /{database}/kitchen/items?label=1` - Creates a stock item plus label-related output on the backend side. + Used for label image preview rendering. - `POST /{database}/kitchen/items?label=1&preview=1` Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview. - `POST /{database}/kitchen/items/{uuid_b64}/stock` 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. - `DELETE /{database}/kitchen/items/{uuid_b64}` - Marks an individual stock item gone. + Compatibility fallback when `/use` is not available on the backend. - `GET /{database}/kitchen/locations` Returns a nested location tree. @@ -181,4 +187,5 @@ Expected shapes today: - Hash-based routing is used to keep static deployment simple. - 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. -- `includeKitchen: false` in the API client only removes the kitchen path segment; it does not disable bearer authentication. +- 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. diff --git a/package-lock.json b/package-lock.json index ac59e17..5878e7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lonc-web", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "alpinejs": "^3.14.9", "bootstrap": "^5.3.3" diff --git a/package.json b/package.json index 20563f2..7ad0a56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.1.0", + "version": "0.1.2", "private": true, "type": "module", "scripts": { diff --git a/src/api/auth.js b/src/api/auth.js index fd45a26..b912101 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -22,7 +22,6 @@ export async function login(store, credentials) { user: credentials.userLogin, application: TRYTON_APPLICATION, }, - includeKitchen: false, }); const applicationKey = extractKey(payload); @@ -66,7 +65,6 @@ export async function logout(store) { key: store.session.applicationKey, application: TRYTON_APPLICATION, }, - includeKitchen: false, skipAuthFailureHandler: true, }); } diff --git a/src/api/client.js b/src/api/client.js index 323aa2a..2e7ab66 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -20,7 +20,7 @@ function isSameOriginBaseUrl(baseUrl) { } } -function buildPathname({ database, kitchenId, path, includeKitchen = true }) { +function buildPathname({ database, path }) { const encodedDatabase = encodeURIComponent(database); const rawPath = String(path || '').replace(/^\/+/, ''); const keepTrailingSlash = rawPath.endsWith('/'); @@ -30,23 +30,17 @@ function buildPathname({ database, kitchenId, path, includeKitchen = true }) { .map((segment) => encodeURIComponent(segment)); const segments = [encodedDatabase]; - if (includeKitchen && kitchenId) { - segments.push('kitchen', encodeURIComponent(String(kitchenId))); - } - segments.push(...encodedPathSegments); const pathname = `/${segments.join('/')}`; return keepTrailingSlash ? `${pathname}/` : pathname; } -function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) { +function buildUrl({ baseUrl, database, path, query = {} }) { const cleanBaseUrl = normalizeBaseUrl(baseUrl); const pathname = buildPathname({ database, - kitchenId, path, - includeKitchen, }); const searchParams = new URLSearchParams(); @@ -191,6 +185,10 @@ function isKitchensPath(path) { return String(path || '').replace(/^\/+/, '').replace(/\/+$/, '') === API_PATHS.kitchens; } +function isKitchenApiPath(path) { + return String(path || '').replace(/^\/+/, '').startsWith('kitchen/'); +} + function shouldInvalidateValidatedSession(store, path, options = {}) { if (options.skipAuthFailureHandler) { return false; @@ -202,15 +200,16 @@ function shouldInvalidateValidatedSession(store, path, options = {}) { return ( isKitchensPath(path) || - options.includeKitchen !== false || + isKitchenApiPath(path) || path === API_PATHS.items || path === API_PATHS.locations || + path === API_PATHS.changes || String(path || '').startsWith(`${API_PATHS.items}/`) ); } export async function apiRequest(store, path, options = {}) { - const { config, session, activeKitchen } = store; + const { config, session } = store; if (!config.database) { throw new Error('Database name is required.'); @@ -220,10 +219,8 @@ export async function apiRequest(store, path, options = {}) { const url = buildUrl({ baseUrl: config.baseUrl, database: config.database, - kitchenId: activeKitchen?.id, path, query: options.query, - includeKitchen: options.includeKitchen !== false, }); const headers = new Headers(options.headers || {}); headers.set('Accept', options.accept || 'application/json'); @@ -304,9 +301,7 @@ export function buildKitchenApiUrl(store, path, query = {}) { return buildUrl({ baseUrl: store.config.baseUrl, database: store.config.database, - kitchenId: store.activeKitchen?.id, path, query, - includeKitchen: true, }); } diff --git a/src/api/kitchens.js b/src/api/kitchens.js index aba3bbf..a975fc0 100644 --- a/src/api/kitchens.js +++ b/src/api/kitchens.js @@ -1,9 +1,7 @@ import { apiRequest, getPath } from './client.js'; export async function listKitchens(store) { - const payload = await apiRequest(store, getPath('kitchens'), { - includeKitchen: false, - }); + const payload = await apiRequest(store, getPath('kitchens')); if (Array.isArray(payload)) { return payload; } diff --git a/src/api/labels.js b/src/api/labels.js index 36fc865..fab528d 100644 --- a/src/api/labels.js +++ b/src/api/labels.js @@ -42,7 +42,6 @@ export async function previewLabel(store, body) { method: 'POST', body, accept: 'image/svg+xml, image/png, application/json', - includeKitchen: false, query: { label: 1, preview: 1 }, }); diff --git a/src/api/locations.js b/src/api/locations.js index 987f4c3..02bbc46 100644 --- a/src/api/locations.js +++ b/src/api/locations.js @@ -42,9 +42,7 @@ export async function fetchLocations(store) { return cached.value; } - const payload = await apiRequest(store, getPath('locations'), { - includeKitchen: false, - }); + const payload = await apiRequest(store, getPath('locations')); const tree = Array.isArray(payload) ? payload : payload?.data || payload?.locations || []; diff --git a/src/api/stock.js b/src/api/stock.js index 4cd276f..f29e018 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -10,7 +10,6 @@ export async function searchItemDefinitions(store, query) { } const payload = await apiRequest(store, `${getPath('items')}/grouped`, { - includeKitchen: false, query: { search_name: query, expanded: 0 }, }); @@ -22,9 +21,7 @@ export async function searchItemDefinitions(store, query) { } export async function listStockEntries(store, filters = {}) { - const payload = await apiRequest(store, getPath('items'), { - includeKitchen: false, - }); + const payload = await apiRequest(store, getPath('items')); if (Array.isArray(payload)) { return payload; @@ -35,7 +32,6 @@ export async function listStockEntries(store, filters = {}) { export async function listGroupedStockEntries(store) { const payload = await apiRequest(store, `${getPath('items')}/grouped`, { - includeKitchen: false, query: { expanded: 1 }, }); @@ -47,9 +43,7 @@ export async function listGroupedStockEntries(store) { } export async function getStockEntry(store, stockId) { - const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, { - includeKitchen: false, - }); + const payload = await apiRequest(store, `${getPath('items')}/${stockId}`); return unwrapEntryPayload(payload); } @@ -57,17 +51,47 @@ export async function createStockEntry(store, body) { const payload = await apiRequest(store, getPath('items'), { method: 'POST', body, - includeKitchen: false, query: { label: 1, print: 1 }, }); return unwrapEntryPayload(payload); } +function normalizeUpsertResponse(payload) { + return { + status: payload?.status || null, + mode: payload?.mode || null, + operation: payload?.operation || null, + matchType: payload?.match_type || null, + matchedItem: payload?.matched_item || null, + item: payload?.item || null, + payload: payload?.payload || null, + }; +} + +export async function previewItemUpsert(store, body) { + const payload = await apiRequest(store, `${getPath('items')}/upsert`, { + method: 'POST', + body, + query: { mode: 'preview' }, + }); + + return normalizeUpsertResponse(payload); +} + +export async function applyItemUpsert(store, body) { + const payload = await apiRequest(store, `${getPath('items')}/upsert`, { + method: 'POST', + body, + query: { mode: 'apply' }, + }); + + return normalizeUpsertResponse(payload); +} + export async function updateStockItem(store, uuidB64, body) { const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, { method: 'POST', body, - includeKitchen: false, }); return unwrapEntryPayload(payload); } @@ -75,16 +99,50 @@ export async function updateStockItem(store, uuidB64, body) { export async function deleteStockItem(store, uuidB64) { const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, { method: 'DELETE', - includeKitchen: false, }); return unwrapEntryPayload(payload); } +export async function useStockItem(store, uuidB64) { + try { + await apiRequest(store, `${getPath('items')}/${uuidB64}/use`, { + method: 'POST', + }); + return { status: 'used' }; + } catch (error) { + const status = error?.status || error?.cause?.status; + if (status === 409) { + return { status: 'already_gone' }; + } + + if (status === 404 || status === 405) { + await deleteStockItem(store, uuidB64); + return { status: 'fallback_delete' }; + } + + throw error; + } +} + export async function adjustStockEntry(store, stockId, body) { const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, { method: 'POST', body, - includeKitchen: false, }); return unwrapEntryPayload(payload); } + +export async function listKitchenChanges(store, { since, limit = 10 } = {}) { + const payload = await apiRequest(store, getPath('changes'), { + query: { + since, + limit, + }, + }); + + return { + since: payload?.since || null, + nextCursor: payload?.next_cursor || null, + changes: Array.isArray(payload?.changes) ? payload.changes : [], + }; +} diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js index f58d23b..1d95127 100644 --- a/src/app/bootstrap.js +++ b/src/app/bootstrap.js @@ -2,7 +2,7 @@ import Alpine from 'alpinejs'; import { logout, restoreSession, verifyConnection } from '../api/auth.js'; import { listKitchens } from '../api/kitchens.js'; -import { APP_NAME } from './config.js'; +import { APP_NAME, APP_VERSION } from './config.js'; import { createRouter, navigate } from './router.js'; import { createAppStore } from './store.js'; import { appShell } from '../components/app-shell.js'; @@ -30,7 +30,11 @@ export function bootstrapApp() { registerFeatureData(Alpine, store); const appRoot = document.querySelector('#app'); - appRoot.innerHTML = appShell(APP_NAME); + appRoot.innerHTML = appShell( + APP_NAME, + APP_VERSION, + import.meta.env.DEV ? 'development' : 'production', + ); Alpine.initTree(appRoot); const navRoot = document.querySelector('#app-nav'); diff --git a/src/app/config.js b/src/app/config.js index 76e3b0a..26ecc8b 100644 --- a/src/app/config.js +++ b/src/app/config.js @@ -1,4 +1,5 @@ export const APP_NAME = 'Lonc'; +export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.2'; export const TRYTON_APPLICATION = 'kitchen'; export const CONNECTION_STATES = { @@ -24,8 +25,8 @@ export const API_PATHS = { userApplication: 'user/application/', kitchens: 'kitchen/kitchens', items: 'kitchen/items', - stockEntries: 'stock', locations: 'kitchen/locations', + changes: 'kitchen/changes', }; export const ROUTES = { diff --git a/src/components/app-shell.js b/src/components/app-shell.js index e20218c..094811e 100644 --- a/src/components/app-shell.js +++ b/src/components/app-shell.js @@ -1,12 +1,22 @@ import { navBar } from './nav-bar.js'; -export function appShell(appName) { +export function appShell(appName, appVersion, runtimeMode) { + const currentYear = new Date().getFullYear(); + return `
${navBar(appName)}
+
+
+
© ${currentYear} AKLARO
+
+ ${appName} v${appVersion} • ${runtimeMode} mode • PWA frontend for Tryton kitchen +
+
+
+ + + +
@@ -478,6 +490,10 @@ function diffDays(fromIsoDate, toIsoDate) { function createDefaultForm() { return { itemId: '', + itemUuidB64: '', + identifierCode: '', + externalSource: '', + externalId: '', search: '', name: '', description: '', @@ -505,6 +521,10 @@ function loadLabelDraft() { ? '' : draft.quantity, itemId: '', + itemUuidB64: '', + identifierCode: '', + externalSource: '', + externalId: '', search: '', }; } @@ -513,6 +533,10 @@ function buildDraftPayload(form) { return { ...form, itemId: '', + itemUuidB64: '', + identifierCode: '', + externalSource: '', + externalId: '', search: '', }; } @@ -536,6 +560,7 @@ export function labelCreatePageData(store) { successMessage: '', submitError: '', fieldErrors: {}, + upsertPreview: null, form: { ...loadLabelDraft(), }, @@ -590,6 +615,14 @@ export function labelCreatePageData(store) { } }, onSearchInput() { + this.upsertPreview = null; + if (this.form.itemUuidB64 || this.form.itemId) { + this.form.itemId = ''; + this.form.itemUuidB64 = ''; + this.form.identifierCode = ''; + this.form.externalSource = ''; + this.form.externalId = ''; + } this.persistDraft(); this.searchDebounced(); }, @@ -603,6 +636,10 @@ export function labelCreatePageData(store) { : null; this.form.itemId = item.id; + this.form.itemUuidB64 = item.uuid_b64 || ''; + this.form.identifierCode = item.identifier_code || ''; + this.form.externalSource = item.external_source || ''; + this.form.externalId = item.external_id || ''; this.form.search = item.name; this.form.name = item.name; this.form.description = item.description || this.form.description; @@ -623,7 +660,12 @@ export function labelCreatePageData(store) { }, clearItemSearch() { this.form.itemId = ''; + this.form.itemUuidB64 = ''; + this.form.identifierCode = ''; + this.form.externalSource = ''; + this.form.externalId = ''; this.form.search = ''; + this.upsertPreview = null; this.suggestions = []; this.persistDraft(); }, @@ -908,6 +950,8 @@ export function labelCreatePageData(store) { : null : Number(this.form.quantity); + const selectedLocationUuidB64 = this.selectedLocation?.uuid_b64 || null; + return { item_id: this.form.itemId || null, name: this.form.name.trim(), @@ -920,13 +964,51 @@ export function labelCreatePageData(store) { 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, + location_initial: selectedLocationUuidB64, kitchen_id: store.activeKitchen?.id || null, }; }, + buildUpsertPayload() { + const basePayload = this.buildPayload(); + const itemPayload = { + name: basePayload.name, + description: basePayload.description, + quantity_initial: basePayload.quantity_initial, + uom_symbol: basePayload.uom_symbol, + calories: basePayload.calories, + calories_unit: basePayload.calories_unit, + stock_type: basePayload.stock_type, + level: basePayload.level, + date: basePayload.date, + expire_date: basePayload.expire_date, + location_initial: basePayload.location_initial, + }; + + return { + uuid_b64: this.form.itemUuidB64 || null, + identifier_code: this.form.identifierCode || null, + external_source: this.form.externalSource || null, + external_id: this.form.externalId || null, + item: itemPayload, + }; + }, + upsertPreviewSummary() { + if (!this.upsertPreview || this.upsertPreview.error) { + return ''; + } + + if (this.upsertPreview.operation === 'update') { + const name = this.upsertPreview.matchedItem?.name || this.form.name; + const matchType = this.upsertPreview.matchType ? ` (matched by ${this.upsertPreview.matchType})` : ''; + return `Submit will update: ${name}${matchType}.`; + } + + return 'Submit will create a new stock item.'; + }, async preview() { this.submitError = ''; this.fieldErrors = {}; + this.upsertPreview = null; if (!this.validateBeforeSubmit()) { this.previewState.error = 'Please fill out the required fields before previewing the label.'; @@ -940,6 +1022,13 @@ export function labelCreatePageData(store) { URL.revokeObjectURL(this.previewUrl); } this.previewUrl = result.objectUrl; + try { + this.upsertPreview = await previewItemUpsert(store, this.buildUpsertPayload()); + } catch (error) { + this.upsertPreview = { + error: error.message || 'Upsert preview failed.', + }; + } this.persistDraft(); }); }, @@ -948,22 +1037,25 @@ export function labelCreatePageData(store) { this.fieldErrors = {}; if (!this.validateBeforeSubmit()) { - this.submitError = 'Please fill out the required fields before creating the stock entry.'; + this.submitError = 'Please fill out the required fields before saving the stock entry.'; return; } await runAsyncState(this.createState, async () => { try { - const entry = await createStockEntry(store, this.buildPayload()); + const entry = await applyItemUpsert(store, this.buildUpsertPayload()); if (this.previewUrl && this.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(this.previewUrl); } this.previewUrl = ''; - this.successMessage = `${entry.name || this.form.name} was created successfully.`; + const entryName = entry.item?.name || this.form.name; + const operationVerb = entry.operation === 'update' ? 'updated' : 'created'; + this.successMessage = `${entryName} was ${operationVerb} successfully.`; store.addAlert({ type: 'success', - message: `${entry.name || this.form.name} was created successfully.`, + message: `${entryName} was ${operationVerb} successfully.`, }); + this.upsertPreview = entry; saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form)); } catch (error) { this.fieldErrors = normalizeValidationError(error); @@ -981,6 +1073,7 @@ export function labelCreatePageData(store) { this.successMessage = ''; this.submitError = ''; this.fieldErrors = {}; + this.upsertPreview = null; saveStoredValue(STORAGE_KEYS.labelDraft, this.form); if (revokePreview && this.previewUrl.startsWith('blob:')) { URL.revokeObjectURL(this.previewUrl); diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index 457de0e..453329b 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -1,7 +1,7 @@ import { adjustStockEntry, - deleteStockItem, getStockEntry, + useStockItem, } from '../../api/stock.js'; import { getRouteContext } from '../../app/router.js'; import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; @@ -207,7 +207,7 @@ export function stockDetailPageData(store) { await runAsyncState(this.adjustmentState, async () => { if (this.adjustment.level === 'gone') { const entryName = this.entry.name; - await deleteStockItem(store, this.entry.uuid_b64); + await useStockItem(store, this.entry.uuid_b64); store.addAlert({ type: 'success', message: `${entryName} was marked gone.` }); window.__loncApp.navigate('/stock'); return; @@ -225,8 +225,14 @@ export function stockDetailPageData(store) { } await runAsyncState(this.adjustmentState, async () => { - await deleteStockItem(store, this.entry.uuid_b64); - store.addAlert({ type: 'success', message: `${this.entry.name} was marked gone.` }); + 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.`, + }); window.__loncApp.navigate('/stock'); }).catch(() => {}); }, diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index df5cd5c..bb85855 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -1,8 +1,8 @@ import { - deleteStockItem, listGroupedStockEntries, listStockEntries, updateStockItem, + useStockItem, } from '../../api/stock.js'; import { fetchLocations } from '../../api/locations.js'; import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; @@ -1204,12 +1204,12 @@ export function stockListPageData(store) { }, formatDate, async updateBinary(entry, level) { - await this.deleteEntry(entry); + await this.useEntry(entry); }, async saveLevel(entry) { const level = this.editForms[entry.id]?.level || 'plenty'; if (level === 'gone') { - await this.deleteEntry(entry); + await this.useEntry(entry); return; } @@ -1229,7 +1229,7 @@ export function stockListPageData(store) { }, { quantity }); }, async markGone(entry) { - await this.deleteEntry(entry); + await this.useEntry(entry); }, async saveEntryUpdate(entry, payload, localPatch) { this.editErrors[entry.id] = ''; @@ -1245,20 +1245,23 @@ export function stockListPageData(store) { this.editErrors[entry.id] = error.message || 'Update failed.'; } }, - async deleteEntry(entry) { + async useEntry(entry) { this.editErrors[entry.id] = ''; try { - await deleteStockItem(store, entry.uuid_b64); + const result = await useStockItem(store, entry.uuid_b64); this.entries = this.entries.filter((candidate) => candidate.id !== entry.id); delete this.editForms[entry.id]; delete this.editErrors[entry.id]; + const alreadyGone = result.status === 'already_gone'; store.addAlert({ - type: 'success', - message: `${entry.name} was marked gone and removed from the list.`, + type: alreadyGone ? 'info' : 'success', + message: alreadyGone + ? `${entry.name} was already out of stock and removed from the list.` + : `${entry.name} was marked gone and removed from the list.`, }); } catch (error) { - this.editErrors[entry.id] = error.message || 'Delete failed.'; + this.editErrors[entry.id] = error.message || 'Mark gone failed.'; } }, replaceEntry(entryId, nextEntry) { diff --git a/src/styles/app.css b/src/styles/app.css index 9179731..f5665fc 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -26,6 +26,11 @@ body { position: relative; } +.app-footer { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(4px); +} + .brand-mark { display: inline-grid; place-items: center; diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 3e0f8c6..8e85094 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -48,17 +48,15 @@ describe('api/client', () => { it('returns configured path constants', () => { expect(getPath('items')).toBe('kitchen/items'); expect(getPath('userApplication')).toBe('user/application/'); + expect(getPath('changes')).toBe('kitchen/changes'); }); - it('builds kitchen urls with encoded path segments and query values', () => { + it('builds database-scoped kitchen urls with encoded query values', () => { const store = createStore({ config: { baseUrl: 'https://api.example.com', database: 'my db', }, - activeKitchen: { - id: 'kitchen/01', - }, }); const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', { @@ -68,7 +66,7 @@ describe('api/client', () => { }); expect(url).toBe( - 'https://api.example.com/my%20db/kitchen/kitchen%2F01/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1', + 'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1', ); }); @@ -97,7 +95,7 @@ describe('api/client', () => { expect(payload).toEqual({ ok: true }); const [url, request] = fetchSpy.mock.calls[0]; - expect(url).toBe('/kitchen-db/kitchen/kitchen-1/kitchen/items?label=1'); + expect(url).toBe('/kitchen-db/kitchen/items?label=1'); expect(request.method).toBe('POST'); expect(request.body).toBe('{"name":"Rice"}'); expect(request.headers.get('Accept')).toBe('application/json'); diff --git a/tests/api/stock.test.js b/tests/api/stock.test.js new file mode 100644 index 0000000..131f559 --- /dev/null +++ b/tests/api/stock.test.js @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiRequestMock = vi.fn(); + +vi.mock('../../src/api/client.js', () => ({ + getPath(key) { + const paths = { + items: 'kitchen/items', + changes: 'kitchen/changes', + }; + return paths[key]; + }, + apiRequest: (...args) => apiRequestMock(...args), +})); + +const { + applyItemUpsert, + listKitchenChanges, + previewItemUpsert, + useStockItem, +} = await import('../../src/api/stock.js'); + +describe('api/stock', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + it('listKitchenChanges returns normalized changes payload', async () => { + apiRequestMock.mockResolvedValueOnce({ + since: 'cursor-1', + next_cursor: 'cursor-2', + changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }], + }); + + const result = await listKitchenChanges({ config: { database: 'db' } }, { limit: 10 }); + + expect(result).toEqual({ + since: 'cursor-1', + nextCursor: 'cursor-2', + changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }], + }); + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/changes', + { query: { since: undefined, limit: 10 } }, + ); + }); + + it('listKitchenChanges falls back to empty shape when changes are missing', async () => { + apiRequestMock.mockResolvedValueOnce({}); + + const result = await listKitchenChanges({ config: { database: 'db' } }, {}); + + expect(result).toEqual({ + since: null, + nextCursor: null, + changes: [], + }); + }); + + it('previewItemUpsert normalizes preview response', async () => { + apiRequestMock.mockResolvedValueOnce({ + status: 'ok', + mode: 'preview', + operation: 'update', + match_type: 'uuid_b64', + matched_item: { uuid_b64: 'abc', name: 'Rice' }, + payload: { name: 'Rice' }, + }); + + const response = await previewItemUpsert({ config: { database: 'db' } }, { item: { name: 'Rice' } }); + + expect(response).toEqual({ + status: 'ok', + mode: 'preview', + operation: 'update', + matchType: 'uuid_b64', + matchedItem: { uuid_b64: 'abc', name: 'Rice' }, + item: null, + payload: { name: 'Rice' }, + }); + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/upsert', + { + method: 'POST', + body: { item: { name: 'Rice' } }, + query: { mode: 'preview' }, + }, + ); + }); + + it('applyItemUpsert normalizes apply response', async () => { + apiRequestMock.mockResolvedValueOnce({ + status: 'ok', + mode: 'apply', + operation: 'create', + match_type: null, + item: { uuid_b64: 'new1', name: 'Beans' }, + }); + + const response = await applyItemUpsert({ config: { database: 'db' } }, { item: { name: 'Beans' } }); + + expect(response).toEqual({ + status: 'ok', + mode: 'apply', + operation: 'create', + matchType: null, + matchedItem: null, + item: { uuid_b64: 'new1', name: 'Beans' }, + payload: null, + }); + }); + + it('useStockItem returns used on 204', async () => { + apiRequestMock.mockResolvedValueOnce(null); + + const result = await useStockItem({ config: { database: 'db' } }, 'item-1'); + + expect(result).toEqual({ status: 'used' }); + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/items/item-1/use', + { method: 'POST' }, + ); + }); + + it('useStockItem returns already_gone on 409', async () => { + apiRequestMock.mockRejectedValueOnce({ status: 409, message: 'Item is out of stock.' }); + + const result = await useStockItem({ config: { database: 'db' } }, 'item-1'); + + expect(result).toEqual({ status: 'already_gone' }); + }); + + it('useStockItem falls back to delete on 404/405', async () => { + apiRequestMock + .mockRejectedValueOnce({ status: 404 }) + .mockResolvedValueOnce(null); + + const result = await useStockItem({ config: { database: 'db' } }, 'item-1'); + + expect(result).toEqual({ status: 'fallback_delete' }); + expect(apiRequestMock).toHaveBeenNthCalledWith( + 2, + { config: { database: 'db' } }, + 'kitchen/items/item-1', + { method: 'DELETE' }, + ); + }); + + it('useStockItem does not fallback on unrelated client errors', async () => { + apiRequestMock.mockRejectedValueOnce({ status: 422, message: 'validation_error' }); + + await expect(useStockItem({ config: { database: 'db' } }, 'item-1')).rejects.toMatchObject({ + status: 422, + message: 'validation_error', + }); + expect(apiRequestMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/features/dashboard/dashboard-page.test.js b/tests/features/dashboard/dashboard-page.test.js new file mode 100644 index 0000000..18c6c9a --- /dev/null +++ b/tests/features/dashboard/dashboard-page.test.js @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; + +const listKitchenChangesMock = vi.fn(); +const getStockEntryMock = vi.fn(); +const fetchLocationsMock = vi.fn(); + +vi.mock('../../../src/api/stock.js', () => ({ + listKitchenChanges: (...args) => listKitchenChangesMock(...args), + getStockEntry: (...args) => getStockEntryMock(...args), +})); + +vi.mock('../../../src/api/locations.js', () => ({ + fetchLocations: (...args) => fetchLocationsMock(...args), +})); + +const { dashboardPageData, renderDashboardPage } = await import('../../../src/features/dashboard/dashboard-page.js'); + +describe('features/dashboard/dashboard-page', () => { + it('renders dashboard with recent changes section', () => { + const html = renderDashboardPage(); + expect(html).toContain('Recent changes'); + expect(html).toContain('x-data="dashboardPage()"'); + expect(html).toContain('Saved means the backend created or updated a record.'); + }); + + it('loads recent changes on init and renders item-focused state lines', async () => { + listKitchenChangesMock.mockResolvedValueOnce({ + since: null, + nextCursor: null, + changes: [{ + type: 'item', + action: 'upsert', + timestamp: '2026-04-10T10:00:00Z', + item: { + uuid_b64: 'u1', + name: 'Rice', + stock_type: 'measured', + quantity: 3, + uom_symbol: 'kg', + level: 'good', + expire_date: '2026-04-21', + location_initial_uuid_b64: 'loc1', + }, + }], + }); + fetchLocationsMock.mockResolvedValueOnce({ + flat: [{ uuid_b64: 'loc1', pathLabel: 'Pantry / Shelf A' }], + }); + + const store = { + isConnected: true, + setActiveKitchen: vi.fn(), + addAlert: vi.fn(), + }; + const data = dashboardPageData(store); + + await data.init(); + + expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 10 }); + expect(data.recentChanges).toHaveLength(1); + expect(data.changesState.error).toBe(''); + expect(data.changeHeadline(data.recentChanges[0])).toBe('Item saved: Rice'); + expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 3 kg'); + expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Shelf A'); + expect(getStockEntryMock).not.toHaveBeenCalled(); + }); + + it('resolves stock event item context via item lookup when needed', async () => { + listKitchenChangesMock.mockResolvedValueOnce({ + since: null, + nextCursor: null, + changes: [{ + type: 'stock', + action: 'upsert', + timestamp: '2026-04-10T10:00:00Z', + stock: { + item_uuid_b64: 'item-uuid-1', + quantity: 0.5, + uom_symbol: 'kg', + level: 'some', + location_uuid_b64: 'loc2', + }, + }], + }); + getStockEntryMock.mockResolvedValueOnce({ + uuid_b64: 'item-uuid-1', + name: 'Flour', + stock_type: 'measured', + expire_date: '2026-05-02', + }); + fetchLocationsMock.mockResolvedValueOnce({ + flat: [{ uuid_b64: 'loc2', pathLabel: 'Pantry / Bin 2' }], + }); + + const data = dashboardPageData({ + isConnected: true, + setActiveKitchen: vi.fn(), + addAlert: vi.fn(), + }); + + await data.refreshChanges(); + + expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Flour'); + expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 0.5 kg'); + expect(data.changeStateLine(data.recentChanges[0])).toContain('Level: Some'); + expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Bin 2'); + expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1'); + }); + + it('keeps empty state when API returns no changes', async () => { + listKitchenChangesMock.mockResolvedValueOnce({ + since: null, + nextCursor: null, + changes: [], + }); + fetchLocationsMock.mockResolvedValueOnce({ flat: [] }); + + const data = dashboardPageData({ + isConnected: true, + setActiveKitchen: vi.fn(), + addAlert: vi.fn(), + }); + + await data.refreshChanges(); + + expect(data.recentChanges).toEqual([]); + expect(data.changesState.error).toBe(''); + }); + + it('captures refresh errors in async state', async () => { + listKitchenChangesMock.mockRejectedValueOnce(new Error('Feed unavailable')); + const data = dashboardPageData({ + isConnected: true, + setActiveKitchen: vi.fn(), + addAlert: vi.fn(), + }); + + await data.refreshChanges(); + + expect(data.changesState.error).toBe('Feed unavailable'); + expect(data.recentChanges).toEqual([]); + }); +}); diff --git a/tests/features/labels/upsert-submit.test.js b/tests/features/labels/upsert-submit.test.js new file mode 100644 index 0000000..ed6acea --- /dev/null +++ b/tests/features/labels/upsert-submit.test.js @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; + +const applyItemUpsertMock = vi.fn(); +const previewItemUpsertMock = vi.fn(); + +vi.mock('../../../src/api/stock.js', () => ({ + applyItemUpsert: (...args) => applyItemUpsertMock(...args), + previewItemUpsert: (...args) => previewItemUpsertMock(...args), + searchItemDefinitions: vi.fn(async () => []), +})); + +vi.mock('../../../src/api/labels.js', () => ({ + previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })), +})); + +vi.mock('../../../src/api/locations.js', () => ({ + fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })), +})); + +const { labelCreatePageData } = await import('../../../src/features/labels/label-create-page.js'); + +describe('label create upsert-first submit', () => { + it('builds upsert payload with selected template uuid', () => { + const store = { + isConnected: false, + activeKitchen: { id: 7 }, + addAlert: vi.fn(), + }; + const data = labelCreatePageData(store); + data.form = { + ...data.form, + itemUuidB64: 'uuid-template-1', + name: 'Beans', + description: 'Dry beans', + stockType: 'measured', + quantity: '2', + uom: 'kg', + level: '', + productionDate: '2026-04-10', + expirationDate: '2026-08-10', + locationId: '', + identifierCode: '12345', + }; + + const payload = data.buildUpsertPayload(); + + expect(payload.uuid_b64).toBe('uuid-template-1'); + expect(payload.identifier_code).toBe('12345'); + expect(payload.item.name).toBe('Beans'); + expect(payload.item.quantity_initial).toBe(2); + }); + + it('create uses applyItemUpsert and sets operation-aware success message', async () => { + applyItemUpsertMock.mockResolvedValueOnce({ + operation: 'update', + item: { name: 'Rice' }, + }); + + const addAlert = vi.fn(); + const store = { + isConnected: false, + activeKitchen: { id: 3 }, + addAlert, + }; + const data = labelCreatePageData(store); + data.validateBeforeSubmit = () => true; + data.form = { + ...data.form, + name: 'Rice', + stockType: 'binary', + locationId: '', + productionDate: '2026-04-10', + itemUuidB64: 'uuid-rice-1', + }; + + await data.create(); + + expect(applyItemUpsertMock).toHaveBeenCalledTimes(1); + expect(applyItemUpsertMock.mock.calls[0][1].uuid_b64).toBe('uuid-rice-1'); + expect(data.successMessage).toBe('Rice was updated successfully.'); + expect(addAlert).toHaveBeenCalledWith({ + type: 'success', + message: 'Rice was updated successfully.', + }); + }); +}); diff --git a/tests/features/stock/mark-gone.test.js b/tests/features/stock/mark-gone.test.js new file mode 100644 index 0000000..acd9370 --- /dev/null +++ b/tests/features/stock/mark-gone.test.js @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const useStockItemMock = vi.fn(); +const getStockEntryMock = vi.fn(); + +vi.mock('../../../src/api/stock.js', () => ({ + useStockItem: (...args) => useStockItemMock(...args), + getStockEntry: (...args) => getStockEntryMock(...args), + adjustStockEntry: vi.fn(), + listStockEntries: vi.fn(), + listGroupedStockEntries: vi.fn(), + 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'); +const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js'); + +describe('stock mark-gone behavior', () => { + beforeEach(() => { + useStockItemMock.mockReset(); + getStockEntryMock.mockReset(); + globalThis.window = { + __loncApp: { + navigate: vi.fn(), + }, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete globalThis.window; + }); + + it('stock detail markGone uses /use and shows info for already gone', async () => { + useStockItemMock.mockResolvedValueOnce({ status: 'already_gone' }); + const addAlert = vi.fn(); + const data = stockDetailPageData({ addAlert }); + data.entry = { uuid_b64: 'item-1', name: 'Rice' }; + + await data.markGone(); + + expect(useStockItemMock).toHaveBeenCalledWith({ addAlert }, 'item-1'); + expect(addAlert).toHaveBeenCalledWith({ + type: 'info', + message: 'Rice was already out of stock.', + }); + expect(globalThis.window.__loncApp.navigate).toHaveBeenCalledWith('/stock'); + }); + + it('stock list markGone removes entry and uses /use path', async () => { + useStockItemMock.mockResolvedValueOnce({ status: 'used' }); + const addAlert = vi.fn(); + const data = stockListPageData({ addAlert, isConnected: false }); + data.entries = [{ id: 1, uuid_b64: 'item-1', name: 'Flour' }]; + data.editForms = { 1: { level: 'plenty', quantity: 1 } }; + data.editErrors = {}; + + await data.markGone(data.entries[0]); + + expect(useStockItemMock).toHaveBeenCalledWith({ addAlert, isConnected: false }, 'item-1'); + expect(data.entries).toEqual([]); + expect(addAlert).toHaveBeenCalledWith({ + type: 'success', + message: 'Flour was marked gone and removed from the list.', + }); + }); +}); diff --git a/vite.config.js b/vite.config.js index 0fd0f89..19cd7b0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,10 @@ import { defineConfig } from 'vite'; +import packageJson from './package.json'; export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + }, server: { port: 4173, }, -- 2.52.0 From e1383c4d56640f3bee509f83f3b94226980e88ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Bregar?= Date: Fri, 10 Apr 2026 22:08:01 +0200 Subject: [PATCH 2/2] Add label printing functionality and error handling in stock and label flows --- AGENTS.md | 3 +- README.md | 4 +- src/api/labels.js | 54 ++++ src/features/labels/label-create-page.js | 62 ++++- src/features/stock/stock-detail-page.js | 292 +++++++++++++++++++- src/features/stock/stock-list-page.js | 48 +++- src/styles/app.css | 39 +++ tests/api/labels.test.js | 23 ++ tests/features/labels/upsert-submit.test.js | 49 +++- tests/features/stock/print-label.test.js | 74 +++++ 10 files changed, 627 insertions(+), 21 deletions(-) create mode 100644 tests/api/labels.test.js create mode 100644 tests/features/stock/print-label.test.js diff --git a/AGENTS.md b/AGENTS.md index 268a14c..7803e4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index e61e87b..437fb22 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/src/api/labels.js b/src/api/labels.js index fab528d..514635f 100644 --- a/src/api/labels.js +++ b/src/api/labels.js @@ -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; +} diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js index 4b08c7d..a7c09e6 100644 --- a/src/features/labels/label-create-page.js +++ b/src/features/labels/label-create-page.js @@ -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() {
- -
-
- - +
+ + + Print + + +
- +
* 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); diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index 453329b..a577ae7 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -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 `
@@ -42,14 +118,45 @@ export function renderStockDetailPage() {
Quantity
Location
-
+
Production date
Expiration date
+
Expiration status
+
+
+ +
+
+
Stock type
+ +
+

Nutrition

+
+
Nutri-Score
+
+
Nutriments
+
+ + +
+
+
@@ -83,12 +190,23 @@ export function renderStockDetailPage() { +
+ + +
@@ -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(); }, diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index bb85855..677091e 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -660,7 +660,7 @@ export function renderStockListPage() {
@@ -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); + }, }; } diff --git a/src/styles/app.css b/src/styles/app.css index f5665fc..28d0d38 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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; diff --git a/tests/api/labels.test.js b/tests/api/labels.test.js new file mode 100644 index 0000000..cf1e8b9 --- /dev/null +++ b/tests/api/labels.test.js @@ -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'); + }); +}); diff --git a/tests/features/labels/upsert-submit.test.js b/tests/features/labels/upsert-submit.test.js index ed6acea..5a42434 100644 --- a/tests/features/labels/upsert-submit.test.js +++ b/tests/features/labels/upsert-submit.test.js @@ -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.', + }); + }); }); diff --git a/tests/features/stock/print-label.test.js b/tests/features/stock/print-label.test.js new file mode 100644 index 0000000..8caccf7 --- /dev/null +++ b/tests/features/stock/print-label.test.js @@ -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.', + }); + }); + +}); -- 2.52.0