2026-04-07 00:41:55 +02:00
|
|
|
import {
|
|
|
|
|
listGroupedStockEntries,
|
2026-04-12 13:05:14 +02:00
|
|
|
listKitchenChanges,
|
2026-04-07 00:41:55 +02:00
|
|
|
listStockEntries,
|
|
|
|
|
updateStockItem,
|
2026-04-10 15:43:39 +02:00
|
|
|
useStockItem,
|
2026-04-07 00:41:55 +02:00
|
|
|
} from '../../api/stock.js';
|
2026-04-06 09:24:22 +02:00
|
|
|
import { fetchLocations } from '../../api/locations.js';
|
2026-04-12 13:05:14 +02:00
|
|
|
import { createAsyncState } from '../shared/ui-state.js';
|
2026-04-06 09:24:22 +02:00
|
|
|
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.' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-04-06 21:34:02 +02:00
|
|
|
const EXPIRATION_KEYS = EXPIRATION_LEGEND.map((state) => state.key);
|
2026-04-12 13:05:14 +02:00
|
|
|
const GROUPED_PAGE_SIZE = 24;
|
|
|
|
|
const CHANGE_POLL_INTERVAL_MS = 60 * 1000;
|
2026-04-06 21:34:02 +02:00
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
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) {
|
2026-04-07 00:41:55 +02:00
|
|
|
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) {
|
2026-04-06 09:24:22 +02:00
|
|
|
return {
|
|
|
|
|
key: 'none',
|
|
|
|
|
label: 'No expiration date',
|
|
|
|
|
detail: 'No expiration date',
|
|
|
|
|
sortRank: 4,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const today = todayAtMidnight();
|
2026-04-07 00:41:55 +02:00
|
|
|
const expireDate = parseDateValue(expireDateValue);
|
2026-04-06 09:24:22 +02:00
|
|
|
const expireIn =
|
2026-04-07 00:41:55 +02:00
|
|
|
typeof expireInValue === 'number'
|
|
|
|
|
? expireInValue
|
|
|
|
|
: expireDate
|
|
|
|
|
? Math.round((expireDate - today) / (24 * 60 * 60 * 1000))
|
|
|
|
|
: null;
|
2026-04-06 09:24:22 +02:00
|
|
|
|
|
|
|
|
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 || '');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 00:41:55 +02:00
|
|
|
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 || '');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 00:41:55 +02:00
|
|
|
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()}...`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
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>
|
|
|
|
|
|
2026-04-07 00:41:55 +02:00
|
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
|
|
|
<div class="card-body p-3">
|
2026-04-12 13:05:14 +02:00
|
|
|
<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>
|
2026-04-07 00:41:55 +02:00
|
|
|
<button
|
2026-04-12 13:05:14 +02:00
|
|
|
class="btn btn-outline-secondary"
|
2026-04-07 00:41:55 +02:00
|
|
|
type="button"
|
2026-04-12 13:05:14 +02:00
|
|
|
@click="refreshCurrentView()"
|
|
|
|
|
:disabled="state.isLoading || state.isRefreshing"
|
2026-04-07 00:41:55 +02:00
|
|
|
>
|
2026-04-12 13:05:14 +02:00
|
|
|
<span x-show="!state.isLoading && !state.isRefreshing">Refresh</span>
|
|
|
|
|
<span x-show="state.isLoading || state.isRefreshing">Refreshing...</span>
|
2026-04-07 00:41:55 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-04-12 13:05:14 +02:00
|
|
|
</div>
|
|
|
|
|
<div class="small text-body-secondary mt-2" x-show="state.isRefreshing && !state.isLoading">
|
|
|
|
|
Updating in background...
|
2026-04-07 00:41:55 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
<div class="card border-0 shadow-sm mb-4">
|
|
|
|
|
<div class="card-body p-4">
|
2026-04-06 21:34:02 +02:00
|
|
|
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
|
|
|
|
<label class="form-label mb-0">Search stock</label>
|
|
|
|
|
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click="clearFilters()">Reset</button>
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
<div class="row g-3">
|
|
|
|
|
<div class="col-12">
|
|
|
|
|
<input
|
|
|
|
|
class="form-control"
|
|
|
|
|
type="text"
|
2026-04-12 13:05:14 +02:00
|
|
|
x-model.debounce.250ms="filters.search"
|
2026-04-06 09:24:22 +02:00
|
|
|
placeholder="Search by item, description, location, or id"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-06 21:34:02 +02:00
|
|
|
<div class="row g-4 mb-4 align-items-start" :class="overviewRowClass()">
|
|
|
|
|
<div class="col-12" :class="overviewColClass('expiration')">
|
|
|
|
|
<details
|
|
|
|
|
class="card border-0 shadow-sm overview-panel"
|
|
|
|
|
x-ref="expirationOverview"
|
|
|
|
|
@toggle="setOverviewOpen('expiration', $event.target.open)"
|
|
|
|
|
>
|
|
|
|
|
<summary class="card-body p-4 overview-summary">
|
|
|
|
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="h5 mb-1">Expiration overview</h2>
|
|
|
|
|
<p class="text-body-secondary small mb-0">Tap to focus on one or more expiration states.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|
|
|
|
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllExpirationFilters()">Show all</button>
|
|
|
|
|
<div class="small text-body-secondary text-end">
|
2026-04-07 00:41:55 +02:00
|
|
|
<span class="fw-semibold text-body" x-text="visibleResultCount"></span>
|
|
|
|
|
<span x-text="viewMode === 'grouped' ? 'group(s) visible' : 'item(s) visible'"></span>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-06 21:34:02 +02:00
|
|
|
</summary>
|
|
|
|
|
<div class="card-body pt-0 px-4 pb-4">
|
|
|
|
|
<div class="overview-list" :class="{ 'overview-list-split': isOnlyOverviewOpen('expiration') }">
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="col-12" :class="overviewColClass('location')">
|
|
|
|
|
<details
|
|
|
|
|
class="card border-0 shadow-sm overview-panel"
|
|
|
|
|
x-ref="locationOverview"
|
|
|
|
|
@toggle="setOverviewOpen('location', $event.target.open)"
|
|
|
|
|
>
|
|
|
|
|
<summary class="card-body p-4 overview-summary">
|
|
|
|
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="h5 mb-1">Location overview</h2>
|
|
|
|
|
<p class="text-body-secondary small mb-0">Tap locations to focus the list. Parent locations include their children.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="d-flex flex-column align-items-end gap-1">
|
|
|
|
|
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllLocations()">Show all</button>
|
|
|
|
|
<div class="small text-body-secondary text-end" x-text="selectedLocationSummary()"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</summary>
|
|
|
|
|
<div class="card-body pt-0 px-4 pb-4">
|
|
|
|
|
<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>
|
2026-04-06 09:24:22 +02:00
|
|
|
</div>
|
2026-04-06 21:34:02 +02:00
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
<template x-if="showInitialLoader()">
|
2026-04-06 09:24:22 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-07 00:41:55 +02:00
|
|
|
<template x-if="!state.isLoading && !state.error && viewMode === 'items' && !filteredEntries.length">
|
2026-04-06 09:24:22 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-04-07 00:41:55 +02:00
|
|
|
<template x-if="!state.isLoading && !state.error && viewMode === 'items' && filteredEntries.length">
|
2026-04-06 09:24:22 +02:00
|
|
|
<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>
|
2026-04-07 00:41:55 +02:00
|
|
|
<a class="small text-decoration-none fw-semibold" :href="detailHref(entry)">View item</a>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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" @click="updateBinary(entry, 'gone')">Mark gone</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>
|
2026-04-06 21:34:02 +02:00
|
|
|
<button class="btn btn-sm btn-outline-danger" type="button" @click="markGone(entry)">Mark gone</button>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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>
|
2026-04-06 21:34:02 +02:00
|
|
|
<button class="btn btn-sm btn-outline-danger" type="button" @click="markGone(entry)">Mark gone</button>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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>
|
2026-04-07 00:41:55 +02:00
|
|
|
<a class="small text-decoration-none fw-semibold" :href="detailHref(entry)">View item</a>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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" @click="updateBinary(entry, 'gone')">Mark gone</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>
|
2026-04-06 21:34:02 +02:00
|
|
|
<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" @click="markGone(entry)">Mark gone</button>
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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>
|
2026-04-06 21:34:02 +02:00
|
|
|
<button class="btn btn-sm btn-outline-danger" type="button" @click="markGone(entry)">Mark gone</button>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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>
|
2026-04-07 00:41:55 +02:00
|
|
|
|
|
|
|
|
<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">
|
2026-04-12 13:05:14 +02:00
|
|
|
Try clearing the filters or create a new stock label.
|
2026-04-07 00:41:55 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-if="!state.isLoading && !state.error && viewMode === 'grouped' && filteredGroupedEntries.length">
|
|
|
|
|
<div class="d-grid gap-3">
|
2026-04-12 13:05:14 +02:00
|
|
|
<template x-for="group in visibleGroupedEntries" :key="group.id">
|
2026-04-07 00:41:55 +02:00
|
|
|
<details
|
|
|
|
|
class="card border-0 shadow-sm grouped-stock-card"
|
|
|
|
|
:class="rowClass(group)"
|
2026-04-12 13:05:14 +02:00
|
|
|
:data-group-id="String(group.id)"
|
2026-04-07 00:41:55 +02:00
|
|
|
@toggle="handleGroupedToggle($event)"
|
|
|
|
|
>
|
|
|
|
|
<summary class="card-body p-4 grouped-stock-summary">
|
|
|
|
|
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="fw-semibold fs-5" x-text="group.name"></div>
|
|
|
|
|
<div class="text-body-secondary small mb-2" x-text="group.description || 'No description'"></div>
|
2026-04-12 13:05:14 +02:00
|
|
|
<div class="d-flex flex-wrap gap-3 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">Latest quantity:</span> <span class="fw-semibold text-body" x-text="quantityLabel(group)"></span></span>
|
2026-04-07 00:41:55 +02:00
|
|
|
</div>
|
2026-04-12 13:05:14 +02:00
|
|
|
<a class="small text-decoration-none fw-semibold d-inline-block mt-2" :href="groupDetailHref(group)" x-show="groupDetailHref(group)">View item</a>
|
2026-04-07 00:41:55 +02:00
|
|
|
</div>
|
|
|
|
|
<div class="d-flex flex-column align-items-xl-end gap-2">
|
|
|
|
|
<span class="badge rounded-pill align-self-start align-self-xl-end" :class="badgeClass(group)" x-text="expirationFor(group).label"></span>
|
|
|
|
|
<div class="small text-body-secondary text-xl-end">
|
|
|
|
|
<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">Show items</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</summary>
|
|
|
|
|
<div class="card-body pt-0 px-4 pb-4">
|
2026-04-12 13:05:14 +02:00
|
|
|
<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>
|
2026-04-07 00:41:55 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
<template x-if="isGroupedCardOpen(group.id)">
|
|
|
|
|
<div>
|
|
|
|
|
<template x-if="group.items?.length">
|
|
|
|
|
<div class="grouped-stock-items">
|
|
|
|
|
<template x-for="item in group.items" :key="item.id">
|
|
|
|
|
<div class="grouped-stock-item" :class="groupedItemClass(item)">
|
|
|
|
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="fw-semibold" x-text="item.name"></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>
|
|
|
|
|
<a class="small text-decoration-none fw-semibold" :href="detailHref(item)">View item</a>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="d-flex flex-wrap gap-3 small text-body-secondary grouped-stock-item-meta">
|
|
|
|
|
<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>
|
|
|
|
|
<span class="font-monospace" x-text="shortId(item)"></span>
|
|
|
|
|
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" @click="markGoneFromGroup(item, group)">Mark gone</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<template x-if="editErrors[item.id]">
|
|
|
|
|
<div class="small text-danger mt-2" x-text="editErrors[item.id]"></div>
|
|
|
|
|
</template>
|
2026-04-07 00:41:55 +02:00
|
|
|
</div>
|
2026-04-12 13:05:14 +02:00
|
|
|
</template>
|
2026-04-07 00:41:55 +02:00
|
|
|
</div>
|
2026-04-12 13:05:14 +02:00
|
|
|
</template>
|
|
|
|
|
<template x-if="!group.items?.length && groupedHydrating">
|
|
|
|
|
<div class="small text-body-secondary py-2">Loading grouped items...</div>
|
|
|
|
|
</template>
|
|
|
|
|
<template x-if="!group.items?.length && !groupedHydrating">
|
|
|
|
|
<div class="small text-body-secondary py-2">No items currently available in this group.</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-04-07 00:41:55 +02:00
|
|
|
|
|
|
|
|
<div class="mt-3 d-flex justify-content-end">
|
|
|
|
|
<button
|
|
|
|
|
class="btn btn-link p-0 text-decoration-none grouped-stock-close"
|
|
|
|
|
type="button"
|
|
|
|
|
@click.prevent="closeGroupedCard($el.closest('details'))"
|
|
|
|
|
>
|
|
|
|
|
Close group
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
</template>
|
2026-04-12 13:05:14 +02:00
|
|
|
<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>
|
2026-04-07 00:41:55 +02:00
|
|
|
</div>
|
|
|
|
|
</template>
|
2026-04-06 09:24:22 +02:00
|
|
|
</section>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function stockListPageData(store) {
|
|
|
|
|
return {
|
2026-04-12 13:05:14 +02:00
|
|
|
state: {
|
|
|
|
|
...createAsyncState(),
|
|
|
|
|
isRefreshing: false,
|
|
|
|
|
},
|
|
|
|
|
viewMode: 'grouped',
|
2026-04-06 09:24:22 +02:00
|
|
|
entries: [],
|
2026-04-12 13:05:14 +02:00
|
|
|
entriesVersion: 0,
|
|
|
|
|
itemsLoaded: false,
|
2026-04-07 00:41:55 +02:00
|
|
|
groupedEntries: [],
|
2026-04-12 13:05:14 +02:00
|
|
|
groupedVersion: 0,
|
2026-04-07 00:41:55 +02:00
|
|
|
groupedLoaded: false,
|
2026-04-12 13:05:14 +02:00
|
|
|
groupedHydrated: false,
|
|
|
|
|
groupedHydrating: false,
|
|
|
|
|
groupedPageSize: GROUPED_PAGE_SIZE,
|
|
|
|
|
groupedVisibleLimit: GROUPED_PAGE_SIZE,
|
|
|
|
|
openGroupedCards: {},
|
|
|
|
|
refreshActivityCount: 0,
|
2026-04-06 09:24:22 +02:00
|
|
|
locations: [],
|
2026-04-12 13:05:14 +02:00
|
|
|
locationsVersion: 0,
|
2026-04-06 09:24:22 +02:00
|
|
|
locationMap: {},
|
|
|
|
|
locationDescendants: {},
|
2026-04-12 13:05:14 +02:00
|
|
|
locationLineage: {},
|
2026-04-06 09:24:22 +02:00
|
|
|
editForms: {},
|
|
|
|
|
editErrors: {},
|
|
|
|
|
levelOptions: LEVEL_OPTIONS,
|
|
|
|
|
expirationLegend: EXPIRATION_LEGEND,
|
2026-04-06 21:34:02 +02:00
|
|
|
overviewOpen: {
|
|
|
|
|
expiration: false,
|
|
|
|
|
location: false,
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
filters: {
|
|
|
|
|
search: '',
|
2026-04-06 21:34:02 +02:00
|
|
|
expiration: [],
|
|
|
|
|
location: [],
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
memo: {
|
|
|
|
|
filteredEntriesSig: '',
|
|
|
|
|
filteredEntries: [],
|
|
|
|
|
filteredGroupedEntriesSig: '',
|
|
|
|
|
filteredGroupedEntries: [],
|
|
|
|
|
expirationCountsSig: '',
|
|
|
|
|
expirationCounts: {},
|
|
|
|
|
locationCountsSig: '',
|
|
|
|
|
locationCounts: {},
|
|
|
|
|
},
|
|
|
|
|
changeCursor: null,
|
|
|
|
|
changePollTimer: null,
|
|
|
|
|
routeChangeHandler: null,
|
|
|
|
|
isPollingChanges: false,
|
2026-04-06 09:24:22 +02:00
|
|
|
async init() {
|
2026-04-06 18:31:31 +02:00
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-12 13:05:14 +02:00
|
|
|
|
|
|
|
|
this.registerRouteCleanup();
|
|
|
|
|
await Promise.all([
|
|
|
|
|
this.loadLocations(),
|
|
|
|
|
this.loadGroupedEntries({ expanded: 0, resetVisible: true }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await this.$nextTick();
|
|
|
|
|
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
|
|
|
|
await this.primeChangeCursor();
|
|
|
|
|
this.startChangePolling();
|
|
|
|
|
},
|
|
|
|
|
registerRouteCleanup() {
|
|
|
|
|
if (this.routeChangeHandler) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.routeChangeHandler = () => {
|
|
|
|
|
if (!this.isStockListRoute()) {
|
|
|
|
|
this.stopChangePolling();
|
|
|
|
|
window.removeEventListener('hashchange', this.routeChangeHandler);
|
|
|
|
|
this.routeChangeHandler = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('hashchange', this.routeChangeHandler);
|
|
|
|
|
},
|
|
|
|
|
isStockListRoute() {
|
|
|
|
|
const route = (window.location.hash || '#/').replace(/^#/, '') || '/';
|
|
|
|
|
return route === '/stock';
|
|
|
|
|
},
|
|
|
|
|
async primeChangeCursor() {
|
|
|
|
|
try {
|
|
|
|
|
const payload = await listKitchenChanges(store, { limit: 1 });
|
|
|
|
|
this.changeCursor = payload.nextCursor || payload.since || this.changeCursor;
|
|
|
|
|
} catch {
|
|
|
|
|
this.changeCursor = this.changeCursor || null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
startChangePolling() {
|
|
|
|
|
if (this.changePollTimer) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.changePollTimer = window.setInterval(() => {
|
|
|
|
|
this.pollKitchenChanges().catch(() => {});
|
|
|
|
|
}, CHANGE_POLL_INTERVAL_MS);
|
|
|
|
|
},
|
|
|
|
|
stopChangePolling() {
|
|
|
|
|
if (this.changePollTimer) {
|
|
|
|
|
window.clearInterval(this.changePollTimer);
|
|
|
|
|
this.changePollTimer = null;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
async pollKitchenChanges() {
|
|
|
|
|
if (this.isPollingChanges || !store.isConnected || !this.isStockListRoute()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.isPollingChanges = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload = await listKitchenChanges(store, {
|
|
|
|
|
since: this.changeCursor || undefined,
|
|
|
|
|
limit: 25,
|
|
|
|
|
});
|
|
|
|
|
this.changeCursor = payload.nextCursor || payload.since || this.changeCursor;
|
|
|
|
|
|
|
|
|
|
if (!payload.changes.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.refreshCurrentView({ background: true });
|
|
|
|
|
} finally {
|
|
|
|
|
this.isPollingChanges = false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
showInitialLoader() {
|
|
|
|
|
if (!this.state.isLoading) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.viewMode === 'grouped' ? !this.groupedLoaded : !this.itemsLoaded;
|
|
|
|
|
},
|
|
|
|
|
beginBackgroundRefresh() {
|
|
|
|
|
this.refreshActivityCount += 1;
|
|
|
|
|
this.state.isRefreshing = true;
|
|
|
|
|
},
|
|
|
|
|
endBackgroundRefresh() {
|
|
|
|
|
this.refreshActivityCount = Math.max(0, this.refreshActivityCount - 1);
|
|
|
|
|
this.state.isRefreshing = this.refreshActivityCount > 0;
|
|
|
|
|
},
|
|
|
|
|
invalidateMemo() {
|
|
|
|
|
this.memo.filteredEntriesSig = '';
|
|
|
|
|
this.memo.filteredGroupedEntriesSig = '';
|
|
|
|
|
this.memo.expirationCountsSig = '';
|
|
|
|
|
this.memo.locationCountsSig = '';
|
|
|
|
|
},
|
|
|
|
|
indexEntry(entry) {
|
|
|
|
|
const indexed = { ...entry };
|
|
|
|
|
indexed._searchBlob = searchBlob(indexed, this.locationMap);
|
|
|
|
|
return indexed;
|
|
|
|
|
},
|
|
|
|
|
indexGroup(group) {
|
|
|
|
|
const indexedItems = Array.isArray(group.items)
|
|
|
|
|
? group.items.map((item) => this.indexEntry(item))
|
|
|
|
|
: [];
|
|
|
|
|
const indexed = {
|
|
|
|
|
...group,
|
|
|
|
|
items: indexedItems,
|
|
|
|
|
};
|
|
|
|
|
indexed._searchBlob = groupSearchBlob(indexed, this.locationMap);
|
|
|
|
|
return indexed;
|
|
|
|
|
},
|
|
|
|
|
reindexSearchData() {
|
|
|
|
|
if (this.entries.length) {
|
|
|
|
|
this.entries = this.entries.map((entry) => this.indexEntry(entry));
|
|
|
|
|
this.entriesVersion += 1;
|
|
|
|
|
}
|
|
|
|
|
if (this.groupedEntries.length) {
|
|
|
|
|
this.groupedEntries = this.groupedEntries.map((group) => this.indexGroup(group));
|
|
|
|
|
this.groupedVersion += 1;
|
|
|
|
|
}
|
|
|
|
|
this.invalidateMemo();
|
|
|
|
|
},
|
|
|
|
|
syncEditFormsFromEntries() {
|
|
|
|
|
this.editForms = Object.fromEntries(
|
|
|
|
|
this.entries.map((entry) => [
|
|
|
|
|
entry.id,
|
|
|
|
|
{
|
|
|
|
|
level: entry.level || 'plenty',
|
|
|
|
|
quantity: entry.quantity ?? '',
|
|
|
|
|
},
|
|
|
|
|
]),
|
|
|
|
|
);
|
|
|
|
|
this.editErrors = {};
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-07 00:41:55 +02:00
|
|
|
async switchView(mode) {
|
|
|
|
|
this.viewMode = mode;
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
if (mode === 'grouped') {
|
|
|
|
|
if (!this.groupedLoaded) {
|
|
|
|
|
await this.loadGroupedEntries({ expanded: 0, resetVisible: true });
|
|
|
|
|
}
|
|
|
|
|
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.itemsLoaded) {
|
|
|
|
|
await this.loadEntries();
|
2026-04-07 00:41:55 +02:00
|
|
|
}
|
|
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
async refreshCurrentView({ background = false } = {}) {
|
|
|
|
|
const useBackground = background || (this.viewMode === 'grouped'
|
|
|
|
|
? this.groupedLoaded
|
|
|
|
|
: this.itemsLoaded);
|
|
|
|
|
|
2026-04-07 00:41:55 +02:00
|
|
|
if (this.viewMode === 'grouped') {
|
2026-04-12 13:05:14 +02:00
|
|
|
await this.loadGroupedEntries({ expanded: 0, background: useBackground });
|
|
|
|
|
this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {});
|
2026-04-07 00:41:55 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
await this.loadEntries({ background: useBackground });
|
2026-04-07 00:41:55 +02:00
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
async loadEntries({ background = false } = {}) {
|
2026-04-06 18:31:31 +02:00
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
const shouldBlock = !background && !this.itemsLoaded;
|
|
|
|
|
if (background) {
|
|
|
|
|
this.beginBackgroundRefresh();
|
|
|
|
|
} else if (shouldBlock) {
|
|
|
|
|
this.state.isLoading = true;
|
|
|
|
|
this.state.error = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-06 09:24:22 +02:00
|
|
|
const loadedEntries = await listStockEntries(store);
|
2026-04-12 13:05:14 +02:00
|
|
|
this.entries = sortEntries(loadedEntries.map((entry) => this.indexEntry(entry)));
|
|
|
|
|
this.itemsLoaded = true;
|
|
|
|
|
this.entriesVersion += 1;
|
|
|
|
|
this.syncEditFormsFromEntries();
|
|
|
|
|
this.invalidateMemo();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!background) {
|
|
|
|
|
this.state.error = error.message || 'Could not load stock items.';
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (background) {
|
|
|
|
|
this.endBackgroundRefresh();
|
|
|
|
|
} else if (shouldBlock) {
|
|
|
|
|
this.state.isLoading = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
applyGroupedSummary(loadedGroups, { resetVisible = false } = {}) {
|
|
|
|
|
const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
|
|
|
|
|
const nextGroups = loadedGroups.map((group) => {
|
|
|
|
|
const existing = existingById.get(group.id);
|
|
|
|
|
const preservedItems = Array.isArray(group.items) ? group.items : existing?.items || [];
|
|
|
|
|
return this.indexGroup({
|
|
|
|
|
...existing,
|
|
|
|
|
...group,
|
|
|
|
|
items: preservedItems,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.groupedEntries = sortGroupedEntries(nextGroups);
|
|
|
|
|
this.groupedLoaded = true;
|
|
|
|
|
this.groupedHydrated = false;
|
|
|
|
|
this.groupedVersion += 1;
|
|
|
|
|
this.pruneOpenGroupedCards();
|
|
|
|
|
this.invalidateMemo();
|
|
|
|
|
|
|
|
|
|
if (resetVisible) {
|
|
|
|
|
this.resetGroupedVisibleLimit();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
applyGroupedHydration(loadedGroups) {
|
|
|
|
|
const existingById = new Map(this.groupedEntries.map((group) => [group.id, group]));
|
|
|
|
|
const nextGroups = loadedGroups.map((group) => {
|
|
|
|
|
const existing = existingById.get(group.id);
|
|
|
|
|
const mergedItems = Array.isArray(group.items) ? group.items : existing?.items || [];
|
|
|
|
|
return this.indexGroup({
|
|
|
|
|
...existing,
|
|
|
|
|
...group,
|
|
|
|
|
items: mergedItems,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.groupedEntries = sortGroupedEntries(nextGroups);
|
|
|
|
|
this.groupedLoaded = true;
|
|
|
|
|
this.groupedHydrated = true;
|
|
|
|
|
this.groupedVersion += 1;
|
|
|
|
|
this.pruneOpenGroupedCards();
|
|
|
|
|
this.invalidateMemo();
|
|
|
|
|
},
|
|
|
|
|
async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
|
2026-04-07 00:41:55 +02:00
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
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);
|
2026-04-07 00:41:55 +02:00
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
async loadLocations() {
|
2026-04-06 18:31:31 +02:00
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
try {
|
|
|
|
|
const { flat } = await fetchLocations(store);
|
|
|
|
|
this.locations = flat;
|
|
|
|
|
this.locationMap = Object.fromEntries(
|
|
|
|
|
flat.map((location) => [location.uuid_b64, location.pathLabel]),
|
|
|
|
|
);
|
2026-04-12 13:05:14 +02:00
|
|
|
this.locationLineage = Object.fromEntries(
|
|
|
|
|
flat.map((location) => [location.uuid_b64, location.lineage_uuid_b64 || []]),
|
|
|
|
|
);
|
2026-04-06 09:24:22 +02:00
|
|
|
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 = {};
|
2026-04-12 13:05:14 +02:00
|
|
|
this.locationLineage = {};
|
2026-04-06 09:24:22 +02:00
|
|
|
this.locationDescendants = {};
|
2026-04-12 13:05:14 +02:00
|
|
|
} finally {
|
|
|
|
|
this.locationsVersion += 1;
|
|
|
|
|
this.reindexSearchData();
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
resetGroupedVisibleLimit() {
|
|
|
|
|
this.groupedVisibleLimit = this.groupedPageSize;
|
|
|
|
|
},
|
|
|
|
|
showMoreGroups() {
|
|
|
|
|
this.groupedVisibleLimit += this.groupedPageSize;
|
|
|
|
|
},
|
|
|
|
|
groupItemCount(group) {
|
|
|
|
|
if (Number.isFinite(group.items_count)) {
|
|
|
|
|
return group.items_count;
|
|
|
|
|
}
|
|
|
|
|
if (Number.isFinite(group.count)) {
|
|
|
|
|
return group.count;
|
|
|
|
|
}
|
|
|
|
|
return Array.isArray(group.items) ? group.items.length : 0;
|
|
|
|
|
},
|
|
|
|
|
groupDetailHref(group) {
|
|
|
|
|
if (group?.uuid_b64) {
|
|
|
|
|
return this.detailHref(group);
|
|
|
|
|
}
|
|
|
|
|
const firstItem = Array.isArray(group?.items) ? group.items[0] : null;
|
|
|
|
|
return firstItem?.uuid_b64 ? this.detailHref(firstItem) : '';
|
|
|
|
|
},
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
clearFilters() {
|
|
|
|
|
this.filters = {
|
|
|
|
|
search: '',
|
2026-04-06 21:34:02 +02:00
|
|
|
expiration: [],
|
|
|
|
|
location: [],
|
2026-04-06 09:24:22 +02:00
|
|
|
};
|
2026-04-12 13:05:14 +02:00
|
|
|
this.resetGroupedVisibleLimit();
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-06 21:34:02 +02:00
|
|
|
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';
|
|
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
isAllExpirationSelected() {
|
|
|
|
|
return (
|
|
|
|
|
this.filters.expiration.length === 0
|
|
|
|
|
|| this.filters.expiration.length === EXPIRATION_KEYS.length
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
effectiveExpirationFilters() {
|
2026-04-06 21:34:02 +02:00
|
|
|
if (this.isAllExpirationSelected()) {
|
2026-04-12 13:05:14 +02:00
|
|
|
return [];
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
2026-04-12 13:05:14 +02:00
|
|
|
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 [];
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
2026-04-12 13:05:14 +02:00
|
|
|
return unique;
|
2026-04-06 21:34:02 +02:00
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
normalizedSearchTerm() {
|
|
|
|
|
return String(this.filters.search || '').trim().toLowerCase();
|
2026-04-06 21:34:02 +02:00
|
|
|
},
|
|
|
|
|
toggleAllExpirationFilters() {
|
|
|
|
|
this.filters.expiration = [];
|
2026-04-12 13:05:14 +02:00
|
|
|
this.resetGroupedVisibleLimit();
|
2026-04-06 21:34:02 +02:00
|
|
|
},
|
|
|
|
|
toggleExpirationOverviewFilter(key) {
|
|
|
|
|
if (this.isAllExpirationSelected()) {
|
|
|
|
|
this.filters.expiration = [key];
|
2026-04-12 13:05:14 +02:00
|
|
|
this.resetGroupedVisibleLimit();
|
2026-04-06 21:34:02 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.toggleExpirationFilter(key);
|
|
|
|
|
},
|
|
|
|
|
toggleExpirationFilter(key) {
|
|
|
|
|
if (this.filters.expiration.includes(key)) {
|
|
|
|
|
this.filters.expiration = this.filters.expiration.filter((value) => value !== key);
|
2026-04-12 13:05:14 +02:00
|
|
|
} else {
|
|
|
|
|
this.filters.expiration = [...this.filters.expiration, key];
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
2026-04-12 13:05:14 +02:00
|
|
|
this.resetGroupedVisibleLimit();
|
2026-04-06 21:34:02 +02:00
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
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);
|
2026-04-06 21:34:02 +02:00
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
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])];
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
2026-04-12 13:05:14 +02:00
|
|
|
this.resetGroupedVisibleLimit();
|
2026-04-06 21:34:02 +02:00
|
|
|
},
|
|
|
|
|
selectedLocationSummary() {
|
|
|
|
|
if (this.isAllLocationsSelected()) {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
return `${this.filters.location.length} selected`;
|
|
|
|
|
},
|
|
|
|
|
locationOverviewGroups() {
|
2026-04-12 13:05:14 +02:00
|
|
|
return this.locations
|
2026-04-06 21:34:02 +02:00
|
|
|
.filter((location) => location.depth === 0)
|
|
|
|
|
.map((parent) => ({
|
|
|
|
|
parent,
|
2026-04-12 13:05:14 +02:00
|
|
|
items: this.locations.filter(
|
|
|
|
|
(location) => location.lineage_uuid_b64[0] === parent.uuid_b64,
|
2026-04-06 21:34:02 +02:00
|
|
|
),
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
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;
|
|
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
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;
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
const activeExpirationFilters = this.effectiveExpirationFilters();
|
|
|
|
|
const activeLocationFilters = this.effectiveLocationFilters();
|
|
|
|
|
const filtered = this.entries.filter((entry) => {
|
|
|
|
|
if (searchTerm && !(entry._searchBlob || '').includes(searchTerm)) {
|
2026-04-06 09:24:22 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (
|
2026-04-12 13:05:14 +02:00
|
|
|
activeExpirationFilters.length
|
|
|
|
|
&& !activeExpirationFilters.includes(expirationInfo(entry).key)
|
2026-04-06 09:24:22 +02:00
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (
|
2026-04-12 13:05:14 +02:00
|
|
|
activeLocationFilters.length
|
|
|
|
|
&& !this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, activeLocationFilters)
|
2026-04-06 09:24:22 +02:00
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
2026-04-12 13:05:14 +02:00
|
|
|
|
|
|
|
|
this.memo.filteredEntriesSig = signature;
|
|
|
|
|
this.memo.filteredEntries = filtered;
|
|
|
|
|
return filtered;
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-07 00:41:55 +02:00
|
|
|
get filteredGroupedEntries() {
|
2026-04-12 13:05:14 +02:00
|
|
|
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)) {
|
2026-04-07 00:41:55 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (
|
2026-04-12 13:05:14 +02:00
|
|
|
activeExpirationFilters.length
|
|
|
|
|
&& !activeExpirationFilters.includes(expirationInfo(group).key)
|
2026-04-07 00:41:55 +02:00
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (
|
2026-04-12 13:05:14 +02:00
|
|
|
activeLocationFilters.length
|
|
|
|
|
&& !this.groupMatchesLocationFilters(group, activeLocationFilters)
|
2026-04-07 00:41:55 +02:00
|
|
|
) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
});
|
2026-04-12 13:05:14 +02:00
|
|
|
|
|
|
|
|
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;
|
2026-04-07 00:41:55 +02:00
|
|
|
},
|
|
|
|
|
get visibleResultCount() {
|
|
|
|
|
return this.viewMode === 'grouped'
|
|
|
|
|
? this.filteredGroupedEntries.length
|
|
|
|
|
: this.filteredEntries.length;
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
expirationFor(entry) {
|
|
|
|
|
return expirationInfo(entry);
|
|
|
|
|
},
|
2026-04-06 21:34:02 +02:00
|
|
|
isExpirationFilterActive(key) {
|
|
|
|
|
return this.isAllExpirationSelected() || this.filters.expiration.includes(key);
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
rowClass(entry) {
|
|
|
|
|
return `expiration-${expirationInfo(entry).key}`;
|
|
|
|
|
},
|
|
|
|
|
badgeClass(entry) {
|
|
|
|
|
return `expiration-badge-${expirationInfo(entry).key}`;
|
|
|
|
|
},
|
2026-04-07 00:41:55 +02:00
|
|
|
groupedItemClass(entry) {
|
|
|
|
|
return `expiration-soft-${expirationInfo(entry).key}`;
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
legendClass(key) {
|
2026-04-12 13:05:14 +02:00
|
|
|
const hasActiveFilters = !this.isAllExpirationSelected();
|
2026-04-06 21:34:02 +02:00
|
|
|
return [
|
|
|
|
|
`legend-${key}`,
|
|
|
|
|
hasActiveFilters && this.isExpirationFilterActive(key) ? 'legend-card-active' : '',
|
|
|
|
|
hasActiveFilters && !this.isExpirationFilterActive(key) ? 'legend-card-inactive' : '',
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(' ');
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
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();
|
2026-04-07 00:41:55 +02:00
|
|
|
const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries;
|
2026-04-12 13:05:14 +02:00
|
|
|
source.forEach((entry) => {
|
|
|
|
|
const blob = entry._searchBlob || '';
|
|
|
|
|
if (searchTerm && !blob.includes(searchTerm)) {
|
|
|
|
|
return;
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
const matchesLocation = this.viewMode === 'grouped'
|
|
|
|
|
? this.groupMatchesLocationFilters(entry, activeLocationFilters)
|
|
|
|
|
: this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, activeLocationFilters);
|
|
|
|
|
if (!matchesLocation) {
|
|
|
|
|
return;
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
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;
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
shortId(entry) {
|
|
|
|
|
return entry.uuid_b64 ? entry.uuid_b64.slice(0, 10) : 'No id';
|
|
|
|
|
},
|
|
|
|
|
locationLabel(entry) {
|
|
|
|
|
return resolveLocationLabel(entry, this.locationMap);
|
|
|
|
|
},
|
2026-04-06 21:34:02 +02:00
|
|
|
isLocationFilterActive(uuid) {
|
|
|
|
|
return this.isAllLocationsSelected() || this.filters.location.includes(uuid);
|
|
|
|
|
},
|
|
|
|
|
toggleLocationOverviewFilter(uuid) {
|
|
|
|
|
if (this.isAllLocationsSelected()) {
|
|
|
|
|
this.filters.location = [...this.locationSubtree(uuid)];
|
2026-04-12 13:05:14 +02:00
|
|
|
this.resetGroupedVisibleLimit();
|
2026-04-06 21:34:02 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.toggleLocationFilter(uuid);
|
|
|
|
|
},
|
|
|
|
|
locationOverviewClass(location) {
|
2026-04-12 13:05:14 +02:00
|
|
|
const hasActiveFilters = !this.isAllLocationsSelected();
|
2026-04-06 21:34:02 +02:00
|
|
|
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);`;
|
|
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
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);
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
2026-04-12 13:05:14 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-04-06 21:34:02 +02:00
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-06 21:34:02 +02:00
|
|
|
if (
|
2026-04-12 13:05:14 +02:00
|
|
|
activeExpirationFilters.length
|
|
|
|
|
&& !activeExpirationFilters.includes(expirationInfo(entry).key)
|
2026-04-06 21:34:02 +02:00
|
|
|
) {
|
2026-04-12 13:05:14 +02:00
|
|
|
return;
|
2026-04-06 21:34:02 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
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;
|
2026-04-06 21:34:02 +02:00
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
|
|
|
|
|
if (!selectedLocationUuid) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allowed = this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid];
|
|
|
|
|
return allowed.includes(entryLocationUuid);
|
|
|
|
|
},
|
2026-04-06 21:34:02 +02:00
|
|
|
locationSubtree(selectedLocationUuid) {
|
|
|
|
|
return this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid];
|
|
|
|
|
},
|
|
|
|
|
locationMatchesAnyFilter(entryLocationUuid, selectedLocationUuids) {
|
|
|
|
|
if (!selectedLocationUuids.length) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return selectedLocationUuids.some((selectedLocationUuid) =>
|
|
|
|
|
this.locationMatchesFilter(entryLocationUuid, selectedLocationUuid),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-04-07 00:41:55 +02:00
|
|
|
groupMatchesLocationFilter(group, selectedLocationUuid) {
|
2026-04-12 13:05:14 +02:00
|
|
|
if (!selectedLocationUuid) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 00:41:55 +02:00
|
|
|
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;
|
2026-04-12 13:05:14 +02:00
|
|
|
if (details.dataset?.groupId) {
|
|
|
|
|
this.openGroupedCards = {
|
|
|
|
|
...this.openGroupedCards,
|
|
|
|
|
[String(details.dataset.groupId)]: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-04-07 00:41:55 +02:00
|
|
|
},
|
|
|
|
|
handleGroupedToggle(event) {
|
|
|
|
|
const details = event.target;
|
2026-04-12 13:05:14 +02:00
|
|
|
if (!(details instanceof HTMLDetailsElement)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (details.dataset?.groupId) {
|
|
|
|
|
this.openGroupedCards = {
|
|
|
|
|
...this.openGroupedCards,
|
|
|
|
|
[String(details.dataset.groupId)]: details.open,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (details.open) {
|
2026-04-07 00:41:55 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const summary = details.querySelector('.grouped-stock-summary');
|
|
|
|
|
if (!summary) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
summary.scrollIntoView({
|
|
|
|
|
block: 'start',
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
groupedPrimaryDate,
|
|
|
|
|
groupedFirstProductionDate,
|
|
|
|
|
groupedFirstExpireDate,
|
|
|
|
|
shortDescription,
|
2026-04-06 09:24:22 +02:00
|
|
|
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,
|
2026-04-12 13:05:14 +02:00
|
|
|
async updateBinary(entry) {
|
2026-04-10 15:43:39 +02:00
|
|
|
await this.useEntry(entry);
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
async saveLevel(entry) {
|
|
|
|
|
const level = this.editForms[entry.id]?.level || 'plenty';
|
2026-04-06 21:34:02 +02:00
|
|
|
if (level === 'gone') {
|
2026-04-10 15:43:39 +02:00
|
|
|
await this.useEntry(entry);
|
2026-04-06 21:34:02 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
await this.saveEntryUpdate(
|
|
|
|
|
entry,
|
|
|
|
|
{
|
|
|
|
|
level,
|
|
|
|
|
},
|
|
|
|
|
{ level },
|
|
|
|
|
);
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 13:05:14 +02:00
|
|
|
await this.saveEntryUpdate(
|
|
|
|
|
entry,
|
|
|
|
|
{
|
|
|
|
|
quantity,
|
|
|
|
|
},
|
|
|
|
|
{ quantity },
|
|
|
|
|
);
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-06 21:34:02 +02:00
|
|
|
async markGone(entry) {
|
2026-04-10 15:43:39 +02:00
|
|
|
await this.useEntry(entry);
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
2026-04-10 22:08:01 +02:00
|
|
|
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);
|
2026-04-12 13:05:14 +02:00
|
|
|
this.removeEntryLocally(item.id);
|
2026-04-10 22:08:01 +02:00
|
|
|
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.`,
|
|
|
|
|
});
|
2026-04-12 13:05:14 +02:00
|
|
|
this.refreshLoadedViewsInBackground().catch(() => {});
|
2026-04-10 22:08:01 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
this.editErrors[item.id] = error.message || 'Mark gone failed.';
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
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.`,
|
|
|
|
|
});
|
2026-04-12 13:05:14 +02:00
|
|
|
this.refreshLoadedViewsInBackground().catch(() => {});
|
2026-04-06 09:24:22 +02:00
|
|
|
} catch (error) {
|
|
|
|
|
this.editErrors[entry.id] = error.message || 'Update failed.';
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-10 15:43:39 +02:00
|
|
|
async useEntry(entry) {
|
2026-04-06 09:24:22 +02:00
|
|
|
this.editErrors[entry.id] = '';
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-10 15:43:39 +02:00
|
|
|
const result = await useStockItem(store, entry.uuid_b64);
|
2026-04-12 13:05:14 +02:00
|
|
|
const alreadyGone = result.status === 'already_gone';
|
|
|
|
|
this.removeEntryLocally(entry.id);
|
2026-04-06 09:24:22 +02:00
|
|
|
delete this.editForms[entry.id];
|
|
|
|
|
delete this.editErrors[entry.id];
|
|
|
|
|
store.addAlert({
|
2026-04-10 15:43:39 +02:00
|
|
|
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.`,
|
2026-04-06 09:24:22 +02:00
|
|
|
});
|
2026-04-12 13:05:14 +02:00
|
|
|
this.refreshLoadedViewsInBackground().catch(() => {});
|
2026-04-06 09:24:22 +02:00
|
|
|
} catch (error) {
|
2026-04-10 15:43:39 +02:00
|
|
|
this.editErrors[entry.id] = error.message || 'Mark gone failed.';
|
2026-04-06 09:24:22 +02:00
|
|
|
}
|
|
|
|
|
},
|
2026-04-12 13:05:14 +02:00
|
|
|
removeEntryLocally(entryId) {
|
|
|
|
|
if (!this.entries.length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.entries = this.entries.filter((entry) => entry.id !== entryId);
|
|
|
|
|
this.entriesVersion += 1;
|
|
|
|
|
this.invalidateMemo();
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
replaceEntry(entryId, nextEntry) {
|
|
|
|
|
this.entries = sortEntries(
|
2026-04-12 13:05:14 +02:00
|
|
|
this.entries.map((entry) =>
|
|
|
|
|
entry.id === entryId ? this.indexEntry(nextEntry) : entry,
|
|
|
|
|
),
|
2026-04-06 09:24:22 +02:00
|
|
|
);
|
2026-04-12 13:05:14 +02:00
|
|
|
this.entriesVersion += 1;
|
|
|
|
|
this.invalidateMemo();
|
2026-04-06 09:24:22 +02:00
|
|
|
this.editForms[entryId] = {
|
|
|
|
|
level: nextEntry.level || 'plenty',
|
|
|
|
|
quantity: nextEntry.quantity ?? '',
|
|
|
|
|
};
|
|
|
|
|
},
|
2026-04-10 22:08:01 +02:00
|
|
|
removeGroupedItem(groupId, itemId) {
|
2026-04-12 13:05:14 +02:00
|
|
|
this.groupedEntries = sortGroupedEntries(
|
|
|
|
|
this.groupedEntries
|
|
|
|
|
.map((group) => {
|
|
|
|
|
if (group.id !== groupId) {
|
|
|
|
|
return group;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextItems = (group.items || []).filter((candidate) => candidate.id !== itemId);
|
|
|
|
|
if (!nextItems.length) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.indexGroup({
|
|
|
|
|
...group,
|
|
|
|
|
items: nextItems,
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
);
|
|
|
|
|
this.groupedVersion += 1;
|
|
|
|
|
this.pruneOpenGroupedCards();
|
|
|
|
|
this.invalidateMemo();
|
2026-04-10 22:08:01 +02:00
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
};
|
|
|
|
|
}
|