diff --git a/AGENTS.md b/AGENTS.md index 7803e4c..86cd02d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,17 +110,29 @@ Reason: - grouped result is a better template for “new item from existing definition” - not expanding children keeps the query lighter +### List pagination + +- `GET /{database}/kitchen/items` and `GET /{database}/kitchen/items/grouped` are paginated (`limit`/`offset`, backend default `limit=100`) +- frontend API helpers aggregate pages when no explicit `limit`/`offset` is requested + ### Grouped stock view Grouped stock view uses: -- `GET /{database}/kitchen/items/grouped?expanded=1` +- `GET /{database}/kitchen/items/grouped?expanded=0` for summary +- `GET /{database}/kitchen/items/grouped?expanded=1` for hydrated child details Important: - group-level fields are meaningful and should be used - group expiration status should follow the backend-provided “first item expires” semantics -- do not assume grouped child records are always returned unless `expanded=1` +- with `expanded=0`, grouped child `items` may be ID-only stubs (`{ id }`) +- do not assume grouped child records are fully returned unless `expanded=1` + +### Stock updates + +- `POST /{database}/kitchen/items/{uuid_b64}/stock` creates a stock event and returns `{ status, stock }` +- frontend refreshes item details with `GET /{database}/kitchen/items/{uuid_b64}` after stock updates ## UX rules that should be preserved diff --git a/README.md b/README.md index 4cd51e0..1799285 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,10 @@ Expected shapes today: - `GET /{database}/kitchen/items?search_name=...` Returns item definitions for autocomplete. - `GET /{database}/kitchen/items` - Returns the current stock review list. + 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 and hydrates item children in background. + 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/{uuid_b64}` Returns one item detail payload. - `GET /{database}/kitchen/changes` @@ -182,13 +183,12 @@ Expected shapes today: - `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 }`. + Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`. + 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. - `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. - `PATCH /{database}/kitchen/items/{uuid_b64}` Used for item-level edits from stock detail (for example identifier code updates). - `GET /{database}/kitchen/locations` diff --git a/package-lock.json b/package-lock.json index d458545..ec6e49e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lonc-web", - "version": "0.2.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lonc-web", - "version": "0.2.0", + "version": "0.2.2", "dependencies": { "@zxing/browser": "^0.1.5", "alpinejs": "^3.14.9", diff --git a/package.json b/package.json index 1f571cf..cf2ec1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lonc-web", - "version": "0.2.1", + "version": "0.2.2", "private": true, "type": "module", "scripts": { diff --git a/src/api/stock.js b/src/api/stock.js index fd8e2c7..7473ae1 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -1,9 +1,51 @@ import { apiRequest, getPath } from './client.js'; +const DEFAULT_LIST_PAGE_LIMIT = 100; + function unwrapEntryPayload(payload) { return payload?.data || payload?.entry || payload?.item || payload; } +function unwrapListPayload(payload) { + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data || payload?.entries || payload?.items || payload?.groups || []; +} + +function hasExplicitPagination(filters = {}) { + return ( + (filters.limit !== undefined && filters.limit !== null) + || (filters.offset !== undefined && filters.offset !== null) + ); +} + +async function fetchAllListPages(store, path, baseQuery = {}) { + const items = []; + let offset = 0; + + while (true) { + const payload = await apiRequest(store, path, { + 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 searchItemDefinitions(store, query) { if (query.trim().length <= 2) { return []; @@ -21,59 +63,57 @@ export async function searchItemDefinitions(store, query) { } export async function listStockEntries(store, filters = {}) { - const query = {}; + const baseQuery = {}; const searchName = filters.searchName || filters.search_name; if (searchName) { - query.search_name = searchName; - } - if (filters.limit !== undefined && filters.limit !== null) { - query.limit = filters.limit; - } - if (filters.offset !== undefined && filters.offset !== null) { - query.offset = filters.offset; - } - if (filters.cursor) { - query.cursor = filters.cursor; + baseQuery.search_name = searchName; } - const payload = await apiRequest(store, getPath('items'), { - query, - }); + 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; + } - if (Array.isArray(payload)) { - return payload; + const payload = await apiRequest(store, getPath('items'), { + query, + }); + + return unwrapListPayload(payload); } - return payload?.data || payload?.entries || payload?.items || []; + return fetchAllListPages(store, getPath('items'), baseQuery); } export async function listGroupedStockEntries(store, options = {}) { - const query = {}; + const baseQuery = {}; const expanded = options.expanded ?? 1; - query.expanded = expanded; + baseQuery.expanded = expanded; const searchName = options.searchName || options.search_name; if (searchName) { - query.search_name = searchName; - } - if (options.limit !== undefined && options.limit !== null) { - query.limit = options.limit; - } - if (options.offset !== undefined && options.offset !== null) { - query.offset = options.offset; - } - if (options.cursor) { - query.cursor = options.cursor; + baseQuery.search_name = searchName; } - const payload = await apiRequest(store, `${getPath('items')}/grouped`, { - query, - }); + if (hasExplicitPagination(options)) { + const query = { ...baseQuery }; + if (options.limit !== undefined && options.limit !== null) { + query.limit = options.limit; + } + if (options.offset !== undefined && options.offset !== null) { + query.offset = options.offset; + } - if (Array.isArray(payload)) { - return payload; + const payload = await apiRequest(store, `${getPath('items')}/grouped`, { + query, + }); + + return unwrapListPayload(payload); } - return payload?.data || payload?.entries || payload?.items || payload?.groups || []; + return fetchAllListPages(store, `${getPath('items')}/grouped`, baseQuery); } export async function getStockEntry(store, stockId) { @@ -183,11 +223,11 @@ export async function patchStockItem(store, uuidB64, body) { } export async function updateStockItem(store, uuidB64, body) { - const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, { + await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, { method: 'POST', body, }); - return unwrapEntryPayload(payload); + return getStockEntry(store, uuidB64); } export async function deleteStockItem(store, uuidB64) { @@ -205,25 +245,20 @@ export async function useStockItem(store, uuidB64) { return { status: 'used' }; } catch (error) { const status = error?.status || error?.cause?.status; - if (status === 409) { + if (status === 409 || status === 404) { 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`, { + await apiRequest(store, `${getPath('items')}/${stockId}/stock`, { method: 'POST', body, }); - return unwrapEntryPayload(payload); + return getStockEntry(store, stockId); } export async function listKitchenChanges(store, { since, limit = 10 } = {}) { diff --git a/src/app/config.js b/src/app/config.js index 7b3e317..7f19a04 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.1'; +export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.2'; export const TRYTON_APPLICATION = 'kitchen'; export const CONNECTION_STATES = { diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index 131d66a..a610561 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -257,6 +257,23 @@ function groupedFirstExpireDate(group) { return group.first_expire_date || group.expire_date; } +function isGroupedChildStub(item) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return true; + } + + return !( + 'uuid_b64' in item + || 'name' in item + || 'stock_type' in item + || 'date' in item + || 'expire_date' in item + || 'location_initial_uuid_b64' in item + || 'quantity' in item + || 'level' in item + ); +} + function shortDescription(value, maxLength = 24) { if (!value) { return 'No description'; @@ -744,9 +761,9 @@ export function renderStockListPage() {