import { listGroupedStockEntries, listKitchenChanges, listStockEntries, updateStockItem, useStockItem, } from '../../api/stock.js'; import { fetchLocations } from '../../api/locations.js'; import { createAsyncState } from '../shared/ui-state.js'; import { formatDate } from '../shared/date-utils.js'; const LEVEL_LABELS = { plenty: 'Plenty', good: 'Good', some: 'Some', low: 'Low', trace: 'Trace', gone: 'Gone', }; const LEVEL_OPTIONS = [ { value: 'plenty', label: 'Plenty' }, { value: 'good', label: 'Good' }, { value: 'some', label: 'Some' }, { value: 'low', label: 'Low' }, { value: 'trace', label: 'Trace' }, { value: 'gone', label: 'Gone' }, ]; const EXPIRATION_LEGEND = [ { key: 'expired', label: 'Expired', description: 'The expiration date has already passed.' }, { key: 'use-first', label: 'Use first', description: 'Still within date, but should be prioritized soonest for consumption.' }, { key: 'upcoming', label: 'Upcoming expiration', description: 'Within date, but approaching expiration in the near term.' }, { key: 'within-date', label: 'Within date', description: 'Still within the expected shelf-life window.' }, { key: 'none', label: 'No expiration', description: 'No expiration date is assigned.' }, ]; const EXPIRATION_KEYS = EXPIRATION_LEGEND.map((state) => state.key); const GROUPED_PAGE_SIZE = 24; const CHANGE_POLL_INTERVAL_MS = 60 * 1000; function todayAtMidnight() { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()); } function parseDateValue(value) { if (!value) { return null; } const [year, month, day] = value.split('-').map(Number); if (!year || !month || !day) { return null; } return new Date(year, month - 1, day); } function expirationInfo(entry) { 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', detail: 'No expiration date', sortRank: 4, }; } const today = todayAtMidnight(); const expireDate = parseDateValue(expireDateValue); const expireIn = typeof expireInValue === 'number' ? expireInValue : expireDate ? Math.round((expireDate - today) / (24 * 60 * 60 * 1000)) : null; if (expireIn < 0) { return { key: 'expired', label: 'Expired', detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`, sortRank: 0, }; } if (expireIn <= 2) { return { key: 'use-first', label: expireIn === 0 ? 'Use today' : 'Use first', detail: expireIn === 0 ? 'Expires today' : `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`, sortRank: 1, }; } if (expireIn <= 7) { return { key: 'upcoming', label: 'Upcoming expiration', detail: `Expires in ${expireIn} days`, sortRank: 2, }; } return { key: 'within-date', label: 'Within date', detail: `Expires in ${expireIn} days`, sortRank: 3, }; } function sortEntries(entries) { return [...entries].sort((left, right) => { const leftExpiration = expirationInfo(left); const rightExpiration = expirationInfo(right); if (leftExpiration.sortRank !== rightExpiration.sortRank) { return leftExpiration.sortRank - rightExpiration.sortRank; } const leftExpire = left.expire_date || '9999-12-31'; const rightExpire = right.expire_date || '9999-12-31'; if (leftExpire !== rightExpire) { return leftExpire.localeCompare(rightExpire); } return (left.name || '').localeCompare(right.name || ''); }); } 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'; } const numeric = entry.quantity ?? null; const uom = entry.uom_symbol || ''; const measured = numeric !== null && numeric !== undefined ? `${numeric} ${uom}`.trim() : ''; const level = entry.level ? LEVEL_LABELS[entry.level] || entry.level : ''; if (entry.stock_type === 'descriptive') { return level || 'No stock level'; } if (measured && level) { return `${measured} • ${level}`; } return measured || level || 'No quantity'; } function resolveLocationLabel(entry, locationMap) { if (!entry.location_initial_uuid_b64) { return 'No location assigned'; } return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved'; } function searchBlob(entry, locationMap) { return [ entry.name, entry.description, entry.level, entry.stock_type, resolveLocationLabel(entry, locationMap), entry.uuid_b64, ] .filter(Boolean) .join(' ') .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 `

Stock Review

Review stock and act quickly

Focus on expiration, stock state, and location without leaving the overview.

New stock label
Stock view mode
Updating in background...
`; } export function stockListPageData(store) { return { state: { ...createAsyncState(), isRefreshing: false, }, viewMode: 'grouped', entries: [], entriesVersion: 0, itemsLoaded: false, groupedEntries: [], groupedVersion: 0, groupedLoaded: false, groupedHydrated: false, groupedHydrating: false, groupedPageSize: GROUPED_PAGE_SIZE, groupedVisibleLimit: GROUPED_PAGE_SIZE, openGroupedCards: {}, refreshActivityCount: 0, locations: [], locationsVersion: 0, locationMap: {}, locationDescendants: {}, locationLineage: {}, editForms: {}, editErrors: {}, levelOptions: LEVEL_OPTIONS, expirationLegend: EXPIRATION_LEGEND, overviewOpen: { expiration: false, location: false, }, filters: { search: '', expiration: [], location: [], }, memo: { filteredEntriesSig: '', filteredEntries: [], filteredGroupedEntriesSig: '', filteredGroupedEntries: [], expirationCountsSig: '', expirationCounts: {}, locationCountsSig: '', locationCounts: {}, }, changeCursor: null, changePollTimer: null, routeChangeHandler: null, isPollingChanges: false, async init() { if (!store.isConnected) { return; } this.registerRouteCleanup(); await Promise.all([ this.loadLocations(), this.loadGroupedEntries({ expanded: 0, resetVisible: true }), ]); await this.$nextTick(); this.hydrateGroupedEntriesInBackground().catch(() => {}); await this.primeChangeCursor(); this.startChangePolling(); }, registerRouteCleanup() { if (this.routeChangeHandler) { return; } this.routeChangeHandler = () => { if (!this.isStockListRoute()) { this.stopChangePolling(); window.removeEventListener('hashchange', this.routeChangeHandler); this.routeChangeHandler = null; } }; window.addEventListener('hashchange', this.routeChangeHandler); }, isStockListRoute() { const route = (window.location.hash || '#/').replace(/^#/, '') || '/'; return route === '/stock'; }, async primeChangeCursor() { try { const payload = await listKitchenChanges(store, { limit: 1 }); this.changeCursor = payload.nextCursor || payload.since || this.changeCursor; } catch { this.changeCursor = this.changeCursor || null; } }, startChangePolling() { if (this.changePollTimer) { return; } this.changePollTimer = window.setInterval(() => { this.pollKitchenChanges().catch(() => {}); }, CHANGE_POLL_INTERVAL_MS); }, stopChangePolling() { if (this.changePollTimer) { window.clearInterval(this.changePollTimer); this.changePollTimer = null; } }, async pollKitchenChanges() { if (this.isPollingChanges || !store.isConnected || !this.isStockListRoute()) { return; } this.isPollingChanges = true; try { const payload = await listKitchenChanges(store, { since: this.changeCursor || undefined, limit: 25, }); this.changeCursor = payload.nextCursor || payload.since || this.changeCursor; if (!payload.changes.length) { return; } await this.refreshCurrentView({ background: true }); } finally { this.isPollingChanges = false; } }, showInitialLoader() { if (!this.state.isLoading) { return false; } return this.viewMode === 'grouped' ? !this.groupedLoaded : !this.itemsLoaded; }, beginBackgroundRefresh() { this.refreshActivityCount += 1; this.state.isRefreshing = true; }, endBackgroundRefresh() { this.refreshActivityCount = Math.max(0, this.refreshActivityCount - 1); this.state.isRefreshing = this.refreshActivityCount > 0; }, invalidateMemo() { this.memo.filteredEntriesSig = ''; this.memo.filteredGroupedEntriesSig = ''; this.memo.expirationCountsSig = ''; this.memo.locationCountsSig = ''; }, indexEntry(entry) { const indexed = { ...entry }; indexed._searchBlob = searchBlob(indexed, this.locationMap); return indexed; }, indexGroup(group) { const indexedItems = Array.isArray(group.items) ? group.items.map((item) => this.indexEntry(item)) : []; const indexed = { ...group, items: indexedItems, }; indexed._searchBlob = groupSearchBlob(indexed, this.locationMap); return indexed; }, reindexSearchData() { if (this.entries.length) { this.entries = this.entries.map((entry) => this.indexEntry(entry)); this.entriesVersion += 1; } if (this.groupedEntries.length) { this.groupedEntries = this.groupedEntries.map((group) => this.indexGroup(group)); this.groupedVersion += 1; } this.invalidateMemo(); }, syncEditFormsFromEntries() { this.editForms = Object.fromEntries( this.entries.map((entry) => [ entry.id, { level: entry.level || 'plenty', quantity: entry.quantity ?? '', }, ]), ); this.editErrors = {}; }, async switchView(mode) { this.viewMode = mode; if (mode === 'grouped') { if (!this.groupedLoaded) { await this.loadGroupedEntries({ expanded: 0, resetVisible: true }); } this.hydrateGroupedEntriesInBackground().catch(() => {}); return; } if (!this.itemsLoaded) { await this.loadEntries(); } }, async refreshCurrentView({ background = false } = {}) { const useBackground = background || (this.viewMode === 'grouped' ? this.groupedLoaded : this.itemsLoaded); if (this.viewMode === 'grouped') { await this.loadGroupedEntries({ expanded: 0, background: useBackground }); this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {}); return; } await this.loadEntries({ background: useBackground }); }, async loadEntries({ background = false } = {}) { if (!store.isConnected) { return; } const shouldBlock = !background && !this.itemsLoaded; if (background) { this.beginBackgroundRefresh(); } else if (shouldBlock) { this.state.isLoading = true; this.state.error = ''; } try { const loadedEntries = await listStockEntries(store); this.entries = sortEntries(loadedEntries.map((entry) => this.indexEntry(entry))); this.itemsLoaded = true; this.entriesVersion += 1; this.syncEditFormsFromEntries(); this.invalidateMemo(); } catch (error) { if (!background) { this.state.error = error.message || 'Could not load stock items.'; } } finally { if (background) { this.endBackgroundRefresh(); } else if (shouldBlock) { this.state.isLoading = false; } } }, applyGroupedSummary(loadedGroups, { resetVisible = false } = {}) { const existingById = new Map(this.groupedEntries.map((group) => [group.id, group])); const nextGroups = loadedGroups.map((group) => { const existing = existingById.get(group.id); const preservedItems = Array.isArray(group.items) ? group.items : existing?.items || []; return this.indexGroup({ ...existing, ...group, items: preservedItems, }); }); this.groupedEntries = sortGroupedEntries(nextGroups); this.groupedLoaded = true; this.groupedHydrated = false; this.groupedVersion += 1; this.pruneOpenGroupedCards(); this.invalidateMemo(); if (resetVisible) { this.resetGroupedVisibleLimit(); } }, applyGroupedHydration(loadedGroups) { const existingById = new Map(this.groupedEntries.map((group) => [group.id, group])); const nextGroups = loadedGroups.map((group) => { const existing = existingById.get(group.id); const mergedItems = Array.isArray(group.items) ? group.items : existing?.items || []; return this.indexGroup({ ...existing, ...group, items: mergedItems, }); }); this.groupedEntries = sortGroupedEntries(nextGroups); this.groupedLoaded = true; this.groupedHydrated = true; this.groupedVersion += 1; this.pruneOpenGroupedCards(); this.invalidateMemo(); }, async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) { if (!store.isConnected) { return; } const shouldBlock = !background && !this.groupedLoaded; if (background) { this.beginBackgroundRefresh(); } else if (shouldBlock) { this.state.isLoading = true; this.state.error = ''; } try { const loadedGroups = await listGroupedStockEntries(store, { expanded }); if (expanded === 0) { this.applyGroupedSummary(loadedGroups, { resetVisible }); return; } this.applyGroupedHydration(loadedGroups); } catch (error) { if (!background) { this.state.error = error.message || 'Could not load grouped stock.'; } } finally { if (background) { this.endBackgroundRefresh(); } else if (shouldBlock) { this.state.isLoading = false; } } }, async hydrateGroupedEntriesInBackground({ force = false } = {}) { if (!this.groupedLoaded || this.groupedHydrating) { return; } if (this.groupedHydrated && !force) { return; } this.groupedHydrating = true; try { await this.loadGroupedEntries({ expanded: 1, background: true }); } finally { this.groupedHydrating = false; } }, async refreshLoadedViewsInBackground() { const tasks = []; if (this.itemsLoaded) { tasks.push(this.loadEntries({ background: true })); } if (this.groupedLoaded) { tasks.push( this.loadGroupedEntries({ expanded: 0, background: true }).then(() => this.hydrateGroupedEntriesInBackground({ force: true }), ), ); } await Promise.allSettled(tasks); }, async loadLocations() { if (!store.isConnected) { return; } try { const { flat } = await fetchLocations(store); this.locations = flat; this.locationMap = Object.fromEntries( flat.map((location) => [location.uuid_b64, location.pathLabel]), ); this.locationLineage = Object.fromEntries( flat.map((location) => [location.uuid_b64, location.lineage_uuid_b64 || []]), ); this.locationDescendants = Object.fromEntries( flat.map((location) => [ location.uuid_b64, flat .filter((candidate) => candidate.lineage_uuid_b64.includes(location.uuid_b64)) .map((candidate) => candidate.uuid_b64), ]), ); } catch { this.locations = []; this.locationMap = {}; this.locationLineage = {}; this.locationDescendants = {}; } finally { this.locationsVersion += 1; this.reindexSearchData(); } }, resetGroupedVisibleLimit() { this.groupedVisibleLimit = this.groupedPageSize; }, showMoreGroups() { this.groupedVisibleLimit += this.groupedPageSize; }, groupItemCount(group) { if (Number.isFinite(group.items_count)) { return group.items_count; } if (Number.isFinite(group.count)) { return group.count; } return Array.isArray(group.items) ? group.items.length : 0; }, isGroupedCardOpen(groupId) { return Boolean(this.openGroupedCards[String(groupId)]); }, pruneOpenGroupedCards() { const activeIds = new Set(this.groupedEntries.map((group) => String(group.id))); this.openGroupedCards = Object.fromEntries( Object.entries(this.openGroupedCards).filter( ([groupId, isOpen]) => isOpen && activeIds.has(groupId), ), ); }, clearFilters() { this.filters = { search: '', expiration: [], location: [], }; this.resetGroupedVisibleLimit(); }, setOverviewOpen(key, open) { this.overviewOpen[key] = open; if (!open || !this.isCompactOverviewLayout()) { return; } const otherKey = key === 'expiration' ? 'location' : 'expiration'; this.overviewOpen[otherKey] = false; const otherPanel = otherKey === 'expiration' ? this.$refs.expirationOverview : this.$refs.locationOverview; if (otherPanel?.open) { otherPanel.open = false; } }, isCompactOverviewLayout() { return window.matchMedia('(max-width: 1199.98px)').matches; }, openOverviewCount() { return Number(this.overviewOpen.expiration) + Number(this.overviewOpen.location); }, isOnlyOverviewOpen(key) { return this.openOverviewCount() === 1 && this.overviewOpen[key]; }, overviewRowClass() { return this.openOverviewCount() === 1 ? 'overview-row-single-open' : ''; }, overviewColClass(key) { if (this.openOverviewCount() === 1) { return 'col-xl-12'; } return key === 'expiration' ? 'col-xl-5' : 'col-xl-7'; }, isAllExpirationSelected() { return ( this.filters.expiration.length === 0 || this.filters.expiration.length === EXPIRATION_KEYS.length ); }, effectiveExpirationFilters() { if (this.isAllExpirationSelected()) { return []; } return [...new Set(this.filters.expiration)]; }, effectiveLocationFilters() { const unique = [...new Set(this.filters.location)]; if (unique.length === 0 || (this.locations.length > 0 && unique.length === this.locations.length)) { return []; } return unique; }, expirationFilterSummary() { const active = this.effectiveExpirationFilters(); if (!active.length) { return 'All states'; } if (active.length === 1) { return this.expirationLegend.find((state) => state.key === active[0])?.label || '1 state'; } return `${active.length} states selected`; }, locationFilterSummary() { const active = this.effectiveLocationFilters(); if (!active.length) { return 'All locations'; } if (active.length === 1) { return this.locationMap[active[0]] || '1 location selected'; } return `${active.length} locations selected`; }, normalizedSearchTerm() { return String(this.filters.search || '').trim().toLowerCase(); }, toggleAllExpirationFilters() { this.filters.expiration = []; this.resetGroupedVisibleLimit(); }, toggleExpirationOverviewFilter(key) { if (this.isAllExpirationSelected()) { this.filters.expiration = [key]; this.resetGroupedVisibleLimit(); return; } this.toggleExpirationFilter(key); }, toggleExpirationFilter(key) { if (this.filters.expiration.includes(key)) { this.filters.expiration = this.filters.expiration.filter((value) => value !== key); } else { this.filters.expiration = [...this.filters.expiration, key]; } this.resetGroupedVisibleLimit(); }, isAllLocationsSelected() { return ( this.filters.location.length === 0 || (this.locations.length > 0 && this.filters.location.length === this.locations.length) ); }, toggleAllLocations() { this.filters.location = []; this.resetGroupedVisibleLimit(); }, toggleLocationFilter(uuid) { const subtree = this.locationSubtree(uuid); if (this.filters.location.includes(uuid)) { this.filters.location = this.filters.location.filter((value) => !subtree.includes(value)); } else { this.filters.location = [...new Set([...this.filters.location, ...subtree])]; } this.resetGroupedVisibleLimit(); }, selectedLocationSummary() { if (this.isAllLocationsSelected()) { return ''; } return `${this.filters.location.length} selected`; }, locationOverviewGroups() { return this.locations .filter((location) => location.depth === 0) .map((parent) => ({ parent, items: this.locations.filter( (location) => location.lineage_uuid_b64[0] === parent.uuid_b64, ), })); }, balancedLocationOverviewColumns() { const columns = [[], []]; const sizes = [0, 0]; this.locationOverviewGroups().forEach((group) => { const groupSize = group.items.length; const targetIndex = sizes[0] <= sizes[1] ? 0 : 1; columns[targetIndex].push(group); sizes[targetIndex] += groupSize; }); return columns; }, get filteredEntries() { const searchTerm = this.normalizedSearchTerm(); const expirationFilters = this.effectiveExpirationFilters().slice().sort().join(','); const locationFilters = this.effectiveLocationFilters().slice().sort().join(','); const signature = [ this.entriesVersion, this.locationsVersion, searchTerm, expirationFilters, locationFilters, ].join('|'); if (this.memo.filteredEntriesSig === signature) { return this.memo.filteredEntries; } const activeExpirationFilters = this.effectiveExpirationFilters(); const activeLocationFilters = this.effectiveLocationFilters(); const filtered = this.entries.filter((entry) => { if (searchTerm && !(entry._searchBlob || '').includes(searchTerm)) { return false; } if ( activeExpirationFilters.length && !activeExpirationFilters.includes(expirationInfo(entry).key) ) { return false; } if ( activeLocationFilters.length && !this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, activeLocationFilters) ) { return false; } return true; }); this.memo.filteredEntriesSig = signature; this.memo.filteredEntries = filtered; return filtered; }, get filteredGroupedEntries() { const searchTerm = this.normalizedSearchTerm(); const expirationFilters = this.effectiveExpirationFilters().slice().sort().join(','); const locationFilters = this.effectiveLocationFilters().slice().sort().join(','); const signature = [ this.groupedVersion, this.locationsVersion, searchTerm, expirationFilters, locationFilters, ].join('|'); if (this.memo.filteredGroupedEntriesSig === signature) { return this.memo.filteredGroupedEntries; } const activeExpirationFilters = this.effectiveExpirationFilters(); const activeLocationFilters = this.effectiveLocationFilters(); const filtered = this.groupedEntries.filter((group) => { if (searchTerm && !(group._searchBlob || '').includes(searchTerm)) { return false; } if ( activeExpirationFilters.length && !activeExpirationFilters.includes(expirationInfo(group).key) ) { return false; } if ( activeLocationFilters.length && !this.groupMatchesLocationFilters(group, activeLocationFilters) ) { return false; } return true; }); this.memo.filteredGroupedEntriesSig = signature; this.memo.filteredGroupedEntries = filtered; return filtered; }, get visibleGroupedEntries() { return this.filteredGroupedEntries.slice(0, this.groupedVisibleLimit); }, get hasMoreGroupedEntries() { return this.filteredGroupedEntries.length > this.visibleGroupedEntries.length; }, get visibleResultCount() { return this.viewMode === 'grouped' ? this.filteredGroupedEntries.length : this.filteredEntries.length; }, expirationFor(entry) { return expirationInfo(entry); }, isExpirationFilterActive(key) { return this.isAllExpirationSelected() || this.filters.expiration.includes(key); }, rowClass(entry) { return `expiration-${expirationInfo(entry).key}`; }, badgeClass(entry) { return `expiration-badge-${expirationInfo(entry).key}`; }, groupedItemClass(entry) { return `expiration-soft-${expirationInfo(entry).key}`; }, legendClass(key) { const hasActiveFilters = !this.isAllExpirationSelected(); return [ `legend-${key}`, hasActiveFilters && this.isExpirationFilterActive(key) ? 'legend-card-active' : '', hasActiveFilters && !this.isExpirationFilterActive(key) ? 'legend-card-inactive' : '', ] .filter(Boolean) .join(' '); }, getExpirationCounts() { const searchTerm = this.normalizedSearchTerm(); const locationFilters = this.effectiveLocationFilters().slice().sort().join(','); const sourceVersion = this.viewMode === 'grouped' ? this.groupedVersion : this.entriesVersion; const signature = [ this.viewMode, sourceVersion, this.locationsVersion, searchTerm, locationFilters, ].join('|'); if (this.memo.expirationCountsSig === signature) { return this.memo.expirationCounts; } const counts = Object.fromEntries(EXPIRATION_KEYS.map((key) => [key, 0])); const activeLocationFilters = this.effectiveLocationFilters(); const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries; source.forEach((entry) => { const blob = entry._searchBlob || ''; if (searchTerm && !blob.includes(searchTerm)) { return; } const matchesLocation = this.viewMode === 'grouped' ? this.groupMatchesLocationFilters(entry, activeLocationFilters) : this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, activeLocationFilters); if (!matchesLocation) { return; } const key = expirationInfo(entry).key; counts[key] += 1; }); this.memo.expirationCountsSig = signature; this.memo.expirationCounts = counts; return counts; }, expirationCount(key) { return this.getExpirationCounts()[key] || 0; }, shortId(entry) { return entry.uuid_b64 ? entry.uuid_b64.slice(0, 10) : 'No id'; }, locationLabel(entry) { return resolveLocationLabel(entry, this.locationMap); }, isLocationFilterActive(uuid) { return this.isAllLocationsSelected() || this.filters.location.includes(uuid); }, toggleLocationOverviewFilter(uuid) { if (this.isAllLocationsSelected()) { this.filters.location = [...this.locationSubtree(uuid)]; this.resetGroupedVisibleLimit(); return; } this.toggleLocationFilter(uuid); }, locationOverviewClass(location) { const hasActiveFilters = !this.isAllLocationsSelected(); return [ 'location-overview', `location-type-${location.type || 'unknown'}`, location.depth ? 'location-overview-child' : 'location-overview-parent', hasActiveFilters && this.isLocationFilterActive(location.uuid_b64) ? 'legend-card-active' : '', hasActiveFilters && !this.isLocationFilterActive(location.uuid_b64) ? 'legend-card-inactive' : '', ] .filter(Boolean) .join(' '); }, locationOverviewStyle(location) { const offset = location.depth; return `margin-left: ${offset}rem; width: calc(100% - ${offset}rem);`; }, locationAncestors(locationUuid) { if (!locationUuid) { return []; } return this.locationLineage[locationUuid] || [locationUuid]; }, groupLocationAncestors(group) { const allLocations = new Set(); if (group.location_initial_uuid_b64) { allLocations.add(group.location_initial_uuid_b64); } (group.items || []).forEach((item) => { if (item.location_initial_uuid_b64) { allLocations.add(item.location_initial_uuid_b64); } }); const ancestors = new Set(); allLocations.forEach((locationUuid) => { this.locationAncestors(locationUuid).forEach((ancestor) => ancestors.add(ancestor)); }); return [...ancestors]; }, getLocationCounts() { const searchTerm = this.normalizedSearchTerm(); const expirationFilters = this.effectiveExpirationFilters().slice().sort().join(','); const sourceVersion = this.viewMode === 'grouped' ? this.groupedVersion : this.entriesVersion; const signature = [ this.viewMode, sourceVersion, this.locationsVersion, searchTerm, expirationFilters, ].join('|'); if (this.memo.locationCountsSig === signature) { return this.memo.locationCounts; } const counts = Object.fromEntries(this.locations.map((location) => [location.uuid_b64, 0])); const activeExpirationFilters = this.effectiveExpirationFilters(); const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries; source.forEach((entry) => { const blob = entry._searchBlob || ''; if (searchTerm && !blob.includes(searchTerm)) { return; } if ( activeExpirationFilters.length && !activeExpirationFilters.includes(expirationInfo(entry).key) ) { return; } const ancestors = this.viewMode === 'grouped' ? this.groupLocationAncestors(entry) : this.locationAncestors(entry.location_initial_uuid_b64); ancestors.forEach((ancestorUuid) => { if (counts[ancestorUuid] !== undefined) { counts[ancestorUuid] += 1; } }); }); this.memo.locationCountsSig = signature; this.memo.locationCounts = counts; return counts; }, locationCount(locationUuid) { return this.getLocationCounts()[locationUuid] || 0; }, locationMatchesFilter(entryLocationUuid, selectedLocationUuid) { if (!selectedLocationUuid) { return true; } const allowed = this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid]; return allowed.includes(entryLocationUuid); }, locationSubtree(selectedLocationUuid) { return this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid]; }, locationMatchesAnyFilter(entryLocationUuid, selectedLocationUuids) { if (!selectedLocationUuids.length) { return true; } return selectedLocationUuids.some((selectedLocationUuid) => this.locationMatchesFilter(entryLocationUuid, selectedLocationUuid), ); }, groupMatchesLocationFilter(group, selectedLocationUuid) { if (!selectedLocationUuid) { return true; } if (this.locationMatchesFilter(group.location_initial_uuid_b64, selectedLocationUuid)) { return true; } return (group.items || []).some((item) => this.locationMatchesFilter(item.location_initial_uuid_b64, selectedLocationUuid), ); }, groupMatchesLocationFilters(group, selectedLocationUuids) { if (!selectedLocationUuids.length) { return true; } return selectedLocationUuids.some((selectedLocationUuid) => this.groupMatchesLocationFilter(group, selectedLocationUuid), ); }, detailHref(entry) { return `#/stock/${entry.uuid_b64}`; }, closeGroupedCard(details) { if (!details) { return; } details.open = false; if (details.dataset?.groupId) { this.openGroupedCards = { ...this.openGroupedCards, [String(details.dataset.groupId)]: false, }; } }, handleGroupedToggle(event) { const details = event.target; if (!(details instanceof HTMLDetailsElement)) { return; } if (details.dataset?.groupId) { this.openGroupedCards = { ...this.openGroupedCards, [String(details.dataset.groupId)]: details.open, }; } if (details.open) { return; } const summary = details.querySelector('.grouped-stock-summary'); if (!summary) { return; } requestAnimationFrame(() => { summary.scrollIntoView({ block: 'start', behavior: 'smooth', }); }); }, groupedPrimaryDate, groupedFirstProductionDate, groupedFirstExpireDate, shortDescription, quantityLabel, stockTypeDetail(entry) { if (entry.stock_type === 'binary') { return 'Binary stock'; } if (entry.stock_type === 'descriptive') { return `Level: ${LEVEL_LABELS[entry.level] || 'Not set'}`; } return entry.uom_symbol ? `Measured in ${entry.uom_symbol}` : 'Measured stock'; }, formatDate, async updateBinary(entry) { await this.useEntry(entry); }, async saveLevel(entry) { const level = this.editForms[entry.id]?.level || 'plenty'; if (level === 'gone') { await this.useEntry(entry); return; } await this.saveEntryUpdate( entry, { level, }, { level }, ); }, async saveQuantity(entry) { const quantity = Number(this.editForms[entry.id]?.quantity); if (Number.isNaN(quantity) || quantity < 0) { this.editErrors[entry.id] = 'Enter a valid quantity first.'; return; } await this.saveEntryUpdate( entry, { quantity, }, { quantity }, ); }, async markGone(entry) { await this.useEntry(entry); }, async markGoneFromGroup(item, group) { this.editErrors[item.id] = ''; try { const result = await useStockItem(store, item.uuid_b64); const alreadyGone = result.status === 'already_gone'; this.removeGroupedItem(group.id, item.id); this.removeEntryLocally(item.id); delete this.editForms[item.id]; delete this.editErrors[item.id]; store.addAlert({ type: alreadyGone ? 'info' : 'success', message: alreadyGone ? `${item.name} was already out of stock and removed from the group.` : `${item.name} was marked gone and removed from the group.`, }); this.refreshLoadedViewsInBackground().catch(() => {}); } catch (error) { this.editErrors[item.id] = error.message || 'Mark gone failed.'; } }, async saveEntryUpdate(entry, payload, localPatch) { this.editErrors[entry.id] = ''; try { const updated = await updateStockItem(store, entry.uuid_b64, payload); this.replaceEntry(entry.id, { ...entry, ...localPatch, ...updated }); store.addAlert({ type: 'success', message: `${entry.name} updated successfully.`, }); this.refreshLoadedViewsInBackground().catch(() => {}); } catch (error) { this.editErrors[entry.id] = error.message || 'Update failed.'; } }, async useEntry(entry) { this.editErrors[entry.id] = ''; try { const result = await useStockItem(store, entry.uuid_b64); const alreadyGone = result.status === 'already_gone'; this.removeEntryLocally(entry.id); delete this.editForms[entry.id]; delete this.editErrors[entry.id]; store.addAlert({ type: alreadyGone ? 'info' : 'success', message: alreadyGone ? `${entry.name} was already out of stock and removed from the list.` : `${entry.name} was marked gone and removed from the list.`, }); this.refreshLoadedViewsInBackground().catch(() => {}); } catch (error) { this.editErrors[entry.id] = error.message || 'Mark gone failed.'; } }, removeEntryLocally(entryId) { if (!this.entries.length) { return; } this.entries = this.entries.filter((entry) => entry.id !== entryId); this.entriesVersion += 1; this.invalidateMemo(); }, replaceEntry(entryId, nextEntry) { this.entries = sortEntries( this.entries.map((entry) => entry.id === entryId ? this.indexEntry(nextEntry) : entry, ), ); this.entriesVersion += 1; this.invalidateMemo(); this.editForms[entryId] = { level: nextEntry.level || 'plenty', quantity: nextEntry.quantity ?? '', }; }, removeGroupedItem(groupId, itemId) { this.groupedEntries = sortGroupedEntries( this.groupedEntries .map((group) => { if (group.id !== groupId) { return group; } const nextItems = (group.items || []).filter((candidate) => candidate.id !== itemId); if (!nextItems.length) { return null; } return this.indexGroup({ ...group, items: nextItems, }); }) .filter(Boolean), ); this.groupedVersion += 1; this.pruneOpenGroupedCards(); this.invalidateMemo(); }, }; }