Add grouped stock view with expiration and location filtering

This commit is contained in:
2026-04-07 00:41:55 +02:00
parent a2819f88d2
commit 385cd95aaf
5 changed files with 550 additions and 21 deletions
+354 -16
View File
@@ -1,4 +1,9 @@
import { deleteStockItem, listStockEntries, updateStockItem } from '../../api/stock.js';
import {
deleteStockItem,
listGroupedStockEntries,
listStockEntries,
updateStockItem,
} from '../../api/stock.js';
import { fetchLocations } from '../../api/locations.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
import { formatDate } from '../shared/date-utils.js';
@@ -50,7 +55,16 @@ function parseDateValue(value) {
}
function expirationInfo(entry) {
if (!entry.expire_date) {
const expireDateValue =
Array.isArray(entry.items) && (entry.first_expire_date || entry.first_expire_in !== undefined)
? entry.first_expire_date || entry.expire_date
: entry.expire_date;
const expireInValue =
Array.isArray(entry.items) && entry.first_expire_in !== undefined
? entry.first_expire_in
: entry.expire_in;
if (!expireDateValue) {
return {
key: 'none',
label: 'No expiration date',
@@ -60,11 +74,13 @@ function expirationInfo(entry) {
}
const today = todayAtMidnight();
const expireDate = parseDateValue(entry.expire_date);
const expireDate = parseDateValue(expireDateValue);
const expireIn =
typeof entry.expire_in === 'number'
? entry.expire_in
: Math.round((expireDate - today) / (24 * 60 * 60 * 1000));
typeof expireInValue === 'number'
? expireInValue
: expireDate
? Math.round((expireDate - today) / (24 * 60 * 60 * 1000))
: null;
if (expireIn < 0) {
return {
@@ -123,6 +139,25 @@ function sortEntries(entries) {
});
}
function sortGroupedEntries(groups) {
return [...groups].sort((left, right) => {
const leftExpiration = expirationInfo(left);
const rightExpiration = expirationInfo(right);
if (leftExpiration.sortRank !== rightExpiration.sortRank) {
return leftExpiration.sortRank - rightExpiration.sortRank;
}
const leftExpire = left.first_expire_date || left.expire_date || '9999-12-31';
const rightExpire = right.first_expire_date || right.expire_date || '9999-12-31';
if (leftExpire !== rightExpire) {
return leftExpire.localeCompare(rightExpire);
}
return (left.name || '').localeCompare(right.name || '');
});
}
function quantityLabel(entry) {
if (entry.stock_type === 'binary') {
return entry.level === 'gone' ? 'Gone' : 'Available';
@@ -166,6 +201,40 @@ function searchBlob(entry, locationMap) {
.toLowerCase();
}
function groupSearchBlob(group, locationMap) {
return [
searchBlob(group, locationMap),
...(group.items || []).map((item) => searchBlob(item, locationMap)),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
}
function groupedPrimaryDate(group) {
return group.date;
}
function groupedFirstProductionDate(group) {
return group.first_production_date || group.date;
}
function groupedFirstExpireDate(group) {
return group.first_expire_date || group.expire_date;
}
function shortDescription(value, maxLength = 24) {
if (!value) {
return 'No description';
}
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength).trimEnd()}...`;
}
export function renderStockListPage() {
return `
<section class="container-xxl py-4 py-lg-5" x-data="stockListPage()" x-init="init()">
@@ -180,6 +249,40 @@ export function renderStockListPage() {
<a href="#/labels/new" class="btn btn-primary">New stock label</a>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="stock-view-switch" role="tablist" aria-label="Stock view">
<button
class="btn"
type="button"
:class="viewMode === 'items' ? 'btn-primary' : 'btn-outline-secondary'"
@click="switchView('items')"
>
Stock items
</button>
<button
class="btn"
type="button"
:class="viewMode === 'grouped' ? 'btn-primary' : 'btn-outline-secondary'"
@click="switchView('grouped')"
>
Grouped items
</button>
</div>
<button
class="btn btn-outline-secondary"
type="button"
@click="refreshCurrentView()"
:disabled="state.isLoading"
>
<span x-show="!state.isLoading">Refresh</span>
<span x-show="state.isLoading">Refreshing...</span>
</button>
</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
@@ -215,8 +318,8 @@ export function renderStockListPage() {
<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">
<span class="fw-semibold text-body" x-text="filteredEntries.length"></span>
item(s) visible
<span class="fw-semibold text-body" x-text="visibleResultCount"></span>
<span x-text="viewMode === 'grouped' ? 'group(s) visible' : 'item(s) visible'"></span>
</div>
</div>
</div>
@@ -329,7 +432,7 @@ export function renderStockListPage() {
</div>
</template>
<template x-if="!state.isLoading && !state.error && !filteredEntries.length">
<template x-if="!state.isLoading && !state.error && viewMode === 'items' && !filteredEntries.length">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 text-center">
<h2 class="h5">No stock items to show</h2>
@@ -340,7 +443,7 @@ export function renderStockListPage() {
</div>
</template>
<template x-if="!state.isLoading && !state.error && filteredEntries.length">
<template x-if="!state.isLoading && !state.error && viewMode === 'items' && filteredEntries.length">
<div>
<div class="d-none d-xl-block">
<div class="card border-0 shadow-sm overflow-hidden">
@@ -363,6 +466,7 @@ 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>
</td>
<td>
<div class="d-flex align-items-center gap-2 mb-1">
@@ -426,6 +530,7 @@ 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>
</div>
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
</div>
@@ -487,6 +592,112 @@ export function renderStockListPage() {
</div>
</div>
</template>
<template x-if="!state.isLoading && !state.error && viewMode === 'grouped' && !filteredGroupedEntries.length">
<div class="card border-0 shadow-sm">
<div class="card-body p-4 text-center">
<h2 class="h5">No grouped stock items to show</h2>
<p class="text-body-secondary mb-0">
Try clearing the filters or switch back to the stock items view.
</p>
</div>
</div>
</template>
<template x-if="!state.isLoading && !state.error && viewMode === 'grouped' && filteredGroupedEntries.length">
<div class="d-grid gap-3">
<template x-for="group in filteredGroupedEntries" :key="group.id">
<details
class="card border-0 shadow-sm grouped-stock-card"
:class="rowClass(group)"
@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="small text-body-secondary mb-1">Latest item in group</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>
<div class="d-flex flex-wrap gap-3 small text-body-secondary grouped-stock-summary-meta">
<span><span class="fw-semibold text-body" x-text="group.items?.length || 0"></span> item(s)</span>
<span><span class="text-body-secondary">Latest added:</span> <span x-text="formatDate(groupedPrimaryDate(group))"></span></span>
<span><span class="text-body-secondary">First expires:</span> <span x-text="formatDate(groupedFirstExpireDate(group))"></span></span>
</div>
</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">
<div class="row g-3 small mb-3 grouped-stock-meta">
<div class="col-6 col-xl-3">
<div class="text-body-secondary">Latest quantity / level</div>
<div class="fw-semibold" x-text="quantityLabel(group)"></div>
</div>
<div class="col-6 col-xl-3">
<div class="text-body-secondary">Latest location</div>
<div class="fw-semibold" x-text="locationLabel(group)"></div>
</div>
<div class="col-6 col-xl-3">
<div class="text-body-secondary">Latest production date</div>
<div x-text="formatDate(groupedPrimaryDate(group))"></div>
</div>
<div class="col-6 col-xl-3">
<div class="text-body-secondary">First expires</div>
<div x-text="formatDate(groupedFirstExpireDate(group))"></div>
</div>
<div class="col-6 col-xl-3">
<div class="text-body-secondary">First production date</div>
<div x-text="formatDate(groupedFirstProductionDate(group))"></div>
</div>
</div>
<div class="grouped-stock-items">
<template x-for="item in group.items" :key="item.id">
<a class="grouped-stock-item text-decoration-none" :class="groupedItemClass(item)" :href="detailHref(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>
</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>
</div>
</div>
</a>
</template>
</div>
<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>
</div>
</template>
</section>
`;
}
@@ -494,7 +705,10 @@ export function renderStockListPage() {
export function stockListPageData(store) {
return {
state: createAsyncState(),
viewMode: 'items',
entries: [],
groupedEntries: [],
groupedLoaded: false,
locations: [],
locationMap: {},
locationDescendants: {},
@@ -517,6 +731,21 @@ export function stockListPageData(store) {
}
await Promise.all([this.loadLocations(), this.loadEntries()]);
},
async switchView(mode) {
this.viewMode = mode;
if (mode === 'grouped' && !this.groupedLoaded) {
await this.loadGroupedEntries();
}
},
async refreshCurrentView() {
if (this.viewMode === 'grouped') {
await this.loadGroupedEntries();
return;
}
await this.loadEntries();
},
async loadEntries() {
if (!store.isConnected) {
return;
@@ -537,6 +766,17 @@ export function stockListPageData(store) {
this.editErrors = {};
}).catch(() => {});
},
async loadGroupedEntries() {
if (!store.isConnected) {
return;
}
await runAsyncState(this.state, async () => {
const loadedGroups = await listGroupedStockEntries(store);
this.groupedEntries = sortGroupedEntries(loadedGroups);
this.groupedLoaded = true;
}).catch(() => {});
},
async loadLocations() {
if (!store.isConnected) {
return;
@@ -739,6 +979,39 @@ export function stockListPageData(store) {
return true;
});
},
get filteredGroupedEntries() {
return this.groupedEntries.filter((group) => {
if (
this.filters.search &&
!groupSearchBlob(group, this.locationMap).includes(this.filters.search.toLowerCase())
) {
return false;
}
if (
this.filters.expiration.length &&
this.filters.expiration.length !== EXPIRATION_KEYS.length &&
!this.filters.expiration.includes(expirationInfo(group).key)
) {
return false;
}
if (
this.filters.location.length &&
this.filters.location.length !== this.locations.length &&
!this.groupMatchesLocationFilters(group, this.filters.location)
) {
return false;
}
return true;
});
},
get visibleResultCount() {
return this.viewMode === 'grouped'
? this.filteredGroupedEntries.length
: this.filteredEntries.length;
},
expirationFor(entry) {
return expirationInfo(entry);
},
@@ -751,6 +1024,9 @@ export function stockListPageData(store) {
badgeClass(entry) {
return `expiration-badge-${expirationInfo(entry).key}`;
},
groupedItemClass(entry) {
return `expiration-soft-${expirationInfo(entry).key}`;
},
legendClass(key) {
const hasActiveFilters = this.filters.expiration.length > 0 && !this.isAllExpirationSelected();
return [
@@ -762,10 +1038,14 @@ export function stockListPageData(store) {
.join(' ');
},
expirationCount(key) {
return this.entries.filter((entry) => {
const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries;
return source.filter((entry) => {
if (
this.filters.search &&
!searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase())
!(this.viewMode === 'grouped'
? groupSearchBlob(entry, this.locationMap)
: searchBlob(entry, this.locationMap)
).includes(this.filters.search.toLowerCase())
) {
return false;
}
@@ -773,7 +1053,9 @@ export function stockListPageData(store) {
if (
this.filters.location.length &&
this.filters.location.length !== this.locations.length &&
!this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location)
!(this.viewMode === 'grouped'
? this.groupMatchesLocationFilters(entry, this.filters.location)
: this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location))
) {
return false;
}
@@ -815,10 +1097,14 @@ export function stockListPageData(store) {
return `margin-left: ${offset}rem; width: calc(100% - ${offset}rem);`;
},
locationCount(locationUuid) {
return this.entries.filter((entry) => {
const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries;
return source.filter((entry) => {
if (
this.filters.search &&
!searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase())
!(this.viewMode === 'grouped'
? groupSearchBlob(entry, this.locationMap)
: searchBlob(entry, this.locationMap)
).includes(this.filters.search.toLowerCase())
) {
return false;
}
@@ -831,7 +1117,9 @@ export function stockListPageData(store) {
return false;
}
return this.locationMatchesFilter(entry.location_initial_uuid_b64, locationUuid);
return this.viewMode === 'grouped'
? this.groupMatchesLocationFilter(entry, locationUuid)
: this.locationMatchesFilter(entry.location_initial_uuid_b64, locationUuid);
}).length;
},
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
@@ -854,6 +1142,56 @@ export function stockListPageData(store) {
this.locationMatchesFilter(entryLocationUuid, selectedLocationUuid),
);
},
groupMatchesLocationFilter(group, selectedLocationUuid) {
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;
},
handleGroupedToggle(event) {
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || details.open) {
return;
}
const summary = details.querySelector('.grouped-stock-summary');
if (!summary) {
return;
}
requestAnimationFrame(() => {
summary.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
});
},
groupedPrimaryDate,
groupedFirstProductionDate,
groupedFirstExpireDate,
shortDescription,
quantityLabel,
stockTypeDetail(entry) {
if (entry.stock_type === 'binary') {