codex/promote-grouped-stock-view #7
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
export const APP_NAME = 'Lonc';
|
export const APP_NAME = 'Lonc';
|
||||||
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.0';
|
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.1';
|
||||||
export const TRYTON_APPLICATION = 'kitchen';
|
export const TRYTON_APPLICATION = 'kitchen';
|
||||||
|
|
||||||
export const CONNECTION_STATES = {
|
export const CONNECTION_STATES = {
|
||||||
@@ -14,6 +14,7 @@ export const STORAGE_KEYS = {
|
|||||||
session: 'lonc.auth.session',
|
session: 'lonc.auth.session',
|
||||||
activeKitchen: 'lonc.kitchen.active',
|
activeKitchen: 'lonc.kitchen.active',
|
||||||
labelDraft: 'lonc.labels.draft',
|
labelDraft: 'lonc.labels.draft',
|
||||||
|
stockListContext: 'lonc.stock.list.context',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = {
|
export const DEFAULT_CONFIG = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
getStockEntry,
|
||||||
listGroupedStockEntries,
|
listGroupedStockEntries,
|
||||||
listKitchenChanges,
|
listKitchenChanges,
|
||||||
listStockEntries,
|
listStockEntries,
|
||||||
@@ -6,6 +7,8 @@ import {
|
|||||||
useStockItem,
|
useStockItem,
|
||||||
} from '../../api/stock.js';
|
} from '../../api/stock.js';
|
||||||
import { fetchLocations } from '../../api/locations.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 { createAsyncState } from '../shared/ui-state.js';
|
||||||
import { formatDate } from '../shared/date-utils.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 EXPIRATION_KEYS = EXPIRATION_LEGEND.map((state) => state.key);
|
||||||
const GROUPED_PAGE_SIZE = 24;
|
const GROUPED_PAGE_SIZE = 24;
|
||||||
const CHANGE_POLL_INTERVAL_MS = 60 * 1000;
|
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() {
|
function todayAtMidnight() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -57,6 +78,16 @@ function parseDateValue(value) {
|
|||||||
return new Date(year, month - 1, day);
|
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) {
|
function expirationInfo(entry) {
|
||||||
const expireDateValue =
|
const expireDateValue =
|
||||||
Array.isArray(entry.items) && (entry.first_expire_date || entry.first_expire_in !== undefined)
|
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="fw-semibold" x-text="entry.name"></div>
|
||||||
<div class="small text-body-secondary" x-text="entry.description || 'No description'"></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>
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-2 mb-1">
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
@@ -518,7 +561,7 @@ export function renderStockListPage() {
|
|||||||
<div class="quick-edit-stack">
|
<div class="quick-edit-stack">
|
||||||
<template x-if="entry.stock_type === 'binary'">
|
<template x-if="entry.stock_type === 'binary'">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="entry.stock_type === 'descriptive'">
|
<template x-if="entry.stock_type === 'descriptive'">
|
||||||
@@ -529,14 +572,14 @@ export function renderStockListPage() {
|
|||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save</button>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="entry.stock_type === 'measured'">
|
<template x-if="entry.stock_type === 'measured'">
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
<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" />
|
<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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="editErrors[entry.id]">
|
<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="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" x-text="entry.description || 'No description'"></div>
|
||||||
<div class="text-body-secondary small font-monospace" x-text="shortId(entry)"></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>
|
</div>
|
||||||
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
|
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,7 +643,7 @@ export function renderStockListPage() {
|
|||||||
<div class="quick-edit-stack">
|
<div class="quick-edit-stack">
|
||||||
<template x-if="entry.stock_type === 'binary'">
|
<template x-if="entry.stock_type === 'binary'">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="entry.stock_type === 'descriptive'">
|
<template x-if="entry.stock_type === 'descriptive'">
|
||||||
@@ -600,7 +655,7 @@ export function renderStockListPage() {
|
|||||||
</select>
|
</select>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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" />
|
<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">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -641,6 +696,7 @@ export function renderStockListPage() {
|
|||||||
<details
|
<details
|
||||||
class="card border-0 shadow-sm grouped-stock-card"
|
class="card border-0 shadow-sm grouped-stock-card"
|
||||||
:class="rowClass(group)"
|
:class="rowClass(group)"
|
||||||
|
:open="isGroupedCardOpen(group.id)"
|
||||||
:data-group-id="String(group.id)"
|
:data-group-id="String(group.id)"
|
||||||
@toggle="handleGroupedToggle($event)"
|
@toggle="handleGroupedToggle($event)"
|
||||||
>
|
>
|
||||||
@@ -649,7 +705,7 @@ export function renderStockListPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold grouped-stock-summary-title" x-text="group.name"></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="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="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 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>
|
<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">
|
<template x-if="group.items?.length">
|
||||||
<div class="grouped-stock-items">
|
<div class="grouped-stock-items">
|
||||||
<template x-for="item in group.items" :key="item.id">
|
<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-row">
|
||||||
<div class="grouped-stock-item-main">
|
<div class="grouped-stock-item-main">
|
||||||
<div class="grouped-stock-item-title-line">
|
<div class="grouped-stock-item-title-line">
|
||||||
@@ -713,8 +769,20 @@ export function renderStockListPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grouped-stock-item-actions">
|
<div class="grouped-stock-item-actions">
|
||||||
<a class="text-decoration-none fw-semibold grouped-stock-item-link" :href="detailHref(item)">Details</a>
|
<a
|
||||||
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" @click="markGoneFromGroup(item, group)">Mark gone</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
<template x-if="editErrors[item.id]">
|
<template x-if="editErrors[item.id]">
|
||||||
@@ -811,22 +879,246 @@ export function stockListPageData(store) {
|
|||||||
changePollTimer: null,
|
changePollTimer: null,
|
||||||
routeChangeHandler: null,
|
routeChangeHandler: null,
|
||||||
isPollingChanges: false,
|
isPollingChanges: false,
|
||||||
|
pendingRestoredScrollY: null,
|
||||||
|
itemRefreshCounts: {},
|
||||||
async init() {
|
async init() {
|
||||||
if (!store.isConnected) {
|
if (!store.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoredContext = this.restoreStockListContext();
|
||||||
this.registerRouteCleanup();
|
this.registerRouteCleanup();
|
||||||
await Promise.all([
|
|
||||||
this.loadLocations(),
|
const restoredFromRuntime = restoredContext
|
||||||
this.loadGroupedEntries({ expanded: 0, resetVisible: true }),
|
? 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();
|
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();
|
await this.primeChangeCursor();
|
||||||
this.startChangePolling();
|
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() {
|
registerRouteCleanup() {
|
||||||
if (this.routeChangeHandler) {
|
if (this.routeChangeHandler) {
|
||||||
return;
|
return;
|
||||||
@@ -907,6 +1199,42 @@ export function stockListPageData(store) {
|
|||||||
this.refreshActivityCount = Math.max(0, this.refreshActivityCount - 1);
|
this.refreshActivityCount = Math.max(0, this.refreshActivityCount - 1);
|
||||||
this.state.isRefreshing = this.refreshActivityCount > 0;
|
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() {
|
invalidateMemo() {
|
||||||
this.memo.filteredEntriesSig = '';
|
this.memo.filteredEntriesSig = '';
|
||||||
this.memo.filteredGroupedEntriesSig = '';
|
this.memo.filteredGroupedEntriesSig = '';
|
||||||
@@ -1000,6 +1328,7 @@ export function stockListPageData(store) {
|
|||||||
this.entriesVersion += 1;
|
this.entriesVersion += 1;
|
||||||
this.syncEditFormsFromEntries();
|
this.syncEditFormsFromEntries();
|
||||||
this.invalidateMemo();
|
this.invalidateMemo();
|
||||||
|
this.persistRuntimeCache();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!background) {
|
if (!background) {
|
||||||
this.state.error = error.message || 'Could not load stock items.';
|
this.state.error = error.message || 'Could not load stock items.';
|
||||||
@@ -1030,6 +1359,7 @@ export function stockListPageData(store) {
|
|||||||
this.groupedVersion += 1;
|
this.groupedVersion += 1;
|
||||||
this.pruneOpenGroupedCards();
|
this.pruneOpenGroupedCards();
|
||||||
this.invalidateMemo();
|
this.invalidateMemo();
|
||||||
|
this.persistRuntimeCache();
|
||||||
|
|
||||||
if (resetVisible) {
|
if (resetVisible) {
|
||||||
this.resetGroupedVisibleLimit();
|
this.resetGroupedVisibleLimit();
|
||||||
@@ -1053,6 +1383,7 @@ export function stockListPageData(store) {
|
|||||||
this.groupedVersion += 1;
|
this.groupedVersion += 1;
|
||||||
this.pruneOpenGroupedCards();
|
this.pruneOpenGroupedCards();
|
||||||
this.invalidateMemo();
|
this.invalidateMemo();
|
||||||
|
this.persistRuntimeCache();
|
||||||
},
|
},
|
||||||
async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
|
async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
|
||||||
if (!store.isConnected) {
|
if (!store.isConnected) {
|
||||||
@@ -1147,6 +1478,7 @@ export function stockListPageData(store) {
|
|||||||
} finally {
|
} finally {
|
||||||
this.locationsVersion += 1;
|
this.locationsVersion += 1;
|
||||||
this.reindexSearchData();
|
this.reindexSearchData();
|
||||||
|
this.persistRuntimeCache();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resetGroupedVisibleLimit() {
|
resetGroupedVisibleLimit() {
|
||||||
@@ -1647,9 +1979,55 @@ export function stockListPageData(store) {
|
|||||||
this.groupMatchesLocationFilter(group, selectedLocationUuid),
|
this.groupMatchesLocationFilter(group, selectedLocationUuid),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onItemDetailNavigate(entry, event) {
|
||||||
|
if (this.isItemRefreshing(entry)) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rememberStockListContext(entry?.uuid_b64);
|
||||||
|
},
|
||||||
detailHref(entry) {
|
detailHref(entry) {
|
||||||
return `#/stock/${entry.uuid_b64}`;
|
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) {
|
closeGroupedCard(details) {
|
||||||
if (!details) {
|
if (!details) {
|
||||||
return;
|
return;
|
||||||
@@ -1676,10 +2054,6 @@ export function stockListPageData(store) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (details.open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = details.querySelector('.grouped-stock-summary');
|
const summary = details.querySelector('.grouped-stock-summary');
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
return;
|
return;
|
||||||
@@ -1707,7 +2081,88 @@ export function stockListPageData(store) {
|
|||||||
return entry.uom_symbol ? `Measured in ${entry.uom_symbol}` : 'Measured stock';
|
return entry.uom_symbol ? `Measured in ${entry.uom_symbol}` : 'Measured stock';
|
||||||
},
|
},
|
||||||
formatDate,
|
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) {
|
async updateBinary(entry) {
|
||||||
|
if (this.isItemRefreshing(entry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.useEntry(entry);
|
await this.useEntry(entry);
|
||||||
},
|
},
|
||||||
async saveLevel(entry) {
|
async saveLevel(entry) {
|
||||||
@@ -1741,9 +2196,17 @@ export function stockListPageData(store) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
async markGone(entry) {
|
async markGone(entry) {
|
||||||
|
if (this.isItemRefreshing(entry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.useEntry(entry);
|
await this.useEntry(entry);
|
||||||
},
|
},
|
||||||
async markGoneFromGroup(item, group) {
|
async markGoneFromGroup(item, group) {
|
||||||
|
if (this.isItemRefreshing(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.editErrors[item.id] = '';
|
this.editErrors[item.id] = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1780,6 +2243,10 @@ export function stockListPageData(store) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async useEntry(entry) {
|
async useEntry(entry) {
|
||||||
|
if (this.isItemRefreshing(entry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.editErrors[entry.id] = '';
|
this.editErrors[entry.id] = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1807,6 +2274,26 @@ export function stockListPageData(store) {
|
|||||||
this.entries = this.entries.filter((entry) => entry.id !== entryId);
|
this.entries = this.entries.filter((entry) => entry.id !== entryId);
|
||||||
this.entriesVersion += 1;
|
this.entriesVersion += 1;
|
||||||
this.invalidateMemo();
|
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) {
|
replaceEntry(entryId, nextEntry) {
|
||||||
this.entries = sortEntries(
|
this.entries = sortEntries(
|
||||||
@@ -1820,6 +2307,7 @@ export function stockListPageData(store) {
|
|||||||
level: nextEntry.level || 'plenty',
|
level: nextEntry.level || 'plenty',
|
||||||
quantity: nextEntry.quantity ?? '',
|
quantity: nextEntry.quantity ?? '',
|
||||||
};
|
};
|
||||||
|
this.persistRuntimeCache();
|
||||||
},
|
},
|
||||||
removeGroupedItem(groupId, itemId) {
|
removeGroupedItem(groupId, itemId) {
|
||||||
this.groupedEntries = sortGroupedEntries(
|
this.groupedEntries = sortGroupedEntries(
|
||||||
@@ -1834,16 +2322,64 @@ export function stockListPageData(store) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.indexGroup({
|
return this.indexGroup(this.refreshGroupFromItems({
|
||||||
...group,
|
...group,
|
||||||
|
items_count: Number.isFinite(group.items_count)
|
||||||
|
? Math.max(0, group.items_count - 1)
|
||||||
|
: nextItems.length,
|
||||||
items: nextItems,
|
items: nextItems,
|
||||||
});
|
}));
|
||||||
})
|
})
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
);
|
);
|
||||||
this.groupedVersion += 1;
|
this.groupedVersion += 1;
|
||||||
this.pruneOpenGroupedCards();
|
this.pruneOpenGroupedCards();
|
||||||
this.invalidateMemo();
|
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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -723,7 +723,10 @@ button.legend-card:focus-visible {
|
|||||||
|
|
||||||
.grouped-stock-summary-meta {
|
.grouped-stock-summary-meta {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
row-gap: 0.35rem;
|
align-content: flex-start;
|
||||||
|
column-gap: 0.95rem;
|
||||||
|
row-gap: 0.08rem;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grouped-stock-summary-status {
|
.grouped-stock-summary-status {
|
||||||
@@ -887,6 +890,21 @@ button.legend-card:focus-visible {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-item-link-disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-item-refresh-indicator {
|
||||||
|
color: rgba(31, 39, 64, 0.78) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-item-refreshing {
|
||||||
|
opacity: 0.94;
|
||||||
|
}
|
||||||
|
|
||||||
.grouped-stock-mark-gone {
|
.grouped-stock-mark-gone {
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.2rem 0.55rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
const listStockEntriesMock = vi.fn();
|
const listStockEntriesMock = vi.fn();
|
||||||
const listGroupedStockEntriesMock = vi.fn();
|
const listGroupedStockEntriesMock = vi.fn();
|
||||||
const listKitchenChangesMock = vi.fn();
|
const listKitchenChangesMock = vi.fn();
|
||||||
|
const getStockEntryMock = vi.fn();
|
||||||
const updateStockItemMock = vi.fn();
|
const updateStockItemMock = vi.fn();
|
||||||
const useStockItemMock = vi.fn();
|
const useStockItemMock = vi.fn();
|
||||||
const fetchLocationsMock = vi.fn();
|
const fetchLocationsMock = vi.fn();
|
||||||
@@ -11,6 +12,7 @@ vi.mock('../../../src/api/stock.js', () => ({
|
|||||||
listStockEntries: (...args) => listStockEntriesMock(...args),
|
listStockEntries: (...args) => listStockEntriesMock(...args),
|
||||||
listGroupedStockEntries: (...args) => listGroupedStockEntriesMock(...args),
|
listGroupedStockEntries: (...args) => listGroupedStockEntriesMock(...args),
|
||||||
listKitchenChanges: (...args) => listKitchenChangesMock(...args),
|
listKitchenChanges: (...args) => listKitchenChangesMock(...args),
|
||||||
|
getStockEntry: (...args) => getStockEntryMock(...args),
|
||||||
updateStockItem: (...args) => updateStockItemMock(...args),
|
updateStockItem: (...args) => updateStockItemMock(...args),
|
||||||
useStockItem: (...args) => useStockItemMock(...args),
|
useStockItem: (...args) => useStockItemMock(...args),
|
||||||
}));
|
}));
|
||||||
@@ -70,9 +72,11 @@ function createGroupedExpanded() {
|
|||||||
function createWindowMock() {
|
function createWindowMock() {
|
||||||
const intervals = new Map();
|
const intervals = new Map();
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
const storage = new Map();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
location: { hash: '#/stock' },
|
location: { hash: '#/stock' },
|
||||||
|
scrollY: 1,
|
||||||
setInterval: vi.fn((fn) => {
|
setInterval: vi.fn((fn) => {
|
||||||
const id = nextId;
|
const id = nextId;
|
||||||
nextId += 1;
|
nextId += 1;
|
||||||
@@ -85,6 +89,16 @@ function createWindowMock() {
|
|||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
matchMedia: vi.fn(() => ({ matches: false })),
|
matchMedia: vi.fn(() => ({ matches: false })),
|
||||||
|
scrollTo: vi.fn(),
|
||||||
|
localStorage: {
|
||||||
|
getItem: vi.fn((key) => storage.get(key) ?? null),
|
||||||
|
setItem: vi.fn((key, value) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key) => {
|
||||||
|
storage.delete(key);
|
||||||
|
}),
|
||||||
|
},
|
||||||
__intervals: intervals,
|
__intervals: intervals,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -94,6 +108,7 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
listStockEntriesMock.mockReset();
|
listStockEntriesMock.mockReset();
|
||||||
listGroupedStockEntriesMock.mockReset();
|
listGroupedStockEntriesMock.mockReset();
|
||||||
listKitchenChangesMock.mockReset();
|
listKitchenChangesMock.mockReset();
|
||||||
|
getStockEntryMock.mockReset();
|
||||||
updateStockItemMock.mockReset();
|
updateStockItemMock.mockReset();
|
||||||
useStockItemMock.mockReset();
|
useStockItemMock.mockReset();
|
||||||
fetchLocationsMock.mockReset();
|
fetchLocationsMock.mockReset();
|
||||||
@@ -108,6 +123,7 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
delete globalThis.window;
|
delete globalThis.window;
|
||||||
delete globalThis.requestAnimationFrame;
|
delete globalThis.requestAnimationFrame;
|
||||||
delete globalThis.HTMLDetailsElement;
|
delete globalThis.HTMLDetailsElement;
|
||||||
|
delete globalThis.structuredClone;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to grouped mode and loads grouped summary before lazy item list', async () => {
|
it('defaults to grouped mode and loads grouped summary before lazy item list', async () => {
|
||||||
@@ -254,4 +270,149 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
data.handleGroupedToggle({ target: details });
|
data.handleGroupedToggle({ target: details });
|
||||||
expect(data.isGroupedCardOpen(10)).toBe(false);
|
expect(data.isGroupedCardOpen(10)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores from runtime cache on back navigation and refreshes only focused item', async () => {
|
||||||
|
listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
|
||||||
|
getStockEntryMock.mockResolvedValueOnce({
|
||||||
|
id: 100,
|
||||||
|
uuid_b64: 'item-100',
|
||||||
|
name: 'Rice',
|
||||||
|
description: 'Open bag',
|
||||||
|
stock_type: 'measured',
|
||||||
|
level: 'good',
|
||||||
|
quantity: 2,
|
||||||
|
uom_symbol: 'kg',
|
||||||
|
location_initial_uuid_b64: 'loc-root',
|
||||||
|
date: '2026-04-12',
|
||||||
|
expire_date: '2026-04-20',
|
||||||
|
expire_in: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
isConnected: true,
|
||||||
|
addAlert: vi.fn(),
|
||||||
|
activeKitchen: { id: 1 },
|
||||||
|
};
|
||||||
|
const firstVisit = stockListPageData(store);
|
||||||
|
firstVisit.entries = [
|
||||||
|
firstVisit.indexEntry({
|
||||||
|
id: 100,
|
||||||
|
uuid_b64: 'item-100',
|
||||||
|
name: 'Rice',
|
||||||
|
description: 'Open bag',
|
||||||
|
stock_type: 'measured',
|
||||||
|
level: 'good',
|
||||||
|
quantity: 1,
|
||||||
|
uom_symbol: 'kg',
|
||||||
|
location_initial_uuid_b64: 'loc-root',
|
||||||
|
date: '2026-04-10',
|
||||||
|
expire_date: '2026-04-25',
|
||||||
|
expire_in: 13,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
firstVisit.entriesVersion = 1;
|
||||||
|
firstVisit.itemsLoaded = true;
|
||||||
|
firstVisit.groupedEntries = createGroupedExpanded().map((group) => firstVisit.indexGroup(group));
|
||||||
|
firstVisit.groupedVersion = 1;
|
||||||
|
firstVisit.groupedLoaded = true;
|
||||||
|
firstVisit.groupedHydrated = true;
|
||||||
|
firstVisit.locations = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
uuid_b64: 'loc-root',
|
||||||
|
name: 'Pantry',
|
||||||
|
pathLabel: 'Pantry',
|
||||||
|
depth: 0,
|
||||||
|
type: 'storage',
|
||||||
|
lineage_uuid_b64: ['loc-root'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
firstVisit.locationsVersion = 1;
|
||||||
|
firstVisit.locationMap = { 'loc-root': 'Pantry' };
|
||||||
|
firstVisit.locationDescendants = { 'loc-root': ['loc-root'] };
|
||||||
|
firstVisit.locationLineage = { 'loc-root': ['loc-root'] };
|
||||||
|
firstVisit.viewMode = 'grouped';
|
||||||
|
firstVisit.filters.search = 'rice';
|
||||||
|
firstVisit.rememberStockListContext('item-100');
|
||||||
|
|
||||||
|
const returnVisit = stockListPageData(store);
|
||||||
|
returnVisit.$nextTick = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await returnVisit.init();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(listGroupedStockEntriesMock).not.toHaveBeenCalled();
|
||||||
|
expect(listStockEntriesMock).not.toHaveBeenCalled();
|
||||||
|
expect(fetchLocationsMock).not.toHaveBeenCalled();
|
||||||
|
expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-100');
|
||||||
|
expect(returnVisit.entries[0].quantity).toBe(2);
|
||||||
|
expect(returnVisit.groupedEntries[0].items[0].quantity).toBe(2);
|
||||||
|
expect(returnVisit.groupedEntries[0].quantity).toBe(2);
|
||||||
|
expect(returnVisit.groupedEntries[0].first_expire_date).toBe('2026-04-20');
|
||||||
|
expect(returnVisit.groupedEntries[0].date).toBe('2026-04-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks item-level refresh state while focused item refresh is in progress', async () => {
|
||||||
|
let resolveEntry;
|
||||||
|
getStockEntryMock.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveEntry = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||||
|
data.entries = [
|
||||||
|
data.indexEntry({
|
||||||
|
id: 100,
|
||||||
|
uuid_b64: 'item-100',
|
||||||
|
name: 'Rice',
|
||||||
|
description: 'Open bag',
|
||||||
|
stock_type: 'measured',
|
||||||
|
level: 'good',
|
||||||
|
quantity: 1,
|
||||||
|
uom_symbol: 'kg',
|
||||||
|
location_initial_uuid_b64: 'loc-root',
|
||||||
|
date: '2026-04-10',
|
||||||
|
expire_date: '2026-04-25',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
data.entriesVersion = 1;
|
||||||
|
data.itemsLoaded = true;
|
||||||
|
|
||||||
|
const pending = data.refreshFocusedItemInBackground('item-100');
|
||||||
|
expect(data.isItemRefreshing('item-100')).toBe(true);
|
||||||
|
|
||||||
|
resolveEntry({
|
||||||
|
...data.entries[0],
|
||||||
|
quantity: 3,
|
||||||
|
});
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(data.isItemRefreshing('item-100')).toBe(false);
|
||||||
|
expect(data.entries[0].quantity).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when structuredClone throws during runtime cache snapshot', async () => {
|
||||||
|
globalThis.structuredClone = vi.fn(() => {
|
||||||
|
throw new Error('The object can not be cloned.');
|
||||||
|
});
|
||||||
|
|
||||||
|
listGroupedStockEntriesMock
|
||||||
|
.mockResolvedValueOnce(createGroupedSummary())
|
||||||
|
.mockResolvedValueOnce(createGroupedExpanded());
|
||||||
|
listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
|
||||||
|
fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] });
|
||||||
|
|
||||||
|
const store = { isConnected: true, addAlert: vi.fn() };
|
||||||
|
const data = stockListPageData(store);
|
||||||
|
data.$nextTick = vi.fn(async () => {});
|
||||||
|
|
||||||
|
await data.init();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(data.state.error).toBe('');
|
||||||
|
expect(data.groupedEntries.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user