Files
lonc/src/features/stock/stock-list-page.js
T

937 lines
35 KiB
JavaScript

import { deleteStockItem, 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';
const LEVEL_LABELS = {
plenty: 'Plenty',
good: 'Good',
some: 'Some',
low: 'Low',
trace: 'Trace',
gone: 'Gone',
};
const LEVEL_OPTIONS = [
{ value: 'plenty', label: 'Plenty' },
{ value: 'good', label: 'Good' },
{ value: 'some', label: 'Some' },
{ value: 'low', label: 'Low' },
{ value: 'trace', label: 'Trace' },
{ value: 'gone', label: 'Gone' },
];
const EXPIRATION_LEGEND = [
{ key: 'expired', label: 'Expired', description: 'The expiration date has already passed.' },
{ key: 'use-first', label: 'Use first', description: 'Still within date, but should be prioritized soonest for consumption.' },
{ key: 'upcoming', label: 'Upcoming expiration', description: 'Within date, but approaching expiration in the near term.' },
{ key: 'within-date', label: 'Within date', description: 'Still within the expected shelf-life window.' },
{ key: 'none', label: 'No expiration', description: 'No expiration date is assigned.' },
];
const EXPIRATION_KEYS = EXPIRATION_LEGEND.map((state) => state.key);
function todayAtMidnight() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
function parseDateValue(value) {
if (!value) {
return null;
}
const [year, month, day] = value.split('-').map(Number);
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day);
}
function expirationInfo(entry) {
if (!entry.expire_date) {
return {
key: 'none',
label: 'No expiration date',
detail: 'No expiration date',
sortRank: 4,
};
}
const today = todayAtMidnight();
const expireDate = parseDateValue(entry.expire_date);
const expireIn =
typeof entry.expire_in === 'number'
? entry.expire_in
: Math.round((expireDate - today) / (24 * 60 * 60 * 1000));
if (expireIn < 0) {
return {
key: 'expired',
label: 'Expired',
detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`,
sortRank: 0,
};
}
if (expireIn <= 2) {
return {
key: 'use-first',
label: expireIn === 0 ? 'Use today' : 'Use first',
detail:
expireIn === 0
? 'Expires today'
: `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`,
sortRank: 1,
};
}
if (expireIn <= 7) {
return {
key: 'upcoming',
label: 'Upcoming expiration',
detail: `Expires in ${expireIn} days`,
sortRank: 2,
};
}
return {
key: 'within-date',
label: 'Within date',
detail: `Expires in ${expireIn} days`,
sortRank: 3,
};
}
function sortEntries(entries) {
return [...entries].sort((left, right) => {
const leftExpiration = expirationInfo(left);
const rightExpiration = expirationInfo(right);
if (leftExpiration.sortRank !== rightExpiration.sortRank) {
return leftExpiration.sortRank - rightExpiration.sortRank;
}
const leftExpire = left.expire_date || '9999-12-31';
const rightExpire = right.expire_date || '9999-12-31';
if (leftExpire !== rightExpire) {
return leftExpire.localeCompare(rightExpire);
}
return (left.name || '').localeCompare(right.name || '');
});
}
function quantityLabel(entry) {
if (entry.stock_type === 'binary') {
return entry.level === 'gone' ? 'Gone' : 'Available';
}
const numeric = entry.quantity ?? null;
const uom = entry.uom_symbol || '';
const measured = numeric !== null && numeric !== undefined ? `${numeric} ${uom}`.trim() : '';
const level = entry.level ? LEVEL_LABELS[entry.level] || entry.level : '';
if (entry.stock_type === 'descriptive') {
return level || 'No stock level';
}
if (measured && level) {
return `${measured}${level}`;
}
return measured || level || 'No quantity';
}
function resolveLocationLabel(entry, locationMap) {
if (!entry.location_initial_uuid_b64) {
return 'No location assigned';
}
return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved';
}
function searchBlob(entry, locationMap) {
return [
entry.name,
entry.description,
entry.level,
entry.stock_type,
resolveLocationLabel(entry, locationMap),
entry.uuid_b64,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
}
export function renderStockListPage() {
return `
<section class="container-xxl py-4 py-lg-5" x-data="stockListPage()" x-init="init()">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end mb-4">
<div>
<p class="eyebrow mb-2">Stock Review</p>
<h1 class="h3 mb-1">Review stock and act quickly</h1>
<p class="text-body-secondary mb-0">
Focus on expiration, stock state, and location without leaving the overview.
</p>
</div>
<a href="#/labels/new" class="btn btn-primary">New stock label</a>
</div>
<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">
<label class="form-label mb-0">Search stock</label>
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click="clearFilters()">Reset</button>
</div>
<div class="row g-3">
<div class="col-12">
<input
class="form-control"
type="text"
x-model="filters.search"
placeholder="Search by item, description, location, or id"
/>
</div>
</div>
</div>
</div>
<div class="row g-4 mb-4 align-items-start" :class="overviewRowClass()">
<div class="col-12" :class="overviewColClass('expiration')">
<details
class="card border-0 shadow-sm overview-panel"
x-ref="expirationOverview"
@toggle="setOverviewOpen('expiration', $event.target.open)"
>
<summary class="card-body p-4 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h2 class="h5 mb-1">Expiration overview</h2>
<p class="text-body-secondary small mb-0">Tap to focus on one or more expiration states.</p>
</div>
<div class="d-flex flex-column align-items-end gap-1">
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllExpirationFilters()">Show all</button>
<div class="small text-body-secondary text-end">
<span class="fw-semibold text-body" x-text="filteredEntries.length"></span>
item(s) visible
</div>
</div>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-4">
<div class="overview-list" :class="{ 'overview-list-split': isOnlyOverviewOpen('expiration') }">
<template x-for="stateInfo in expirationLegend" :key="stateInfo.key">
<button
class="overview-option text-start"
type="button"
:class="legendClass(stateInfo.key)"
@click="toggleExpirationOverviewFilter(stateInfo.key)"
:aria-pressed="isExpirationFilterActive(stateInfo.key)"
>
<div class="d-flex justify-content-between align-items-start gap-3 mb-1">
<div class="fw-semibold" x-text="stateInfo.label"></div>
<div class="small fw-semibold" x-text="expirationCount(stateInfo.key)"></div>
</div>
<div class="small" x-text="stateInfo.description"></div>
</button>
</template>
</div>
</div>
</details>
</div>
<div class="col-12" :class="overviewColClass('location')">
<details
class="card border-0 shadow-sm overview-panel"
x-ref="locationOverview"
@toggle="setOverviewOpen('location', $event.target.open)"
>
<summary class="card-body p-4 overview-summary">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h2 class="h5 mb-1">Location overview</h2>
<p class="text-body-secondary small mb-0">Tap locations to focus the list. Parent locations include their children.</p>
</div>
<div class="d-flex flex-column align-items-end gap-1">
<button class="btn btn-link p-0 text-decoration-none stock-filter-reset" type="button" @click.prevent.stop="toggleAllLocations()">Show all</button>
<div class="small text-body-secondary text-end" x-text="selectedLocationSummary()"></div>
</div>
</div>
</summary>
<div class="card-body pt-0 px-4 pb-4">
<template x-if="isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations location-overview-columns">
<template x-for="(columnGroups, columnIndex) in balancedLocationOverviewColumns()" :key="columnIndex">
<div class="location-overview-column">
<template x-for="group in columnGroups" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
<button
class="overview-option overview-option-location text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
</div>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
<template x-if="!isOnlyOverviewOpen('location')">
<div class="overview-list overview-list-locations">
<template x-for="group in locationOverviewGroups()" :key="group.parent.id">
<div class="location-overview-group">
<template x-for="location in group.items" :key="location.id">
<button
class="overview-option overview-option-location text-start"
type="button"
:class="locationOverviewClass(location)"
:style="locationOverviewStyle(location)"
@click="toggleLocationOverviewFilter(location.uuid_b64)"
:aria-pressed="isLocationFilterActive(location.uuid_b64)"
>
<span class="stock-filter-location-rail" x-show="location.depth"></span>
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="fw-semibold" :class="location.depth ? 'small mb-0' : ''" x-text="location.name"></div>
<div class="small fw-semibold" x-text="locationCount(location.uuid_b64)"></div>
</div>
</button>
</template>
</div>
</template>
</div>
</template>
</div>
</details>
</div>
</div>
<template x-if="state.isLoading">
<div class="alert alert-secondary">Loading stock review...</div>
</template>
<template x-if="state.error">
<div class="alert alert-danger d-flex justify-content-between align-items-center gap-3">
<span x-text="state.error"></span>
<button class="btn btn-sm btn-outline-danger" type="button" @click="init()">Retry</button>
</div>
</template>
<template x-if="!state.isLoading && !state.error && !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>
<p class="text-body-secondary mb-0">
Try clearing the filters or create a new stock label.
</p>
</div>
</div>
</template>
<template x-if="!state.isLoading && !state.error && filteredEntries.length">
<div>
<div class="d-none d-xl-block">
<div class="card border-0 shadow-sm overflow-hidden">
<div class="table-responsive">
<table class="table align-middle mb-0 stock-review-table">
<thead class="table-light">
<tr>
<th>Item</th>
<th>Expiration</th>
<th>Quantity / level</th>
<th>Location</th>
<th>Dates</th>
<th>Quick actions</th>
</tr>
</thead>
<tbody>
<template x-for="entry in filteredEntries" :key="entry.id">
<tr :class="rowClass(entry)">
<td>
<div class="fw-semibold" x-text="entry.name"></div>
<div class="small text-body-secondary" x-text="entry.description || 'No description'"></div>
<div class="small font-monospace text-body-secondary" x-text="shortId(entry)"></div>
</td>
<td>
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
</div>
<div class="small text-body-secondary" x-text="expirationFor(entry).detail"></div>
</td>
<td>
<div class="fw-semibold" x-text="quantityLabel(entry)"></div>
<div class="small text-body-secondary" x-text="stockTypeDetail(entry)"></div>
</td>
<td x-text="locationLabel(entry)"></td>
<td>
<div class="small"><span class="text-body-secondary">Made:</span> <span x-text="formatDate(entry.date)"></span></div>
<div class="small"><span class="text-body-secondary">Expires:</span> <span x-text="formatDate(entry.expire_date)"></span></div>
</td>
<td>
<div class="quick-edit-stack">
<template x-if="entry.stock_type === 'binary'">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-outline-danger" type="button" @click="updateBinary(entry, 'gone')">Mark gone</button>
</div>
</template>
<template x-if="entry.stock_type === 'descriptive'">
<div class="d-flex flex-wrap gap-2 align-items-center">
<select class="form-select form-select-sm quick-select" x-model="editForms[entry.id].level">
<template x-for="option in levelOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save</button>
<button class="btn btn-sm btn-outline-danger" type="button" @click="markGone(entry)">Mark gone</button>
</div>
</template>
<template x-if="entry.stock_type === 'measured'">
<div class="d-flex flex-wrap gap-2 align-items-center">
<input class="form-control form-control-sm quick-number" type="number" step="0.01" min="0" x-model="editForms[entry.id].quantity" />
<button class="btn btn-sm btn-primary" type="button" @click="saveQuantity(entry)">Save qty</button>
<button class="btn btn-sm btn-outline-danger" type="button" @click="markGone(entry)">Mark gone</button>
</div>
</template>
<template x-if="editErrors[entry.id]">
<div class="small text-danger" x-text="editErrors[entry.id]"></div>
</template>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<div class="d-grid gap-3 d-xl-none">
<template x-for="entry in filteredEntries" :key="entry.id">
<div class="card border-0 shadow-sm stock-review-card" :class="rowClass(entry)">
<div class="card-body p-4">
<div class="d-flex justify-content-between gap-3 align-items-start mb-3">
<div>
<div class="fw-semibold fs-5" x-text="entry.name"></div>
<div class="text-body-secondary small" x-text="entry.description || 'No description'"></div>
<div class="text-body-secondary small font-monospace" x-text="shortId(entry)"></div>
</div>
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
</div>
<div class="row g-3 small mb-3">
<div class="col-6">
<div class="text-body-secondary">Quantity / level</div>
<div class="fw-semibold" x-text="quantityLabel(entry)"></div>
</div>
<div class="col-6">
<div class="text-body-secondary">Location</div>
<div class="fw-semibold" x-text="locationLabel(entry)"></div>
</div>
<div class="col-6">
<div class="text-body-secondary">Production date</div>
<div x-text="formatDate(entry.date)"></div>
</div>
<div class="col-6">
<div class="text-body-secondary">Expiration</div>
<div x-text="expirationFor(entry).detail"></div>
</div>
</div>
<div class="quick-edit-stack">
<template x-if="entry.stock_type === 'binary'">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-outline-danger" type="button" @click="updateBinary(entry, 'gone')">Mark gone</button>
</div>
</template>
<template x-if="entry.stock_type === 'descriptive'">
<div class="d-grid gap-2">
<select class="form-select form-select-sm" x-model="editForms[entry.id].level">
<template x-for="option in levelOptions" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save stock level</button>
<button class="btn btn-sm btn-outline-danger" type="button" @click="markGone(entry)">Mark gone</button>
</div>
</div>
</template>
<template x-if="entry.stock_type === 'measured'">
<div class="d-grid gap-2">
<input class="form-control form-control-sm" type="number" step="0.01" min="0" x-model="editForms[entry.id].quantity" />
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" type="button" @click="saveQuantity(entry)">Save quantity</button>
<button class="btn btn-sm btn-outline-danger" type="button" @click="markGone(entry)">Mark gone</button>
</div>
</div>
</template>
<template x-if="editErrors[entry.id]">
<div class="small text-danger mt-2" x-text="editErrors[entry.id]"></div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
</section>
`;
}
export function stockListPageData(store) {
return {
state: createAsyncState(),
entries: [],
locations: [],
locationMap: {},
locationDescendants: {},
editForms: {},
editErrors: {},
levelOptions: LEVEL_OPTIONS,
expirationLegend: EXPIRATION_LEGEND,
overviewOpen: {
expiration: false,
location: false,
},
filters: {
search: '',
expiration: [],
location: [],
},
async init() {
if (!store.isConnected) {
return;
}
await Promise.all([this.loadLocations(), this.loadEntries()]);
},
async loadEntries() {
if (!store.isConnected) {
return;
}
await runAsyncState(this.state, async () => {
const loadedEntries = await listStockEntries(store);
this.entries = sortEntries(loadedEntries);
this.editForms = Object.fromEntries(
this.entries.map((entry) => [
entry.id,
{
level: entry.level || 'plenty',
quantity: entry.quantity ?? '',
},
]),
);
this.editErrors = {};
}).catch(() => {});
},
async loadLocations() {
if (!store.isConnected) {
return;
}
try {
const { flat } = await fetchLocations(store);
this.locations = flat;
this.locationMap = Object.fromEntries(
flat.map((location) => [location.uuid_b64, location.pathLabel]),
);
this.locationDescendants = Object.fromEntries(
flat.map((location) => [
location.uuid_b64,
flat
.filter((candidate) => candidate.lineage_uuid_b64.includes(location.uuid_b64))
.map((candidate) => candidate.uuid_b64),
]),
);
} catch {
this.locations = [];
this.locationMap = {};
this.locationDescendants = {};
}
},
clearFilters() {
this.filters = {
search: '',
expiration: [],
location: [],
};
},
setOverviewOpen(key, open) {
this.overviewOpen[key] = open;
if (!open || !this.isCompactOverviewLayout()) {
return;
}
const otherKey = key === 'expiration' ? 'location' : 'expiration';
this.overviewOpen[otherKey] = false;
const otherPanel =
otherKey === 'expiration'
? this.$refs.expirationOverview
: this.$refs.locationOverview;
if (otherPanel?.open) {
otherPanel.open = false;
}
},
isCompactOverviewLayout() {
return window.matchMedia('(max-width: 1199.98px)').matches;
},
openOverviewCount() {
return Number(this.overviewOpen.expiration) + Number(this.overviewOpen.location);
},
isOnlyOverviewOpen(key) {
return this.openOverviewCount() === 1 && this.overviewOpen[key];
},
overviewRowClass() {
return this.openOverviewCount() === 1 ? 'overview-row-single-open' : '';
},
overviewColClass(key) {
if (this.openOverviewCount() === 1) {
return 'col-xl-12';
}
return key === 'expiration' ? 'col-xl-5' : 'col-xl-7';
},
expirationFilterSummary() {
if (this.isAllExpirationSelected()) {
return 'Show all';
}
if (!this.filters.expiration.length) {
return 'No expiration states selected';
}
if (this.filters.expiration.length === 1) {
return this.expirationLegend.find((state) => state.key === this.filters.expiration[0])?.label || '1 expiration state';
}
return `${this.filters.expiration.length} expiration states selected`;
},
isAllExpirationSelected() {
return this.filters.expiration.length === 0 || this.filters.expiration.length === EXPIRATION_KEYS.length;
},
toggleAllExpirationFilters() {
this.filters.expiration = [];
},
toggleExpirationOverviewFilter(key) {
if (this.isAllExpirationSelected()) {
this.filters.expiration = [key];
return;
}
this.toggleExpirationFilter(key);
},
toggleExpirationFilter(key) {
if (this.filters.expiration.includes(key)) {
this.filters.expiration = this.filters.expiration.filter((value) => value !== key);
return;
}
this.filters.expiration = [...this.filters.expiration, key];
},
locationFilterSummary() {
if (this.isAllLocationsSelected()) {
return 'All locations';
}
if (!this.filters.location.length) {
return 'No locations selected';
}
if (this.filters.location.length === 1) {
return this.locationMap[this.filters.location[0]] || '1 location selected';
}
return `${this.filters.location.length} locations selected`;
},
selectedLocationSummary() {
if (this.isAllLocationsSelected()) {
return '';
}
if (!this.filters.location.length) {
return 'No locations selected';
}
return `${this.filters.location.length} selected`;
},
locationOverviewGroups() {
const byParent = this.locations
.filter((location) => location.depth === 0)
.map((parent) => ({
parent,
items: this.locations.filter((location) =>
location.lineage_uuid_b64[0] === parent.uuid_b64,
),
}));
return byParent;
},
balancedLocationOverviewColumns() {
const columns = [[], []];
const sizes = [0, 0];
this.locationOverviewGroups().forEach((group) => {
const groupSize = group.items.length;
const targetIndex = sizes[0] <= sizes[1] ? 0 : 1;
columns[targetIndex].push(group);
sizes[targetIndex] += groupSize;
});
return columns;
},
isAllLocationsSelected() {
return this.filters.location.length === 0 || (this.locations.length > 0 && this.filters.location.length === this.locations.length);
},
toggleAllLocations() {
this.filters.location = [];
},
toggleLocationFilter(uuid) {
const subtree = this.locationSubtree(uuid);
if (this.filters.location.includes(uuid)) {
this.filters.location = this.filters.location.filter((value) => !subtree.includes(value));
return;
}
this.filters.location = [...new Set([...this.filters.location, ...subtree])];
},
get filteredEntries() {
return this.entries.filter((entry) => {
if (
this.filters.search &&
!searchBlob(entry, 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(entry).key)
) {
return false;
}
if (
this.filters.location.length &&
this.filters.location.length !== this.locations.length &&
!this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location)
) {
return false;
}
return true;
});
},
expirationFor(entry) {
return expirationInfo(entry);
},
isExpirationFilterActive(key) {
return this.isAllExpirationSelected() || this.filters.expiration.includes(key);
},
rowClass(entry) {
return `expiration-${expirationInfo(entry).key}`;
},
badgeClass(entry) {
return `expiration-badge-${expirationInfo(entry).key}`;
},
legendClass(key) {
const hasActiveFilters = this.filters.expiration.length > 0 && !this.isAllExpirationSelected();
return [
`legend-${key}`,
hasActiveFilters && this.isExpirationFilterActive(key) ? 'legend-card-active' : '',
hasActiveFilters && !this.isExpirationFilterActive(key) ? 'legend-card-inactive' : '',
]
.filter(Boolean)
.join(' ');
},
expirationCount(key) {
return this.entries.filter((entry) => {
if (
this.filters.search &&
!searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase())
) {
return false;
}
if (
this.filters.location.length &&
this.filters.location.length !== this.locations.length &&
!this.locationMatchesAnyFilter(entry.location_initial_uuid_b64, this.filters.location)
) {
return false;
}
return expirationInfo(entry).key === key;
}).length;
},
shortId(entry) {
return entry.uuid_b64 ? entry.uuid_b64.slice(0, 10) : 'No id';
},
locationLabel(entry) {
return resolveLocationLabel(entry, this.locationMap);
},
isLocationFilterActive(uuid) {
return this.isAllLocationsSelected() || this.filters.location.includes(uuid);
},
toggleLocationOverviewFilter(uuid) {
if (this.isAllLocationsSelected()) {
this.filters.location = [...this.locationSubtree(uuid)];
return;
}
this.toggleLocationFilter(uuid);
},
locationOverviewClass(location) {
const hasActiveFilters = this.filters.location.length > 0 && !this.isAllLocationsSelected();
return [
'location-overview',
`location-type-${location.type || 'unknown'}`,
location.depth ? 'location-overview-child' : 'location-overview-parent',
hasActiveFilters && this.isLocationFilterActive(location.uuid_b64) ? 'legend-card-active' : '',
hasActiveFilters && !this.isLocationFilterActive(location.uuid_b64) ? 'legend-card-inactive' : '',
]
.filter(Boolean)
.join(' ');
},
locationOverviewStyle(location) {
const offset = location.depth;
return `margin-left: ${offset}rem; width: calc(100% - ${offset}rem);`;
},
locationCount(locationUuid) {
return this.entries.filter((entry) => {
if (
this.filters.search &&
!searchBlob(entry, 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(entry).key)
) {
return false;
}
return this.locationMatchesFilter(entry.location_initial_uuid_b64, locationUuid);
}).length;
},
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
if (!selectedLocationUuid) {
return true;
}
const allowed = this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid];
return allowed.includes(entryLocationUuid);
},
locationSubtree(selectedLocationUuid) {
return this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid];
},
locationMatchesAnyFilter(entryLocationUuid, selectedLocationUuids) {
if (!selectedLocationUuids.length) {
return true;
}
return selectedLocationUuids.some((selectedLocationUuid) =>
this.locationMatchesFilter(entryLocationUuid, selectedLocationUuid),
);
},
quantityLabel,
stockTypeDetail(entry) {
if (entry.stock_type === 'binary') {
return 'Binary stock';
}
if (entry.stock_type === 'descriptive') {
return `Level: ${LEVEL_LABELS[entry.level] || 'Not set'}`;
}
return entry.uom_symbol ? `Measured in ${entry.uom_symbol}` : 'Measured stock';
},
formatDate,
async updateBinary(entry, level) {
await this.deleteEntry(entry);
},
async saveLevel(entry) {
const level = this.editForms[entry.id]?.level || 'plenty';
if (level === 'gone') {
await this.deleteEntry(entry);
return;
}
await this.saveEntryUpdate(entry, {
level,
}, { level });
},
async saveQuantity(entry) {
const quantity = Number(this.editForms[entry.id]?.quantity);
if (Number.isNaN(quantity) || quantity < 0) {
this.editErrors[entry.id] = 'Enter a valid quantity first.';
return;
}
await this.saveEntryUpdate(entry, {
quantity,
}, { quantity });
},
async markGone(entry) {
await this.deleteEntry(entry);
},
async saveEntryUpdate(entry, payload, localPatch) {
this.editErrors[entry.id] = '';
try {
const updated = await updateStockItem(store, entry.uuid_b64, payload);
this.replaceEntry(entry.id, { ...entry, ...localPatch, ...updated });
store.addAlert({
type: 'success',
message: `${entry.name} updated successfully.`,
});
} catch (error) {
this.editErrors[entry.id] = error.message || 'Update failed.';
}
},
async deleteEntry(entry) {
this.editErrors[entry.id] = '';
try {
await deleteStockItem(store, entry.uuid_b64);
this.entries = this.entries.filter((candidate) => candidate.id !== entry.id);
delete this.editForms[entry.id];
delete this.editErrors[entry.id];
store.addAlert({
type: 'success',
message: `${entry.name} was marked gone and removed from the list.`,
});
} catch (error) {
this.editErrors[entry.id] = error.message || 'Delete failed.';
}
},
replaceEntry(entryId, nextEntry) {
this.entries = sortEntries(
this.entries.map((entry) => (entry.id === entryId ? nextEntry : entry)),
);
this.editForms[entryId] = {
level: nextEntry.level || 'plenty',
quantity: nextEntry.quantity ?? '',
};
},
};
}