diff --git a/src/api/locations.js b/src/api/locations.js index a8259eb..987f4c3 100644 --- a/src/api/locations.js +++ b/src/api/locations.js @@ -1,5 +1,8 @@ import { apiRequest, getPath } from './client.js'; +const LOCATION_CACHE_TTL_MS = 5 * 60 * 1000; +const locationCache = new Map(); + function flattenNodes(nodes, trail = [], lineage = []) { return nodes.flatMap((node) => { const currentTrail = [...trail, node.name]; @@ -23,15 +26,37 @@ function flattenNodes(nodes, trail = [], lineage = []) { }); } +function getLocationCacheKey(store) { + const baseUrl = store.config.baseUrl || ''; + const database = store.config.database || ''; + const applicationKey = store.session.applicationKey || ''; + return `${baseUrl}::${database}::${applicationKey}`; +} + export async function fetchLocations(store) { + const cacheKey = getLocationCacheKey(store); + const cached = locationCache.get(cacheKey); + const now = Date.now(); + + if (cached && now - cached.cachedAt < LOCATION_CACHE_TTL_MS) { + return cached.value; + } + const payload = await apiRequest(store, getPath('locations'), { includeKitchen: false, }); const tree = Array.isArray(payload) ? payload : payload?.data || payload?.locations || []; - return { + const value = { tree, flat: flattenNodes(tree), }; + + locationCache.set(cacheKey, { + cachedAt: now, + value, + }); + + return value; } diff --git a/src/api/stock.js b/src/api/stock.js index 4767e92..df934cf 100644 --- a/src/api/stock.js +++ b/src/api/stock.js @@ -9,9 +9,9 @@ export async function searchItemDefinitions(store, query) { return []; } - const payload = await apiRequest(store, getPath('items'), { + const payload = await apiRequest(store, `${getPath('items')}/grouped`, { includeKitchen: false, - query: { search_name: query }, + query: { search_name: query, expanded: 0 }, }); if (Array.isArray(payload)) { @@ -33,6 +33,19 @@ export async function listStockEntries(store, filters = {}) { return payload?.data || payload?.entries || payload?.items || []; } +export async function listGroupedStockEntries(store) { + const payload = await apiRequest(store, `${getPath('items')}/grouped`, { + includeKitchen: false, + query: { expanded: 1 }, + }); + + if (Array.isArray(payload)) { + return payload; + } + + return payload?.data || payload?.entries || payload?.items || payload?.groups || []; +} + export async function getStockEntry(store, stockId) { const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, { includeKitchen: false, diff --git a/src/features/labels/label-create-page.js b/src/features/labels/label-create-page.js index ab1190c..f9cb2d8 100644 --- a/src/features/labels/label-create-page.js +++ b/src/features/labels/label-create-page.js @@ -551,6 +551,16 @@ export function labelCreatePageData(store) { this.syncStockLevelSelect(); }); this.$watch('form.level', () => this.syncStockLevelSelect()); + this.$watch('form.productionDate', () => { + if (this.form.expireDays !== '') { + this.syncExpireDateFromDays(); + return; + } + + if (this.form.expirationDate) { + this.syncExpireDaysFromDate(); + } + }); this.syncStockTypeState(this.form.stockType); this.syncStockTypeSelect(); this.syncStockLevelSelect(); @@ -584,6 +594,14 @@ export function labelCreatePageData(store) { this.searchDebounced(); }, pickSuggestion(item) { + const baseProductionDate = this.form.productionDate || todayIsoDate(); + const expirationDays = + typeof item.expiration_days === 'number' + ? item.expiration_days + : item.date && item.expire_date + ? diffDays(item.date, item.expire_date) + : null; + this.form.itemId = item.id; this.form.search = item.name; this.form.name = item.name; @@ -595,9 +613,11 @@ export function labelCreatePageData(store) { : this.form.quantity; this.form.stockType = item.stock_type || this.form.stockType; this.form.level = item.level || this.form.level; - this.form.expirationDate = item.expire_date || this.form.expirationDate; + if (expirationDays !== null && expirationDays >= 0) { + this.form.expireDays = String(expirationDays); + this.form.expirationDate = addDaysToIsoDate(baseProductionDate, expirationDays); + } this.applyItemLocation(item.location_initial_uuid_b64); - this.syncExpireDaysFromDate(); this.suggestions = []; this.persistDraft(); }, diff --git a/src/features/stock/stock-list-page.js b/src/features/stock/stock-list-page.js index 76f3bb4..df5cd5c 100644 --- a/src/features/stock/stock-list-page.js +++ b/src/features/stock/stock-list-page.js @@ -1,4 +1,9 @@ -import { deleteStockItem, listStockEntries, updateStockItem } from '../../api/stock.js'; +import { + deleteStockItem, + listGroupedStockEntries, + listStockEntries, + updateStockItem, +} from '../../api/stock.js'; import { fetchLocations } from '../../api/locations.js'; import { createAsyncState, runAsyncState } from '../shared/ui-state.js'; import { formatDate } from '../shared/date-utils.js'; @@ -50,7 +55,16 @@ function parseDateValue(value) { } function expirationInfo(entry) { - if (!entry.expire_date) { + const expireDateValue = + Array.isArray(entry.items) && (entry.first_expire_date || entry.first_expire_in !== undefined) + ? entry.first_expire_date || entry.expire_date + : entry.expire_date; + const expireInValue = + Array.isArray(entry.items) && entry.first_expire_in !== undefined + ? entry.first_expire_in + : entry.expire_in; + + if (!expireDateValue) { return { key: 'none', label: 'No expiration date', @@ -60,11 +74,13 @@ function expirationInfo(entry) { } const today = todayAtMidnight(); - const expireDate = parseDateValue(entry.expire_date); + const expireDate = parseDateValue(expireDateValue); const expireIn = - typeof entry.expire_in === 'number' - ? entry.expire_in - : Math.round((expireDate - today) / (24 * 60 * 60 * 1000)); + typeof expireInValue === 'number' + ? expireInValue + : expireDate + ? Math.round((expireDate - today) / (24 * 60 * 60 * 1000)) + : null; if (expireIn < 0) { return { @@ -123,6 +139,25 @@ function sortEntries(entries) { }); } +function sortGroupedEntries(groups) { + return [...groups].sort((left, right) => { + const leftExpiration = expirationInfo(left); + const rightExpiration = expirationInfo(right); + + if (leftExpiration.sortRank !== rightExpiration.sortRank) { + return leftExpiration.sortRank - rightExpiration.sortRank; + } + + const leftExpire = left.first_expire_date || left.expire_date || '9999-12-31'; + const rightExpire = right.first_expire_date || right.expire_date || '9999-12-31'; + if (leftExpire !== rightExpire) { + return leftExpire.localeCompare(rightExpire); + } + + return (left.name || '').localeCompare(right.name || ''); + }); +} + function quantityLabel(entry) { if (entry.stock_type === 'binary') { return entry.level === 'gone' ? 'Gone' : 'Available'; @@ -166,6 +201,40 @@ function searchBlob(entry, locationMap) { .toLowerCase(); } +function groupSearchBlob(group, locationMap) { + return [ + searchBlob(group, locationMap), + ...(group.items || []).map((item) => searchBlob(item, locationMap)), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); +} + +function groupedPrimaryDate(group) { + return group.date; +} + +function groupedFirstProductionDate(group) { + return group.first_production_date || group.date; +} + +function groupedFirstExpireDate(group) { + return group.first_expire_date || group.expire_date; +} + +function shortDescription(value, maxLength = 24) { + if (!value) { + return 'No description'; + } + + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, maxLength).trimEnd()}...`; +} + export function renderStockListPage() { return `
@@ -180,6 +249,40 @@ export function renderStockListPage() { New stock label +
+
+
+
+ + +
+ +
+
+
+
@@ -215,8 +318,8 @@ export function renderStockListPage() {
- - item(s) visible + +
@@ -329,7 +432,7 @@ export function renderStockListPage() {
-