Improve stock list restore and item-level refresh feedback
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful

This commit is contained in:
2026-04-12 17:23:39 +02:00
parent 1d23279819
commit cc0e368480
5 changed files with 742 additions and 26 deletions
+559 -23
View File
@@ -1,4 +1,5 @@
import {
getStockEntry,
listGroupedStockEntries,
listKitchenChanges,
listStockEntries,
@@ -6,6 +7,8 @@ import {
useStockItem,
} 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';
@@ -38,6 +41,24 @@ const EXPIRATION_LEGEND = [
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();
@@ -57,6 +78,16 @@ function parseDateValue(value) {
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)
@@ -497,7 +528,19 @@ export function renderStockListPage() {
<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" :href="detailHref(entry)">View item</a>
<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">
@@ -518,7 +561,7 @@ export function renderStockListPage() {
<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>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="updateBinary(entry, 'gone')">Mark gone</button>
</div>
</template>
<template x-if="entry.stock_type === 'descriptive'">
@@ -529,14 +572,14 @@ export function renderStockListPage() {
</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" @click="markGone(entry)">Mark gone</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry)">Mark gone</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" @click="markGone(entry)">Mark gone</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry)">Mark gone</button>
</div>
</template>
<template x-if="editErrors[entry.id]">
@@ -561,7 +604,19 @@ export function renderStockListPage() {
<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" :href="detailHref(entry)">View item</a>
<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>
@@ -588,7 +643,7 @@ export function renderStockListPage() {
<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>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="updateBinary(entry, 'gone')">Mark gone</button>
</div>
</template>
<template x-if="entry.stock_type === 'descriptive'">
@@ -600,7 +655,7 @@ export function renderStockListPage() {
</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" @click="markGone(entry)">Mark gone</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry)">Mark gone</button>
</div>
</div>
</template>
@@ -609,7 +664,7 @@ export function renderStockListPage() {
<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" @click="markGone(entry)">Mark gone</button>
<button class="btn btn-sm btn-outline-danger" type="button" :disabled="isItemRefreshing(entry)" @click="markGone(entry)">Mark gone</button>
</div>
</div>
</template>
@@ -641,6 +696,7 @@ export function renderStockListPage() {
<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)"
>
@@ -649,7 +705,7 @@ export function renderStockListPage() {
<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 gap-3 small grouped-stock-summary-meta">
<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">Latest quantity:</span> <span class="fw-semibold text-body" x-text="quantityLabel(group)"></span></span>
@@ -691,7 +747,7 @@ export function renderStockListPage() {
<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="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">
@@ -713,8 +769,20 @@ export function renderStockListPage() {
</span>
</div>
<div class="grouped-stock-item-actions">
<a class="text-decoration-none fw-semibold grouped-stock-item-link" :href="detailHref(item)">Details</a>
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" @click="markGoneFromGroup(item, group)">Mark gone</button>
<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)">Mark gone</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]">
@@ -811,22 +879,246 @@ export function stockListPageData(store) {
changePollTimer: null,
routeChangeHandler: null,
isPollingChanges: false,
pendingRestoredScrollY: null,
itemRefreshCounts: {},
async init() {
if (!store.isConnected) {
return;
}
const restoredContext = this.restoreStockListContext();
this.registerRouteCleanup();
await Promise.all([
this.loadLocations(),
this.loadGroupedEntries({ expanded: 0, resetVisible: true }),
]);
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.hydrateGroupedEntriesInBackground().catch(() => {});
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;
@@ -907,6 +1199,42 @@ export function stockListPageData(store) {
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 = '';
@@ -1000,6 +1328,7 @@ export function stockListPageData(store) {
this.entriesVersion += 1;
this.syncEditFormsFromEntries();
this.invalidateMemo();
this.persistRuntimeCache();
} catch (error) {
if (!background) {
this.state.error = error.message || 'Could not load stock items.';
@@ -1030,6 +1359,7 @@ export function stockListPageData(store) {
this.groupedVersion += 1;
this.pruneOpenGroupedCards();
this.invalidateMemo();
this.persistRuntimeCache();
if (resetVisible) {
this.resetGroupedVisibleLimit();
@@ -1053,6 +1383,7 @@ export function stockListPageData(store) {
this.groupedVersion += 1;
this.pruneOpenGroupedCards();
this.invalidateMemo();
this.persistRuntimeCache();
},
async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
if (!store.isConnected) {
@@ -1147,6 +1478,7 @@ export function stockListPageData(store) {
} finally {
this.locationsVersion += 1;
this.reindexSearchData();
this.persistRuntimeCache();
}
},
resetGroupedVisibleLimit() {
@@ -1647,9 +1979,55 @@ export function stockListPageData(store) {
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;
@@ -1676,10 +2054,6 @@ export function stockListPageData(store) {
};
}
if (details.open) {
return;
}
const summary = details.querySelector('.grouped-stock-summary');
if (!summary) {
return;
@@ -1707,7 +2081,88 @@ export function stockListPageData(store) {
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);
},
async saveLevel(entry) {
@@ -1741,9 +2196,17 @@ export function stockListPageData(store) {
);
},
async markGone(entry) {
if (this.isItemRefreshing(entry)) {
return;
}
await this.useEntry(entry);
},
async markGoneFromGroup(item, group) {
if (this.isItemRefreshing(item)) {
return;
}
this.editErrors[item.id] = '';
try {
@@ -1780,6 +2243,10 @@ export function stockListPageData(store) {
}
},
async useEntry(entry) {
if (this.isItemRefreshing(entry)) {
return;
}
this.editErrors[entry.id] = '';
try {
@@ -1807,6 +2274,26 @@ export function stockListPageData(store) {
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(
@@ -1820,6 +2307,7 @@ export function stockListPageData(store) {
level: nextEntry.level || 'plenty',
quantity: nextEntry.quantity ?? '',
};
this.persistRuntimeCache();
},
removeGroupedItem(groupId, itemId) {
this.groupedEntries = sortGroupedEntries(
@@ -1834,16 +2322,64 @@ export function stockListPageData(store) {
return null;
}
return this.indexGroup({
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;
},
};
}