621 lines
23 KiB
JavaScript
621 lines
23 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.' },
|
||
|
|
];
|
||
|
|
|
||
|
|
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="row g-3">
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label">Search stock</label>
|
||
|
|
<input
|
||
|
|
class="form-control"
|
||
|
|
type="text"
|
||
|
|
x-model="filters.search"
|
||
|
|
placeholder="Search by item, description, location, or id"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<div class="stock-filter-toolbar">
|
||
|
|
<details class="stock-filter-details w-100">
|
||
|
|
<summary class="btn btn-outline-secondary">More filters</summary>
|
||
|
|
<div class="stock-filter-panel mt-3">
|
||
|
|
<div class="row g-3">
|
||
|
|
<div class="col-12 col-md-6">
|
||
|
|
<label class="form-label">Expiration filter</label>
|
||
|
|
<select class="form-select" x-model="filters.expiration">
|
||
|
|
<option value="">All expiration states</option>
|
||
|
|
<option value="expired">Expired</option>
|
||
|
|
<option value="use-first">Use first</option>
|
||
|
|
<option value="upcoming">Upcoming expiration</option>
|
||
|
|
<option value="within-date">Within date</option>
|
||
|
|
<option value="none">No expiration</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div class="col-12 col-md-6">
|
||
|
|
<label class="form-label">Location</label>
|
||
|
|
<select class="form-select" x-model="filters.location">
|
||
|
|
<option value="">All locations</option>
|
||
|
|
<template x-for="location in locations" :key="location.id">
|
||
|
|
<option :value="location.uuid_b64" x-text="location.pathLabel"></option>
|
||
|
|
</template>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</details>
|
||
|
|
<button class="btn btn-outline-secondary stock-filter-clear" type="button" @click="clearFilters()">Clear</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<details class="card border-0 shadow-sm mb-4 stock-guide">
|
||
|
|
<summary class="card-body p-4 d-flex justify-content-between align-items-center gap-3 stock-guide-summary">
|
||
|
|
<div>
|
||
|
|
<h2 class="h5 mb-1">Expiration overview</h2>
|
||
|
|
<p class="text-body-secondary small mb-0">
|
||
|
|
Show what each expiration color means.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div class="small text-body-secondary">
|
||
|
|
<span class="fw-semibold text-body" x-text="filteredEntries.length"></span>
|
||
|
|
item(s) visible
|
||
|
|
</div>
|
||
|
|
</summary>
|
||
|
|
<div class="card-body pt-0 px-4 pb-4">
|
||
|
|
<div class="row g-3">
|
||
|
|
<template x-for="stateInfo in expirationLegend" :key="stateInfo.key">
|
||
|
|
<div class="col-12 col-md-6 col-xl-4">
|
||
|
|
<div class="legend-card h-100" :class="legendClass(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>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</details>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
</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="markMeasuredGone(entry)">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>
|
||
|
|
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save stock level</button>
|
||
|
|
</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="markMeasuredGone(entry)">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,
|
||
|
|
filters: {
|
||
|
|
search: '',
|
||
|
|
expiration: '',
|
||
|
|
location: '',
|
||
|
|
},
|
||
|
|
async init() {
|
||
|
|
await Promise.all([this.loadLocations(), this.loadEntries()]);
|
||
|
|
},
|
||
|
|
async loadEntries() {
|
||
|
|
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() {
|
||
|
|
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: '',
|
||
|
|
};
|
||
|
|
},
|
||
|
|
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 &&
|
||
|
|
expirationInfo(entry).key !== this.filters.expiration
|
||
|
|
) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (
|
||
|
|
this.filters.location &&
|
||
|
|
!this.locationMatchesFilter(entry.location_initial_uuid_b64, this.filters.location)
|
||
|
|
) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
},
|
||
|
|
expirationFor(entry) {
|
||
|
|
return expirationInfo(entry);
|
||
|
|
},
|
||
|
|
rowClass(entry) {
|
||
|
|
return `expiration-${expirationInfo(entry).key}`;
|
||
|
|
},
|
||
|
|
badgeClass(entry) {
|
||
|
|
return `expiration-badge-${expirationInfo(entry).key}`;
|
||
|
|
},
|
||
|
|
legendClass(key) {
|
||
|
|
return `legend-${key}`;
|
||
|
|
},
|
||
|
|
expirationCount(key) {
|
||
|
|
return this.entries.filter((entry) => 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);
|
||
|
|
},
|
||
|
|
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
|
||
|
|
if (!selectedLocationUuid) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const allowed = this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid];
|
||
|
|
return allowed.includes(entryLocationUuid);
|
||
|
|
},
|
||
|
|
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';
|
||
|
|
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 markMeasuredGone(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 ?? '',
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|