import { deleteStockItem, 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'; 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) { if (!entry.expire_date) { return { key: 'none', label: 'No expiration date', detail: 'No expiration date', sortRank: 4, }; } const today = todayAtMidnight(); const expireDate = parseDateValue(entry.expire_date); const expireIn = typeof entry.expire_in === 'number' ? entry.expire_in : Math.round((expireDate - today) / (24 * 60 * 60 * 1000)); 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 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(); } 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.

item(s) visible

Location overview

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

`; } export function stockListPageData(store) { return { state: createAsyncState(), entries: [], 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 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 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; }); }, 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}`; }, 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) { return this.entries.filter((entry) => { if ( this.filters.search && !searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase()) ) { 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 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) { 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; } return 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), ); }, 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.deleteEntry(entry); }, async saveLevel(entry) { const level = this.editForms[entry.id]?.level || 'plenty'; if (level === 'gone') { await this.deleteEntry(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.deleteEntry(entry); }, 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 deleteEntry(entry) { this.editErrors[entry.id] = ''; try { await deleteStockItem(store, entry.uuid_b64); this.entries = this.entries.filter((candidate) => candidate.id !== entry.id); delete this.editForms[entry.id]; delete this.editErrors[entry.id]; store.addAlert({ type: 'success', message: `${entry.name} was marked gone and removed from the list.`, }); } catch (error) { this.editErrors[entry.id] = error.message || 'Delete 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 ?? '', }; }, }; }