+
+
+
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);