Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 114e31ba58 | |||
| 55d6218dd3 | |||
| f67d2c89be | |||
| 054a7ad0dd | |||
| 1fe56a232b |
@@ -163,36 +163,42 @@ Expected shapes today:
|
|||||||
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
|
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
|
||||||
- `GET /{database}/kitchen/items?search_name=...`
|
- `GET /{database}/kitchen/items?search_name=...`
|
||||||
Returns item definitions for autocomplete.
|
Returns item definitions for autocomplete.
|
||||||
|
Item payloads now expose category links via `categories` (array of IDs).
|
||||||
- `GET /{database}/kitchen/items`
|
- `GET /{database}/kitchen/items`
|
||||||
Returns the current stock review list. Endpoint is paginated (`limit`/`offset`, backend default `limit=100`); frontend helpers aggregate pages by default unless explicit pagination is passed.
|
Returns the current stock review list. Endpoint is paginated (`limit`/`offset`, backend default `limit=100`); frontend helpers aggregate pages by default unless explicit pagination is passed.
|
||||||
- `GET /{database}/kitchen/items/grouped?expanded=0|1`
|
- `GET /{database}/kitchen/items/grouped?expanded=true|false`
|
||||||
Returns grouped stock data; grouped review uses summary-first loading (`expanded=0`) and hydrates item children in background (`expanded=1`).
|
Returns grouped stock data; grouped review uses summary-first loading (`expanded=false`) and hydrates item children in background (`expanded=true`).
|
||||||
With `expanded=0`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads.
|
With `expanded=false`, child `items` can be ID-only stubs (`{ id }`) instead of full item payloads.
|
||||||
- `GET /{database}/kitchen/items/{uuid_b64}`
|
- `GET /{database}/kitchen/items/{uuid_b64}`
|
||||||
Returns one item detail payload.
|
Returns one item detail payload.
|
||||||
|
Supports `allow_inactive=true|false` query filtering when needed.
|
||||||
- `GET /{database}/kitchen/changes`
|
- `GET /{database}/kitchen/changes`
|
||||||
Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
|
Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
|
||||||
- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
|
- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
|
||||||
Used by label submit flow for create-or-update behavior and conflict-safe matching.
|
Used by label submit flow for create-or-update behavior and conflict-safe matching.
|
||||||
- `POST /{database}/kitchen/items/lookup`
|
- `POST /{database}/kitchen/items/lookup`
|
||||||
Identifier lookup response includes source/freshness metadata (`source`, `cache_hit`, `stale_cache`, `payload_fetched_at`, `retry_after_seconds`) used for richer user feedback.
|
Identifier lookup response includes source/freshness metadata (`source`, `cache_hit`, `stale_cache`, `payload_fetched_at`, `retry_after_seconds`) used for richer user feedback.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=0|1`
|
- `POST /{database}/kitchen/items/{uuid_b64}/lookup?update=true|false`
|
||||||
Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=0`) or apply missing fields (`update=1`).
|
Item-scoped OpenFoodFacts lookup used by stock detail to preview (`update=false`) or apply missing fields (`update=true`).
|
||||||
- `POST /{database}/kitchen/items?label=1`
|
- `POST /{database}/kitchen/items?label=true&preview=true`
|
||||||
Used for label image preview rendering.
|
|
||||||
- `POST /{database}/kitchen/items?label=1&preview=1`
|
|
||||||
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
|
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
|
||||||
|
- `GET /{database}/kitchen/items/{uuid_b64}/label`
|
||||||
|
Returns rendered label PNG for an existing item.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
|
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
|
||||||
Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`.
|
Creates a stock event for measured or descriptive updates using `{ quantity }` or `{ level }`,
|
||||||
|
and for non-consumed gone transitions (for example `{ level: "gone", gone_reason: "spoiled" }`).
|
||||||
Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update.
|
Response shape is `{ status, stock }`; frontend re-fetches the item detail after successful update.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/use`
|
- `POST /{database}/kitchen/items/{uuid_b64}/use`
|
||||||
Marks an item used up (`gone`) via stock-event semantics.
|
Marks an item consumed/used up (`gone`) via stock-event semantics.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
|
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
|
||||||
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
||||||
- `PATCH /{database}/kitchen/items/{uuid_b64}`
|
- `PATCH /{database}/kitchen/items/{uuid_b64}`
|
||||||
Used for item-level edits from stock detail (for example identifier code updates).
|
Used for item-level edits from stock detail (for example identifier code updates).
|
||||||
- `GET /{database}/kitchen/locations`
|
- `GET /{database}/kitchen/locations`
|
||||||
Returns a nested location tree.
|
Returns a nested location tree.
|
||||||
|
- `GET /{database}/kitchen/categories`
|
||||||
|
Returns categories (paged). Frontend now resolves category labels from
|
||||||
|
`categories_detail` when present, and falls back to this endpoint by ID.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.4",
|
"version": "0.2.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.4",
|
"version": "0.2.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"alpinejs": "^3.14.9",
|
"alpinejs": "^3.14.9",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.2.5",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { apiRequest, getPath } from './client.js';
|
||||||
|
|
||||||
|
const DEFAULT_LIST_PAGE_LIMIT = 100;
|
||||||
|
|
||||||
|
function unwrapListPayload(payload) {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload?.data || payload?.entries || payload?.items || payload?.categories || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExplicitPagination(filters = {}) {
|
||||||
|
return (
|
||||||
|
(filters.limit !== undefined && filters.limit !== null)
|
||||||
|
|| (filters.offset !== undefined && filters.offset !== null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCategoryQuery(filters = {}) {
|
||||||
|
const query = {};
|
||||||
|
const searchName = filters.searchName || filters.search_name;
|
||||||
|
if (searchName) {
|
||||||
|
query.search_name = searchName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.active !== undefined && filters.active !== null && filters.active !== '') {
|
||||||
|
query.active = Boolean(filters.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.orderBy || filters.order_by) {
|
||||||
|
query.order_by = filters.orderBy || filters.order_by;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.orderDir || filters.order_dir) {
|
||||||
|
query.order_dir = filters.orderDir || filters.order_dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.expanded !== undefined && filters.expanded !== null && filters.expanded !== '') {
|
||||||
|
query.expanded = Boolean(filters.expanded);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllCategoryPages(store, baseQuery = {}) {
|
||||||
|
const items = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const payload = await apiRequest(store, getPath('categories'), {
|
||||||
|
query: {
|
||||||
|
...baseQuery,
|
||||||
|
limit: DEFAULT_LIST_PAGE_LIMIT,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pageItems = unwrapListPayload(payload);
|
||||||
|
items.push(...pageItems);
|
||||||
|
|
||||||
|
if (pageItems.length < DEFAULT_LIST_PAGE_LIMIT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += DEFAULT_LIST_PAGE_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCategories(store, filters = {}) {
|
||||||
|
const baseQuery = buildCategoryQuery(filters);
|
||||||
|
|
||||||
|
if (hasExplicitPagination(filters)) {
|
||||||
|
const query = { ...baseQuery };
|
||||||
|
if (filters.limit !== undefined && filters.limit !== null) {
|
||||||
|
query.limit = filters.limit;
|
||||||
|
}
|
||||||
|
if (filters.offset !== undefined && filters.offset !== null) {
|
||||||
|
query.offset = filters.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await apiRequest(store, getPath('categories'), {
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
return unwrapListPayload(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchAllCategoryPages(store, baseQuery);
|
||||||
|
}
|
||||||
+14
-1
@@ -42,7 +42,7 @@ export async function previewLabel(store, body) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
accept: 'image/svg+xml, image/png, application/json',
|
accept: 'image/svg+xml, image/png, application/json',
|
||||||
query: { label: 1, preview: 1 },
|
query: { label: true, preview: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const image = normalizeLabelImagePayload(payload);
|
const image = normalizeLabelImagePayload(payload);
|
||||||
@@ -53,6 +53,19 @@ export async function previewLabel(store, body) {
|
|||||||
throw new Error('Label preview response did not include an image.');
|
throw new Error('Label preview response did not include an image.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getItemLabel(store, uuidB64) {
|
||||||
|
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/label`, {
|
||||||
|
method: 'GET',
|
||||||
|
accept: 'image/png, application/json',
|
||||||
|
});
|
||||||
|
const image = normalizeLabelImagePayload(payload);
|
||||||
|
if (image) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Item label response did not include an image.');
|
||||||
|
}
|
||||||
|
|
||||||
export async function printItemLabel(store, uuidB64) {
|
export async function printItemLabel(store, uuidB64) {
|
||||||
return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
|
return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
+43
-6
@@ -2,6 +2,35 @@ import { apiRequest, getPath } from './client.js';
|
|||||||
|
|
||||||
const DEFAULT_LIST_PAGE_LIMIT = 100;
|
const DEFAULT_LIST_PAGE_LIMIT = 100;
|
||||||
|
|
||||||
|
function toBooleanFlag(value, defaultValue = false) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
function unwrapEntryPayload(payload) {
|
function unwrapEntryPayload(payload) {
|
||||||
return payload?.data || payload?.entry || payload?.item || payload;
|
return payload?.data || payload?.entry || payload?.item || payload;
|
||||||
}
|
}
|
||||||
@@ -52,7 +81,7 @@ export async function searchItemDefinitions(store, query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||||
query: { search_name: query, expanded: 0 },
|
query: { search_name: query, expanded: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
@@ -90,7 +119,7 @@ export async function listStockEntries(store, filters = {}) {
|
|||||||
|
|
||||||
export async function listGroupedStockEntries(store, options = {}) {
|
export async function listGroupedStockEntries(store, options = {}) {
|
||||||
const baseQuery = {};
|
const baseQuery = {};
|
||||||
const expanded = options.expanded ?? 1;
|
const expanded = toBooleanFlag(options.expanded, true);
|
||||||
baseQuery.expanded = expanded;
|
baseQuery.expanded = expanded;
|
||||||
const searchName = options.searchName || options.search_name;
|
const searchName = options.searchName || options.search_name;
|
||||||
if (searchName) {
|
if (searchName) {
|
||||||
@@ -119,7 +148,7 @@ export async function listGroupedStockEntries(store, options = {}) {
|
|||||||
export async function getStockEntry(store, stockId, { allowInactive = false } = {}) {
|
export async function getStockEntry(store, stockId, { allowInactive = false } = {}) {
|
||||||
const path = `${getPath('items')}/${stockId}`;
|
const path = `${getPath('items')}/${stockId}`;
|
||||||
const payload = allowInactive
|
const payload = allowInactive
|
||||||
? await apiRequest(store, path, { query: { allow_inactive: 1 } })
|
? await apiRequest(store, path, { query: { allow_inactive: true } })
|
||||||
: await apiRequest(store, path);
|
: await apiRequest(store, path);
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
@@ -128,7 +157,7 @@ export async function createStockEntry(store, body) {
|
|||||||
const payload = await apiRequest(store, getPath('items'), {
|
const payload = await apiRequest(store, getPath('items'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
query: { label: 1, print: 1 },
|
query: { label: true, print: true },
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
@@ -211,7 +240,7 @@ export async function lookupItemByIdentifier(store, identifierCode) {
|
|||||||
export async function lookupItemDetails(store, uuidB64, { update = false } = {}) {
|
export async function lookupItemDetails(store, uuidB64, { update = false } = {}) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, {
|
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/lookup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
query: { update: update ? 1 : 0 },
|
query: { update: toBooleanFlag(update, false) },
|
||||||
});
|
});
|
||||||
|
|
||||||
return normalizeItemLookupResponse(payload);
|
return normalizeItemLookupResponse(payload);
|
||||||
@@ -246,7 +275,7 @@ export async function createStockEvent(store, uuidB64, body) {
|
|||||||
export async function listStockEvents(store, uuidB64, options = {}) {
|
export async function listStockEvents(store, uuidB64, options = {}) {
|
||||||
const query = {};
|
const query = {};
|
||||||
if (options.allowInactive) {
|
if (options.allowInactive) {
|
||||||
query.allow_inactive = 1;
|
query.allow_inactive = true;
|
||||||
}
|
}
|
||||||
if (options.limit !== undefined && options.limit !== null) {
|
if (options.limit !== undefined && options.limit !== null) {
|
||||||
query.limit = options.limit;
|
query.limit = options.limit;
|
||||||
@@ -269,6 +298,14 @@ export async function listStockEvents(store, uuidB64, options = {}) {
|
|||||||
|
|
||||||
export async function markStockGone(store, uuidB64, reason = 'consumed') {
|
export async function markStockGone(store, uuidB64, reason = 'consumed') {
|
||||||
try {
|
try {
|
||||||
|
if (reason === 'consumed') {
|
||||||
|
const result = await useStockItem(store, uuidB64);
|
||||||
|
if (result.status === 'already_gone') {
|
||||||
|
return { status: 'already_gone', reason };
|
||||||
|
}
|
||||||
|
return { status: 'gone', reason };
|
||||||
|
}
|
||||||
|
|
||||||
await createStockEvent(store, uuidB64, {
|
await createStockEvent(store, uuidB64, {
|
||||||
level: 'gone',
|
level: 'gone',
|
||||||
gone_reason: reason,
|
gone_reason: reason,
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
export const APP_NAME = 'Lonc';
|
export const APP_NAME = 'Lonc';
|
||||||
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.5';
|
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.2.6';
|
||||||
export const TRYTON_APPLICATION = 'kitchen';
|
export const TRYTON_APPLICATION = 'kitchen';
|
||||||
|
|
||||||
export const CONNECTION_STATES = {
|
export const CONNECTION_STATES = {
|
||||||
@@ -27,6 +27,7 @@ export const API_PATHS = {
|
|||||||
kitchens: 'kitchen/kitchens',
|
kitchens: 'kitchen/kitchens',
|
||||||
items: 'kitchen/items',
|
items: 'kitchen/items',
|
||||||
locations: 'kitchen/locations',
|
locations: 'kitchen/locations',
|
||||||
|
categories: 'kitchen/categories',
|
||||||
changes: 'kitchen/changes',
|
changes: 'kitchen/changes',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { fetchLocations } from '../../api/locations.js';
|
import { fetchLocations } from '../../api/locations.js';
|
||||||
import { getStockEntry, listKitchenChanges } from '../../api/stock.js';
|
import { getStockEntry, listKitchenChanges, listStockEvents } from '../../api/stock.js';
|
||||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||||
|
|
||||||
|
const RECENT_CHANGE_FETCH_LIMIT = 200;
|
||||||
|
const RECENT_CHANGE_DISPLAY_LIMIT = 50;
|
||||||
|
|
||||||
export function renderDashboardPage() {
|
export function renderDashboardPage() {
|
||||||
return `
|
return `
|
||||||
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()">
|
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()">
|
||||||
@@ -93,8 +96,7 @@ export function renderDashboardPage() {
|
|||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h5 mb-1">Recent changes</h2>
|
<h2 class="h5 mb-1">Recent changes</h2>
|
||||||
<p class="text-body-secondary mb-0 small">Latest item and stock updates from the kitchen change feed.</p>
|
<p class="text-body-secondary mb-0 small">Latest item and stock updates, including used and inactive stock.</p>
|
||||||
<p class="text-body-secondary mb-0 small">Saved means the backend created or updated a record.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-secondary btn-sm" @click="refreshChanges()" :disabled="changesState.isLoading">
|
<button class="btn btn-outline-secondary btn-sm" @click="refreshChanges()" :disabled="changesState.isLoading">
|
||||||
<span x-show="!changesState.isLoading">Refresh</span>
|
<span x-show="!changesState.isLoading">Refresh</span>
|
||||||
@@ -115,15 +117,46 @@ export function renderDashboardPage() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="recentChanges.length">
|
<template x-if="recentChanges.length">
|
||||||
<div class="list-group list-group-flush">
|
<div class="recent-change-list">
|
||||||
<template x-for="(change, index) in recentChanges" :key="change.timestamp || index">
|
<template x-for="(change, index) in recentChanges" :key="change.timestamp || index">
|
||||||
<div class="list-group-item px-0">
|
<article class="recent-change-item" :class="changeToneClass(change)">
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="recent-change-rail"></div>
|
||||||
<div class="fw-semibold" x-text="changeHeadline(change)"></div>
|
<div class="recent-change-body">
|
||||||
<div class="small text-body-secondary" x-text="formatChangeTimestamp(change.timestamp)"></div>
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
|
||||||
|
<span class="recent-change-tag" :class="changeTagClass(change)" x-text="changeKindLabel(change)"></span>
|
||||||
|
<span class="fw-semibold recent-change-title" x-text="changeHeadline(change)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-body-secondary" x-text="changeSubtitle(change)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-body-secondary recent-change-time" x-text="formatChangeTimestamp(change.timestamp)"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="changeStockTransition(change)">
|
||||||
|
<div class="recent-stock-transition mt-3">
|
||||||
|
<span class="recent-stock-state" x-text="changeStockTransition(change).previous"></span>
|
||||||
|
<span class="recent-stock-arrow" aria-hidden="true">→</span>
|
||||||
|
<span class="recent-stock-state recent-stock-state-current" x-text="changeStockTransition(change).current"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="recent-change-meta mt-3">
|
||||||
|
<template x-for="detail in changeDetails(change)" :key="detail.label">
|
||||||
|
<span class="recent-change-chip">
|
||||||
|
<span class="recent-change-chip-label" x-text="detail.label"></span>
|
||||||
|
<span x-text="detail.value"></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-body-secondary mt-2" x-text="changeDebugLine(change)"></div>
|
||||||
|
|
||||||
|
<template x-if="changeItemHref(change)">
|
||||||
|
<a class="stretched-link recent-change-link" :href="changeItemHref(change)" :aria-label="'Open ' + changeHeadline(change)"></a>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-body-secondary mt-1" x-text="changeStateLine(change)"></div>
|
</article>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -142,6 +175,7 @@ export function dashboardPageData(store) {
|
|||||||
recentChanges: [],
|
recentChanges: [],
|
||||||
locationLabelByUuid: {},
|
locationLabelByUuid: {},
|
||||||
itemByUuid: {},
|
itemByUuid: {},
|
||||||
|
stockEventsByItemUuid: {},
|
||||||
async init() {
|
async init() {
|
||||||
if (!store.isConnected) {
|
if (!store.isConnected) {
|
||||||
return;
|
return;
|
||||||
@@ -151,11 +185,24 @@ export function dashboardPageData(store) {
|
|||||||
},
|
},
|
||||||
async refreshChanges() {
|
async refreshChanges() {
|
||||||
await runAsyncState(this.changesState, async () => {
|
await runAsyncState(this.changesState, async () => {
|
||||||
const payload = await listKitchenChanges(store, { limit: 10 });
|
const payload = await listKitchenChanges(store, { limit: RECENT_CHANGE_FETCH_LIMIT });
|
||||||
this.recentChanges = payload.changes;
|
this.recentChanges = this.normalizeRecentChanges(payload.changes);
|
||||||
await this.loadContextForChanges(payload.changes);
|
await this.loadContextForChanges(payload.changes);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
|
normalizeRecentChanges(changes) {
|
||||||
|
return [...changes]
|
||||||
|
.sort((left, right) => this.changeSortValue(right) - this.changeSortValue(left))
|
||||||
|
.slice(0, RECENT_CHANGE_DISPLAY_LIMIT);
|
||||||
|
},
|
||||||
|
changeSortValue(change) {
|
||||||
|
const date = new Date(change?.timestamp || '');
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.getTime();
|
||||||
|
},
|
||||||
async loadContextForChanges(changes) {
|
async loadContextForChanges(changes) {
|
||||||
const stockItemUuids = Array.from(new Set(
|
const stockItemUuids = Array.from(new Set(
|
||||||
changes
|
changes
|
||||||
@@ -189,6 +236,34 @@ export function dashboardPageData(store) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stockChangeItemUuids = Array.from(new Set(
|
||||||
|
changes
|
||||||
|
.filter((change) => String(change?.type || '') === 'stock')
|
||||||
|
.map((change) => change?.stock?.item_uuid_b64)
|
||||||
|
.filter(Boolean),
|
||||||
|
));
|
||||||
|
const missingStockHistoryUuids = stockChangeItemUuids
|
||||||
|
.filter((uuid) => !this.stockEventsByItemUuid[uuid]);
|
||||||
|
|
||||||
|
if (missingStockHistoryUuids.length) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
missingStockHistoryUuids.map((uuid) => listStockEvents(store, uuid, {
|
||||||
|
allowInactive: true,
|
||||||
|
limit: 50,
|
||||||
|
orderBy: 'id',
|
||||||
|
orderDir: 'desc',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const uuid = missingStockHistoryUuids[index];
|
||||||
|
this.stockEventsByItemUuid[uuid] =
|
||||||
|
result.status === 'fulfilled' && Array.isArray(result.value)
|
||||||
|
? result.value
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(this.locationLabelByUuid).length) {
|
if (Object.keys(this.locationLabelByUuid).length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -210,6 +285,7 @@ export function dashboardPageData(store) {
|
|||||||
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
|
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
|
||||||
this.locationLabelByUuid = {};
|
this.locationLabelByUuid = {};
|
||||||
this.itemByUuid = {};
|
this.itemByUuid = {};
|
||||||
|
this.stockEventsByItemUuid = {};
|
||||||
this.refreshChanges();
|
this.refreshChanges();
|
||||||
},
|
},
|
||||||
resolveItemForChange(change) {
|
resolveItemForChange(change) {
|
||||||
@@ -231,6 +307,19 @@ export function dashboardPageData(store) {
|
|||||||
|
|
||||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
},
|
},
|
||||||
|
goneReasonLabel(reason) {
|
||||||
|
if (reason === 'consumed') {
|
||||||
|
return 'Used';
|
||||||
|
}
|
||||||
|
if (reason === 'spoiled') {
|
||||||
|
return 'Spoilt';
|
||||||
|
}
|
||||||
|
if (reason === 'other') {
|
||||||
|
return 'Gone';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
formatQuantity(quantity, uomSymbol) {
|
formatQuantity(quantity, uomSymbol) {
|
||||||
if (quantity === null || quantity === undefined || quantity === '') {
|
if (quantity === null || quantity === undefined || quantity === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -243,8 +332,151 @@ export function dashboardPageData(store) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (level === 'gone') {
|
||||||
|
return 'Gone';
|
||||||
|
}
|
||||||
|
|
||||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||||
},
|
},
|
||||||
|
formatStockState(stock, item = null) {
|
||||||
|
if (!stock) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stock.level === 'gone') {
|
||||||
|
const reason = this.goneReasonLabel(stock.gone_reason);
|
||||||
|
return reason ? `${reason} (gone)` : 'Gone';
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = this.formatQuantity(
|
||||||
|
stock.quantity,
|
||||||
|
stock.uom_symbol || item?.uom_symbol,
|
||||||
|
);
|
||||||
|
const level = this.formatLevel(stock.level);
|
||||||
|
if (quantity && level) {
|
||||||
|
return `${quantity} · ${level}`;
|
||||||
|
}
|
||||||
|
if (quantity) {
|
||||||
|
return quantity;
|
||||||
|
}
|
||||||
|
if (level) {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
stockEventTimestamp(event) {
|
||||||
|
return event?.write_date || event?.create_date || event?.date || null;
|
||||||
|
},
|
||||||
|
findPreviousStockEvent(change) {
|
||||||
|
const stock = change?.stock;
|
||||||
|
const itemUuid = stock?.item_uuid_b64;
|
||||||
|
if (!itemUuid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = this.stockEventsByItemUuid[itemUuid] || [];
|
||||||
|
const currentId = stock?.id;
|
||||||
|
if (currentId !== undefined && currentId !== null) {
|
||||||
|
const currentIndex = history.findIndex((event) => String(event?.id) === String(currentId));
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
return history[currentIndex + 1] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = this.stockEventTimestamp(stock);
|
||||||
|
if (!currentTime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDate = new Date(currentTime);
|
||||||
|
if (Number.isNaN(currentDate.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return history.find((event) => {
|
||||||
|
const eventTime = this.stockEventTimestamp(event);
|
||||||
|
if (!eventTime) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const eventDate = new Date(eventTime);
|
||||||
|
return !Number.isNaN(eventDate.getTime()) && eventDate.getTime() < currentDate.getTime();
|
||||||
|
}) || null;
|
||||||
|
},
|
||||||
|
isInitialStockChange(change) {
|
||||||
|
return String(change?.type || '') === 'stock'
|
||||||
|
&& Boolean(change?.stock)
|
||||||
|
&& !this.findPreviousStockEvent(change);
|
||||||
|
},
|
||||||
|
isNewItemChange(change) {
|
||||||
|
if (String(change?.type || '') !== 'item' || !change?.item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = change.item;
|
||||||
|
if (!item.create_date || !change.timestamp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = new Date(item.create_date);
|
||||||
|
const changed = new Date(change.timestamp);
|
||||||
|
if (Number.isNaN(created.getTime()) || Number.isNaN(changed.getTime())) {
|
||||||
|
return item.active !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.abs(created.getTime() - changed.getTime()) < 2000;
|
||||||
|
},
|
||||||
|
changeKind(change) {
|
||||||
|
const type = String(change?.type || '');
|
||||||
|
const item = this.resolveItemForChange(change);
|
||||||
|
const stock = change?.stock || null;
|
||||||
|
|
||||||
|
if (type === 'stock' && this.isInitialStockChange(change) && stock?.level !== 'gone') {
|
||||||
|
return 'new';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'stock' && stock?.level === 'gone') {
|
||||||
|
if (stock.gone_reason === 'consumed') {
|
||||||
|
return 'used';
|
||||||
|
}
|
||||||
|
if (stock.gone_reason === 'spoiled') {
|
||||||
|
return 'spoiled';
|
||||||
|
}
|
||||||
|
return 'gone';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'item' && item?.active === false) {
|
||||||
|
return 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'item' && this.isNewItemChange(change)) {
|
||||||
|
return 'new';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'stock') {
|
||||||
|
return 'stock';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'updated';
|
||||||
|
},
|
||||||
|
changeKindLabel(change) {
|
||||||
|
const labels = {
|
||||||
|
new: 'New item',
|
||||||
|
inactive: 'Item gone',
|
||||||
|
used: 'Used up',
|
||||||
|
spoiled: 'Spoilt',
|
||||||
|
gone: 'Gone',
|
||||||
|
stock: 'Stock changed',
|
||||||
|
updated: 'Updated',
|
||||||
|
};
|
||||||
|
return labels[this.changeKind(change)] || 'Updated';
|
||||||
|
},
|
||||||
|
changeToneClass(change) {
|
||||||
|
return `recent-change-${this.changeKind(change)}`;
|
||||||
|
},
|
||||||
|
changeTagClass(change) {
|
||||||
|
return `recent-change-tag-${this.changeKind(change)}`;
|
||||||
|
},
|
||||||
formatShortDate(value) {
|
formatShortDate(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
@@ -275,53 +507,128 @@ export function dashboardPageData(store) {
|
|||||||
const action = String(change?.action || 'updated');
|
const action = String(change?.action || 'updated');
|
||||||
|
|
||||||
if (action === 'upsert' && type === 'item') {
|
if (action === 'upsert' && type === 'item') {
|
||||||
return `Item saved: ${itemName}`;
|
return itemName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'upsert' && type === 'stock') {
|
if (action === 'upsert' && type === 'stock') {
|
||||||
return `Stock saved: ${itemName}`;
|
return itemName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${type} ${action}: ${itemName}`;
|
return `${type} ${action}: ${itemName}`;
|
||||||
},
|
},
|
||||||
changeStateLine(change) {
|
changeSubtitle(change) {
|
||||||
|
const kind = this.changeKind(change);
|
||||||
|
const item = this.resolveItemForChange(change);
|
||||||
|
const stock = change?.stock || null;
|
||||||
|
|
||||||
|
if (kind === 'new') {
|
||||||
|
return String(change?.type || '') === 'stock'
|
||||||
|
? 'Created with initial stock.'
|
||||||
|
: 'Created as a new stock item.';
|
||||||
|
}
|
||||||
|
if (kind === 'inactive') {
|
||||||
|
return 'No longer shown in the active stock list.';
|
||||||
|
}
|
||||||
|
if (kind === 'used') {
|
||||||
|
return 'Marked used and moved out of active stock.';
|
||||||
|
}
|
||||||
|
if (kind === 'spoiled') {
|
||||||
|
return 'Marked spoilt and moved out of active stock.';
|
||||||
|
}
|
||||||
|
if (kind === 'gone') {
|
||||||
|
return 'Marked gone and moved out of active stock.';
|
||||||
|
}
|
||||||
|
if (String(change?.type || '') === 'stock') {
|
||||||
|
const current = this.formatStockState(stock, item);
|
||||||
|
return current ? `Current stock is ${current.toLowerCase()}.` : 'Stock event recorded.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Item details were updated.';
|
||||||
|
},
|
||||||
|
changeStockTransition(change) {
|
||||||
|
if (String(change?.type || '') !== 'stock' || !change?.stock) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = this.resolveItemForChange(change);
|
||||||
|
const previous = this.findPreviousStockEvent(change);
|
||||||
|
const previousLabel = previous
|
||||||
|
? this.formatStockState(previous, item)
|
||||||
|
: 'Initial stock';
|
||||||
|
const currentLabel = this.formatStockState(change.stock, item) || 'Updated';
|
||||||
|
|
||||||
|
return {
|
||||||
|
previous: previousLabel,
|
||||||
|
current: currentLabel,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
changeDetails(change) {
|
||||||
const item = this.resolveItemForChange(change);
|
const item = this.resolveItemForChange(change);
|
||||||
const stock = change?.stock || {};
|
const stock = change?.stock || {};
|
||||||
const state = [];
|
const details = [];
|
||||||
|
|
||||||
const stockType = this.humanStockType(item?.stock_type);
|
const stockType = this.humanStockType(item?.stock_type);
|
||||||
if (stockType) {
|
if (stockType) {
|
||||||
state.push(`Type: ${stockType}`);
|
details.push({ label: 'Type', value: stockType });
|
||||||
}
|
}
|
||||||
|
|
||||||
const quantity = this.formatQuantity(
|
if (String(change?.type || '') !== 'stock') {
|
||||||
stock.quantity ?? item?.quantity,
|
const quantity = this.formatQuantity(
|
||||||
stock.uom_symbol || item?.uom_symbol,
|
item?.quantity,
|
||||||
);
|
item?.uom_symbol,
|
||||||
if (quantity) {
|
);
|
||||||
state.push(`Quantity: ${quantity}`);
|
if (quantity) {
|
||||||
|
details.push({ label: 'Quantity', value: quantity });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const level = this.formatLevel(stock.level || item?.level);
|
const level = this.formatLevel(stock.level || item?.level);
|
||||||
if (level) {
|
if (level) {
|
||||||
state.push(`Level: ${level}`);
|
details.push({ label: 'Level', value: level });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stock.gone_reason) {
|
||||||
|
details.push({
|
||||||
|
label: 'Reason',
|
||||||
|
value: this.goneReasonLabel(stock.gone_reason) || this.formatLevel(stock.gone_reason),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiry = this.formatShortDate(item?.expire_date);
|
const expiry = this.formatShortDate(item?.expire_date);
|
||||||
if (expiry) {
|
if (expiry) {
|
||||||
state.push(`Expires: ${expiry}`);
|
details.push({ label: 'Expires', value: expiry });
|
||||||
}
|
}
|
||||||
|
|
||||||
const location = this.resolveLocationLabel(change, item);
|
const location = this.resolveLocationLabel(change, item);
|
||||||
if (location) {
|
if (location) {
|
||||||
state.push(`Location: ${location}`);
|
details.push({ label: 'Location', value: location });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.length) {
|
return details;
|
||||||
return 'Saved (created or updated).';
|
},
|
||||||
|
changeDebugLine(change) {
|
||||||
|
const parts = [];
|
||||||
|
if (change?.id !== undefined && change?.id !== null) {
|
||||||
|
parts.push(`${String(change?.type || 'change')} #${change.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return state.join(' • ');
|
const itemUuid = change?.stock?.item_uuid_b64 || change?.item?.uuid_b64 || null;
|
||||||
|
if (itemUuid) {
|
||||||
|
parts.push(`item ${String(itemUuid).slice(-8)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' · ');
|
||||||
|
},
|
||||||
|
changeItemHref(change) {
|
||||||
|
const uuidB64 = this.resolveItemForChange(change)?.uuid_b64
|
||||||
|
|| change?.stock?.item_uuid_b64
|
||||||
|
|| change?.item?.uuid_b64
|
||||||
|
|| null;
|
||||||
|
if (!uuidB64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `#/stock/${uuidB64}`;
|
||||||
},
|
},
|
||||||
formatChangeTimestamp(value) {
|
formatChangeTimestamp(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../../api/stock.js';
|
} from '../../api/stock.js';
|
||||||
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
|
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
|
||||||
import { fetchLocations } from '../../api/locations.js';
|
import { fetchLocations } from '../../api/locations.js';
|
||||||
|
import { listCategories } from '../../api/categories.js';
|
||||||
import { getRouteContext } from '../../app/router.js';
|
import { getRouteContext } from '../../app/router.js';
|
||||||
import { renderScannerModal } from '../shared/scanner-modal.js';
|
import { renderScannerModal } from '../shared/scanner-modal.js';
|
||||||
import {
|
import {
|
||||||
@@ -145,6 +146,21 @@ export function renderStockDetailPage() {
|
|||||||
</dd>
|
</dd>
|
||||||
<dt class="col-5">Stock type</dt>
|
<dt class="col-5">Stock type</dt>
|
||||||
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
|
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
|
||||||
|
<dt class="col-5">Main category</dt>
|
||||||
|
<dd class="col-7" x-text="mainCategoryLabel(entry) || 'Not set'"></dd>
|
||||||
|
<dt class="col-5">Categories</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<template x-if="categoryLabels(entry).length">
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<template x-for="label in categoryLabels(entry)" :key="label">
|
||||||
|
<span class="badge text-bg-light border" x-text="label"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!categoryLabels(entry).length">
|
||||||
|
<span class="text-body-secondary">Uncategorized</span>
|
||||||
|
</template>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -464,6 +480,7 @@ export function stockDetailPageData(store) {
|
|||||||
entry: null,
|
entry: null,
|
||||||
stockEvents: [],
|
stockEvents: [],
|
||||||
locationPathByUuid: {},
|
locationPathByUuid: {},
|
||||||
|
categoriesById: {},
|
||||||
identifierDraft: '',
|
identifierDraft: '',
|
||||||
scannerManualCode: '',
|
scannerManualCode: '',
|
||||||
adjustment: {
|
adjustment: {
|
||||||
@@ -484,9 +501,10 @@ export function stockDetailPageData(store) {
|
|||||||
|
|
||||||
const { params } = getRouteContext();
|
const { params } = getRouteContext();
|
||||||
await runAsyncState(this.state, async () => {
|
await runAsyncState(this.state, async () => {
|
||||||
const [entry, locations] = await Promise.all([
|
const [entry, locations, categories] = await Promise.all([
|
||||||
getStockEntry(store, params.id),
|
this.loadStockEntry(params.id),
|
||||||
fetchLocations(store).catch(() => ({ flat: [] })),
|
fetchLocations(store).catch(() => ({ flat: [] })),
|
||||||
|
listCategories(store, { expanded: true }).catch(() => []),
|
||||||
]);
|
]);
|
||||||
this.entry = entry;
|
this.entry = entry;
|
||||||
this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code);
|
this.identifierDraft = normalizeIdentifierCode(entry?.identifier_code);
|
||||||
@@ -495,10 +513,27 @@ export function stockDetailPageData(store) {
|
|||||||
.filter((location) => location.uuid_b64)
|
.filter((location) => location.uuid_b64)
|
||||||
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
|
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
|
||||||
);
|
);
|
||||||
|
this.categoriesById = Object.fromEntries(
|
||||||
|
categories
|
||||||
|
.filter((category) => category?.id !== undefined && category?.id !== null)
|
||||||
|
.map((category) => [String(category.id), category]),
|
||||||
|
);
|
||||||
this.adjustment.level = this.entry?.level || 'plenty';
|
this.adjustment.level = this.entry?.level || 'plenty';
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
this.loadStockHistory().catch(() => {});
|
this.loadStockHistory().catch(() => {});
|
||||||
},
|
},
|
||||||
|
async loadStockEntry(uuidB64) {
|
||||||
|
try {
|
||||||
|
return await getStockEntry(store, uuidB64);
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.status || error?.cause?.status;
|
||||||
|
if (status !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getStockEntry(store, uuidB64, { allowInactive: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
this.stopScanner();
|
this.stopScanner();
|
||||||
},
|
},
|
||||||
@@ -966,6 +1001,35 @@ export function stockDetailPageData(store) {
|
|||||||
|
|
||||||
return this.locationPathByUuid[locationUuid] || 'Location not resolved';
|
return this.locationPathByUuid[locationUuid] || 'Location not resolved';
|
||||||
},
|
},
|
||||||
|
categoryLabel(category) {
|
||||||
|
if (!category || typeof category !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(category.path || category.name || '').trim();
|
||||||
|
},
|
||||||
|
mainCategoryLabel(entry) {
|
||||||
|
const categoryId = entry?.category;
|
||||||
|
if (categoryId === null || categoryId === undefined || categoryId === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]);
|
||||||
|
if (mapped) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(categoryId).trim();
|
||||||
|
},
|
||||||
|
categoryLabels(entry) {
|
||||||
|
const categoryIds = Array.isArray(entry?.categories) ? entry.categories : [];
|
||||||
|
const labels = categoryIds.map((categoryId) => {
|
||||||
|
const mapped = this.categoryLabel(this.categoriesById[String(categoryId)]);
|
||||||
|
return mapped || String(categoryId || '').trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...new Set(labels.filter(Boolean))];
|
||||||
|
},
|
||||||
nutriScoreLabel(entry) {
|
nutriScoreLabel(entry) {
|
||||||
const value = entry?.nutriscore_grade;
|
const value = entry?.nutriscore_grade;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
updateStockItem,
|
updateStockItem,
|
||||||
} from '../../api/stock.js';
|
} from '../../api/stock.js';
|
||||||
import { fetchLocations } from '../../api/locations.js';
|
import { fetchLocations } from '../../api/locations.js';
|
||||||
|
import { listCategories } from '../../api/categories.js';
|
||||||
import { STORAGE_KEYS } from '../../app/config.js';
|
import { STORAGE_KEYS } from '../../app/config.js';
|
||||||
import { clearStoredValue, loadStoredValue, saveStoredValue } from '../shared/storage.js';
|
import { clearStoredValue, loadStoredValue, saveStoredValue } from '../shared/storage.js';
|
||||||
import { createAsyncState } from '../shared/ui-state.js';
|
import { createAsyncState } from '../shared/ui-state.js';
|
||||||
@@ -221,13 +222,35 @@ function resolveLocationLabel(entry, locationMap) {
|
|||||||
return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved';
|
return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved';
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchBlob(entry, locationMap) {
|
function categoryLabel(category) {
|
||||||
|
if (!category || typeof category !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(category.name || category.path || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMainCategoryLabel(entry, categoriesById = {}) {
|
||||||
|
const categoryId = entry?.category;
|
||||||
|
if (categoryId === null || categoryId === undefined || categoryId === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = categoryLabel(categoriesById[String(categoryId)]);
|
||||||
|
if (mapped) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return String(categoryId).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchBlob(entry, locationMap, categoriesById = {}) {
|
||||||
return [
|
return [
|
||||||
entry.name,
|
entry.name,
|
||||||
entry.description,
|
entry.description,
|
||||||
entry.level,
|
entry.level,
|
||||||
entry.stock_type,
|
entry.stock_type,
|
||||||
resolveLocationLabel(entry, locationMap),
|
resolveLocationLabel(entry, locationMap),
|
||||||
|
resolveMainCategoryLabel(entry, categoriesById),
|
||||||
entry.uuid_b64,
|
entry.uuid_b64,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -235,10 +258,10 @@ function searchBlob(entry, locationMap) {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupSearchBlob(group, locationMap) {
|
function groupSearchBlob(group, locationMap, categoriesById = {}) {
|
||||||
return [
|
return [
|
||||||
searchBlob(group, locationMap),
|
searchBlob(group, locationMap, categoriesById),
|
||||||
...(group.items || []).map((item) => searchBlob(item, locationMap)),
|
...(group.items || []).map((item) => searchBlob(item, locationMap, categoriesById)),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -544,6 +567,9 @@ export function renderStockListPage() {
|
|||||||
<td>
|
<td>
|
||||||
<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 mt-1" x-show="mainCategoryLabel(entry)">
|
||||||
|
<span class="badge text-bg-light border" x-text="mainCategoryBadgeLabel(entry)"></span>
|
||||||
|
</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
|
<a
|
||||||
class="small text-decoration-none fw-semibold"
|
class="small text-decoration-none fw-semibold"
|
||||||
@@ -623,6 +649,9 @@ export function renderStockListPage() {
|
|||||||
<div>
|
<div>
|
||||||
<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="small mt-1" x-show="mainCategoryLabel(entry)">
|
||||||
|
<span class="badge text-bg-light border" x-text="mainCategoryBadgeLabel(entry)"></span>
|
||||||
|
</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
|
<a
|
||||||
class="small text-decoration-none fw-semibold"
|
class="small text-decoration-none fw-semibold"
|
||||||
@@ -728,10 +757,16 @@ export function renderStockListPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold grouped-stock-summary-title" x-text="group.name"></div>
|
<div class="fw-semibold grouped-stock-summary-title" x-text="group.name"></div>
|
||||||
<div class="text-body-secondary small grouped-stock-summary-description" x-show="group.description" x-text="group.description"></div>
|
<div class="text-body-secondary small grouped-stock-summary-description" x-show="group.description" x-text="group.description"></div>
|
||||||
|
<div class="small mt-1" x-show="mainCategoryLabel(group)">
|
||||||
|
<span class="badge text-bg-light border" x-text="mainCategoryBadgeLabel(group)"></span>
|
||||||
|
</div>
|
||||||
<div class="d-flex flex-wrap small grouped-stock-summary-meta">
|
<div class="d-flex flex-wrap small grouped-stock-summary-meta">
|
||||||
<span><span class="fw-semibold text-body" x-text="groupItemCount(group)"></span> item(s)</span>
|
<span><span class="fw-semibold text-body" x-text="groupItemCount(group)"></span> item(s)</span>
|
||||||
<span><span class="text-body-secondary">Latest location:</span> <span class="fw-semibold text-body" x-text="locationLabel(group)"></span></span>
|
<span><span class="text-body-secondary">Latest location:</span> <span class="fw-semibold text-body" x-text="locationLabel(group)"></span></span>
|
||||||
<span><span class="text-body-secondary">Quantity:</span> <span class="fw-semibold text-body" x-text="quantityLabel(group)"></span></span>
|
<span>
|
||||||
|
<span class="text-body-secondary" x-text="groupSummaryMetricLabel(group) + ':'"></span>
|
||||||
|
<span class="fw-semibold text-body" x-text="groupSummaryMetricValue(group)"></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grouped-stock-summary-status">
|
<div class="grouped-stock-summary-status">
|
||||||
@@ -781,6 +816,12 @@ export function renderStockListPage() {
|
|||||||
<span x-text="locationLabel(item)"></span>
|
<span x-text="locationLabel(item)"></span>
|
||||||
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
|
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
|
||||||
<span x-text="shortDescription(item.description)"></span>
|
<span x-text="shortDescription(item.description)"></span>
|
||||||
|
<template x-if="mainCategoryLabel(item)">
|
||||||
|
<span>
|
||||||
|
<span class="grouped-stock-subline-separator" aria-hidden="true">•</span>
|
||||||
|
<span class="text-body-tertiary" x-text="'Main: ' + mainCategoryLabel(item)"></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-body-secondary grouped-stock-item-aux">
|
<div class="small text-body-secondary grouped-stock-item-aux">
|
||||||
@@ -876,6 +917,7 @@ export function stockListPageData(store) {
|
|||||||
locationMap: {},
|
locationMap: {},
|
||||||
locationDescendants: {},
|
locationDescendants: {},
|
||||||
locationLineage: {},
|
locationLineage: {},
|
||||||
|
categoriesById: {},
|
||||||
editForms: {},
|
editForms: {},
|
||||||
editErrors: {},
|
editErrors: {},
|
||||||
levelOptions: LEVEL_OPTIONS,
|
levelOptions: LEVEL_OPTIONS,
|
||||||
@@ -918,13 +960,13 @@ export function stockListPageData(store) {
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
if (!restoredFromRuntime) {
|
if (!restoredFromRuntime) {
|
||||||
const initTasks = [this.loadLocations()];
|
const initTasks = [this.loadLocations(), this.loadCategories()];
|
||||||
if (this.viewMode === 'items') {
|
if (this.viewMode === 'items') {
|
||||||
initTasks.push(this.loadEntries());
|
initTasks.push(this.loadEntries());
|
||||||
} else {
|
} else {
|
||||||
initTasks.push(
|
initTasks.push(
|
||||||
this.loadGroupedEntries({
|
this.loadGroupedEntries({
|
||||||
expanded: 0,
|
expanded: false,
|
||||||
resetVisible: !restoredContext,
|
resetVisible: !restoredContext,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -937,6 +979,9 @@ export function stockListPageData(store) {
|
|||||||
if (!restoredFromRuntime && this.viewMode === 'grouped') {
|
if (!restoredFromRuntime && this.viewMode === 'grouped') {
|
||||||
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (restoredFromRuntime) {
|
||||||
|
this.refreshLoadedViewsInBackground().catch(() => {});
|
||||||
|
}
|
||||||
if (restoredFromRuntime && restoredContext?.focusedItemUuid) {
|
if (restoredFromRuntime && restoredContext?.focusedItemUuid) {
|
||||||
this.refreshFocusedItemInBackground(restoredContext.focusedItemUuid).catch(() => {});
|
this.refreshFocusedItemInBackground(restoredContext.focusedItemUuid).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -1047,6 +1092,7 @@ export function stockListPageData(store) {
|
|||||||
locationMap: this.locationMap,
|
locationMap: this.locationMap,
|
||||||
locationDescendants: this.locationDescendants,
|
locationDescendants: this.locationDescendants,
|
||||||
locationLineage: this.locationLineage,
|
locationLineage: this.locationLineage,
|
||||||
|
categoriesById: this.categoriesById,
|
||||||
changeCursor: this.changeCursor,
|
changeCursor: this.changeCursor,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -1106,6 +1152,9 @@ export function stockListPageData(store) {
|
|||||||
this.locationLineage = payload.locationLineage && typeof payload.locationLineage === 'object'
|
this.locationLineage = payload.locationLineage && typeof payload.locationLineage === 'object'
|
||||||
? payload.locationLineage
|
? payload.locationLineage
|
||||||
: {};
|
: {};
|
||||||
|
this.categoriesById = payload.categoriesById && typeof payload.categoriesById === 'object'
|
||||||
|
? payload.categoriesById
|
||||||
|
: {};
|
||||||
this.changeCursor = payload.changeCursor || this.changeCursor;
|
this.changeCursor = payload.changeCursor || this.changeCursor;
|
||||||
|
|
||||||
if (this.itemsLoaded) {
|
if (this.itemsLoaded) {
|
||||||
@@ -1267,7 +1316,7 @@ export function stockListPageData(store) {
|
|||||||
},
|
},
|
||||||
indexEntry(entry) {
|
indexEntry(entry) {
|
||||||
const indexed = { ...entry };
|
const indexed = { ...entry };
|
||||||
indexed._searchBlob = searchBlob(indexed, this.locationMap);
|
indexed._searchBlob = searchBlob(indexed, this.locationMap, this.categoriesById);
|
||||||
return indexed;
|
return indexed;
|
||||||
},
|
},
|
||||||
indexGroup(group) {
|
indexGroup(group) {
|
||||||
@@ -1278,7 +1327,7 @@ export function stockListPageData(store) {
|
|||||||
...group,
|
...group,
|
||||||
items: indexedItems,
|
items: indexedItems,
|
||||||
};
|
};
|
||||||
indexed._searchBlob = groupSearchBlob(indexed, this.locationMap);
|
indexed._searchBlob = groupSearchBlob(indexed, this.locationMap, this.categoriesById);
|
||||||
return indexed;
|
return indexed;
|
||||||
},
|
},
|
||||||
reindexSearchData() {
|
reindexSearchData() {
|
||||||
@@ -1309,7 +1358,7 @@ export function stockListPageData(store) {
|
|||||||
|
|
||||||
if (mode === 'grouped') {
|
if (mode === 'grouped') {
|
||||||
if (!this.groupedLoaded) {
|
if (!this.groupedLoaded) {
|
||||||
await this.loadGroupedEntries({ expanded: 0, resetVisible: true });
|
await this.loadGroupedEntries({ expanded: false, resetVisible: true });
|
||||||
}
|
}
|
||||||
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
this.hydrateGroupedEntriesInBackground().catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -1325,7 +1374,7 @@ export function stockListPageData(store) {
|
|||||||
: this.itemsLoaded);
|
: this.itemsLoaded);
|
||||||
|
|
||||||
if (this.viewMode === 'grouped') {
|
if (this.viewMode === 'grouped') {
|
||||||
await this.loadGroupedEntries({ expanded: 0, background: useBackground });
|
await this.loadGroupedEntries({ expanded: false, background: useBackground });
|
||||||
this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {});
|
this.hydrateGroupedEntriesInBackground({ force: true }).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1417,7 +1466,7 @@ export function stockListPageData(store) {
|
|||||||
this.invalidateMemo();
|
this.invalidateMemo();
|
||||||
this.persistRuntimeCache();
|
this.persistRuntimeCache();
|
||||||
},
|
},
|
||||||
async loadGroupedEntries({ expanded = 1, background = false, resetVisible = false } = {}) {
|
async loadGroupedEntries({ expanded = true, background = false, resetVisible = false } = {}) {
|
||||||
if (!store.isConnected) {
|
if (!store.isConnected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1432,7 +1481,7 @@ export function stockListPageData(store) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const loadedGroups = await listGroupedStockEntries(store, { expanded });
|
const loadedGroups = await listGroupedStockEntries(store, { expanded });
|
||||||
if (expanded === 0) {
|
if (!expanded) {
|
||||||
this.applyGroupedSummary(loadedGroups, { resetVisible });
|
this.applyGroupedSummary(loadedGroups, { resetVisible });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1461,7 +1510,7 @@ export function stockListPageData(store) {
|
|||||||
|
|
||||||
this.groupedHydrating = true;
|
this.groupedHydrating = true;
|
||||||
try {
|
try {
|
||||||
await this.loadGroupedEntries({ expanded: 1, background: true });
|
await this.loadGroupedEntries({ expanded: true, background: true });
|
||||||
} finally {
|
} finally {
|
||||||
this.groupedHydrating = false;
|
this.groupedHydrating = false;
|
||||||
}
|
}
|
||||||
@@ -1473,7 +1522,7 @@ export function stockListPageData(store) {
|
|||||||
}
|
}
|
||||||
if (this.groupedLoaded) {
|
if (this.groupedLoaded) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
this.loadGroupedEntries({ expanded: 0, background: true }).then(() =>
|
this.loadGroupedEntries({ expanded: false, background: true }).then(() =>
|
||||||
this.hydrateGroupedEntriesInBackground({ force: true }),
|
this.hydrateGroupedEntriesInBackground({ force: true }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1513,6 +1562,25 @@ export function stockListPageData(store) {
|
|||||||
this.persistRuntimeCache();
|
this.persistRuntimeCache();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async loadCategories() {
|
||||||
|
if (!store.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const categories = await listCategories(store, { expanded: true });
|
||||||
|
this.categoriesById = Object.fromEntries(
|
||||||
|
categories
|
||||||
|
.filter((category) => category?.id !== undefined && category?.id !== null)
|
||||||
|
.map((category) => [String(category.id), category]),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.categoriesById = {};
|
||||||
|
} finally {
|
||||||
|
this.reindexSearchData();
|
||||||
|
this.persistRuntimeCache();
|
||||||
|
}
|
||||||
|
},
|
||||||
resetGroupedVisibleLimit() {
|
resetGroupedVisibleLimit() {
|
||||||
this.groupedVisibleLimit = this.groupedPageSize;
|
this.groupedVisibleLimit = this.groupedPageSize;
|
||||||
},
|
},
|
||||||
@@ -1526,6 +1594,28 @@ export function stockListPageData(store) {
|
|||||||
|
|
||||||
return group.items.filter((item) => !isGroupedChildStub(item));
|
return group.items.filter((item) => !isGroupedChildStub(item));
|
||||||
},
|
},
|
||||||
|
mainCategoryLabel(entry) {
|
||||||
|
return resolveMainCategoryLabel(entry, this.categoriesById);
|
||||||
|
},
|
||||||
|
mainCategoryBadgeLabel(entry) {
|
||||||
|
return `Main category: ${this.mainCategoryLabel(entry)}`;
|
||||||
|
},
|
||||||
|
groupSummaryMetricLabel(group) {
|
||||||
|
if (group?.stock_type === 'measured') {
|
||||||
|
return 'Quantity';
|
||||||
|
}
|
||||||
|
if (group?.stock_type === 'descriptive') {
|
||||||
|
return 'Stock level';
|
||||||
|
}
|
||||||
|
if (group?.stock_type === 'binary') {
|
||||||
|
return 'Stock state';
|
||||||
|
}
|
||||||
|
return 'Stock';
|
||||||
|
},
|
||||||
|
groupSummaryMetricValue(group) {
|
||||||
|
// Use backend-provided grouped fields as the single source of truth.
|
||||||
|
return quantityLabel(group);
|
||||||
|
},
|
||||||
hasGroupedChildStubs(group) {
|
hasGroupedChildStubs(group) {
|
||||||
if (!Array.isArray(group?.items)) {
|
if (!Array.isArray(group?.items)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -2269,7 +2359,7 @@ export function stockListPageData(store) {
|
|||||||
? `${item.name} was already out of stock and removed from the group.`
|
? `${item.name} was already out of stock and removed from the group.`
|
||||||
: `${item.name} was ${actionLabel} and removed from the group.`,
|
: `${item.name} was ${actionLabel} and removed from the group.`,
|
||||||
});
|
});
|
||||||
this.loadGroupedEntries({ expanded: 0, background: true }).catch(() => {});
|
this.loadGroupedEntries({ expanded: false, background: true }).catch(() => {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.editErrors[item.id] = error.message || 'Removal failed.';
|
this.editErrors[item.id] = error.message || 'Removal failed.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,165 @@ body {
|
|||||||
color: var(--lonc-primary);
|
color: var(--lonc-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-w-0 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.3rem minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--lonc-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-item:has(.recent-change-link):hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(31, 75, 153, 0.2);
|
||||||
|
box-shadow: 0 12px 24px rgba(24, 42, 79, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-rail {
|
||||||
|
background: rgba(31, 75, 153, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-body {
|
||||||
|
position: relative;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-title {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-time {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-tag,
|
||||||
|
.recent-change-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-tag {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #1f2740;
|
||||||
|
background: rgba(31, 75, 153, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-chip {
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.28rem 0.55rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: rgba(31, 39, 64, 0.06);
|
||||||
|
color: var(--lonc-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-chip-label {
|
||||||
|
color: var(--lonc-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-stock-transition {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: rgba(255, 255, 255, 0.62);
|
||||||
|
border: 1px dashed rgba(31, 75, 153, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-stock-state {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-stock-state-current {
|
||||||
|
color: var(--lonc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-stock-arrow {
|
||||||
|
color: var(--lonc-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-link {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-new {
|
||||||
|
background: rgba(25, 135, 84, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-new .recent-change-rail,
|
||||||
|
.recent-change-tag-new {
|
||||||
|
background: rgba(25, 135, 84, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-stock {
|
||||||
|
background: rgba(31, 75, 153, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-stock .recent-change-rail,
|
||||||
|
.recent-change-tag-stock {
|
||||||
|
background: rgba(31, 75, 153, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-used,
|
||||||
|
.recent-change-inactive,
|
||||||
|
.recent-change-gone {
|
||||||
|
background: rgba(108, 117, 125, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-used .recent-change-rail,
|
||||||
|
.recent-change-inactive .recent-change-rail,
|
||||||
|
.recent-change-gone .recent-change-rail,
|
||||||
|
.recent-change-tag-used,
|
||||||
|
.recent-change-tag-inactive,
|
||||||
|
.recent-change-tag-gone {
|
||||||
|
background: rgba(108, 117, 125, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-spoiled {
|
||||||
|
background: rgba(253, 126, 20, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-spoiled .recent-change-rail,
|
||||||
|
.recent-change-tag-spoiled {
|
||||||
|
background: rgba(253, 126, 20, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-change-updated .recent-change-rail,
|
||||||
|
.recent-change-tag-updated {
|
||||||
|
background: rgba(93, 169, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-frame,
|
.preview-frame,
|
||||||
.empty-preview {
|
.empty-preview {
|
||||||
min-height: 24rem;
|
min-height: 24rem;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const apiRequestMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../src/api/client.js', () => ({
|
||||||
|
getPath(key) {
|
||||||
|
const paths = {
|
||||||
|
categories: 'kitchen/categories',
|
||||||
|
};
|
||||||
|
return paths[key];
|
||||||
|
},
|
||||||
|
apiRequest: (...args) => apiRequestMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { listCategories } = await import('../../src/api/categories.js');
|
||||||
|
|
||||||
|
describe('api/categories', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiRequestMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards explicit pagination and filters', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await listCategories(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
{ searchName: 'dairy', active: true, limit: 10, offset: 20, orderBy: 'name', orderDir: 'asc' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/categories',
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
search_name: 'dairy',
|
||||||
|
active: true,
|
||||||
|
order_by: 'name',
|
||||||
|
order_dir: 'asc',
|
||||||
|
limit: 10,
|
||||||
|
offset: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates category pages by default', async () => {
|
||||||
|
apiRequestMock
|
||||||
|
.mockResolvedValueOnce(Array.from({ length: 100 }, (_, id) => ({ id: id + 1 })))
|
||||||
|
.mockResolvedValueOnce([{ id: 101 }]);
|
||||||
|
|
||||||
|
const response = await listCategories({ config: { database: 'db' } }, {});
|
||||||
|
|
||||||
|
expect(response).toHaveLength(101);
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/categories',
|
||||||
|
{ query: { limit: 100, offset: 0 } },
|
||||||
|
);
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/categories',
|
||||||
|
{ query: { limit: 100, offset: 100 } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,12 +61,12 @@ describe('api/client', () => {
|
|||||||
|
|
||||||
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
|
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
|
||||||
search_name: 'Milk + eggs',
|
search_name: 'Milk + eggs',
|
||||||
expanded: 1,
|
expanded: true,
|
||||||
ignored: '',
|
ignored: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe(
|
expect(url).toBe(
|
||||||
'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=1',
|
'https://api.example.com/my%20db/kitchen/items/grouped?search_name=Milk+%2B+eggs&expanded=true',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,14 +88,14 @@ describe('api/client', () => {
|
|||||||
name: 'Rice',
|
name: 'Rice',
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
label: 1,
|
label: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload).toEqual({ ok: true });
|
expect(payload).toEqual({ ok: true });
|
||||||
|
|
||||||
const [url, request] = fetchSpy.mock.calls[0];
|
const [url, request] = fetchSpy.mock.calls[0];
|
||||||
expect(url).toBe('/kitchen-db/kitchen/items?label=1');
|
expect(url).toBe('/kitchen-db/kitchen/items?label=true');
|
||||||
expect(request.method).toBe('POST');
|
expect(request.method).toBe('POST');
|
||||||
expect(request.body).toBe('{"name":"Rice"}');
|
expect(request.body).toBe('{"name":"Rice"}');
|
||||||
expect(request.headers.get('Accept')).toBe('application/json');
|
expect(request.headers.get('Accept')).toBe('application/json');
|
||||||
|
|||||||
@@ -1,6 +1,72 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { formatPrintErrorMessage } from '../../src/api/labels.js';
|
const apiRequestMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../src/api/client.js', () => ({
|
||||||
|
getPath(key) {
|
||||||
|
const paths = {
|
||||||
|
items: 'kitchen/items',
|
||||||
|
};
|
||||||
|
return paths[key];
|
||||||
|
},
|
||||||
|
apiRequest: (...args) => apiRequestMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
formatPrintErrorMessage,
|
||||||
|
getItemLabel,
|
||||||
|
previewLabel,
|
||||||
|
} = await import('../../src/api/labels.js');
|
||||||
|
|
||||||
|
describe('api/labels', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiRequestMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previewLabel uses boolean label/preview query flags', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
|
label: 'YWJj',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await previewLabel({ config: { database: 'db' } }, { name: 'Rice' });
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: 'Rice' },
|
||||||
|
accept: 'image/svg+xml, image/png, application/json',
|
||||||
|
query: { label: true, preview: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(response).toEqual({
|
||||||
|
objectUrl: 'data:image/png;base64,YWJj',
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemLabel fetches PNG from /label endpoint', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
|
label: 'YWJj',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await getItemLabel({ config: { database: 'db' } }, 'item-1');
|
||||||
|
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1/label',
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
accept: 'image/png, application/json',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(response).toEqual({
|
||||||
|
objectUrl: 'data:image/png;base64,YWJj',
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('api/labels formatPrintErrorMessage', () => {
|
describe('api/labels formatPrintErrorMessage', () => {
|
||||||
it('maps printer_unavailable payload to user-friendly message', () => {
|
it('maps printer_unavailable payload to user-friendly message', () => {
|
||||||
|
|||||||
+34
-7
@@ -20,6 +20,7 @@ const {
|
|||||||
listGroupedStockEntries,
|
listGroupedStockEntries,
|
||||||
listKitchenChanges,
|
listKitchenChanges,
|
||||||
listStockEntries,
|
listStockEntries,
|
||||||
|
markStockGone,
|
||||||
lookupItemByIdentifier,
|
lookupItemByIdentifier,
|
||||||
lookupItemDetails,
|
lookupItemDetails,
|
||||||
patchStockItem,
|
patchStockItem,
|
||||||
@@ -84,7 +85,7 @@ describe('api/stock', () => {
|
|||||||
|
|
||||||
await listGroupedStockEntries(
|
await listGroupedStockEntries(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
{ expanded: 0, searchName: 'Rice', limit: 10, offset: 0 },
|
{ expanded: false, searchName: 'Rice', limit: 10, offset: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
@@ -92,7 +93,7 @@ describe('api/stock', () => {
|
|||||||
'kitchen/items/grouped',
|
'kitchen/items/grouped',
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
expanded: 0,
|
expanded: false,
|
||||||
search_name: 'Rice',
|
search_name: 'Rice',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
@@ -108,7 +109,7 @@ describe('api/stock', () => {
|
|||||||
|
|
||||||
const response = await listGroupedStockEntries(
|
const response = await listGroupedStockEntries(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
{ expanded: 1, searchName: 'Rice' },
|
{ expanded: true, searchName: 'Rice' },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response).toHaveLength(101);
|
expect(response).toHaveLength(101);
|
||||||
@@ -116,13 +117,13 @@ describe('api/stock', () => {
|
|||||||
1,
|
1,
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/grouped',
|
'kitchen/items/grouped',
|
||||||
{ query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 0 } },
|
{ query: { expanded: true, search_name: 'Rice', limit: 100, offset: 0 } },
|
||||||
);
|
);
|
||||||
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/grouped',
|
'kitchen/items/grouped',
|
||||||
{ query: { expanded: 1, search_name: 'Rice', limit: 100, offset: 100 } },
|
{ query: { expanded: true, search_name: 'Rice', limit: 100, offset: 100 } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ describe('api/stock', () => {
|
|||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/item-2',
|
'kitchen/items/item-2',
|
||||||
{ query: { allow_inactive: 1 } },
|
{ query: { allow_inactive: true } },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +298,7 @@ describe('api/stock', () => {
|
|||||||
expect(apiRequestMock).toHaveBeenCalledWith(
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
{ config: { database: 'db' } },
|
{ config: { database: 'db' } },
|
||||||
'kitchen/items/item-1/lookup',
|
'kitchen/items/item-1/lookup',
|
||||||
{ method: 'POST', query: { update: 1 } },
|
{ method: 'POST', query: { update: true } },
|
||||||
);
|
);
|
||||||
expect(response).toEqual({
|
expect(response).toEqual({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
@@ -426,4 +427,30 @@ describe('api/stock', () => {
|
|||||||
});
|
});
|
||||||
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('markStockGone uses /use endpoint for consumed reason', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'consumed');
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'gone', reason: 'consumed' });
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1/use',
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markStockGone uses /stock endpoint for non-consumed reasons', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce({ status: 'OK', stock: { id: 3 } });
|
||||||
|
|
||||||
|
const result = await markStockGone({ config: { database: 'db' } }, 'item-1', 'spoiled');
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'gone', reason: 'spoiled' });
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1/stock',
|
||||||
|
{ method: 'POST', body: { level: 'gone', gone_reason: 'spoiled' } },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const listKitchenChangesMock = vi.fn();
|
const listKitchenChangesMock = vi.fn();
|
||||||
|
const listStockEventsMock = vi.fn();
|
||||||
const getStockEntryMock = vi.fn();
|
const getStockEntryMock = vi.fn();
|
||||||
const fetchLocationsMock = vi.fn();
|
const fetchLocationsMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../../../src/api/stock.js', () => ({
|
vi.mock('../../../src/api/stock.js', () => ({
|
||||||
listKitchenChanges: (...args) => listKitchenChangesMock(...args),
|
listKitchenChanges: (...args) => listKitchenChangesMock(...args),
|
||||||
|
listStockEvents: (...args) => listStockEventsMock(...args),
|
||||||
getStockEntry: (...args) => getStockEntryMock(...args),
|
getStockEntry: (...args) => getStockEntryMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ const { dashboardPageData, renderDashboardPage } = await import('../../../src/fe
|
|||||||
describe('features/dashboard/dashboard-page', () => {
|
describe('features/dashboard/dashboard-page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listKitchenChangesMock.mockReset();
|
listKitchenChangesMock.mockReset();
|
||||||
|
listStockEventsMock.mockReset();
|
||||||
getStockEntryMock.mockReset();
|
getStockEntryMock.mockReset();
|
||||||
fetchLocationsMock.mockReset();
|
fetchLocationsMock.mockReset();
|
||||||
});
|
});
|
||||||
@@ -26,10 +29,11 @@ describe('features/dashboard/dashboard-page', () => {
|
|||||||
const html = renderDashboardPage();
|
const html = renderDashboardPage();
|
||||||
expect(html).toContain('Recent changes');
|
expect(html).toContain('Recent changes');
|
||||||
expect(html).toContain('x-data="dashboardPage()"');
|
expect(html).toContain('x-data="dashboardPage()"');
|
||||||
expect(html).toContain('Saved means the backend created or updated a record.');
|
expect(html).toContain('Latest item and stock updates, including used and inactive stock.');
|
||||||
|
expect(html).toContain('recent-change-list');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads recent changes on init and renders item-focused state lines', async () => {
|
it('loads recent changes on init and renders item-focused details', async () => {
|
||||||
listKitchenChangesMock.mockResolvedValueOnce({
|
listKitchenChangesMock.mockResolvedValueOnce({
|
||||||
since: null,
|
since: null,
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
@@ -62,16 +66,20 @@ describe('features/dashboard/dashboard-page', () => {
|
|||||||
|
|
||||||
await data.init();
|
await data.init();
|
||||||
|
|
||||||
expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 10 });
|
expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 200 });
|
||||||
expect(data.recentChanges).toHaveLength(1);
|
expect(data.recentChanges).toHaveLength(1);
|
||||||
expect(data.changesState.error).toBe('');
|
expect(data.changesState.error).toBe('');
|
||||||
expect(data.changeHeadline(data.recentChanges[0])).toBe('Item saved: Rice');
|
expect(data.changeHeadline(data.recentChanges[0])).toBe('Rice');
|
||||||
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 3 kg');
|
expect(data.changeKindLabel(data.recentChanges[0])).toBe('Updated');
|
||||||
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Shelf A');
|
expect(data.changeSubtitle(data.recentChanges[0])).toBe('Item details were updated.');
|
||||||
|
expect(data.changeDetails(data.recentChanges[0])).toEqual(expect.arrayContaining([
|
||||||
|
{ label: 'Quantity', value: '3 kg' },
|
||||||
|
{ label: 'Location', value: 'Pantry / Shelf A' },
|
||||||
|
]));
|
||||||
expect(getStockEntryMock).not.toHaveBeenCalled();
|
expect(getStockEntryMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves stock event item context via item lookup when needed', async () => {
|
it('resolves stock event item context and renders stock transitions', async () => {
|
||||||
listKitchenChangesMock.mockResolvedValueOnce({
|
listKitchenChangesMock.mockResolvedValueOnce({
|
||||||
since: null,
|
since: null,
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
@@ -80,6 +88,7 @@ describe('features/dashboard/dashboard-page', () => {
|
|||||||
action: 'upsert',
|
action: 'upsert',
|
||||||
timestamp: '2026-04-10T10:00:00Z',
|
timestamp: '2026-04-10T10:00:00Z',
|
||||||
stock: {
|
stock: {
|
||||||
|
id: 11,
|
||||||
item_uuid_b64: 'item-uuid-1',
|
item_uuid_b64: 'item-uuid-1',
|
||||||
quantity: 0.5,
|
quantity: 0.5,
|
||||||
uom_symbol: 'kg',
|
uom_symbol: 'kg',
|
||||||
@@ -88,6 +97,10 @@ describe('features/dashboard/dashboard-page', () => {
|
|||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
listStockEventsMock.mockResolvedValueOnce([
|
||||||
|
{ id: 11, quantity: 0.5, uom_symbol: 'kg', level: 'some' },
|
||||||
|
{ id: 10, quantity: 1, uom_symbol: 'kg', level: 'good' },
|
||||||
|
]);
|
||||||
getStockEntryMock.mockResolvedValueOnce({
|
getStockEntryMock.mockResolvedValueOnce({
|
||||||
uuid_b64: 'item-uuid-1',
|
uuid_b64: 'item-uuid-1',
|
||||||
name: 'Flour',
|
name: 'Flour',
|
||||||
@@ -106,11 +119,23 @@ describe('features/dashboard/dashboard-page', () => {
|
|||||||
|
|
||||||
await data.refreshChanges();
|
await data.refreshChanges();
|
||||||
|
|
||||||
expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Flour');
|
expect(data.changeHeadline(data.recentChanges[0])).toBe('Flour');
|
||||||
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 0.5 kg');
|
expect(data.changeKindLabel(data.recentChanges[0])).toBe('Stock changed');
|
||||||
expect(data.changeStateLine(data.recentChanges[0])).toContain('Level: Some');
|
expect(data.changeStockTransition(data.recentChanges[0])).toEqual({
|
||||||
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Bin 2');
|
previous: '1 kg · Good',
|
||||||
|
current: '0.5 kg · Some',
|
||||||
|
});
|
||||||
|
expect(data.changeDetails(data.recentChanges[0])).toEqual(expect.arrayContaining([
|
||||||
|
{ label: 'Level', value: 'Some' },
|
||||||
|
{ label: 'Location', value: 'Pantry / Bin 2' },
|
||||||
|
]));
|
||||||
expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1');
|
expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1');
|
||||||
|
expect(listStockEventsMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1', {
|
||||||
|
allowInactive: true,
|
||||||
|
limit: 50,
|
||||||
|
orderBy: 'id',
|
||||||
|
orderDir: 'desc',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries stock event item lookup with allowInactive after 404', async () => {
|
it('retries stock event item lookup with allowInactive after 404', async () => {
|
||||||
@@ -122,12 +147,16 @@ describe('features/dashboard/dashboard-page', () => {
|
|||||||
action: 'upsert',
|
action: 'upsert',
|
||||||
timestamp: '2026-04-10T10:00:00Z',
|
timestamp: '2026-04-10T10:00:00Z',
|
||||||
stock: {
|
stock: {
|
||||||
|
id: 12,
|
||||||
item_uuid_b64: 'item-uuid-2',
|
item_uuid_b64: 'item-uuid-2',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
uom_symbol: 'pcs',
|
uom_symbol: 'pcs',
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
listStockEventsMock.mockResolvedValueOnce([
|
||||||
|
{ id: 12, quantity: 1, uom_symbol: 'pcs' },
|
||||||
|
]);
|
||||||
getStockEntryMock
|
getStockEntryMock
|
||||||
.mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 }))
|
.mockRejectedValueOnce(Object.assign(new Error('Not found'), { status: 404 }))
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -153,8 +182,12 @@ describe('features/dashboard/dashboard-page', () => {
|
|||||||
'item-uuid-2',
|
'item-uuid-2',
|
||||||
{ allowInactive: true },
|
{ allowInactive: true },
|
||||||
);
|
);
|
||||||
expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Archived pasta');
|
expect(data.changeHeadline(data.recentChanges[0])).toBe('Archived pasta');
|
||||||
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 1 pcs');
|
expect(data.changeKindLabel(data.recentChanges[0])).toBe('New item');
|
||||||
|
expect(data.changeStockTransition(data.recentChanges[0])).toEqual({
|
||||||
|
previous: 'Initial stock',
|
||||||
|
current: '1 pcs',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps empty state when API returns no changes', async () => {
|
it('keeps empty state when API returns no changes', async () => {
|
||||||
|
|||||||
@@ -130,6 +130,6 @@ describe('stock mark-gone behavior', () => {
|
|||||||
message: 'Beans was marked used and removed from the group.',
|
message: 'Beans was marked used and removed from the group.',
|
||||||
});
|
});
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1);
|
expect(listGroupedStockEntriesMock).toHaveBeenCalledTimes(1);
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: 0 });
|
expect(listGroupedStockEntriesMock).toHaveBeenCalledWith(store, { expanded: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const getStockEntryMock = vi.fn();
|
|||||||
const updateStockItemMock = vi.fn();
|
const updateStockItemMock = vi.fn();
|
||||||
const useStockItemMock = vi.fn();
|
const useStockItemMock = vi.fn();
|
||||||
const fetchLocationsMock = vi.fn();
|
const fetchLocationsMock = vi.fn();
|
||||||
|
const listCategoriesMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../../../src/api/stock.js', () => ({
|
vi.mock('../../../src/api/stock.js', () => ({
|
||||||
listStockEntries: (...args) => listStockEntriesMock(...args),
|
listStockEntries: (...args) => listStockEntriesMock(...args),
|
||||||
@@ -21,6 +22,10 @@ vi.mock('../../../src/api/locations.js', () => ({
|
|||||||
fetchLocations: (...args) => fetchLocationsMock(...args),
|
fetchLocations: (...args) => fetchLocationsMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/categories.js', () => ({
|
||||||
|
listCategories: (...args) => listCategoriesMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
|
const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
|
||||||
|
|
||||||
function createGroupedSummary() {
|
function createGroupedSummary() {
|
||||||
@@ -112,6 +117,8 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
updateStockItemMock.mockReset();
|
updateStockItemMock.mockReset();
|
||||||
useStockItemMock.mockReset();
|
useStockItemMock.mockReset();
|
||||||
fetchLocationsMock.mockReset();
|
fetchLocationsMock.mockReset();
|
||||||
|
listCategoriesMock.mockReset();
|
||||||
|
listCategoriesMock.mockResolvedValue([]);
|
||||||
|
|
||||||
globalThis.window = createWindowMock();
|
globalThis.window = createWindowMock();
|
||||||
globalThis.requestAnimationFrame = (callback) => callback();
|
globalThis.requestAnimationFrame = (callback) => callback();
|
||||||
@@ -133,6 +140,7 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
listStockEntriesMock.mockResolvedValueOnce([]);
|
listStockEntriesMock.mockResolvedValueOnce([]);
|
||||||
listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
|
listKitchenChangesMock.mockResolvedValue({ since: null, nextCursor: null, changes: [] });
|
||||||
fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] });
|
fetchLocationsMock.mockResolvedValue({ flat: [], tree: [] });
|
||||||
|
listCategoriesMock.mockResolvedValue([]);
|
||||||
|
|
||||||
const store = { isConnected: true, addAlert: vi.fn() };
|
const store = { isConnected: true, addAlert: vi.fn() };
|
||||||
const data = stockListPageData(store);
|
const data = stockListPageData(store);
|
||||||
@@ -141,12 +149,12 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
await data.init();
|
await data.init();
|
||||||
|
|
||||||
expect(data.viewMode).toBe('grouped');
|
expect(data.viewMode).toBe('grouped');
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: 0 });
|
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false });
|
||||||
expect(listStockEntriesMock).not.toHaveBeenCalled();
|
expect(listStockEntriesMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: 1 });
|
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true });
|
||||||
|
|
||||||
await data.switchView('items');
|
await data.switchView('items');
|
||||||
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
|
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -162,7 +170,7 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
.mockResolvedValueOnce(createGroupedExpanded());
|
.mockResolvedValueOnce(createGroupedExpanded());
|
||||||
|
|
||||||
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
const data = stockListPageData({ isConnected: true, addAlert: vi.fn() });
|
||||||
await data.loadGroupedEntries({ expanded: 0, resetVisible: true });
|
await data.loadGroupedEntries({ expanded: false, resetVisible: true });
|
||||||
|
|
||||||
expect(data.groupDisplayItems(data.groupedEntries[0])).toEqual([]);
|
expect(data.groupDisplayItems(data.groupedEntries[0])).toEqual([]);
|
||||||
expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true);
|
expect(data.hasGroupedChildStubs(data.groupedEntries[0])).toBe(true);
|
||||||
@@ -226,7 +234,7 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
data.groupedLoaded = true;
|
data.groupedLoaded = true;
|
||||||
data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group));
|
data.groupedEntries = createGroupedSummary().map((group) => data.indexGroup(group));
|
||||||
|
|
||||||
const pending = data.loadGroupedEntries({ expanded: 0, background: true });
|
const pending = data.loadGroupedEntries({ expanded: false, background: true });
|
||||||
|
|
||||||
expect(data.state.isRefreshing).toBe(true);
|
expect(data.state.isRefreshing).toBe(true);
|
||||||
expect(data.groupedEntries).toHaveLength(1);
|
expect(data.groupedEntries).toHaveLength(1);
|
||||||
@@ -357,8 +365,10 @@ describe('stock list grouped-first behavior', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(listGroupedStockEntriesMock).not.toHaveBeenCalled();
|
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(1, store, { expanded: false });
|
||||||
expect(listStockEntriesMock).not.toHaveBeenCalled();
|
expect(listGroupedStockEntriesMock).toHaveBeenNthCalledWith(2, store, { expanded: true });
|
||||||
|
expect(listStockEntriesMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(listStockEntriesMock).toHaveBeenCalledWith(store);
|
||||||
expect(fetchLocationsMock).not.toHaveBeenCalled();
|
expect(fetchLocationsMock).not.toHaveBeenCalled();
|
||||||
expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-100');
|
expect(getStockEntryMock).toHaveBeenCalledWith(store, 'item-100');
|
||||||
expect(returnVisit.entries[0].quantity).toBe(2);
|
expect(returnVisit.entries[0].quantity).toBe(2);
|
||||||
|
|||||||
Reference in New Issue
Block a user