diff --git a/README.md b/README.md index 9998788..5d24055 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,9 @@ Expected shapes today: 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/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/app/config.js b/src/app/config.js index 1a1dddf..174d9ff 100644 --- a/src/app/config.js +++ b/src/app/config.js @@ -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() {
Stock type
+
Main category
+
+
Categories
+
+ + +
@@ -464,6 +480,7 @@ export function stockDetailPageData(store) { entry: null, stockEvents: [], locationPathByUuid: {}, + categoriesById: {}, identifierDraft: '', scannerManualCode: '', adjustment: { @@ -484,9 +501,10 @@ export function stockDetailPageData(store) { const { params } = getRouteContext(); await runAsyncState(this.state, async () => { - const [entry, locations] = await Promise.all([ + const [entry, locations, categories] = await Promise.all([ getStockEntry(store, params.id), fetchLocations(store).catch(() => ({ flat: [] })), + listCategories(store, { expanded: true }).catch(() => []), ]); this.entry = entry; this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code); @@ -495,6 +513,11 @@ export function stockDetailPageData(store) { .filter((location) => location.uuid_b64) .map((location) => [location.uuid_b64, location.pathLabel || location.name]), ); + this.categoriesById = Object.fromEntries( + categories + .filter((category) => category?.id !== undefined && category?.id !== null) + .map((category) => [String(category.id), category]), + ); this.adjustment.level = this.entry?.level || 'plenty'; }).catch(() => {}); this.loadStockHistory().catch(() => {}); @@ -966,6 +989,35 @@ export function stockDetailPageData(store) { return this.locationPathByUuid[locationUuid] || 'Location not resolved'; }, + categoryLabel(category) { + if (!category || typeof category !== 'object') { + return ''; + } + + return String(category.path || category.name || '').trim(); + }, + mainCategoryLabel(entry) { + const categoryId = entry?.category; + if (categoryId === null || categoryId === undefined || categoryId === '') { + return ''; + } + + const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]); + if (mapped) { + return mapped; + } + + return String(categoryId).trim(); + }, + categoryLabels(entry) { + const categoryIds = Array.isArray(entry?.categories) ? entry.categories : []; + const labels = categoryIds.map((categoryId) => { + const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]); + return mapped || String(categoryId || '').trim(); + }); + + return [...new Set(labels.filter(Boolean))]; + }, nutriScoreLabel(entry) { const value = entry?.nutriscore_grade; if (!value) { diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index c83aac5..b051806 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -7,6 +7,7 @@ import { updateStockItem, } from '../../api/stock.js'; import { fetchLocations } from '../../api/locations.js'; +import { listCategories } from '../../api/categories.js'; import { STORAGE_KEYS } from '../../app/config.js'; import { clearStoredValue, loadStoredValue, saveStoredValue } from '../shared/storage.js'; import { createAsyncState } from '../shared/ui-state.js'; @@ -221,13 +222,35 @@ function resolveLocationLabel(entry, locationMap) { return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved'; } -function searchBlob(entry, locationMap) { +function categoryLabel(category) { + if (!category || typeof category !== 'object') { + return ''; + } + + return String(category.name || category.path || '').trim(); +} + +function resolveMainCategoryLabel(entry, categoriesById = {}) { + const categoryId = entry?.category; + if (categoryId === null || categoryId === undefined || categoryId === '') { + return ''; + } + + const mapped = categoryLabel(categoriesById[String(categoryId)]); + if (mapped) { + return mapped; + } + return String(categoryId).trim(); +} + +function searchBlob(entry, locationMap, categoriesById = {}) { return [ entry.name, entry.description, entry.level, entry.stock_type, resolveLocationLabel(entry, locationMap), + resolveMainCategoryLabel(entry, categoriesById), entry.uuid_b64, ] .filter(Boolean) @@ -235,10 +258,10 @@ function searchBlob(entry, locationMap) { .toLowerCase(); } -function groupSearchBlob(group, locationMap) { +function groupSearchBlob(group, locationMap, categoriesById = {}) { return [ - searchBlob(group, locationMap), - ...(group.items || []).map((item) => searchBlob(item, locationMap)), + searchBlob(group, locationMap, categoriesById), + ...(group.items || []).map((item) => searchBlob(item, locationMap, categoriesById)), ] .filter(Boolean) .join(' ') @@ -544,6 +567,9 @@ export function renderStockListPage() {
+
+ +
+
+ +
+
+ +
item(s) Latest location: - Quantity: + + + +
@@ -781,6 +816,12 @@ export function renderStockListPage() { +
@@ -876,6 +917,7 @@ export function stockListPageData(store) { locationMap: {}, locationDescendants: {}, locationLineage: {}, + categoriesById: {}, editForms: {}, editErrors: {}, levelOptions: LEVEL_OPTIONS, @@ -918,7 +960,7 @@ export function stockListPageData(store) { : false; if (!restoredFromRuntime) { - const initTasks = [this.loadLocations()]; + const initTasks = [this.loadLocations(), this.loadCategories()]; if (this.viewMode === 'items') { initTasks.push(this.loadEntries()); } else { @@ -937,6 +979,9 @@ export function stockListPageData(store) { if (!restoredFromRuntime && this.viewMode === 'grouped') { this.hydrateGroupedEntriesInBackground().catch(() => {}); } + if (restoredFromRuntime) { + this.refreshLoadedViewsInBackground().catch(() => {}); + } if (restoredFromRuntime && restoredContext?.focusedItemUuid) { this.refreshFocusedItemInBackground(restoredContext.focusedItemUuid).catch(() => {}); } @@ -1047,6 +1092,7 @@ export function stockListPageData(store) { locationMap: this.locationMap, locationDescendants: this.locationDescendants, locationLineage: this.locationLineage, + categoriesById: this.categoriesById, changeCursor: this.changeCursor, }), }; @@ -1106,6 +1152,9 @@ export function stockListPageData(store) { this.locationLineage = payload.locationLineage && typeof payload.locationLineage === 'object' ? payload.locationLineage : {}; + this.categoriesById = payload.categoriesById && typeof payload.categoriesById === 'object' + ? payload.categoriesById + : {}; this.changeCursor = payload.changeCursor || this.changeCursor; if (this.itemsLoaded) { @@ -1267,7 +1316,7 @@ export function stockListPageData(store) { }, indexEntry(entry) { const indexed = { ...entry }; - indexed._searchBlob = searchBlob(indexed, this.locationMap); + indexed._searchBlob = searchBlob(indexed, this.locationMap, this.categoriesById); return indexed; }, indexGroup(group) { @@ -1278,7 +1327,7 @@ export function stockListPageData(store) { ...group, items: indexedItems, }; - indexed._searchBlob = groupSearchBlob(indexed, this.locationMap); + indexed._searchBlob = groupSearchBlob(indexed, this.locationMap, this.categoriesById); return indexed; }, reindexSearchData() { @@ -1513,6 +1562,25 @@ export function stockListPageData(store) { this.persistRuntimeCache(); } }, + async loadCategories() { + if (!store.isConnected) { + return; + } + + try { + const categories = await listCategories(store, { expanded: true }); + this.categoriesById = Object.fromEntries( + categories + .filter((category) => category?.id !== undefined && category?.id !== null) + .map((category) => [String(category.id), category]), + ); + } catch { + this.categoriesById = {}; + } finally { + this.reindexSearchData(); + this.persistRuntimeCache(); + } + }, resetGroupedVisibleLimit() { this.groupedVisibleLimit = this.groupedPageSize; }, @@ -1526,6 +1594,28 @@ export function stockListPageData(store) { return group.items.filter((item) => !isGroupedChildStub(item)); }, + mainCategoryLabel(entry) { + return resolveMainCategoryLabel(entry, this.categoriesById); + }, + mainCategoryBadgeLabel(entry) { + return `Main category: ${this.mainCategoryLabel(entry)}`; + }, + groupSummaryMetricLabel(group) { + if (group?.stock_type === 'measured') { + return 'Quantity'; + } + if (group?.stock_type === 'descriptive') { + return 'Stock level'; + } + if (group?.stock_type === 'binary') { + return 'Stock state'; + } + return 'Stock'; + }, + groupSummaryMetricValue(group) { + // Use backend-provided grouped fields as the single source of truth. + return quantityLabel(group); + }, hasGroupedChildStubs(group) { if (!Array.isArray(group?.items)) { return false; diff --git a/tests/api/categories.test.js b/tests/api/categories.test.js new file mode 100644 index 0000000..b3f46c5 --- /dev/null +++ b/tests/api/categories.test.js @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiRequestMock = vi.fn(); + +vi.mock('../../src/api/client.js', () => ({ + getPath(key) { + const paths = { + categories: 'kitchen/categories', + }; + return paths[key]; + }, + apiRequest: (...args) => apiRequestMock(...args), +})); + +const { listCategories } = await import('../../src/api/categories.js'); + +describe('api/categories', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + it('forwards explicit pagination and filters', async () => { + apiRequestMock.mockResolvedValueOnce([]); + + await listCategories( + { config: { database: 'db' } }, + { searchName: 'dairy', active: true, limit: 10, offset: 20, orderBy: 'name', orderDir: 'asc' }, + ); + + expect(apiRequestMock).toHaveBeenCalledWith( + { config: { database: 'db' } }, + 'kitchen/categories', + { + query: { + search_name: 'dairy', + active: true, + order_by: 'name', + order_dir: 'asc', + limit: 10, + offset: 20, + }, + }, + ); + }); + + it('aggregates category pages by default', async () => { + apiRequestMock + .mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 }))) + .mockResolvedValueOnce([{ id: 101 }]); + + const response = await listCategories({ config: { database: 'db' } }, {}); + + expect(response).toHaveLength(101); + expect(apiRequestMock).toHaveBeenNthCalledWith( + 1, + { config: { database: 'db' } }, + 'kitchen/categories', + { query: { limit: 100, offset: 0 } }, + ); + expect(apiRequestMock).toHaveBeenNthCalledWith( + 2, + { config: { database: 'db' } }, + 'kitchen/categories', + { query: { limit: 100, offset: 100 } }, + ); + }); +}); diff --git a/tests/features/stock/stock-list-page-grouped.test.js b/tests/features/stock/stock-list-page-grouped.test.js index c13a9d4..cb0fa3e 100644 --- a/tests/features/stock/stock-list-page-grouped.test.js +++ b/tests/features/stock/stock-list-page-grouped.test.js @@ -7,6 +7,7 @@ const getStockEntryMock = vi.fn(); const updateStockItemMock = vi.fn(); const useStockItemMock = vi.fn(); const fetchLocationsMock = vi.fn(); +const listCategoriesMock = vi.fn(); vi.mock('../../../src/api/stock.js', () => ({ listStockEntries: (...args) => listStockEntriesMock(...args), @@ -21,6 +22,10 @@ vi.mock('../../../src/api/locations.js', () => ({ fetchLocations: (...args) => fetchLocationsMock(...args), })); +vi.mock('../../../src/api/categories.js', () => ({ + listCategories: (...args) => listCategoriesMock(...args), +})); + const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js'); function createGroupedSummary() { @@ -112,6 +117,8 @@ describe('stock list grouped-first behavior', () => { updateStockItemMock.mockReset(); useStockItemMock.mockReset(); fetchLocationsMock.mockReset(); + listCategoriesMock.mockReset(); + listCategoriesMock.mockResolvedValue([]); globalThis.window = createWindowMock(); globalThis.requestAnimationFrame = (callback) => callback(); @@ -133,6 +140,7 @@ describe('stock list grouped-first behavior', () => { listStockEntriesMock.mockResolvedValueOnce([]); listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] }); fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] }); + listCategoriesMock.mockResolvedValue([]); const store = { isConnected: true, addAlert: vi.fn() }; const data = stockListPageData(store); @@ -357,8 +365,10 @@ describe('stock list grouped-first behavior', () => { await Promise.resolve(); await Promise.resolve(); - expect(listGroupedStockEntriesMock).not.toHaveBeenCalled(); - expect(listStockEntriesMock).not.toHaveBeenCalled(); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false }); + expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true }); + expect(listStockEntriesMock).toHaveBeenCalledTimes(1); + expect(listStockEntriesMock).toHaveBeenCalledWith(store); expect(fetchLocationsMock).not.toHaveBeenCalled(); expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-100'); expect(returnVisit.entries[0].quantity).toBe(2);