Files
lonc/src/features/stock/stock-list-page.js
T
bblaz 47434db5b5
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
Add scanner utility, modal, and stock scan page implementation
2026-05-01 23:32:13 +02:00

2434 lines
89 KiB
JavaScript

import {
getStockEntry,
listGroupedStockEntries,
listKitchenChanges,
listStockEntries,
markStockGone,
updateStockItem,
} from '../../api/stock.js';
import { fetchLocations } from '../../api/locations.js';
import { STORAGE_KEYS } from '../../app/config.js';
import { clearStoredValue, loadStoredValue, saveStoredValue } from '../shared/storage.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;
const STOCK_LIST_CONTEXT_TTL_MS = 10 * 60 * 1000;
let stockListRuntimeCache = null;
function cloneRuntimeSnapshot(value) {
if (typeof structuredClone === 'function') {
try {
return structuredClone(value);
} catch {
// Alpine/reactive proxies can throw DataCloneError in some browsers.
}
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return null;
}
}
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 daysUntilDate(value) {
const parsed = parseDateValue(value);
if (!parsed) {
return null;
}
const today = todayAtMidnight();
return Math.round((parsed - today) / (24 * 60 * 60 * 1000));
}
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 isGroupedChildStub(item) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return true;
}
return !(
'uuid_b64' in item
|| 'name' in item
|| 'stock_type' in item
|| 'date' in item
|| 'expire_date' in item
|| 'location_initial_uuid_b64' in item
|| 'quantity' in item
|| 'level' in item
);
}
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 `
<section class="container-xxl py-4 py-lg-5" x-data="stockListPage()" x-init="init()">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end mb-4">
<div>
<p class="eyebrow mb-2">Stock Review</p>
<h1 class="h3 mb-1">Review stock and act quickly</h1>
<p class="text-body-secondary mb-0">
Focus on expiration, stock state, and location without leaving the overview.
</p>
</div>
<a href="#/labels/new" class="btn btn-primary">New stock label</a>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
<div class="stock-view-switch-wrap">
<div class="small text-body-secondary fw-semibold mb-2">Stock view mode</div>
<div class="stock-view-switch" role="tablist" aria-label="Stock view mode">
<button
class="stock-view-tab"
type="button"
role="tab"
:class="viewMode === 'grouped' ? 'stock-view-tab-active' : ''"
:aria-selected="viewMode === 'grouped'"
@click="switchView('grouped')"
>
<span class="stock-view-tab-title">Grouped stock</span>
<span class="stock-view-tab-subtitle">Fast overview by product group</span>
</button>
<button
class="stock-view-tab"
type="button"
role="tab"
:class="viewMode === 'items' ? 'stock-view-tab-active' : ''"
:aria-selected="viewMode === 'items'"
@click="switchView('items')"
>
<span class="stock-view-tab-title">Individual items</span>
<span class="stock-view-tab-subtitle">Detailed per-item adjustments</span>
</button>
</div>
</div>
<div>
<button
class="btn btn-outline-secondary"
type="button"
@click="refreshCurrentView()"
:disabled="state.isLoading || state.isRefreshing"
>
<span x-show="!state.isLoading && !state.isRefreshing">Refresh</span>
<span x-show="state.isLoading || state.isRefreshing">Refreshing...</span>
</button>
</div>
</div>
<div class="small text-body-secondary mt-2" x-show="state.isRefreshing && !state.isLoading">
Updating in background...
</div>
</div>
</div>
<div class="row g-4 stock-workspace">
<aside class="col-12 col-xl-4 col-xxl-3 stock-filter-rail">
<div class="card border-0 shadow-sm stock-filter-hub">
<div class="card-body p-4">
<div class="d-flex flex-column gap-2 mb-3">
<div>
<h2 class="h5 mb-1">Search and filters</h2>
<p class="text-body-secondary small mb-0">
Focus the stock list with one control panel.
</p>
</div>
<div class="d-flex justify-content-between align-items-center">
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click="clearFilters()">Reset all</button>
<div class="small text-body-secondary text-end">
<span class="fw-semibold text-body" x-text="visibleResultCount"></span>
<span x-text="viewMode === 'grouped' ? 'group(s)' : 'item(s)'"></span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label mb-1">Search stock</label>
<input
class="form-control"
type="text"
x-model.debounce.250ms="filters.search"
placeholder="Search by item, description, location, or id"
/>
</div>
<div class="stock-filter-summary mb-3">
<div class="small text-body-secondary mb-2">Active scope</div>
<div class="d-grid gap-1">
<div class="small"><span class="fw-semibold">Expiration:</span> <span x-text="expirationFilterSummary()"></span></div>
<div class="small"><span class="fw-semibold">Location:</span> <span x-text="locationFilterSummary()"></span></div>
</div>
</div>
<div
class="d-grid gap-3 stock-filter-panels"
:class="{ 'stock-filter-panels-single-open': openOverviewCount() > 0 }"
>
<details
class="overview-panel stock-filter-panel-card"
x-ref="expirationOverview"
@toggle="setOverviewOpen('expiration', $event.target.open)"
>
<summary class="p-3 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3 class="h6 mb-1">Expiration overview</h3>
<p class="text-body-secondary small mb-0">Tap to focus expiration states.</p>
</div>
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllExpirationFilters()">Show all</button>
</div>
</summary>
<div class="pt-0 px-3 pb-3">
<div class="overview-list">
<template x-for="stateInfo in expirationLegend" :key="stateInfo.key">
<button
class="overview-option text-start"
type="button"
:class="legendClass(stateInfo.key)"
@click="toggleExpirationOverviewFilter(stateInfo.key)"
:aria-pressed="isExpirationFilterActive(stateInfo.key)"
>
<div class="d-flex justify-content-between align-items-start gap-3 mb-1">
<div class="fw-semibold" x-text="stateInfo.label"></div>
<div class="small fw-semibold" x-text="expirationCount(stateInfo.key)"></div>
</div>
<div class="small" x-text="stateInfo.description"></div>
</button>
</template>
</div>
</div>
</details>
<details
class="overview-panel stock-filter-panel-card"
x-ref="locationOverview"
@toggle="setOverviewOpen('location', $event.target.open)"
>
<summary class="p-3 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3 class="h6 mb-1">Location overview</h3>
<p class="text-body-secondary small mb-0">Parent locations include children.</p>
</div>
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllLocations()">Show all</button>
</div>
</summary>
<div class="pt-0 px-3 pb-3">
<template x-if="isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations location-overview-columns">
<template x-for="(columnGroups, columnIndex) in balancedLocationOverviewColumns()" :key="columnIndex">
<div class="location-overview-column">
<template x-for="group in columnGroups" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
<button
class="overview-option overview-option-location text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
</div>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
<template x-if="!isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations">
<template x-for="group in locationOverviewGroups()" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
<button
class="overview-option overview-option-location text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
</div>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</details>
</div>
</div>
</div>
</aside>
<div class="col-12 col-xl-8 col-xxl-9 stock-results-pane">
<template x-if="showInitialLoader()">
<div class="alert alert-secondary">Loading stock review...</div>
</template>
<template x-if="state.error">
<div class="alert alert-danger d-flex justify-content-between align-items-center gap-3">
<span x-text="state.error"></span>
<button class="btn btn-sm btn-outline-danger" type="button" @click="init()">Retry</button>
</div>
</template>
<template x-if="!state.isLoading && !state.error && viewMode === 'items' && !filteredEntries.length">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 text-center">
<h2 class="h5">No stock items to show</h2>
<p class="text-body-secondary mb-0">
Try clearing the filters or create a new stock label.
</p>
</div>
</div>
</template>
<template x-if="!state.isLoading && !state.error && viewMode === 'items' && filteredEntries.length">
<div>
<div class="d-none d-xl-block">
<div class="card border-0 shadow-sm overflow-hidden">
<div class="table-responsive">
<table class="table align-middle mb-0 stock-review-table">
<thead class="table-light">
<tr>
<th>Item</th>
<th>Expiration</th>
<th>Quantity / level</th>
<th>Location</th>
<th>Dates</th>
<th>Quick actions</th>
</tr>
</thead>
<tbody>
<template x-for="entry in filteredEntries" :key="entry.id">
<tr :class="rowClass(entry)">
<td>
<div class="fw-semibold" x-text="entry.name"></div>
<div class="small text-body-secondary" x-text="entry.description || 'No description'"></div>
<div class="small font-monospace text-body-secondary" x-text="shortId(entry)"></div>
<a
class="small text-decoration-none fw-semibold"
:class="isItemRefreshing(entry) ? 'stock-item-link-disabled' : ''"
:href="detailHref(entry)"
:aria-disabled="isItemRefreshing(entry)"
:tabindex="isItemRefreshing(entry) ? -1 : 0"
@click="onItemDetailNavigate(entry, $event)"
>
View item
</a>
<div class="small text-body-secondary stock-item-refresh-indicator" x-show="isItemRefreshing(entry)">
Refreshing...
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
</div>
<div class="small text-body-secondary" x-text="expirationFor(entry).detail"></div>
</td>
<td>
<div class="fw-semibold" x-text="quantityLabel(entry)"></div>
<div class="small text-body-secondary" x-text="stockTypeDetail(entry)"></div>
</td>
<td x-text="locationLabel(entry)"></td>
<td>
<div class="small"><span class="text-body-secondary">Made:</span> <span x-text="formatDate(entry.date)"></span></div>
<div class="small"><span class="text-body-secondary">Expires:</span> <span x-text="formatDate(entry.expire_date)"></span></div>
</td>
<td>
<div class="quick-edit-stack">
<template x-if="entry.stock_type === 'binary'">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'consumed')">Mark used</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'spoiled')">Spoilt</button>
</div>
</template>
<template x-if="entry.stock_type === 'descriptive'">
<div class="d-flex flex-wrap gap-2 align-items-center">
<select class="form-select form-select-sm quick-select" x-model="editForms[entry.id].level">
<template x-for="option in levelOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'consumed')">Mark used</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'spoiled')">Spoilt</button>
</div>
</template>
<template x-if="entry.stock_type === 'measured'">
<div class="d-flex flex-wrap gap-2 align-items-center">
<input class="form-control form-control-sm quick-number" type="number" step="0.01" min="0" x-model="editForms[entry.id].quantity" />
<button class="btn btn-sm btn-primary" type="button" @click="saveQuantity(entry)">Save qty</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'consumed')">Mark used</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'spoiled')">Spoilt</button>
</div>
</template>
<template x-if="editErrors[entry.id]">
<div class="small text-danger" x-text="editErrors[entry.id]"></div>
</template>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<div class="d-grid gap-3 d-xl-none">
<template x-for="entry in filteredEntries" :key="entry.id">
<div class="card border-0 shadow-sm stock-review-card" :class="rowClass(entry)">
<div class="card-body p-4">
<div class="d-flex justify-content-between gap-3 align-items-start mb-3">
<div>
<div class="fw-semibold fs-5" x-text="entry.name"></div>
<div class="text-body-secondary small" x-text="entry.description || 'No description'"></div>
<div class="text-body-secondary small font-monospace" x-text="shortId(entry)"></div>
<a
class="small text-decoration-none fw-semibold"
:class="isItemRefreshing(entry) ? 'stock-item-link-disabled' : ''"
:href="detailHref(entry)"
:aria-disabled="isItemRefreshing(entry)"
:tabindex="isItemRefreshing(entry) ? -1 : 0"
@click="onItemDetailNavigate(entry, $event)"
>
View item
</a>
<div class="small text-body-secondary stock-item-refresh-indicator" x-show="isItemRefreshing(entry)">
Refreshing...
</div>
</div>
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
</div>
<div class="row g-3 small mb-3">
<div class="col-6">
<div class="text-body-secondary">Quantity / level</div>
<div class="fw-semibold" x-text="quantityLabel(entry)"></div>
</div>
<div class="col-6">
<div class="text-body-secondary">Location</div>
<div class="fw-semibold" x-text="locationLabel(entry)"></div>
</div>
<div class="col-6">
<div class="text-body-secondary">Production date</div>
<div x-text="formatDate(entry.date)"></div>
</div>
<div class="col-6">
<div class="text-body-secondary">Expiration</div>
<div x-text="expirationFor(entry).detail"></div>
</div>
</div>
<div class="quick-edit-stack">
<template x-if="entry.stock_type === 'binary'">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'consumed')">Mark used</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'spoiled')">Spoilt</button>
</div>
</template>
<template x-if="entry.stock_type === 'descriptive'">
<div class="d-grid gap-2">
<select class="form-select form-select-sm" x-model="editForms[entry.id].level">
<template x-for="option in levelOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save stock level</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'consumed')">Mark used</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'spoiled')">Spoilt</button>
</div>
</div>
</template>
<template x-if="entry.stock_type === 'measured'">
<div class="d-grid gap-2">
<input class="form-control form-control-sm" type="number" step="0.01" min="0" x-model="editForms[entry.id].quantity" />
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" type="button" @click="saveQuantity(entry)">Save quantity</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'consumed')">Mark used</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry, 'spoiled')">Spoilt</button>
</div>
</div>
</template>
<template x-if="editErrors[entry.id]">
<div class="small text-danger mt-2" x-text="editErrors[entry.id]"></div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<template x-if="!state.isLoading && !state.error && viewMode === 'grouped' && !filteredGroupedEntries.length">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 text-center">
<h2 class="h5">No grouped stock items to show</h2>
<p class="text-body-secondary mb-0">
Try clearing the filters or create a new stock label.
</p>
</div>
</div>
</template>
<template x-if="!state.isLoading && !state.error && viewMode === 'grouped' && filteredGroupedEntries.length">
<div class="d-grid gap-3">
<template x-for="group in visibleGroupedEntries" :key="group.id">
<details
class="card border-0 shadow-sm grouped-stock-card"
:class="rowClass(group)"
:open="isGroupedCardOpen(group.id)"
:data-group-id="String(group.id)"
@toggle="handleGroupedToggle($event)"
>
<summary class="card-body p-3 grouped-stock-summary">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-2 grouped-stock-summary-row">
<div>
<div class="fw-semibold grouped-stock-summary-title" x-text="group.name"></div>
<div class="text-body-secondary small grouped-stock-summary-description" x-show="group.description" x-text="group.description"></div>
<div class="d-flex flex-wrap small grouped-stock-summary-meta">
<span><span class="fw-semibold text-body" x-text="groupItemCount(group)"></span> item(s)</span>
<span><span class="text-body-secondary">Latest location:</span> <span class="fw-semibold text-body" x-text="locationLabel(group)"></span></span>
<span><span class="text-body-secondary">Quantity:</span> <span class="fw-semibold text-body" x-text="quantityLabel(group)"></span></span>
</div>
</div>
<div class="grouped-stock-summary-status">
<span class="badge rounded-pill" :class="badgeClass(group)" x-text="expirationFor(group).label"></span>
<div class="small text-body-secondary">
<span class="text-body-secondary">First expires:</span>
<span x-text="expirationFor(group).detail"></span>
</div>
<div class="small fw-semibold grouped-stock-toggle-label">Items</div>
</div>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-3">
<div class="grouped-stock-secondary-details mb-3">
<details class="grouped-stock-secondary-toggle">
<summary class="small fw-semibold">More dates and metadata</summary>
<div class="row g-3 small mt-1 grouped-stock-meta">
<div class="col-6 col-xl-4">
<div class="text-body-secondary">Latest added</div>
<div x-text="formatDate(groupedPrimaryDate(group))"></div>
</div>
<div class="col-6 col-xl-4">
<div class="text-body-secondary">First production date</div>
<div x-text="formatDate(groupedFirstProductionDate(group))"></div>
</div>
<div class="col-6 col-xl-4">
<div class="text-body-secondary">First expiration date</div>
<div x-text="formatDate(groupedFirstExpireDate(group))"></div>
</div>
</div>
</details>
</div>
<template x-if="isGroupedCardOpen(group.id)">
<div>
<template x-if="groupDisplayItems(group).length">
<div class="grouped-stock-items">
<template x-for="item in groupDisplayItems(group)" :key="item.id">
<div class="grouped-stock-item" :class="[groupedItemClass(item), isItemRefreshing(item) ? 'stock-item-refreshing' : '']">
<div class="grouped-stock-item-row">
<div class="grouped-stock-item-main">
<div class="grouped-stock-item-title-line">
<div class="fw-semibold grouped-stock-item-name" x-text="item.name"></div>
<span class="font-monospace grouped-stock-item-id" x-text="shortId(item)"></span>
</div>
<div class="small text-body-secondary grouped-stock-item-subline">
<span x-text="locationLabel(item)"></span>
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
<span x-text="shortDescription(item.description)"></span>
</div>
</div>
<div class="small text-body-secondary grouped-stock-item-aux">
<span x-text="quantityLabel(item)"></span>
<span class="grouped-stock-date-pair">
<span x-text="formatDate(item.date)"></span>
<span class="grouped-stock-date-separator" aria-hidden="true">→</span>
<span x-text="formatDate(item.expire_date)"></span>
</span>
</div>
<div class="grouped-stock-item-actions">
<a
class="text-decoration-none fw-semibold grouped-stock-item-link"
:class="isItemRefreshing(item) ? 'stock-item-link-disabled' : ''"
:href="detailHref(item)"
:aria-disabled="isItemRefreshing(item)"
:tabindex="isItemRefreshing(item) ? -1 : 0"
@click="onItemDetailNavigate(item, $event)"
>
Details
</a>
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" :disabled="isItemRefreshing(item)" @click="markGoneFromGroup(item, group, 'consumed')">Mark used</button>
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" :disabled="isItemRefreshing(item)" @click="markGoneFromGroup(item, group, 'spoiled')">Spoilt</button>
<div class="small text-body-secondary stock-item-refresh-indicator" x-show="isItemRefreshing(item)">
Refreshing...
</div>
</div>
</div>
<template x-if="editErrors[item.id]">
<div class="small text-danger mt-2" x-text="editErrors[item.id]"></div>
</template>
</div>
</template>
</div>
</template>
<template x-if="!groupDisplayItems(group).length && (groupedHydrating || hasGroupedChildStubs(group))">
<div class="small text-body-secondary py-2">Loading grouped items...</div>
</template>
<template x-if="!groupDisplayItems(group).length && !groupedHydrating && !hasGroupedChildStubs(group)">
<div class="small text-body-secondary py-2">No items currently available in this group.</div>
</template>
</div>
</template>
<div class="grouped-stock-close-row">
<button
class="btn btn-link text-decoration-none grouped-stock-close"
type="button"
@click.prevent="closeGroupedCard($el.closest('details'))"
>
Collapse group
</button>
</div>
</div>
</details>
</template>
<template x-if="hasMoreGroupedEntries">
<div class="d-flex justify-content-center">
<button class="btn btn-outline-secondary" type="button" @click="showMoreGroups()">
Show more groups
</button>
</div>
</template>
</div>
</template>
</div>
</div>
</section>
`;
}
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,
pendingRestoredScrollY: null,
itemRefreshCounts: {},
async init() {
if (!store.isConnected) {
return;
}
const restoredContext = this.restoreStockListContext();
this.registerRouteCleanup();
const restoredFromRuntime = restoredContext
? this.restoreFromRuntimeCache(restoredContext)
: false;
if (!restoredFromRuntime) {
const initTasks = [this.loadLocations()];
if (this.viewMode === 'items') {
initTasks.push(this.loadEntries());
} else {
initTasks.push(
this.loadGroupedEntries({
expanded: 0,
resetVisible: !restoredContext,
}),
);
}
await Promise.all(initTasks);
}
await this.$nextTick();
this.restoreScrollPosition();
if (!restoredFromRuntime && this.viewMode === 'grouped') {
this.hydrateGroupedEntriesInBackground().catch(() => {});
}
if (restoredFromRuntime && restoredContext?.focusedItemUuid) {
this.refreshFocusedItemInBackground(restoredContext.focusedItemUuid).catch(() => {});
}
await this.primeChangeCursor();
this.startChangePolling();
},
restoreStockListContext() {
const context = loadStoredValue(STORAGE_KEYS.stockListContext, null);
clearStoredValue(STORAGE_KEYS.stockListContext);
if (!context || typeof context !== 'object') {
return null;
}
const savedAt = Number(context.savedAt || 0);
if (!savedAt || Date.now() - savedAt > STOCK_LIST_CONTEXT_TTL_MS) {
return null;
}
if (
Number.isFinite(context.kitchenId)
&& Number.isFinite(store.activeKitchen?.id)
&& Number(context.kitchenId) !== Number(store.activeKitchen.id)
) {
return null;
}
const mode = context.viewMode === 'items' ? 'items' : 'grouped';
this.viewMode = mode;
this.filters = {
search: String(context.filters?.search || ''),
expiration: Array.isArray(context.filters?.expiration) ? context.filters.expiration : [],
location: Array.isArray(context.filters?.location) ? context.filters.location : [],
};
if (mode === 'grouped') {
const restoredLimit = Number(context.groupedVisibleLimit);
if (Number.isFinite(restoredLimit) && restoredLimit > 0) {
this.groupedVisibleLimit = Math.max(this.groupedPageSize, Math.round(restoredLimit));
}
this.openGroupedCards = context.openGroupedCards && typeof context.openGroupedCards === 'object'
? context.openGroupedCards
: {};
}
const restoredScrollY = Number(context.scrollY);
if (Number.isFinite(restoredScrollY) && restoredScrollY >= 0) {
this.pendingRestoredScrollY = restoredScrollY;
}
return {
viewMode: mode,
focusedItemUuid:
typeof context.focusedItemUuid === 'string' && context.focusedItemUuid.trim()
? context.focusedItemUuid.trim()
: null,
};
},
restoreScrollPosition() {
if (!Number.isFinite(this.pendingRestoredScrollY)) {
return;
}
const top = this.pendingRestoredScrollY;
this.pendingRestoredScrollY = null;
requestAnimationFrame(() => {
window.scrollTo({
top,
behavior: 'auto',
});
});
},
rememberStockListContext(focusedItemUuid = null) {
this.persistRuntimeCache();
saveStoredValue(STORAGE_KEYS.stockListContext, {
savedAt: Date.now(),
kitchenId: Number(store.activeKitchen?.id) || null,
viewMode: this.viewMode,
filters: this.filters,
groupedVisibleLimit: this.groupedVisibleLimit,
openGroupedCards: this.openGroupedCards,
focusedItemUuid:
typeof focusedItemUuid === 'string' && focusedItemUuid.trim()
? focusedItemUuid.trim()
: null,
scrollY:
window.scrollY
|| window.pageYOffset
|| document.documentElement?.scrollTop
|| 0,
});
},
persistRuntimeCache() {
stockListRuntimeCache = {
savedAt: Date.now(),
kitchenId: Number(store.activeKitchen?.id) || null,
payload: cloneRuntimeSnapshot({
entries: this.entries,
entriesVersion: this.entriesVersion,
itemsLoaded: this.itemsLoaded,
groupedEntries: this.groupedEntries,
groupedVersion: this.groupedVersion,
groupedLoaded: this.groupedLoaded,
groupedHydrated: this.groupedHydrated,
locations: this.locations,
locationsVersion: this.locationsVersion,
locationMap: this.locationMap,
locationDescendants: this.locationDescendants,
locationLineage: this.locationLineage,
changeCursor: this.changeCursor,
}),
};
},
restoreFromRuntimeCache(restoredContext) {
if (!restoredContext) {
return false;
}
const cached = stockListRuntimeCache;
if (!cached || !cached.payload) {
return false;
}
if (!cached.savedAt || Date.now() - cached.savedAt > STOCK_LIST_CONTEXT_TTL_MS) {
stockListRuntimeCache = null;
return false;
}
if (
Number.isFinite(cached.kitchenId)
&& Number.isFinite(store.activeKitchen?.id)
&& Number(cached.kitchenId) !== Number(store.activeKitchen.id)
) {
return false;
}
const payload = cloneRuntimeSnapshot(cached.payload);
if (!payload) {
return false;
}
this.entries = Array.isArray(payload.entries) ? payload.entries : [];
this.entriesVersion = Number.isFinite(payload.entriesVersion)
? payload.entriesVersion
: (this.entries.length ? 1 : 0);
this.itemsLoaded = Boolean(payload.itemsLoaded);
this.groupedEntries = Array.isArray(payload.groupedEntries) ? payload.groupedEntries : [];
this.groupedVersion = Number.isFinite(payload.groupedVersion)
? payload.groupedVersion
: (this.groupedEntries.length ? 1 : 0);
this.groupedLoaded = Boolean(payload.groupedLoaded);
this.groupedHydrated = Boolean(payload.groupedHydrated);
this.locations = Array.isArray(payload.locations) ? payload.locations : [];
this.locationsVersion = Number.isFinite(payload.locationsVersion)
? payload.locationsVersion
: (this.locations.length ? 1 : 0);
this.locationMap = payload.locationMap && typeof payload.locationMap === 'object'
? payload.locationMap
: {};
this.locationDescendants =
payload.locationDescendants && typeof payload.locationDescendants === 'object'
? payload.locationDescendants
: {};
this.locationLineage = payload.locationLineage && typeof payload.locationLineage === 'object'
? payload.locationLineage
: {};
this.changeCursor = payload.changeCursor || this.changeCursor;
if (this.itemsLoaded) {
this.syncEditFormsFromEntries();
} else {
this.editForms = {};
this.editErrors = {};
}
this.pruneOpenGroupedCards();
this.invalidateMemo();
return this.viewMode === 'grouped' ? this.groupedLoaded : this.itemsLoaded;
},
async refreshFocusedItemInBackground(uuidB64) {
if (!uuidB64 || !store.isConnected) {
return;
}
this.startItemRefresh(uuidB64);
this.beginBackgroundRefresh();
try {
const updatedEntry = await getStockEntry(store, uuidB64);
this.applyUpdatedItemToLists(updatedEntry);
} catch (error) {
const status = error?.status || error?.cause?.status;
if (status === 404) {
const removedFromItems = this.removeEntryByUuid(uuidB64);
const removedFromGroups = this.removeGroupedItemByUuid(uuidB64);
if (removedFromItems || removedFromGroups) {
this.persistRuntimeCache();
}
}
} finally {
this.endItemRefresh(uuidB64);
this.endBackgroundRefresh();
}
},
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;
},
startItemRefresh(uuidB64) {
if (!uuidB64) {
return;
}
const nextCount = (this.itemRefreshCounts[uuidB64] || 0) + 1;
this.itemRefreshCounts = {
...this.itemRefreshCounts,
[uuidB64]: nextCount,
};
},
endItemRefresh(uuidB64) {
if (!uuidB64 || !this.itemRefreshCounts[uuidB64]) {
return;
}
const nextCount = Math.max(0, (this.itemRefreshCounts[uuidB64] || 0) - 1);
if (!nextCount) {
const { [uuidB64]: _, ...rest } = this.itemRefreshCounts;
this.itemRefreshCounts = rest;
return;
}
this.itemRefreshCounts = {
...this.itemRefreshCounts,
[uuidB64]: nextCount,
};
},
isItemRefreshing(entryOrUuid) {
const uuidB64 = typeof entryOrUuid === 'string' ? entryOrUuid : entryOrUuid?.uuid_b64;
if (!uuidB64) {
return false;
}
return Number(this.itemRefreshCounts[uuidB64] || 0) > 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();
this.persistRuntimeCache();
} 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 incomingItems = Array.isArray(group.items) ? group.items : [];
const hasDetailedIncomingItems = incomingItems.some((item) => !isGroupedChildStub(item));
const preservedItems = hasDetailedIncomingItems
? incomingItems
: existing?.items || incomingItems;
return this.indexGroup({
...existing,
...group,
items: preservedItems,
});
});
this.groupedEntries = sortGroupedEntries(nextGroups);
this.groupedLoaded = true;
this.groupedHydrated = false;
this.groupedVersion += 1;
this.pruneOpenGroupedCards();
this.invalidateMemo();
this.persistRuntimeCache();
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 incomingItems = Array.isArray(group.items) ? group.items : [];
const hasDetailedIncomingItems = incomingItems.some((item) => !isGroupedChildStub(item));
const mergedItems = hasDetailedIncomingItems
? incomingItems
: existing?.items || incomingItems;
return this.indexGroup({
...existing,
...group,
items: mergedItems,
});
});
this.groupedEntries = sortGroupedEntries(nextGroups);
this.groupedLoaded = true;
this.groupedHydrated = true;
this.groupedVersion += 1;
this.pruneOpenGroupedCards();
this.invalidateMemo();
this.persistRuntimeCache();
},
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();
this.persistRuntimeCache();
}
},
resetGroupedVisibleLimit() {
this.groupedVisibleLimit = this.groupedPageSize;
},
showMoreGroups() {
this.groupedVisibleLimit += this.groupedPageSize;
},
groupDisplayItems(group) {
if (!Array.isArray(group?.items)) {
return [];
}
return group.items.filter((item) => !isGroupedChildStub(item));
},
hasGroupedChildStubs(group) {
if (!Array.isArray(group?.items)) {
return false;
}
return group.items.some((item) => isGroupedChildStub(item));
},
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),
);
},
onItemDetailNavigate(entry, event) {
if (this.isItemRefreshing(entry)) {
event.preventDefault();
return;
}
this.rememberStockListContext(entry?.uuid_b64);
},
detailHref(entry) {
return `#/stock/${entry.uuid_b64}`;
},
refreshGroupFromItems(group) {
if (!group || !Array.isArray(group.items) || !group.items.length) {
return group;
}
const sortedByDateDesc = [...group.items].sort((left, right) =>
(right.date || '').localeCompare(left.date || ''),
);
const latestItem = sortedByDateDesc[0] || group;
const datedItems = group.items.filter((item) => item.date);
const productionDates = datedItems.map((item) => item.date).sort((left, right) =>
left.localeCompare(right),
);
const expirationDates = group.items
.filter((item) => item.expire_date)
.map((item) => item.expire_date)
.sort((left, right) => left.localeCompare(right));
const firstExpireDate = expirationDates[0] || null;
const firstProductionDate = productionDates[0] || null;
return {
...group,
items_count: Number.isFinite(group.items_count) ? group.items_count : group.items.length,
date: latestItem.date || group.date,
stock_type: latestItem.stock_type || group.stock_type,
level: latestItem.level || group.level,
quantity:
latestItem.quantity !== undefined && latestItem.quantity !== null
? latestItem.quantity
: group.quantity,
uom_symbol: latestItem.uom_symbol || group.uom_symbol,
location_initial_uuid_b64: latestItem.location_initial_uuid_b64 || null,
first_production_date: firstProductionDate,
first_expire_date: firstExpireDate,
first_expire_in: firstExpireDate ? daysUntilDate(firstExpireDate) : null,
expire_date: firstExpireDate,
};
},
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,
};
}
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,
applyUpdatedItemToLists(updatedEntry) {
if (!updatedEntry || typeof updatedEntry !== 'object' || !updatedEntry.uuid_b64) {
return;
}
let changedEntries = false;
if (this.entries.length) {
this.entries = sortEntries(
this.entries.map((entry) => {
if (entry.uuid_b64 !== updatedEntry.uuid_b64) {
return entry;
}
changedEntries = true;
return this.indexEntry({
...entry,
...updatedEntry,
});
}),
);
}
if (changedEntries) {
this.entriesVersion += 1;
}
let changedGrouped = false;
if (this.groupedEntries.length) {
this.groupedEntries = sortGroupedEntries(
this.groupedEntries.map((group) => {
if (!Array.isArray(group.items) || !group.items.length) {
return group;
}
let groupChanged = false;
const nextItems = group.items.map((item) => {
if (item.uuid_b64 !== updatedEntry.uuid_b64) {
return item;
}
groupChanged = true;
return this.indexEntry({
...item,
...updatedEntry,
});
});
if (!groupChanged) {
return group;
}
changedGrouped = true;
return this.indexGroup(this.refreshGroupFromItems({
...group,
items: nextItems,
}));
}),
);
}
if (changedGrouped) {
this.groupedVersion += 1;
}
if (!changedEntries && !changedGrouped) {
return;
}
this.invalidateMemo();
if (updatedEntry.id !== undefined && updatedEntry.id !== null) {
this.editForms[updatedEntry.id] = {
level: updatedEntry.level || 'plenty',
quantity: updatedEntry.quantity ?? '',
};
}
this.persistRuntimeCache();
},
async updateBinary(entry) {
if (this.isItemRefreshing(entry)) {
return;
}
await this.useEntry(entry, 'consumed');
},
async saveLevel(entry) {
const level = this.editForms[entry.id]?.level || 'plenty';
if (level === 'gone') {
await this.useEntry(entry, 'consumed');
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, reason = 'consumed') {
if (this.isItemRefreshing(entry)) {
return;
}
await this.useEntry(entry, reason);
},
async markGoneFromGroup(item, group, reason = 'consumed') {
if (this.isItemRefreshing(item)) {
return;
}
this.editErrors[item.id] = '';
try {
const result = await markStockGone(store, item.uuid_b64, reason);
const alreadyGone = result.status === 'already_gone';
const actionLabel = reason === 'spoiled' ? 'marked spoilt' : 'marked used';
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 ${actionLabel} and removed from the group.`,
});
this.loadGroupedEntries({ expanded: 0, background: true }).catch(() => {});
} catch (error) {
this.editErrors[item.id] = error.message || 'Removal 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, reason = 'consumed') {
if (this.isItemRefreshing(entry)) {
return;
}
this.editErrors[entry.id] = '';
try {
const result = await markStockGone(store, entry.uuid_b64, reason);
const alreadyGone = result.status === 'already_gone';
const actionLabel = reason === 'spoiled' ? 'marked spoilt' : 'marked used';
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 ${actionLabel} and removed from the list.`,
});
this.refreshLoadedViewsInBackground().catch(() => {});
} catch (error) {
this.editErrors[entry.id] = error.message || 'Removal failed.';
}
},
removeEntryLocally(entryId) {
if (!this.entries.length) {
return;
}
this.entries = this.entries.filter((entry) => entry.id !== entryId);
this.entriesVersion += 1;
this.invalidateMemo();
this.persistRuntimeCache();
},
removeEntryByUuid(uuidB64) {
if (!uuidB64 || !this.entries.length) {
return false;
}
const removed = this.entries.filter((entry) => entry.uuid_b64 === uuidB64);
if (!removed.length) {
return false;
}
this.entries = this.entries.filter((entry) => entry.uuid_b64 !== uuidB64);
removed.forEach((entry) => {
delete this.editForms[entry.id];
delete this.editErrors[entry.id];
});
this.entriesVersion += 1;
this.invalidateMemo();
return true;
},
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 ?? '',
};
this.persistRuntimeCache();
},
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(this.refreshGroupFromItems({
...group,
items_count: Number.isFinite(group.items_count)
? Math.max(0, group.items_count - 1)
: nextItems.length,
items: nextItems,
}));
})
.filter(Boolean),
);
this.groupedVersion += 1;
this.pruneOpenGroupedCards();
this.invalidateMemo();
this.persistRuntimeCache();
},
removeGroupedItemByUuid(uuidB64) {
if (!uuidB64 || !this.groupedEntries.length) {
return false;
}
let changed = false;
const nextGroups = this.groupedEntries
.map((group) => {
if (!Array.isArray(group.items) || !group.items.length) {
return group;
}
const removedCount = group.items.filter((item) => item.uuid_b64 === uuidB64).length;
if (!removedCount) {
return group;
}
changed = true;
const nextItems = group.items.filter((item) => item.uuid_b64 !== uuidB64);
const hasKnownCount = Number.isFinite(group.items_count);
const nextCount = hasKnownCount ? Math.max(0, group.items_count - removedCount) : null;
if (!nextItems.length && hasKnownCount && nextCount <= 0) {
return null;
}
return this.indexGroup(this.refreshGroupFromItems({
...group,
items: nextItems,
...(hasKnownCount ? { items_count: nextCount } : {}),
}));
})
.filter(Boolean);
if (!changed) {
return false;
}
this.groupedEntries = sortGroupedEntries(nextGroups);
this.groupedVersion += 1;
this.pruneOpenGroupedCards();
this.invalidateMemo();
return true;
},
};
}