diff --git a/README.md b/README.md index 1799285..5d24055 100644 --- a/README.md +++ b/README.md @@ -163,36 +163,42 @@ Expected shapes today: Returns `{ data: [...] }` or `{ kitchens: [...] }`. - `GET /{database}/kitchen/items?search_name=...` Returns item definitions for autocomplete. + Item payloads now expose category links via `categories` (array of IDs). - `GET /{database}/kitchen/items` Returns the current stock review list. Endpoint is paginated (`limit`/`offset`, backend default `limit=100`); frontend helpers aggregate pages by default unless explicit pagination is passed. -- `GET /{database}/kitchen/items/grouped?expanded=0|1` - Returns grouped stock data; grouped review uses summary-first loading (`expanded=0`) and hydrates item children in background (`expanded=1`). - With `expanded=0`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads. +- `GET /{database}/kitchen/items/grouped?expanded=true|false` + Returns grouped stock data; grouped review uses summary-first loading (`expanded=false`) and hydrates item children in background (`expanded=true`). + With `expanded=false`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads. - `GET /{database}/kitchen/items/{uuid_b64}` Returns one item detail payload. + Supports `allow_inactive=true|false` query filtering when needed. - `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/lookup` Identifier lookup response includes source/freshness metadata (`source`, `cache_hit`, `stale_cache`, `payload_fetched_at`, `retry_after_seconds`) used for richer user feedback. -- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=0|1` - Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=0`) or apply missing fields (`update=1`). -- `POST /{database}/kitchen/items?label=1` - Used for label image preview rendering. -- `POST /{database}/kitchen/items?label=1&preview=1` +- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=true|false` + Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=false`) or apply missing fields (`update=true`). +- `POST /{database}/kitchen/items?label=true&preview=true` Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview. +- `GET /{database}/kitchen/items/{uuid_b64}/label` + Returns rendered label PNG for an existing item. - `POST /{database}/kitchen/items/{uuid_b64}/stock` - Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`. + Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`, + and for non-consumed gone transitions (for example `{ level: "gone", gone_reason: "spoiled" }`). Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update. - `POST /{database}/kitchen/items/{uuid_b64}/use` - Marks an item used up (`gone`) via stock-event semantics. + Marks an item consumed/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. - `PATCH /{database}/kitchen/items/{uuid_b64}` Used for item-level edits from stock detail (for example identifier code updates). - `GET /{database}/kitchen/locations` Returns a nested location tree. +- `GET /{database}/kitchen/categories` + Returns categories (paged). Frontend now resolves category labels from + `categories_detail` when present, and falls back to this endpoint by ID. ## Notes diff --git a/package-lock.json b/package-lock.json index d8b10e9..3fa5c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lonc-web", - "version": "0.2.4", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.2.4", + "version": "0.2.6", "dependencies": { "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", diff --git a/package.json b/package.json index 1100bd1..ee0424d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.2.5", + "version": "0.2.6", "private": true, "type": "module", "scripts": { diff --git a/src/api/categories.js b/src/api/categories.js new file mode 100644 index 0000000..65e91cf --- /dev/null +++ b/src/api/categories.js @@ -0,0 +1,90 @@ +import { apiRequest, getPath } from './client.js'; + +const DEFAULT_LIST_PAGE_LIMIT = 100; + +function unwrapListPayload(payload) { + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data || payload?.entries || payload?.items || payload?.categories || []; +} + +function hasExplicitPagination(filters = {}) { + return ( + (filters.limit !== undefined && filters.limit !== null) + || (filters.offset !== undefined && filters.offset !== null) + ); +} + +function buildCategoryQuery(filters = {}) { + const query = {}; + const searchName = filters.searchName || filters.search_name; + if (searchName) { + query.search_name = searchName; + } + + if (filters.active !== undefined && filters.active !== null && filters.active !== '') { + query.active = Boolean(filters.active); + } + + if (filters.orderBy || filters.order_by) { + query.order_by = filters.orderBy || filters.order_by; + } + + if (filters.orderDir || filters.order_dir) { + query.order_dir = filters.orderDir || filters.order_dir; + } + + if (filters.expanded !== undefined && filters.expanded !== null && filters.expanded !== '') { + query.expanded = Boolean(filters.expanded); + } + + return query; +} + +async function fetchAllCategoryPages(store, baseQuery = {}) { + const items = []; + let offset = 0; + + while (true) { + const payload = await apiRequest(store, getPath('categories'), { + query: { + ...baseQuery, + limit: DEFAULT_LIST_PAGE_LIMIT, + offset, + }, + }); + const pageItems = unwrapListPayload(payload); + items.push(...pageItems); + + if (pageItems.length < DEFAULT_LIST_PAGE_LIMIT) { + break; + } + + offset += DEFAULT_LIST_PAGE_LIMIT; + } + + return items; +} + +export async function listCategories(store, filters = {}) { + const baseQuery = buildCategoryQuery(filters); + + if (hasExplicitPagination(filters)) { + const query = { ...baseQuery }; + if (filters.limit !== undefined && filters.limit !== null) { + query.limit = filters.limit; + } + if (filters.offset !== undefined && filters.offset !== null) { + query.offset = filters.offset; + } + + const payload = await apiRequest(store, getPath('categories'), { + query, + }); + return unwrapListPayload(payload); + } + + return fetchAllCategoryPages(store, baseQuery); +} diff --git a/src/api/labels.js b/src/api/labels.js index 514635f..5ac4f31 100644 --- a/src/api/labels.js +++ b/src/api/labels.js @@ -42,7 +42,7 @@ export async function previewLabel(store, body) { method: 'POST', body, accept: 'image/svg+xml, image/png, application/json', - query: { label: 1, preview: 1 }, + query: { label: true, preview: true }, }); const image = normalizeLabelImagePayload(payload); @@ -53,6 +53,19 @@ export async function previewLabel(store, body) { throw new Error('Label preview response did not include an image.'); } +export async function getItemLabel(store, uuidB64) { + const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/label`, { + method: 'GET', + accept: 'image/png, application/json', + }); + const image = normalizeLabelImagePayload(payload); + if (image) { + return image; + } + + throw new Error('Item label response did not include an image.'); +} + export async function printItemLabel(store, uuidB64) { return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, { method: 'POST', diff --git a/src/api/stock.js b/src/api/stock.js index 7f39dae..8fcc69a 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -2,6 +2,35 @@ import { apiRequest, getPath } from './client.js'; const DEFAULT_LIST_PAGE_LIMIT = 100; +function toBooleanFlag(value, defaultValue = false) { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return value !== 0; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return defaultValue; + } + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + } + + return Boolean(value); +} + function unwrapEntryPayload(payload) { return payload?.data || payload?.entry || payload?.item || payload; } @@ -52,7 +81,7 @@ export async function searchItemDefinitions(store, query) { } const payload = await apiRequest(store, `${getPath('items')}/grouped`, { - query: { search_name: query, expanded: 0 }, + query: { search_name: query, expanded: false }, }); if (Array.isArray(payload)) { @@ -90,7 +119,7 @@ export async function listStockEntries(store, filters = {}) { export async function listGroupedStockEntries(store, options = {}) { const baseQuery = {}; - const expanded = options.expanded ?? 1; + const expanded = toBooleanFlag(options.expanded, true); baseQuery.expanded = expanded; const searchName = options.searchName || options.search_name; if (searchName) { @@ -119,7 +148,7 @@ export async function listGroupedStockEntries(store, options = {}) { export async function getStockEntry(store, stockId, { allowInactive = false } = {}) { const path = `${getPath('items')}/${stockId}`; const payload = allowInactive - ? await apiRequest(store, path, { query: { allow_inactive: 1 } }) + ? await apiRequest(store, path, { query: { allow_inactive: true } }) : await apiRequest(store, path); return unwrapEntryPayload(payload); } @@ -128,7 +157,7 @@ export async function createStockEntry(store, body) { const payload = await apiRequest(store, getPath('items'), { method: 'POST', body, - query: { label: 1, print: 1 }, + query: { label: true, print: true }, }); return unwrapEntryPayload(payload); } @@ -211,7 +240,7 @@ export async function lookupItemByIdentifier(store, identifierCode) { export async function lookupItemDetails(store, uuidB64, { update = false } = {}) { const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, { method: 'POST', - query: { update: update ? 1 : 0 }, + query: { update: toBooleanFlag(update, false) }, }); return normalizeItemLookupResponse(payload); @@ -246,7 +275,7 @@ export async function createStockEvent(store, uuidB64, body) { export async function listStockEvents(store, uuidB64, options = {}) { const query = {}; if (options.allowInactive) { - query.allow_inactive = 1; + query.allow_inactive = true; } if (options.limit !== undefined && options.limit !== null) { query.limit = options.limit; @@ -269,6 +298,14 @@ export async function listStockEvents(store, uuidB64, options = {}) { export async function markStockGone(store, uuidB64, reason = 'consumed') { try { + if (reason === 'consumed') { + const result = await useStockItem(store, uuidB64); + if (result.status === 'already_gone') { + return { status: 'already_gone', reason }; + } + return { status: 'gone', reason }; + } + await createStockEvent(store, uuidB64, { level: 'gone', gone_reason: reason, diff --git a/src/app/config.js b/src/app/config.js index 94008eb..174d9ff 100644 --- a/src/app/config.js +++ b/src/app/config.js @@ -1,5 +1,5 @@ export const APP_NAME = 'Lonc'; -export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.5'; +export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.6'; export const TRYTON_APPLICATION = 'kitchen'; export const CONNECTION_STATES = { @@ -27,6 +27,7 @@ export const API_PATHS = { kitchens: 'kitchen/kitchens', items: 'kitchen/items', locations: 'kitchen/locations', + categories: 'kitchen/categories', changes: 'kitchen/changes', }; diff --git a/src/features/stock/stock-detail-page.js b/src/features/stock/stock-detail-page.js index 356ead2..92bf648 100644 --- a/src/features/stock/stock-detail-page.js +++ b/src/features/stock/stock-detail-page.js @@ -8,6 +8,7 @@ import { } from '../../api/stock.js'; import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js'; import { fetchLocations } from '../../api/locations.js'; +import { listCategories } from '../../api/categories.js'; import { getRouteContext } from '../../app/router.js'; import { renderScannerModal } from '../shared/scanner-modal.js'; import { @@ -145,6 +146,21 @@ export function renderStockDetailPage() {