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

537 lines
20 KiB
JavaScript
Raw Normal View History

import {
adjustStockEntry,
getStockEntry,
useStockItem,
} from '../../api/stock.js';
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
import { fetchLocations } from '../../api/locations.js';
import { getRouteContext } from '../../app/router.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
import { formatDate } from '../shared/date-utils.js';
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] = String(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',
};
}
const expireDate = parseDateValue(entry.expire_date);
const expireIn =
typeof entry.expire_in === 'number'
? entry.expire_in
: expireDate
? Math.round((expireDate - todayAtMidnight()) / (24 * 60 * 60 * 1000))
: null;
if (expireIn === null) {
return {
key: 'none',
label: 'No expiration date',
detail: 'No expiration date',
};
}
if (expireIn < 0) {
return {
key: 'expired',
label: 'Expired',
detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`,
};
}
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'}`,
};
}
if (expireIn <= 7) {
return {
key: 'upcoming',
label: 'Upcoming expiration',
detail: `Expires in ${expireIn} days`,
};
}
return {
key: 'within-date',
label: 'Within date',
detail: `Expires in ${expireIn} days`,
};
}
export function renderStockDetailPage() {
return `
<section class="container-xxl py-4 py-lg-5" x-data="stockDetailPage()" x-init="init()">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center mb-4">
<div>
<a href="#/stock" class="link-secondary text-decoration-none small">&larr; Back to stock</a>
<h1 class="h3 mb-1 mt-2">Stock detail</h1>
<p class="text-body-secondary mb-0">Inspect the entry and update its quantity without leaving the workflow.</p>
</div>
</div>
<template x-if="state.error">
<div class="alert alert-danger" x-text="state.error"></div>
</template>
<template x-if="entry">
<div class="row g-4">
<div class="col-12 col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-4">
<div>
<p class="eyebrow mb-2">Entry</p>
<h2 class="h4 mb-1" x-text="entry.name"></h2>
<p class="text-body-secondary mb-0" x-text="entry.description || 'No description'"></p>
</div>
<span class="badge rounded-pill text-bg-light border align-self-start" x-text="entry.status || 'ok'"></span>
</div>
<dl class="row mb-0 detail-grid">
<dt class="col-5">Kitchen</dt>
<dd class="col-7" x-text="$store.app.activeKitchen?.name || 'Unknown'"></dd>
<dt class="col-5">Quantity</dt>
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
<dt class="col-5">Location</dt>
<dd class="col-7" x-text="locationLabel(entry)"></dd>
<dt class="col-5">Production date</dt>
<dd class="col-7" x-text="formatDate(entry.date)"></dd>
<dt class="col-5">Expiration date</dt>
<dd class="col-7" x-text="formatDate(entry.expire_date)"></dd>
<dt class="col-5">Expiration status</dt>
<dd class="col-7">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="badge rounded-pill" :class="expirationBadgeClass(entry)" x-text="expirationFor(entry).label"></span>
</div>
<div class="small text-body-secondary" x-text="expirationFor(entry).detail"></div>
</dd>
<dt class="col-5">Stock type</dt>
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
</dl>
<div class="mt-4">
<h3 class="h6 mb-3">Nutrition</h3>
<dl class="row mb-0 detail-grid">
<dt class="col-5">Nutri-Score</dt>
<dd class="col-7" x-text="nutriScoreLabel(entry)"></dd>
<dt class="col-5">Nutriments</dt>
<dd class="col-7">
<template x-if="nutritionFactsRows(entry).length">
<ul class="list-unstyled mb-0 small d-grid gap-1">
<template x-for="fact in nutritionFactsRows(entry)" :key="fact.key">
<li>
<span class="text-body-secondary" x-text="fact.label + ':'"></span>
<span class="fw-semibold" x-text="fact.value"></span>
</li>
</template>
</ul>
</template>
<template x-if="!nutritionFactsRows(entry).length">
<span class="text-body-secondary">Not available</span>
</template>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card border-0 shadow-sm">
<div class="card-body p-4">
<p class="eyebrow mb-2">Adjustment</p>
<h2 class="h5 mb-3">Update current stock level</h2>
<template x-if="entry.stock_type === 'measured'">
<form class="vstack gap-3" @submit.prevent="submitMeasuredAdjustment()">
<div>
<label class="form-label">Adjustment mode</label>
<select class="form-select" x-model="adjustment.mode">
<option value="increment">Add quantity</option>
<option value="decrement">Subtract quantity</option>
<option value="set">Set exact quantity</option>
</select>
</div>
<div>
<label class="form-label">Quantity</label>
<input class="form-control" type="number" min="0" step="0.01" x-model="adjustment.quantity" required />
</div>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(1)">+1</button>
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(-1)">-1</button>
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(0.5)">+0.5</button>
</div>
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<template x-if="printFeedback.message">
<div
class="alert mb-0"
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
x-text="printFeedback.message"
></div>
</template>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Save quantity</span>
<span x-show="adjustmentState.isLoading">Saving...</span>
</button>
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
<span x-show="!printState.isLoading">Print label</span>
<span x-show="printState.isLoading">Printing...</span>
</button>
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Mark gone</span>
<span x-show="adjustmentState.isLoading">Removing...</span>
</button>
</div>
</form>
</template>
<template x-if="entry.stock_type === 'descriptive'">
<form class="vstack gap-3" @submit.prevent="submitLevelAdjustment()">
<div>
<label class="form-label">Stock level</label>
<select class="form-select" x-model="adjustment.level">
<option value="plenty">Plenty</option>
<option value="good">Good</option>
<option value="some">Some</option>
<option value="low">Low</option>
<option value="trace">Trace</option>
<option value="gone">Gone</option>
</select>
</div>
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<template x-if="printFeedback.message">
<div
class="alert mb-0"
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
x-text="printFeedback.message"
></div>
</template>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Save stock level</span>
<span x-show="adjustmentState.isLoading">Saving...</span>
</button>
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
<span x-show="!printState.isLoading">Print label</span>
<span x-show="printState.isLoading">Printing...</span>
</button>
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Mark gone</span>
<span x-show="adjustmentState.isLoading">Removing...</span>
</button>
</div>
</form>
</template>
<template x-if="entry.stock_type === 'binary'">
<div class="vstack gap-3">
<p class="text-body-secondary mb-0">
Binary stock items can be marked gone from this screen.
</p>
<template x-if="adjustmentState.error">
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
</template>
<template x-if="printFeedback.message">
<div
class="alert mb-0"
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
x-text="printFeedback.message"
></div>
</template>
<button class="btn btn-outline-danger align-self-start" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
<span x-show="!adjustmentState.isLoading">Mark gone</span>
<span x-show="adjustmentState.isLoading">Removing...</span>
</button>
<button class="btn btn-outline-secondary align-self-start" type="button" @click="printLabel()" :disabled="printState.isLoading">
<span x-show="!printState.isLoading">Print label</span>
<span x-show="printState.isLoading">Printing...</span>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
</section>
`;
}
export function stockDetailPageData(store) {
return {
state: createAsyncState(),
adjustmentState: createAsyncState(),
printState: createAsyncState(),
printFeedback: {
type: '',
message: '',
},
entry: null,
locationPathByUuid: {},
adjustment: {
mode: 'increment',
quantity: '1',
level: 'plenty',
},
async init() {
if (!store.isConnected) {
return;
}
const { params } = getRouteContext();
await runAsyncState(this.state, async () => {
const [entry, locations] = await Promise.all([
getStockEntry(store, params.id),
fetchLocations(store).catch(() => ({ flat: [] })),
]);
this.entry = entry;
this.locationPathByUuid = Object.fromEntries(
(locations.flat || [])
.filter((location) => location.uuid_b64)
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
);
this.adjustment.level = this.entry?.level || 'plenty';
}).catch(() => {});
},
async submitMeasuredAdjustment() {
if (!this.entry) {
return;
}
await runAsyncState(this.adjustmentState, async () => {
const requestedQuantity = Number(this.adjustment.quantity);
if (Number.isNaN(requestedQuantity) || requestedQuantity < 0) {
throw new Error('Enter a valid quantity first.');
}
const currentQuantity = Number(this.entry.quantity || 0);
const exactQuantity =
this.adjustment.mode === 'increment'
? currentQuantity + requestedQuantity
: this.adjustment.mode === 'decrement'
? Math.max(currentQuantity - requestedQuantity, 0)
: requestedQuantity;
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
quantity: exactQuantity,
});
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
}).catch(() => {});
},
async submitLevelAdjustment() {
if (!this.entry) {
return;
}
await runAsyncState(this.adjustmentState, async () => {
if (this.adjustment.level === 'gone') {
const entryName = this.entry.name;
await useStockItem(store, this.entry.uuid_b64);
store.addAlert({ type: 'success', message: `${entryName} was marked gone.` });
window.__loncApp.navigate('/stock');
return;
}
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
level: this.adjustment.level,
});
store.addAlert({ type: 'success', message: 'Stock level updated.' });
}).catch(() => {});
},
async markGone() {
if (!this.entry) {
return;
}
await runAsyncState(this.adjustmentState, async () => {
const result = await useStockItem(store, this.entry.uuid_b64);
const alreadyGone = result.status === 'already_gone';
store.addAlert({
type: alreadyGone ? 'info' : 'success',
message: alreadyGone
? `${this.entry.name} was already out of stock.`
: `${this.entry.name} was marked gone.`,
});
window.__loncApp.navigate('/stock');
}).catch(() => {});
},
async printLabel() {
if (!this.entry?.uuid_b64) {
return;
}
this.printFeedback = {
type: '',
message: '',
};
await runAsyncState(this.printState, async () => {
try {
await printItemLabel(store, this.entry.uuid_b64);
this.printFeedback = {
type: 'success',
message: 'Label printed successfully.',
};
store.addAlert({
type: 'success',
message: `${this.entry.name} label sent to printer.`,
});
} catch (error) {
const parsed = formatPrintErrorMessage(error);
this.printFeedback = {
type: 'warning',
message: parsed,
};
store.addAlert({
type: 'warning',
message: `Could not print ${this.entry.name} label: ${parsed}`,
});
}
}).catch(() => {});
},
quickAdjust(step) {
const current = Number(this.adjustment.quantity || 0);
this.adjustment.quantity = String(Math.max(current + step, 0));
},
formatDate,
expirationFor(entry) {
return expirationInfo(entry);
},
expirationBadgeClass(entry) {
const key = this.expirationFor(entry).key;
if (key === 'expired') {
return 'text-bg-danger';
}
if (key === 'use-first') {
return 'text-bg-warning';
}
if (key === 'upcoming') {
return 'text-bg-secondary';
}
if (key === 'within-date') {
return 'text-bg-success';
}
return 'text-bg-light border';
},
locationLabel(entry) {
const locationUuid = entry?.location_initial_uuid_b64;
if (!locationUuid) {
return 'Unassigned';
}
return this.locationPathByUuid[locationUuid] || 'Location not resolved';
},
nutriScoreLabel(entry) {
const value = entry?.nutriscore_grade;
if (!value) {
return 'Not available';
}
return String(value).toUpperCase();
},
nutritionFactsRows(entry) {
const facts = entry?.nutrition_facts;
if (!facts || typeof facts !== 'object' || Array.isArray(facts)) {
return [];
}
const preferredOrder = [
'per',
'serving_size',
'energy_kj',
'energy_kcal',
'fat',
'saturated_fat',
'carbohydrates',
'sugars',
'fibers',
'proteins',
'salt',
'sodium',
];
const rankByKey = new Map(preferredOrder.map((key, index) => [key, index]));
return Object.entries(facts)
.sort(([leftKey], [rightKey]) => {
const leftRank = rankByKey.has(leftKey) ? rankByKey.get(leftKey) : Number.POSITIVE_INFINITY;
const rightRank = rankByKey.has(rightKey) ? rankByKey.get(rightKey) : Number.POSITIVE_INFINITY;
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
return leftKey.localeCompare(rightKey);
})
.map(([key, value]) => ({
key,
label: this.nutritionLabel(key),
value: this.formatNutritionValue(value),
}));
},
nutritionLabel(key) {
const labels = {
per: 'Per',
serving_size: 'Serving size',
energy_kj: 'Energy (kJ)',
energy_kcal: 'Energy (kcal)',
fat: 'Fat',
saturated_fat: 'Saturated fat',
carbohydrates: 'Carbohydrates',
sugars: 'Sugars',
fibers: 'Fibers',
proteins: 'Proteins',
salt: 'Salt',
sodium: 'Sodium',
};
return labels[key] || key.replace(/_/g, ' ');
},
formatNutritionValue(value) {
if (value === null || value === undefined || value === '') {
return 'n/a';
}
return String(value);
},
formatQuantity(entry) {
return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
},
};
}