Add grouped stock view with expiration and location filtering
This commit is contained in:
+26
-1
@@ -1,5 +1,8 @@
|
|||||||
import { apiRequest, getPath } from './client.js';
|
import { apiRequest, getPath } from './client.js';
|
||||||
|
|
||||||
|
const LOCATION_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const locationCache = new Map();
|
||||||
|
|
||||||
function flattenNodes(nodes, trail = [], lineage = []) {
|
function flattenNodes(nodes, trail = [], lineage = []) {
|
||||||
return nodes.flatMap((node) => {
|
return nodes.flatMap((node) => {
|
||||||
const currentTrail = [...trail, node.name];
|
const currentTrail = [...trail, node.name];
|
||||||
@@ -23,15 +26,37 @@ function flattenNodes(nodes, trail = [], lineage = []) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocationCacheKey(store) {
|
||||||
|
const baseUrl = store.config.baseUrl || '';
|
||||||
|
const database = store.config.database || '';
|
||||||
|
const applicationKey = store.session.applicationKey || '';
|
||||||
|
return `${baseUrl}::${database}::${applicationKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchLocations(store) {
|
export async function fetchLocations(store) {
|
||||||
|
const cacheKey = getLocationCacheKey(store);
|
||||||
|
const cached = locationCache.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cached && now - cached.cachedAt < LOCATION_CACHE_TTL_MS) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await apiRequest(store, getPath('locations'), {
|
const payload = await apiRequest(store, getPath('locations'), {
|
||||||
includeKitchen: false,
|
includeKitchen: false,
|
||||||
});
|
});
|
||||||
const tree = Array.isArray(payload)
|
const tree = Array.isArray(payload)
|
||||||
? payload
|
? payload
|
||||||
: payload?.data || payload?.locations || [];
|
: payload?.data || payload?.locations || [];
|
||||||
return {
|
const value = {
|
||||||
tree,
|
tree,
|
||||||
flat: flattenNodes(tree),
|
flat: flattenNodes(tree),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
locationCache.set(cacheKey, {
|
||||||
|
cachedAt: now,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -9,9 +9,9 @@ export async function searchItemDefinitions(store, query) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await apiRequest(store, getPath('items'), {
|
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||||
includeKitchen: false,
|
includeKitchen: false,
|
||||||
query: { search_name: query },
|
query: { search_name: query, expanded: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
@@ -33,6 +33,19 @@ export async function listStockEntries(store, filters = {}) {
|
|||||||
return payload?.data || payload?.entries || payload?.items || [];
|
return payload?.data || payload?.entries || payload?.items || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listGroupedStockEntries(store) {
|
||||||
|
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||||
|
includeKitchen: false,
|
||||||
|
query: { expanded: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload?.data || payload?.entries || payload?.items || payload?.groups || [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function getStockEntry(store, stockId) {
|
export async function getStockEntry(store, stockId) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
|
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
|
||||||
includeKitchen: false,
|
includeKitchen: false,
|
||||||
|
|||||||
@@ -551,6 +551,16 @@ export function labelCreatePageData(store) {
|
|||||||
this.syncStockLevelSelect();
|
this.syncStockLevelSelect();
|
||||||
});
|
});
|
||||||
this.$watch('form.level', () => this.syncStockLevelSelect());
|
this.$watch('form.level', () => this.syncStockLevelSelect());
|
||||||
|
this.$watch('form.productionDate', () => {
|
||||||
|
if (this.form.expireDays !== '') {
|
||||||
|
this.syncExpireDateFromDays();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.form.expirationDate) {
|
||||||
|
this.syncExpireDaysFromDate();
|
||||||
|
}
|
||||||
|
});
|
||||||
this.syncStockTypeState(this.form.stockType);
|
this.syncStockTypeState(this.form.stockType);
|
||||||
this.syncStockTypeSelect();
|
this.syncStockTypeSelect();
|
||||||
this.syncStockLevelSelect();
|
this.syncStockLevelSelect();
|
||||||
@@ -584,6 +594,14 @@ export function labelCreatePageData(store) {
|
|||||||
this.searchDebounced();
|
this.searchDebounced();
|
||||||
},
|
},
|
||||||
pickSuggestion(item) {
|
pickSuggestion(item) {
|
||||||
|
const baseProductionDate = this.form.productionDate || todayIsoDate();
|
||||||
|
const expirationDays =
|
||||||
|
typeof item.expiration_days === 'number'
|
||||||
|
? item.expiration_days
|
||||||
|
: item.date && item.expire_date
|
||||||
|
? diffDays(item.date, item.expire_date)
|
||||||
|
: null;
|
||||||
|
|
||||||
this.form.itemId = item.id;
|
this.form.itemId = item.id;
|
||||||
this.form.search = item.name;
|
this.form.search = item.name;
|
||||||
this.form.name = item.name;
|
this.form.name = item.name;
|
||||||
@@ -595,9 +613,11 @@ export function labelCreatePageData(store) {
|
|||||||
: this.form.quantity;
|
: this.form.quantity;
|
||||||
this.form.stockType = item.stock_type || this.form.stockType;
|
this.form.stockType = item.stock_type || this.form.stockType;
|
||||||
this.form.level = item.level || this.form.level;
|
this.form.level = item.level || this.form.level;
|
||||||
this.form.expirationDate = item.expire_date || this.form.expirationDate;
|
if (expirationDays !== null && expirationDays >= 0) {
|
||||||
|
this.form.expireDays = String(expirationDays);
|
||||||
|
this.form.expirationDate = addDaysToIsoDate(baseProductionDate, expirationDays);
|
||||||
|
}
|
||||||
this.applyItemLocation(item.location_initial_uuid_b64);
|
this.applyItemLocation(item.location_initial_uuid_b64);
|
||||||
this.syncExpireDaysFromDate();
|
|
||||||
this.suggestions = [];
|
this.suggestions = [];
|
||||||
this.persistDraft();
|
this.persistDraft();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { fetchLocations } from '../../api/locations.js';
|
||||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||||
import { formatDate } from '../shared/date-utils.js';
|
import { formatDate } from '../shared/date-utils.js';
|
||||||
@@ -50,7 +55,16 @@ function parseDateValue(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function expirationInfo(entry) {
|
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 {
|
return {
|
||||||
key: 'none',
|
key: 'none',
|
||||||
label: 'No expiration date',
|
label: 'No expiration date',
|
||||||
@@ -60,11 +74,13 @@ function expirationInfo(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const today = todayAtMidnight();
|
const today = todayAtMidnight();
|
||||||
const expireDate = parseDateValue(entry.expire_date);
|
const expireDate = parseDateValue(expireDateValue);
|
||||||
const expireIn =
|
const expireIn =
|
||||||
typeof entry.expire_in === 'number'
|
typeof expireInValue === 'number'
|
||||||
? entry.expire_in
|
? expireInValue
|
||||||
: Math.round((expireDate - today) / (24 * 60 * 60 * 1000));
|
: expireDate
|
||||||
|
? Math.round((expireDate - today) / (24 * 60 * 60 * 1000))
|
||||||
|
: null;
|
||||||
|
|
||||||
if (expireIn < 0) {
|
if (expireIn < 0) {
|
||||||
return {
|
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) {
|
function quantityLabel(entry) {
|
||||||
if (entry.stock_type === 'binary') {
|
if (entry.stock_type === 'binary') {
|
||||||
return entry.level === 'gone' ? 'Gone' : 'Available';
|
return entry.level === 'gone' ? 'Gone' : 'Available';
|
||||||
@@ -166,6 +201,40 @@ function searchBlob(entry, locationMap) {
|
|||||||
.toLowerCase();
|
.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() {
|
export function renderStockListPage() {
|
||||||
return `
|
return `
|
||||||
<section class="container-xxl py-4 py-lg-5" x-data="stockListPage()" x-init="init()">
|
<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>
|
<a href="#/labels/new" class="btn btn-primary">New stock label</a>
|
||||||
</div>
|
</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 border-0 shadow-sm mb-4">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="d-flex justify-content-between align-items-center gap-3 mb-3">
|
<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">
|
<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>
|
<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">
|
<div class="small text-body-secondary text-end">
|
||||||
<span class="fw-semibold text-body" x-text="filteredEntries.length"></span>
|
<span class="fw-semibold text-body" x-text="visibleResultCount"></span>
|
||||||
item(s) visible
|
<span x-text="viewMode === 'grouped' ? 'group(s) visible' : 'item(s) visible'"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +432,7 @@ export function renderStockListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 border-0 shadow-sm">
|
||||||
<div class="card-body p-4 text-center">
|
<div class="card-body p-4 text-center">
|
||||||
<h2 class="h5">No stock items to show</h2>
|
<h2 class="h5">No stock items to show</h2>
|
||||||
@@ -340,7 +443,7 @@ export function renderStockListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="!state.isLoading && !state.error && filteredEntries.length">
|
<template x-if="!state.isLoading && !state.error && viewMode === 'items' && filteredEntries.length">
|
||||||
<div>
|
<div>
|
||||||
<div class="d-none d-xl-block">
|
<div class="d-none d-xl-block">
|
||||||
<div class="card border-0 shadow-sm overflow-hidden">
|
<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="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>
|
||||||
</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">
|
||||||
@@ -426,6 +530,7 @@ 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>
|
||||||
</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>
|
||||||
@@ -487,6 +592,112 @@ export function renderStockListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -494,7 +705,10 @@ export function renderStockListPage() {
|
|||||||
export function stockListPageData(store) {
|
export function stockListPageData(store) {
|
||||||
return {
|
return {
|
||||||
state: createAsyncState(),
|
state: createAsyncState(),
|
||||||
|
viewMode: 'items',
|
||||||
entries: [],
|
entries: [],
|
||||||
|
groupedEntries: [],
|
||||||
|
groupedLoaded: false,
|
||||||
locations: [],
|
locations: [],
|
||||||
locationMap: {},
|
locationMap: {},
|
||||||
locationDescendants: {},
|
locationDescendants: {},
|
||||||
@@ -517,6 +731,21 @@ export function stockListPageData(store) {
|
|||||||
}
|
}
|
||||||
await Promise.all([this.loadLocations(), this.loadEntries()]);
|
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() {
|
async loadEntries() {
|
||||||
if (!store.isConnected) {
|
if (!store.isConnected) {
|
||||||
return;
|
return;
|
||||||
@@ -537,6 +766,17 @@ export function stockListPageData(store) {
|
|||||||
this.editErrors = {};
|
this.editErrors = {};
|
||||||
}).catch(() => {});
|
}).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() {
|
async loadLocations() {
|
||||||
if (!store.isConnected) {
|
if (!store.isConnected) {
|
||||||
return;
|
return;
|
||||||
@@ -739,6 +979,39 @@ export function stockListPageData(store) {
|
|||||||
return true;
|
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) {
|
expirationFor(entry) {
|
||||||
return expirationInfo(entry);
|
return expirationInfo(entry);
|
||||||
},
|
},
|
||||||
@@ -751,6 +1024,9 @@ export function stockListPageData(store) {
|
|||||||
badgeClass(entry) {
|
badgeClass(entry) {
|
||||||
return `expiration-badge-${expirationInfo(entry).key}`;
|
return `expiration-badge-${expirationInfo(entry).key}`;
|
||||||
},
|
},
|
||||||
|
groupedItemClass(entry) {
|
||||||
|
return `expiration-soft-${expirationInfo(entry).key}`;
|
||||||
|
},
|
||||||
legendClass(key) {
|
legendClass(key) {
|
||||||
const hasActiveFilters = this.filters.expiration.length > 0 && !this.isAllExpirationSelected();
|
const hasActiveFilters = this.filters.expiration.length > 0 && !this.isAllExpirationSelected();
|
||||||
return [
|
return [
|
||||||
@@ -762,10 +1038,14 @@ export function stockListPageData(store) {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
},
|
},
|
||||||
expirationCount(key) {
|
expirationCount(key) {
|
||||||
return this.entries.filter((entry) => {
|
const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries;
|
||||||
|
return source.filter((entry) => {
|
||||||
if (
|
if (
|
||||||
this.filters.search &&
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -773,7 +1053,9 @@ export function stockListPageData(store) {
|
|||||||
if (
|
if (
|
||||||
this.filters.location.length &&
|
this.filters.location.length &&
|
||||||
this.filters.location.length !== this.locations.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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -815,10 +1097,14 @@ export function stockListPageData(store) {
|
|||||||
return `margin-left: ${offset}rem; width: calc(100% - ${offset}rem);`;
|
return `margin-left: ${offset}rem; width: calc(100% - ${offset}rem);`;
|
||||||
},
|
},
|
||||||
locationCount(locationUuid) {
|
locationCount(locationUuid) {
|
||||||
return this.entries.filter((entry) => {
|
const source = this.viewMode === 'grouped' ? this.groupedEntries : this.entries;
|
||||||
|
return source.filter((entry) => {
|
||||||
if (
|
if (
|
||||||
this.filters.search &&
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -831,7 +1117,9 @@ export function stockListPageData(store) {
|
|||||||
return false;
|
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;
|
}).length;
|
||||||
},
|
},
|
||||||
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
|
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
|
||||||
@@ -854,6 +1142,56 @@ export function stockListPageData(store) {
|
|||||||
this.locationMatchesFilter(entryLocationUuid, selectedLocationUuid),
|
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,
|
quantityLabel,
|
||||||
stockTypeDetail(entry) {
|
stockTypeDetail(entry) {
|
||||||
if (entry.stock_type === 'binary') {
|
if (entry.stock_type === 'binary') {
|
||||||
|
|||||||
@@ -275,6 +275,12 @@ body {
|
|||||||
color: var(--lonc-primary);
|
color: var(--lonc-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-view-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.overview-row-single-open > [class*='col-'] {
|
.overview-row-single-open > [class*='col-'] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -537,6 +543,133 @@ button.legend-card:focus-visible {
|
|||||||
border-left-color: #6c757d;
|
border-left-color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grouped-stock-card {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-summary {
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-summary-meta {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-toggle-label {
|
||||||
|
color: var(--lonc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-toggle-label::after {
|
||||||
|
content: 'Expand';
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-card[open] .grouped-stock-toggle-label::after {
|
||||||
|
content: 'Hide';
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-card.expiration-expired {
|
||||||
|
border-left-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-card.expiration-use-first {
|
||||||
|
border-left-color: #fd7e14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-card.expiration-upcoming {
|
||||||
|
border-left-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-card.expiration-within-date {
|
||||||
|
border-left-color: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-card.expiration-none {
|
||||||
|
border-left-color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item {
|
||||||
|
display: block;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
border: 1px solid var(--lonc-border);
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 12px 24px rgba(24, 42, 79, 0.08);
|
||||||
|
border-color: rgba(31, 75, 153, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item.expiration-soft-expired {
|
||||||
|
background: rgba(220, 53, 69, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item.expiration-soft-use-first {
|
||||||
|
background: rgba(253, 126, 20, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item.expiration-soft-upcoming {
|
||||||
|
background: rgba(255, 193, 7, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item.expiration-soft-within-date {
|
||||||
|
background: rgba(25, 135, 84, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item.expiration-soft-none {
|
||||||
|
background: rgba(108, 117, 125, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item-meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-item-subline {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-subline-separator {
|
||||||
|
color: var(--lonc-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-date-pair {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-stock-date-separator {
|
||||||
|
color: var(--lonc-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.grouped-stock-item-meta {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stock-review-card {
|
.stock-review-card {
|
||||||
border-left: 6px solid transparent;
|
border-left: 6px solid transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user