import { listGroupedStockEntries, listStockEntries, updateStockItem, useStockItem, } 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'; 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); 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

Expiration overview

Tap to focus on one or more expiration states.

Location overview

Tap locations to focus the list. Parent locations include their children.

`; } export function stockListPageData(store) { return { state: createAsyncState(), viewMode: 'items', entries: [], groupedEntries: [], groupedLoaded: false, locations: [], locationMap: {}, locationDescendants: {}, editForms: {}, editErrors: {}, levelOptions: LEVEL_OPTIONS, expirationLegend: EXPIRATION_LEGEND, overviewOpen: { expiration: false, location: false, }, filters: { search: '', expiration: [], location: [], }, async init() { if (!store.isConnected) { return; } await Promise.all([this.loadLocations(), this.loadEntries()]); }, async switchView(mode) { this.viewMode = mode; if (mode === 'grouped' && !this.groupedLoaded) { await this.loadGroupedEntries(); } }, async refreshCurrentView() { if (this.viewMode === 'grouped') { await this.loadGroupedEntries(); return; } await this.loadEntries(); }, async loadEntries() { if (!store.isConnected) { return; } await runAsyncState(this.state, async () => { const loadedEntries = await listStockEntries(store); this.entries = sortEntries(loadedEntries); this.editForms = Object.fromEntries( this.entries.map((entry) => [ entry.id, { level: entry.level || 'plenty', quantity: entry.quantity ?? '', }, ]), ); this.editErrors = {}; }).catch(() => {}); }, async loadGroupedEntries() { if (!store.isConnected) { return; } await runAsyncState(this.state, async () => { const loadedGroups = await listGroupedStockEntries(store); this.groupedEntries = sortGroupedEntries(loadedGroups); this.groupedLoaded = true; }).catch(() => {}); }, 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.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.locationDescendants = {}; } }, clearFilters() { this.filters = { search: '', expiration: [], location: [], }; }, 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'; }, expirationFilterSummary() { if (this.isAllExpirationSelected()) { return 'Show all'; } if (!this.filters.expiration.length) { return 'No expiration states selected'; } if (this.filters.expiration.length === 1) { return this.expirationLegend.find((state) => state.key === this.filters.expiration[0])?.label || '1 expiration state'; } return `${this.filters.expiration.length} expiration states selected`; }, isAllExpirationSelected() { return this.filters.expiration.length === 0 || this.filters.expiration.length === EXPIRATION_KEYS.length; }, toggleAllExpirationFilters() { this.filters.expiration = []; }, toggleExpirationOverviewFilter(key) { if (this.isAllExpirationSelected()) { this.filters.expiration = [key]; return; } this.toggleExpirationFilter(key); }, toggleExpirationFilter(key) { if (this.filters.expiration.includes(key)) { this.filters.expiration = this.filters.expiration.filter((value) => value !== key); return; } this.filters.expiration = [...this.filters.expiration, key]; }, locationFilterSummary() { if (this.isAllLocationsSelected()) { return 'All locations'; } if (!this.filters.location.length) { return 'No locations selected'; } if (this.filters.location.length === 1) { return this.locationMap[this.filters.location[0]] || '1 location selected'; } return `${this.filters.location.length} locations selected`; }, selectedLocationSummary() { if (this.isAllLocationsSelected()) { return ''; } if (!this.filters.location.length) { return 'No locations selected'; } return `${this.filters.location.length} selected`; }, locationOverviewGroups() { const byParent = this.locations .filter((location) => location.depth === 0) .map((parent) => ({ parent, items: this.locations.filter((location) => location.lineage_uuid_b64[0] === parent.uuid_b64, ), })); return byParent; }, 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; }, isAllLocationsSelected() { return this.filters.location.length === 0 || (this.locations.length > 0 && this.filters.location.length === this.locations.length); }, toggleAllLocations() { this.filters.location = []; }, toggleLocationFilter(uuid) { const subtree = this.locationSubtree(uuid); if (this.filters.location.includes(uuid)) { this.filters.location = this.filters.location.filter((value) => !subtree.includes(value)); return; } this.filters.location = [...new Set([...this.filters.location, ...subtree])]; }, get filteredEntries() { return this.entries.filter((entry) => { if ( this.filters.search && !searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase()) ) { return false; } if ( this.filters.expiration.length && this.filters.expiration.length !== EXPIRATION_KEYS.length && !this.filters.expiration.includes(expirationInfo(entry).key) ) { return false; } if ( this.filters.location.length && this.filters.location.length !== this.locations.length && !this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location) ) { return false; } return true; }); }, get filteredGroupedEntries() { return this.groupedEntries.filter((group) => { if ( this.filters.search && !groupSearchBlob(group, this.locationMap).includes(this.filters.search.toLowerCase()) ) { return false; } if ( this.filters.expiration.length && this.filters.expiration.length !== EXPIRATION_KEYS.length && !this.filters.expiration.includes(expirationInfo(group).key) ) { return false; } if ( this.filters.location.length && this.filters.location.length !== this.locations.length && !this.groupMatchesLocationFilters(group, this.filters.location) ) { return false; } return true; }); }, 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.filters.expiration.length > 0 && !this.isAllExpirationSelected(); return [ `legend-${key}`, hasActiveFilters && this.isExpirationFilterActive(key) ? 'legend-card-active' : '', hasActiveFilters && !this.isExpirationFilterActive(key) ? 'legend-card-inactive' : '', ] .filter(Boolean) .join(' '); }, expirationCount(key) { const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries; return source.filter((entry) => { if ( this.filters.search && !(this.viewMode === 'grouped' ? groupSearchBlob(entry, this.locationMap) : searchBlob(entry, this.locationMap) ).includes(this.filters.search.toLowerCase()) ) { return false; } if ( this.filters.location.length && this.filters.location.length !== this.locations.length && !(this.viewMode === 'grouped' ? this.groupMatchesLocationFilters(entry, this.filters.location) : this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location)) ) { return false; } return expirationInfo(entry).key === key; }).length; }, 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)]; return; } this.toggleLocationFilter(uuid); }, locationOverviewClass(location) { const hasActiveFilters = this.filters.location.length > 0 && !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);`; }, locationCount(locationUuid) { const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries; return source.filter((entry) => { if ( this.filters.search && !(this.viewMode === 'grouped' ? groupSearchBlob(entry, this.locationMap) : searchBlob(entry, this.locationMap) ).includes(this.filters.search.toLowerCase()) ) { return false; } if ( this.filters.expiration.length && this.filters.expiration.length !== EXPIRATION_KEYS.length && !this.filters.expiration.includes(expirationInfo(entry).key) ) { return false; } return this.viewMode === 'grouped' ? this.groupMatchesLocationFilter(entry, locationUuid) : this.locationMatchesFilter(entry.location_initial_uuid_b64, locationUuid); }).length; }, 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 (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; }, handleGroupedToggle(event) { const details = event.target; if (!(details instanceof HTMLDetailsElement) || 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, level) { 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.entries = this.entries.filter((candidate) => candidate.id !== 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.`, }); } 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.`, }); } 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); this.entries = this.entries.filter((candidate) => candidate.id !== entry.id); delete this.editForms[entry.id]; delete this.editErrors[entry.id]; const alreadyGone = result.status === 'already_gone'; 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.`, }); } catch (error) { this.editErrors[entry.id] = error.message || 'Mark gone failed.'; } }, replaceEntry(entryId, nextEntry) { this.entries = sortEntries( this.entries.map((entry) => (entry.id === entryId ? nextEntry : entry)), ); this.editForms[entryId] = { level: nextEntry.level || 'plenty', quantity: nextEntry.quantity ?? '', }; }, removeGroupedItem(groupId, itemId) { this.groupedEntries = 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 { ...group, items: nextItems, }; }) .filter(Boolean); }, }; }