Merge pull request 'codex/update-app-for-new-api' (#2) from codex/update-app-for-new-api into main
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -82,16 +82,20 @@ These are current project assumptions and should not be casually changed.
|
|||||||
- `/{database}/user/application/`
|
- `/{database}/user/application/`
|
||||||
- `/{database}/kitchen/kitchens`
|
- `/{database}/kitchen/kitchens`
|
||||||
- `/{database}/kitchen/items`
|
- `/{database}/kitchen/items`
|
||||||
|
- `/{database}/kitchen/items/upsert`
|
||||||
- `/{database}/kitchen/items/grouped`
|
- `/{database}/kitchen/items/grouped`
|
||||||
- `/{database}/kitchen/items/{uuid_b64}`
|
- `/{database}/kitchen/items/{uuid_b64}`
|
||||||
- `/{database}/kitchen/items/{uuid_b64}/stock`
|
- `/{database}/kitchen/items/{uuid_b64}/stock`
|
||||||
|
- `/{database}/kitchen/items/{uuid_b64}/use`
|
||||||
|
- `/{database}/kitchen/changes`
|
||||||
- `/{database}/kitchen/locations`
|
- `/{database}/kitchen/locations`
|
||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
- Preview uses label-preview flags
|
- Preview uses label-preview flags
|
||||||
- Create uses label-generation flags
|
- Submit/create flow uses upsert apply (`/kitchen/items/upsert?mode=apply`)
|
||||||
- Actual create currently also needs `print=1`
|
- UI exposes a `Print` checkbox next to save (default on for current page session)
|
||||||
|
- If `Print` is enabled and save succeeds, label printing uses `/kitchen/items/{uuid_b64}/print-label`
|
||||||
|
|
||||||
### Item-definition search for label creation
|
### Item-definition search for label creation
|
||||||
|
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ Default endpoint placeholders live in [`src/app/config.js`](/Users/blaz/PycharmP
|
|||||||
|
|
||||||
Expected shapes today:
|
Expected shapes today:
|
||||||
|
|
||||||
- Kitchen-scoped application resources use:
|
- Kitchen application resources use database-scoped routes:
|
||||||
`/{database}/kitchen/{kitchen_id}/{resource}`
|
`/{database}/kitchen/{resource}`
|
||||||
- User application key management uses:
|
- User application key management uses:
|
||||||
`/{database}/user/application/`
|
`/{database}/user/application/`
|
||||||
|
|
||||||
@@ -165,14 +165,22 @@ Expected shapes today:
|
|||||||
Returns the current stock review list.
|
Returns the current stock review list.
|
||||||
- `GET /{database}/kitchen/items/{uuid_b64}`
|
- `GET /{database}/kitchen/items/{uuid_b64}`
|
||||||
Returns one item detail payload.
|
Returns one item detail payload.
|
||||||
|
- `GET /{database}/kitchen/changes`
|
||||||
|
Returns `{ since, next_cursor, changes }` feed payload for item/stock updates.
|
||||||
|
- `POST /{database}/kitchen/items/upsert?mode=preview|apply`
|
||||||
|
Used by label submit flow for create-or-update behavior and conflict-safe matching.
|
||||||
- `POST /{database}/kitchen/items?label=1`
|
- `POST /{database}/kitchen/items?label=1`
|
||||||
Creates a stock item plus label-related output on the backend side.
|
Used for label image preview rendering.
|
||||||
- `POST /{database}/kitchen/items?label=1&preview=1`
|
- `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.
|
||||||
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
|
- `POST /{database}/kitchen/items/{uuid_b64}/stock`
|
||||||
Updates measured or descriptive stock state using `{ quantity }` or `{ level }`.
|
Updates measured or descriptive stock state using `{ quantity }` or `{ level }`.
|
||||||
|
- `POST /{database}/kitchen/items/{uuid_b64}/use`
|
||||||
|
Marks an item used up (`gone`) via stock-event semantics.
|
||||||
|
- `POST /{database}/kitchen/items/{uuid_b64}/print-label`
|
||||||
|
Prints label for an existing item; called from the save flow when `Print` is enabled.
|
||||||
- `DELETE /{database}/kitchen/items/{uuid_b64}`
|
- `DELETE /{database}/kitchen/items/{uuid_b64}`
|
||||||
Marks an individual stock item gone.
|
Compatibility fallback when `/use` is not available on the backend.
|
||||||
- `GET /{database}/kitchen/locations`
|
- `GET /{database}/kitchen/locations`
|
||||||
Returns a nested location tree.
|
Returns a nested location tree.
|
||||||
|
|
||||||
@@ -181,4 +189,5 @@ Expected shapes today:
|
|||||||
- Hash-based routing is used to keep static deployment simple.
|
- Hash-based routing is used to keep static deployment simple.
|
||||||
- Local storage only keeps non-sensitive app config, session payload, active kitchen, and label draft state.
|
- Local storage only keeps non-sensitive app config, session payload, active kitchen, and label draft state.
|
||||||
- Kitchen context now lives in the URL path instead of a custom header.
|
- Kitchen context now lives in the URL path instead of a custom header.
|
||||||
- `includeKitchen: false` in the API client only removes the kitchen path segment; it does not disable bearer authentication.
|
- The API client now builds database-scoped kitchen routes by default; it always keeps bearer authentication handling separate from URL shaping.
|
||||||
|
- Label submit uses upsert-first apply semantics and an optional `Print` checkbox (default on for the current page session).
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alpinejs": "^3.14.9",
|
"alpinejs": "^3.14.9",
|
||||||
"bootstrap": "^5.3.3"
|
"bootstrap": "^5.3.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lonc-web",
|
"name": "lonc-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export async function login(store, credentials) {
|
|||||||
user: credentials.userLogin,
|
user: credentials.userLogin,
|
||||||
application: TRYTON_APPLICATION,
|
application: TRYTON_APPLICATION,
|
||||||
},
|
},
|
||||||
includeKitchen: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const applicationKey = extractKey(payload);
|
const applicationKey = extractKey(payload);
|
||||||
@@ -66,7 +65,6 @@ export async function logout(store) {
|
|||||||
key: store.session.applicationKey,
|
key: store.session.applicationKey,
|
||||||
application: TRYTON_APPLICATION,
|
application: TRYTON_APPLICATION,
|
||||||
},
|
},
|
||||||
includeKitchen: false,
|
|
||||||
skipAuthFailureHandler: true,
|
skipAuthFailureHandler: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-14
@@ -20,7 +20,7 @@ function isSameOriginBaseUrl(baseUrl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPathname({ database, kitchenId, path, includeKitchen = true }) {
|
function buildPathname({ database, path }) {
|
||||||
const encodedDatabase = encodeURIComponent(database);
|
const encodedDatabase = encodeURIComponent(database);
|
||||||
const rawPath = String(path || '').replace(/^\/+/, '');
|
const rawPath = String(path || '').replace(/^\/+/, '');
|
||||||
const keepTrailingSlash = rawPath.endsWith('/');
|
const keepTrailingSlash = rawPath.endsWith('/');
|
||||||
@@ -30,23 +30,17 @@ function buildPathname({ database, kitchenId, path, includeKitchen = true }) {
|
|||||||
.map((segment) => encodeURIComponent(segment));
|
.map((segment) => encodeURIComponent(segment));
|
||||||
const segments = [encodedDatabase];
|
const segments = [encodedDatabase];
|
||||||
|
|
||||||
if (includeKitchen && kitchenId) {
|
|
||||||
segments.push('kitchen', encodeURIComponent(String(kitchenId)));
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.push(...encodedPathSegments);
|
segments.push(...encodedPathSegments);
|
||||||
|
|
||||||
const pathname = `/${segments.join('/')}`;
|
const pathname = `/${segments.join('/')}`;
|
||||||
return keepTrailingSlash ? `${pathname}/` : pathname;
|
return keepTrailingSlash ? `${pathname}/` : pathname;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
|
function buildUrl({ baseUrl, database, path, query = {} }) {
|
||||||
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
const pathname = buildPathname({
|
const pathname = buildPathname({
|
||||||
database,
|
database,
|
||||||
kitchenId,
|
|
||||||
path,
|
path,
|
||||||
includeKitchen,
|
|
||||||
});
|
});
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
@@ -191,6 +185,10 @@ function isKitchensPath(path) {
|
|||||||
return String(path || '').replace(/^\/+/, '').replace(/\/+$/, '') === API_PATHS.kitchens;
|
return String(path || '').replace(/^\/+/, '').replace(/\/+$/, '') === API_PATHS.kitchens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKitchenApiPath(path) {
|
||||||
|
return String(path || '').replace(/^\/+/, '').startsWith('kitchen/');
|
||||||
|
}
|
||||||
|
|
||||||
function shouldInvalidateValidatedSession(store, path, options = {}) {
|
function shouldInvalidateValidatedSession(store, path, options = {}) {
|
||||||
if (options.skipAuthFailureHandler) {
|
if (options.skipAuthFailureHandler) {
|
||||||
return false;
|
return false;
|
||||||
@@ -202,15 +200,16 @@ function shouldInvalidateValidatedSession(store, path, options = {}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
isKitchensPath(path) ||
|
isKitchensPath(path) ||
|
||||||
options.includeKitchen !== false ||
|
isKitchenApiPath(path) ||
|
||||||
path === API_PATHS.items ||
|
path === API_PATHS.items ||
|
||||||
path === API_PATHS.locations ||
|
path === API_PATHS.locations ||
|
||||||
|
path === API_PATHS.changes ||
|
||||||
String(path || '').startsWith(`${API_PATHS.items}/`)
|
String(path || '').startsWith(`${API_PATHS.items}/`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest(store, path, options = {}) {
|
export async function apiRequest(store, path, options = {}) {
|
||||||
const { config, session, activeKitchen } = store;
|
const { config, session } = store;
|
||||||
|
|
||||||
if (!config.database) {
|
if (!config.database) {
|
||||||
throw new Error('Database name is required.');
|
throw new Error('Database name is required.');
|
||||||
@@ -220,10 +219,8 @@ export async function apiRequest(store, path, options = {}) {
|
|||||||
const url = buildUrl({
|
const url = buildUrl({
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: config.baseUrl,
|
||||||
database: config.database,
|
database: config.database,
|
||||||
kitchenId: activeKitchen?.id,
|
|
||||||
path,
|
path,
|
||||||
query: options.query,
|
query: options.query,
|
||||||
includeKitchen: options.includeKitchen !== false,
|
|
||||||
});
|
});
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
headers.set('Accept', options.accept || 'application/json');
|
headers.set('Accept', options.accept || 'application/json');
|
||||||
@@ -304,9 +301,7 @@ export function buildKitchenApiUrl(store, path, query = {}) {
|
|||||||
return buildUrl({
|
return buildUrl({
|
||||||
baseUrl: store.config.baseUrl,
|
baseUrl: store.config.baseUrl,
|
||||||
database: store.config.database,
|
database: store.config.database,
|
||||||
kitchenId: store.activeKitchen?.id,
|
|
||||||
path,
|
path,
|
||||||
query,
|
query,
|
||||||
includeKitchen: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -1,9 +1,7 @@
|
|||||||
import { apiRequest, getPath } from './client.js';
|
import { apiRequest, getPath } from './client.js';
|
||||||
|
|
||||||
export async function listKitchens(store) {
|
export async function listKitchens(store) {
|
||||||
const payload = await apiRequest(store, getPath('kitchens'), {
|
const payload = await apiRequest(store, getPath('kitchens'));
|
||||||
includeKitchen: false,
|
|
||||||
});
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-1
@@ -42,7 +42,6 @@ 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',
|
||||||
includeKitchen: false,
|
|
||||||
query: { label: 1, preview: 1 },
|
query: { label: 1, preview: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,3 +52,57 @@ 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 printItemLabel(store, uuidB64) {
|
||||||
|
return apiRequest(store, `${getPath('items')}/${uuidB64}/print-label`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenDetails(details) {
|
||||||
|
if (!details) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof details === 'string') {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(details)) {
|
||||||
|
return details
|
||||||
|
.map((entry) => (typeof entry === 'string' ? entry : JSON.stringify(entry)))
|
||||||
|
.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof details === 'object') {
|
||||||
|
return Object.entries(details)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPrintErrorMessage(error) {
|
||||||
|
const status = error?.status || error?.cause?.status;
|
||||||
|
const payload = error?.payload || error?.cause?.payload || {};
|
||||||
|
const code = String(payload?.code || '').toLowerCase();
|
||||||
|
const detailsText = flattenDetails(payload?.details || error?.details || error?.cause?.details);
|
||||||
|
|
||||||
|
let message;
|
||||||
|
if (code === 'printer_unavailable') {
|
||||||
|
message = 'Printer is unavailable.';
|
||||||
|
} else if (code === 'print_failed') {
|
||||||
|
message = 'Label printing failed.';
|
||||||
|
} else if (status === 503) {
|
||||||
|
message = 'Printer service is unavailable.';
|
||||||
|
} else if (status === 404) {
|
||||||
|
message = 'Saved item could not be found for printing.';
|
||||||
|
} else if (status === 400) {
|
||||||
|
message = 'Print request was invalid.';
|
||||||
|
} else {
|
||||||
|
message = error?.message || 'Printing failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailsText ? `${message} (${detailsText})` : message;
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ export async function fetchLocations(store) {
|
|||||||
return cached.value;
|
return cached.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await apiRequest(store, getPath('locations'), {
|
const payload = await apiRequest(store, getPath('locations'));
|
||||||
includeKitchen: false,
|
|
||||||
});
|
|
||||||
const tree = Array.isArray(payload)
|
const tree = Array.isArray(payload)
|
||||||
? payload
|
? payload
|
||||||
: payload?.data || payload?.locations || [];
|
: payload?.data || payload?.locations || [];
|
||||||
|
|||||||
+70
-12
@@ -10,7 +10,6 @@ export async function searchItemDefinitions(store, query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||||
includeKitchen: false,
|
|
||||||
query: { search_name: query, expanded: 0 },
|
query: { search_name: query, expanded: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,9 +21,7 @@ export async function searchItemDefinitions(store, query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listStockEntries(store, filters = {}) {
|
export async function listStockEntries(store, filters = {}) {
|
||||||
const payload = await apiRequest(store, getPath('items'), {
|
const payload = await apiRequest(store, getPath('items'));
|
||||||
includeKitchen: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
return payload;
|
return payload;
|
||||||
@@ -35,7 +32,6 @@ export async function listStockEntries(store, filters = {}) {
|
|||||||
|
|
||||||
export async function listGroupedStockEntries(store) {
|
export async function listGroupedStockEntries(store) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||||
includeKitchen: false,
|
|
||||||
query: { expanded: 1 },
|
query: { expanded: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,9 +43,7 @@ export async function listGroupedStockEntries(store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getStockEntry(store, stockId) {
|
export async function getStockEntry(store, stockId) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
|
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`);
|
||||||
includeKitchen: false,
|
|
||||||
});
|
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +51,47 @@ 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,
|
||||||
includeKitchen: false,
|
|
||||||
query: { label: 1, print: 1 },
|
query: { label: 1, print: 1 },
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeUpsertResponse(payload) {
|
||||||
|
return {
|
||||||
|
status: payload?.status || null,
|
||||||
|
mode: payload?.mode || null,
|
||||||
|
operation: payload?.operation || null,
|
||||||
|
matchType: payload?.match_type || null,
|
||||||
|
matchedItem: payload?.matched_item || null,
|
||||||
|
item: payload?.item || null,
|
||||||
|
payload: payload?.payload || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewItemUpsert(store, body) {
|
||||||
|
const payload = await apiRequest(store, `${getPath('items')}/upsert`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
query: { mode: 'preview' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeUpsertResponse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyItemUpsert(store, body) {
|
||||||
|
const payload = await apiRequest(store, `${getPath('items')}/upsert`, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
query: { mode: 'apply' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizeUpsertResponse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateStockItem(store, uuidB64, body) {
|
export async function updateStockItem(store, uuidB64, body) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
|
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
includeKitchen: false,
|
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
@@ -75,16 +99,50 @@ export async function updateStockItem(store, uuidB64, body) {
|
|||||||
export async function deleteStockItem(store, uuidB64) {
|
export async function deleteStockItem(store, uuidB64) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
|
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
includeKitchen: false,
|
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function useStockItem(store, uuidB64) {
|
||||||
|
try {
|
||||||
|
await apiRequest(store, `${getPath('items')}/${uuidB64}/use`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
return { status: 'used' };
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.status || error?.cause?.status;
|
||||||
|
if (status === 409) {
|
||||||
|
return { status: 'already_gone' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 404 || status === 405) {
|
||||||
|
await deleteStockItem(store, uuidB64);
|
||||||
|
return { status: 'fallback_delete' };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function adjustStockEntry(store, stockId, body) {
|
export async function adjustStockEntry(store, stockId, body) {
|
||||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
|
const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body,
|
body,
|
||||||
includeKitchen: false,
|
|
||||||
});
|
});
|
||||||
return unwrapEntryPayload(payload);
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listKitchenChanges(store, { since, limit = 10 } = {}) {
|
||||||
|
const payload = await apiRequest(store, getPath('changes'), {
|
||||||
|
query: {
|
||||||
|
since,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
since: payload?.since || null,
|
||||||
|
nextCursor: payload?.next_cursor || null,
|
||||||
|
changes: Array.isArray(payload?.changes) ? payload.changes : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+6
-2
@@ -2,7 +2,7 @@ import Alpine from 'alpinejs';
|
|||||||
|
|
||||||
import { logout, restoreSession, verifyConnection } from '../api/auth.js';
|
import { logout, restoreSession, verifyConnection } from '../api/auth.js';
|
||||||
import { listKitchens } from '../api/kitchens.js';
|
import { listKitchens } from '../api/kitchens.js';
|
||||||
import { APP_NAME } from './config.js';
|
import { APP_NAME, APP_VERSION } from './config.js';
|
||||||
import { createRouter, navigate } from './router.js';
|
import { createRouter, navigate } from './router.js';
|
||||||
import { createAppStore } from './store.js';
|
import { createAppStore } from './store.js';
|
||||||
import { appShell } from '../components/app-shell.js';
|
import { appShell } from '../components/app-shell.js';
|
||||||
@@ -30,7 +30,11 @@ export function bootstrapApp() {
|
|||||||
registerFeatureData(Alpine, store);
|
registerFeatureData(Alpine, store);
|
||||||
|
|
||||||
const appRoot = document.querySelector('#app');
|
const appRoot = document.querySelector('#app');
|
||||||
appRoot.innerHTML = appShell(APP_NAME);
|
appRoot.innerHTML = appShell(
|
||||||
|
APP_NAME,
|
||||||
|
APP_VERSION,
|
||||||
|
import.meta.env.DEV ? 'development' : 'production',
|
||||||
|
);
|
||||||
Alpine.initTree(appRoot);
|
Alpine.initTree(appRoot);
|
||||||
const navRoot = document.querySelector('#app-nav');
|
const navRoot = document.querySelector('#app-nav');
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
export const APP_NAME = 'Lonc';
|
export const APP_NAME = 'Lonc';
|
||||||
|
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.1.2';
|
||||||
export const TRYTON_APPLICATION = 'kitchen';
|
export const TRYTON_APPLICATION = 'kitchen';
|
||||||
|
|
||||||
export const CONNECTION_STATES = {
|
export const CONNECTION_STATES = {
|
||||||
@@ -24,8 +25,8 @@ export const API_PATHS = {
|
|||||||
userApplication: 'user/application/',
|
userApplication: 'user/application/',
|
||||||
kitchens: 'kitchen/kitchens',
|
kitchens: 'kitchen/kitchens',
|
||||||
items: 'kitchen/items',
|
items: 'kitchen/items',
|
||||||
stockEntries: 'stock',
|
|
||||||
locations: 'kitchen/locations',
|
locations: 'kitchen/locations',
|
||||||
|
changes: 'kitchen/changes',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { navBar } from './nav-bar.js';
|
import { navBar } from './nav-bar.js';
|
||||||
|
|
||||||
export function appShell(appName) {
|
export function appShell(appName, appVersion, runtimeMode) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="app-shell d-flex flex-column min-vh-100">
|
<div class="app-shell d-flex flex-column min-vh-100">
|
||||||
<div id="app-nav">
|
<div id="app-nav">
|
||||||
${navBar(appName)}
|
${navBar(appName)}
|
||||||
</div>
|
</div>
|
||||||
<main id="route-view" class="flex-grow-1"></main>
|
<main id="route-view" class="flex-grow-1"></main>
|
||||||
|
<footer class="app-footer border-top mt-auto py-3">
|
||||||
|
<div class="container-xxl d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-1 small">
|
||||||
|
<div>© ${currentYear} AKLARO</div>
|
||||||
|
<div class="text-body-secondary">
|
||||||
|
${appName} v${appVersion} • ${runtimeMode} mode • PWA frontend for Tryton kitchen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
<div class="toast-stack" x-data="alertsData()">
|
<div class="toast-stack" x-data="alertsData()">
|
||||||
<template x-for="alert in alerts" :key="alert.id">
|
<template x-for="alert in alerts" :key="alert.id">
|
||||||
<div class="toast show align-items-center border-0 mb-2 text-bg-dark" role="status">
|
<div class="toast show align-items-center border-0 mb-2 text-bg-dark" role="status">
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { fetchLocations } from '../../api/locations.js';
|
||||||
|
import { getStockEntry, listKitchenChanges } from '../../api/stock.js';
|
||||||
|
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||||
|
|
||||||
export function renderDashboardPage() {
|
export function renderDashboardPage() {
|
||||||
return `
|
return `
|
||||||
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()">
|
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()">
|
||||||
<div class="hero-card p-4 p-lg-5 mb-4">
|
<div class="hero-card p-4 p-lg-5 mb-4">
|
||||||
<div class="row align-items-center g-4">
|
<div class="row align-items-center g-4">
|
||||||
<div class="col-12 col-lg-7">
|
<div class="col-12 col-lg-7">
|
||||||
@@ -80,6 +84,52 @@ export function renderDashboardPage() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mt-1">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||||
|
<div>
|
||||||
|
<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">Saved means the backend created or updated a record.</p>
|
||||||
|
</div>
|
||||||
|
<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">Refreshing...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="changesState.error">
|
||||||
|
<div class="alert alert-warning mb-0" x-text="changesState.error"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!changesState.error && changesState.isLoading && !recentChanges.length">
|
||||||
|
<div class="text-body-secondary">Loading changes...</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="!changesState.error && !changesState.isLoading && !recentChanges.length">
|
||||||
|
<div class="text-body-secondary">No recent changes yet.</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="recentChanges.length">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<template x-for="(change, index) in recentChanges" :key="change.timestamp || index">
|
||||||
|
<div class="list-group-item px-0">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div class="fw-semibold" x-text="changeHeadline(change)"></div>
|
||||||
|
<div class="small text-body-secondary" x-text="formatChangeTimestamp(change.timestamp)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="small text-body-secondary mt-1" x-text="changeStateLine(change)"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -87,10 +137,191 @@ export function renderDashboardPage() {
|
|||||||
export function dashboardPageData(store) {
|
export function dashboardPageData(store) {
|
||||||
return {
|
return {
|
||||||
showKitchenPicker: false,
|
showKitchenPicker: false,
|
||||||
|
changesState: createAsyncState(),
|
||||||
|
recentChanges: [],
|
||||||
|
locationLabelByUuid: {},
|
||||||
|
itemByUuid: {},
|
||||||
|
async init() {
|
||||||
|
if (!store.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshChanges();
|
||||||
|
},
|
||||||
|
async refreshChanges() {
|
||||||
|
await runAsyncState(this.changesState, async () => {
|
||||||
|
const payload = await listKitchenChanges(store, { limit: 10 });
|
||||||
|
this.recentChanges = payload.changes;
|
||||||
|
await this.loadContextForChanges(payload.changes);
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
async loadContextForChanges(changes) {
|
||||||
|
const stockItemUuids = Array.from(new Set(
|
||||||
|
changes
|
||||||
|
.map((change) => change?.stock?.item_uuid_b64)
|
||||||
|
.filter(Boolean),
|
||||||
|
));
|
||||||
|
const missingItemUuids = stockItemUuids.filter((uuid) => !this.itemByUuid[uuid]);
|
||||||
|
|
||||||
|
if (missingItemUuids.length) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
missingItemUuids.map((uuid) => getStockEntry(store, uuid)),
|
||||||
|
);
|
||||||
|
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.status !== 'fulfilled' || !result.value?.uuid_b64) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemByUuid[result.value.uuid_b64] = result.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(this.locationLabelByUuid).length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { flat } = await fetchLocations(store);
|
||||||
|
this.locationLabelByUuid = Object.fromEntries(
|
||||||
|
flat
|
||||||
|
.filter((location) => location.uuid_b64)
|
||||||
|
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.locationLabelByUuid = {};
|
||||||
|
}
|
||||||
|
},
|
||||||
setKitchen(kitchen) {
|
setKitchen(kitchen) {
|
||||||
store.setActiveKitchen(kitchen);
|
store.setActiveKitchen(kitchen);
|
||||||
this.showKitchenPicker = false;
|
this.showKitchenPicker = false;
|
||||||
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
|
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
|
||||||
|
this.locationLabelByUuid = {};
|
||||||
|
this.itemByUuid = {};
|
||||||
|
this.refreshChanges();
|
||||||
|
},
|
||||||
|
resolveItemForChange(change) {
|
||||||
|
if (change?.item?.uuid_b64) {
|
||||||
|
return change.item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stockItemUuid = change?.stock?.item_uuid_b64;
|
||||||
|
if (!stockItemUuid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.itemByUuid[stockItemUuid] || null;
|
||||||
|
},
|
||||||
|
humanStockType(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
},
|
||||||
|
formatQuantity(quantity, uomSymbol) {
|
||||||
|
if (quantity === null || quantity === undefined || quantity === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${quantity}${uomSymbol ? ` ${uomSymbol}` : ''}`;
|
||||||
|
},
|
||||||
|
formatLevel(level) {
|
||||||
|
if (!level) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||||
|
},
|
||||||
|
formatShortDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
},
|
||||||
|
resolveLocationLabel(change, item) {
|
||||||
|
const locationUuid =
|
||||||
|
change?.stock?.location_uuid_b64 ||
|
||||||
|
item?.location_initial_uuid_b64 ||
|
||||||
|
null;
|
||||||
|
if (!locationUuid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.locationLabelByUuid[locationUuid] || locationUuid;
|
||||||
|
},
|
||||||
|
changeHeadline(change) {
|
||||||
|
const item = this.resolveItemForChange(change);
|
||||||
|
const itemName = item?.name || 'Unknown item';
|
||||||
|
const type = String(change?.type || 'change');
|
||||||
|
const action = String(change?.action || 'updated');
|
||||||
|
|
||||||
|
if (action === 'upsert' && type === 'item') {
|
||||||
|
return `Item saved: ${itemName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'upsert' && type === 'stock') {
|
||||||
|
return `Stock saved: ${itemName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${type} ${action}: ${itemName}`;
|
||||||
|
},
|
||||||
|
changeStateLine(change) {
|
||||||
|
const item = this.resolveItemForChange(change);
|
||||||
|
const stock = change?.stock || {};
|
||||||
|
const state = [];
|
||||||
|
|
||||||
|
const stockType = this.humanStockType(item?.stock_type);
|
||||||
|
if (stockType) {
|
||||||
|
state.push(`Type: ${stockType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = this.formatQuantity(
|
||||||
|
stock.quantity ?? item?.quantity,
|
||||||
|
stock.uom_symbol || item?.uom_symbol,
|
||||||
|
);
|
||||||
|
if (quantity) {
|
||||||
|
state.push(`Quantity: ${quantity}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = this.formatLevel(stock.level || item?.level);
|
||||||
|
if (level) {
|
||||||
|
state.push(`Level: ${level}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = this.formatShortDate(item?.expire_date);
|
||||||
|
if (expiry) {
|
||||||
|
state.push(`Expires: ${expiry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = this.resolveLocationLabel(change, item);
|
||||||
|
if (location) {
|
||||||
|
state.push(`Location: ${location}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.length) {
|
||||||
|
return 'Saved (created or updated).';
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.join(' • ');
|
||||||
|
},
|
||||||
|
formatChangeTimestamp(value) {
|
||||||
|
if (!value) {
|
||||||
|
return 'Unknown time';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { createStockEntry, searchItemDefinitions } from '../../api/stock.js';
|
import {
|
||||||
|
applyItemUpsert,
|
||||||
|
previewItemUpsert,
|
||||||
|
searchItemDefinitions,
|
||||||
|
} from '../../api/stock.js';
|
||||||
import { fetchLocations } from '../../api/locations.js';
|
import { fetchLocations } from '../../api/locations.js';
|
||||||
import { previewLabel } from '../../api/labels.js';
|
import {
|
||||||
|
formatPrintErrorMessage,
|
||||||
|
previewLabel,
|
||||||
|
printItemLabel,
|
||||||
|
} from '../../api/labels.js';
|
||||||
import { STORAGE_KEYS } from '../../app/config.js';
|
import { STORAGE_KEYS } from '../../app/config.js';
|
||||||
import { debounce, normalizeValidationError } from '../shared/form-utils.js';
|
import { debounce, normalizeValidationError } from '../shared/form-utils.js';
|
||||||
import { loadStoredValue, saveStoredValue } from '../shared/storage.js';
|
import { loadStoredValue, saveStoredValue } from '../shared/storage.js';
|
||||||
@@ -400,18 +408,36 @@ export function renderLabelCreatePage() {
|
|||||||
<div class="alert alert-success mb-0" x-text="successMessage"></div>
|
<div class="alert alert-success mb-0" x-text="successMessage"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<template x-if="upsertPreview?.mode === 'preview' && !upsertPreview.error">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="alert alert-info mb-0 py-2" x-text="upsertPreviewSummary()"></div>
|
||||||
<button class="btn btn-outline-primary" type="button" @click="preview()" :disabled="previewState.isLoading">
|
</template>
|
||||||
|
|
||||||
|
<template x-if="upsertPreview?.error">
|
||||||
|
<div class="alert alert-warning mb-0 py-2" x-text="upsertPreview.error"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="printIssue">
|
||||||
|
<div class="alert alert-warning mb-0 py-2" x-text="printIssue"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 label-actions-row">
|
||||||
|
<div class="d-flex flex-wrap gap-2 label-actions-primary">
|
||||||
|
<button class="btn btn-outline-primary label-action-btn" type="button" @click="preview()" :disabled="previewState.isLoading">
|
||||||
<span x-show="!previewState.isLoading">Preview label</span>
|
<span x-show="!previewState.isLoading">Preview label</span>
|
||||||
<span x-show="previewState.isLoading">Rendering preview...</span>
|
<span x-show="previewState.isLoading">Rendering preview...</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary" type="submit" :disabled="createState.isLoading">
|
<div class="input-group input-group-label-submit">
|
||||||
<span x-show="!createState.isLoading">Create stock entry</span>
|
<span class="input-group-text">
|
||||||
|
<input class="form-check-input mt-0 me-2" type="checkbox" x-model="printLabelOnSave" aria-label="Print label on save" />
|
||||||
|
Print
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-primary label-action-btn" type="submit" :disabled="createState.isLoading">
|
||||||
|
<span x-show="!createState.isLoading">Save stock entry</span>
|
||||||
<span x-show="createState.isLoading">Saving...</span>
|
<span x-show="createState.isLoading">Saving...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-outline-secondary" type="button" @click="reset()">Clear form</button>
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary label-action-btn" type="button" @click="reset()">Clear form</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-body-secondary">
|
<div class="small text-body-secondary">
|
||||||
<span class="text-danger">*</span> Required field
|
<span class="text-danger">*</span> Required field
|
||||||
@@ -478,6 +504,10 @@ function diffDays(fromIsoDate, toIsoDate) {
|
|||||||
function createDefaultForm() {
|
function createDefaultForm() {
|
||||||
return {
|
return {
|
||||||
itemId: '',
|
itemId: '',
|
||||||
|
itemUuidB64: '',
|
||||||
|
identifierCode: '',
|
||||||
|
externalSource: '',
|
||||||
|
externalId: '',
|
||||||
search: '',
|
search: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -505,6 +535,10 @@ function loadLabelDraft() {
|
|||||||
? ''
|
? ''
|
||||||
: draft.quantity,
|
: draft.quantity,
|
||||||
itemId: '',
|
itemId: '',
|
||||||
|
itemUuidB64: '',
|
||||||
|
identifierCode: '',
|
||||||
|
externalSource: '',
|
||||||
|
externalId: '',
|
||||||
search: '',
|
search: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -513,6 +547,10 @@ function buildDraftPayload(form) {
|
|||||||
return {
|
return {
|
||||||
...form,
|
...form,
|
||||||
itemId: '',
|
itemId: '',
|
||||||
|
itemUuidB64: '',
|
||||||
|
identifierCode: '',
|
||||||
|
externalSource: '',
|
||||||
|
externalId: '',
|
||||||
search: '',
|
search: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -536,6 +574,9 @@ export function labelCreatePageData(store) {
|
|||||||
successMessage: '',
|
successMessage: '',
|
||||||
submitError: '',
|
submitError: '',
|
||||||
fieldErrors: {},
|
fieldErrors: {},
|
||||||
|
upsertPreview: null,
|
||||||
|
printLabelOnSave: true,
|
||||||
|
printIssue: '',
|
||||||
form: {
|
form: {
|
||||||
...loadLabelDraft(),
|
...loadLabelDraft(),
|
||||||
},
|
},
|
||||||
@@ -590,6 +631,14 @@ export function labelCreatePageData(store) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSearchInput() {
|
onSearchInput() {
|
||||||
|
this.upsertPreview = null;
|
||||||
|
if (this.form.itemUuidB64 || this.form.itemId) {
|
||||||
|
this.form.itemId = '';
|
||||||
|
this.form.itemUuidB64 = '';
|
||||||
|
this.form.identifierCode = '';
|
||||||
|
this.form.externalSource = '';
|
||||||
|
this.form.externalId = '';
|
||||||
|
}
|
||||||
this.persistDraft();
|
this.persistDraft();
|
||||||
this.searchDebounced();
|
this.searchDebounced();
|
||||||
},
|
},
|
||||||
@@ -603,6 +652,10 @@ export function labelCreatePageData(store) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
this.form.itemId = item.id;
|
this.form.itemId = item.id;
|
||||||
|
this.form.itemUuidB64 = item.uuid_b64 || '';
|
||||||
|
this.form.identifierCode = item.identifier_code || '';
|
||||||
|
this.form.externalSource = item.external_source || '';
|
||||||
|
this.form.externalId = item.external_id || '';
|
||||||
this.form.search = item.name;
|
this.form.search = item.name;
|
||||||
this.form.name = item.name;
|
this.form.name = item.name;
|
||||||
this.form.description = item.description || this.form.description;
|
this.form.description = item.description || this.form.description;
|
||||||
@@ -623,7 +676,12 @@ export function labelCreatePageData(store) {
|
|||||||
},
|
},
|
||||||
clearItemSearch() {
|
clearItemSearch() {
|
||||||
this.form.itemId = '';
|
this.form.itemId = '';
|
||||||
|
this.form.itemUuidB64 = '';
|
||||||
|
this.form.identifierCode = '';
|
||||||
|
this.form.externalSource = '';
|
||||||
|
this.form.externalId = '';
|
||||||
this.form.search = '';
|
this.form.search = '';
|
||||||
|
this.upsertPreview = null;
|
||||||
this.suggestions = [];
|
this.suggestions = [];
|
||||||
this.persistDraft();
|
this.persistDraft();
|
||||||
},
|
},
|
||||||
@@ -908,6 +966,8 @@ export function labelCreatePageData(store) {
|
|||||||
: null
|
: null
|
||||||
: Number(this.form.quantity);
|
: Number(this.form.quantity);
|
||||||
|
|
||||||
|
const selectedLocationUuidB64 = this.selectedLocation?.uuid_b64 || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item_id: this.form.itemId || null,
|
item_id: this.form.itemId || null,
|
||||||
name: this.form.name.trim(),
|
name: this.form.name.trim(),
|
||||||
@@ -920,13 +980,56 @@ export function labelCreatePageData(store) {
|
|||||||
level: this.form.stockType === 'measured' ? null : this.form.level || null,
|
level: this.form.stockType === 'measured' ? null : this.form.level || null,
|
||||||
date: this.form.productionDate || null,
|
date: this.form.productionDate || null,
|
||||||
expire_date: this.form.expirationDate || null,
|
expire_date: this.form.expirationDate || null,
|
||||||
location_initial: this.form.locationId || null,
|
location_initial: selectedLocationUuidB64,
|
||||||
kitchen_id: store.activeKitchen?.id || null,
|
kitchen_id: store.activeKitchen?.id || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
buildUpsertPayload() {
|
||||||
|
const basePayload = this.buildPayload();
|
||||||
|
const itemPayload = {
|
||||||
|
name: basePayload.name,
|
||||||
|
description: basePayload.description,
|
||||||
|
quantity_initial: basePayload.quantity_initial,
|
||||||
|
uom_symbol: basePayload.uom_symbol,
|
||||||
|
calories: basePayload.calories,
|
||||||
|
calories_unit: basePayload.calories_unit,
|
||||||
|
stock_type: basePayload.stock_type,
|
||||||
|
level: basePayload.level,
|
||||||
|
date: basePayload.date,
|
||||||
|
expire_date: basePayload.expire_date,
|
||||||
|
location_initial: basePayload.location_initial,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid_b64: this.form.itemUuidB64 || null,
|
||||||
|
identifier_code: this.form.identifierCode || null,
|
||||||
|
external_source: this.form.externalSource || null,
|
||||||
|
external_id: this.form.externalId || null,
|
||||||
|
item: itemPayload,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
upsertPreviewSummary() {
|
||||||
|
if (!this.upsertPreview || this.upsertPreview.error) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.upsertPreview.mode !== 'preview') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.upsertPreview.operation === 'update') {
|
||||||
|
const name = this.upsertPreview.matchedItem?.name || this.form.name;
|
||||||
|
const matchType = this.upsertPreview.matchType ? ` (matched by ${this.upsertPreview.matchType})` : '';
|
||||||
|
return `Submit will update: ${name}${matchType}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Submit will create a new stock item.';
|
||||||
|
},
|
||||||
async preview() {
|
async preview() {
|
||||||
this.submitError = '';
|
this.submitError = '';
|
||||||
this.fieldErrors = {};
|
this.fieldErrors = {};
|
||||||
|
this.upsertPreview = null;
|
||||||
|
this.printIssue = '';
|
||||||
|
|
||||||
if (!this.validateBeforeSubmit()) {
|
if (!this.validateBeforeSubmit()) {
|
||||||
this.previewState.error = 'Please fill out the required fields before previewing the label.';
|
this.previewState.error = 'Please fill out the required fields before previewing the label.';
|
||||||
@@ -940,30 +1043,52 @@ export function labelCreatePageData(store) {
|
|||||||
URL.revokeObjectURL(this.previewUrl);
|
URL.revokeObjectURL(this.previewUrl);
|
||||||
}
|
}
|
||||||
this.previewUrl = result.objectUrl;
|
this.previewUrl = result.objectUrl;
|
||||||
|
try {
|
||||||
|
this.upsertPreview = await previewItemUpsert(store, this.buildUpsertPayload());
|
||||||
|
} catch (error) {
|
||||||
|
this.upsertPreview = {
|
||||||
|
error: error.message || 'Upsert preview failed.',
|
||||||
|
};
|
||||||
|
}
|
||||||
this.persistDraft();
|
this.persistDraft();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async create() {
|
async create() {
|
||||||
this.submitError = '';
|
this.submitError = '';
|
||||||
this.fieldErrors = {};
|
this.fieldErrors = {};
|
||||||
|
this.printIssue = '';
|
||||||
|
|
||||||
if (!this.validateBeforeSubmit()) {
|
if (!this.validateBeforeSubmit()) {
|
||||||
this.submitError = 'Please fill out the required fields before creating the stock entry.';
|
this.submitError = 'Please fill out the required fields before saving the stock entry.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await runAsyncState(this.createState, async () => {
|
await runAsyncState(this.createState, async () => {
|
||||||
try {
|
try {
|
||||||
const entry = await createStockEntry(store, this.buildPayload());
|
const entry = await applyItemUpsert(store, this.buildUpsertPayload());
|
||||||
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
|
const entryName = entry.item?.name || this.form.name;
|
||||||
URL.revokeObjectURL(this.previewUrl);
|
const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
|
||||||
|
const createdUuidB64 = entry.item?.uuid_b64 || null;
|
||||||
|
|
||||||
|
if (this.printLabelOnSave && createdUuidB64) {
|
||||||
|
try {
|
||||||
|
await printItemLabel(store, createdUuidB64);
|
||||||
|
} catch (printError) {
|
||||||
|
const parsedPrintMessage = formatPrintErrorMessage(printError);
|
||||||
|
this.printIssue = parsedPrintMessage;
|
||||||
|
store.addAlert({
|
||||||
|
type: 'warning',
|
||||||
|
message: `${entryName} was ${operationVerb}, but printing has an issue: ${parsedPrintMessage}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.previewUrl = '';
|
}
|
||||||
this.successMessage = `${entry.name || this.form.name} was created successfully.`;
|
|
||||||
|
this.successMessage = `${entryName} was ${operationVerb} successfully.`;
|
||||||
store.addAlert({
|
store.addAlert({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: `${entry.name || this.form.name} was created successfully.`,
|
message: `${entryName} was ${operationVerb} successfully.`,
|
||||||
});
|
});
|
||||||
|
this.upsertPreview = entry;
|
||||||
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.fieldErrors = normalizeValidationError(error);
|
this.fieldErrors = normalizeValidationError(error);
|
||||||
@@ -981,6 +1106,8 @@ export function labelCreatePageData(store) {
|
|||||||
this.successMessage = '';
|
this.successMessage = '';
|
||||||
this.submitError = '';
|
this.submitError = '';
|
||||||
this.fieldErrors = {};
|
this.fieldErrors = {};
|
||||||
|
this.upsertPreview = null;
|
||||||
|
this.printIssue = '';
|
||||||
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
|
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
|
||||||
if (revokePreview && this.previewUrl.startsWith('blob:')) {
|
if (revokePreview && this.previewUrl.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(this.previewUrl);
|
URL.revokeObjectURL(this.previewUrl);
|
||||||
|
|||||||
@@ -1,12 +1,88 @@
|
|||||||
import {
|
import {
|
||||||
adjustStockEntry,
|
adjustStockEntry,
|
||||||
deleteStockItem,
|
|
||||||
getStockEntry,
|
getStockEntry,
|
||||||
|
useStockItem,
|
||||||
} from '../../api/stock.js';
|
} from '../../api/stock.js';
|
||||||
|
import { formatPrintErrorMessage, printItemLabel } from '../../api/labels.js';
|
||||||
|
import { fetchLocations } from '../../api/locations.js';
|
||||||
import { getRouteContext } from '../../app/router.js';
|
import { getRouteContext } from '../../app/router.js';
|
||||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||||
import { formatDate } from '../shared/date-utils.js';
|
import { formatDate } from '../shared/date-utils.js';
|
||||||
|
|
||||||
|
function todayAtMidnight() {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateValue(value) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [year, month, day] = String(value).split('-').map(Number);
|
||||||
|
if (!year || !month || !day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expirationInfo(entry) {
|
||||||
|
if (!entry?.expire_date) {
|
||||||
|
return {
|
||||||
|
key: 'none',
|
||||||
|
label: 'No expiration date',
|
||||||
|
detail: 'No expiration date',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expireDate = parseDateValue(entry.expire_date);
|
||||||
|
const expireIn =
|
||||||
|
typeof entry.expire_in === 'number'
|
||||||
|
? entry.expire_in
|
||||||
|
: expireDate
|
||||||
|
? Math.round((expireDate - todayAtMidnight()) / (24 * 60 * 60 * 1000))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (expireIn === null) {
|
||||||
|
return {
|
||||||
|
key: 'none',
|
||||||
|
label: 'No expiration date',
|
||||||
|
detail: 'No expiration date',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expireIn < 0) {
|
||||||
|
return {
|
||||||
|
key: 'expired',
|
||||||
|
label: 'Expired',
|
||||||
|
detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expireIn <= 2) {
|
||||||
|
return {
|
||||||
|
key: 'use-first',
|
||||||
|
label: expireIn === 0 ? 'Use today' : 'Use first',
|
||||||
|
detail: expireIn === 0 ? 'Expires today' : `Expires in ${expireIn} day${expireIn === 1 ? '' : 's'}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expireIn <= 7) {
|
||||||
|
return {
|
||||||
|
key: 'upcoming',
|
||||||
|
label: 'Upcoming expiration',
|
||||||
|
detail: `Expires in ${expireIn} days`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: 'within-date',
|
||||||
|
label: 'Within date',
|
||||||
|
detail: `Expires in ${expireIn} days`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function renderStockDetailPage() {
|
export function renderStockDetailPage() {
|
||||||
return `
|
return `
|
||||||
<section class="container-xxl py-4 py-lg-5" x-data="stockDetailPage()" x-init="init()">
|
<section class="container-xxl py-4 py-lg-5" x-data="stockDetailPage()" x-init="init()">
|
||||||
@@ -42,14 +118,45 @@ export function renderStockDetailPage() {
|
|||||||
<dt class="col-5">Quantity</dt>
|
<dt class="col-5">Quantity</dt>
|
||||||
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
|
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
|
||||||
<dt class="col-5">Location</dt>
|
<dt class="col-5">Location</dt>
|
||||||
<dd class="col-7" x-text="entry.location_initial_uuid_b64 || 'Unassigned'"></dd>
|
<dd class="col-7" x-text="locationLabel(entry)"></dd>
|
||||||
<dt class="col-5">Production date</dt>
|
<dt class="col-5">Production date</dt>
|
||||||
<dd class="col-7" x-text="formatDate(entry.date)"></dd>
|
<dd class="col-7" x-text="formatDate(entry.date)"></dd>
|
||||||
<dt class="col-5">Expiration date</dt>
|
<dt class="col-5">Expiration date</dt>
|
||||||
<dd class="col-7" x-text="formatDate(entry.expire_date)"></dd>
|
<dd class="col-7" x-text="formatDate(entry.expire_date)"></dd>
|
||||||
|
<dt class="col-5">Expiration status</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="badge rounded-pill" :class="expirationBadgeClass(entry)" x-text="expirationFor(entry).label"></span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-body-secondary" x-text="expirationFor(entry).detail"></div>
|
||||||
|
</dd>
|
||||||
<dt class="col-5">Stock type</dt>
|
<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>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="h6 mb-3">Nutrition</h3>
|
||||||
|
<dl class="row mb-0 detail-grid">
|
||||||
|
<dt class="col-5">Nutri-Score</dt>
|
||||||
|
<dd class="col-7" x-text="nutriScoreLabel(entry)"></dd>
|
||||||
|
<dt class="col-5">Nutriments</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<template x-if="nutritionFactsRows(entry).length">
|
||||||
|
<ul class="list-unstyled mb-0 small d-grid gap-1">
|
||||||
|
<template x-for="fact in nutritionFactsRows(entry)" :key="fact.key">
|
||||||
|
<li>
|
||||||
|
<span class="text-body-secondary" x-text="fact.label + ':'"></span>
|
||||||
|
<span class="fw-semibold" x-text="fact.value"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<template x-if="!nutritionFactsRows(entry).length">
|
||||||
|
<span class="text-body-secondary">Not available</span>
|
||||||
|
</template>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,12 +190,23 @@ export function renderStockDetailPage() {
|
|||||||
<template x-if="adjustmentState.error">
|
<template x-if="adjustmentState.error">
|
||||||
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="printFeedback.message">
|
||||||
|
<div
|
||||||
|
class="alert mb-0"
|
||||||
|
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
||||||
|
x-text="printFeedback.message"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
||||||
<span x-show="!adjustmentState.isLoading">Save quantity</span>
|
<span x-show="!adjustmentState.isLoading">Save quantity</span>
|
||||||
<span x-show="adjustmentState.isLoading">Saving...</span>
|
<span x-show="adjustmentState.isLoading">Saving...</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
|
||||||
|
<span x-show="!printState.isLoading">Print label</span>
|
||||||
|
<span x-show="printState.isLoading">Printing...</span>
|
||||||
|
</button>
|
||||||
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
||||||
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
||||||
<span x-show="adjustmentState.isLoading">Removing...</span>
|
<span x-show="adjustmentState.isLoading">Removing...</span>
|
||||||
@@ -114,12 +232,23 @@ export function renderStockDetailPage() {
|
|||||||
<template x-if="adjustmentState.error">
|
<template x-if="adjustmentState.error">
|
||||||
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="printFeedback.message">
|
||||||
|
<div
|
||||||
|
class="alert mb-0"
|
||||||
|
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
||||||
|
x-text="printFeedback.message"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
||||||
<span x-show="!adjustmentState.isLoading">Save stock level</span>
|
<span x-show="!adjustmentState.isLoading">Save stock level</span>
|
||||||
<span x-show="adjustmentState.isLoading">Saving...</span>
|
<span x-show="adjustmentState.isLoading">Saving...</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="printLabel()" :disabled="printState.isLoading">
|
||||||
|
<span x-show="!printState.isLoading">Print label</span>
|
||||||
|
<span x-show="printState.isLoading">Printing...</span>
|
||||||
|
</button>
|
||||||
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
<button class="btn btn-outline-danger" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
||||||
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
||||||
<span x-show="adjustmentState.isLoading">Removing...</span>
|
<span x-show="adjustmentState.isLoading">Removing...</span>
|
||||||
@@ -137,11 +266,22 @@ export function renderStockDetailPage() {
|
|||||||
<template x-if="adjustmentState.error">
|
<template x-if="adjustmentState.error">
|
||||||
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||||
</template>
|
</template>
|
||||||
|
<template x-if="printFeedback.message">
|
||||||
|
<div
|
||||||
|
class="alert mb-0"
|
||||||
|
:class="printFeedback.type === 'success' ? 'alert-success' : 'alert-warning'"
|
||||||
|
x-text="printFeedback.message"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<button class="btn btn-outline-danger align-self-start" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
<button class="btn btn-outline-danger align-self-start" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
||||||
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
||||||
<span x-show="adjustmentState.isLoading">Removing...</span>
|
<span x-show="adjustmentState.isLoading">Removing...</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary align-self-start" type="button" @click="printLabel()" :disabled="printState.isLoading">
|
||||||
|
<span x-show="!printState.isLoading">Print label</span>
|
||||||
|
<span x-show="printState.isLoading">Printing...</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,7 +297,13 @@ export function stockDetailPageData(store) {
|
|||||||
return {
|
return {
|
||||||
state: createAsyncState(),
|
state: createAsyncState(),
|
||||||
adjustmentState: createAsyncState(),
|
adjustmentState: createAsyncState(),
|
||||||
|
printState: createAsyncState(),
|
||||||
|
printFeedback: {
|
||||||
|
type: '',
|
||||||
|
message: '',
|
||||||
|
},
|
||||||
entry: null,
|
entry: null,
|
||||||
|
locationPathByUuid: {},
|
||||||
adjustment: {
|
adjustment: {
|
||||||
mode: 'increment',
|
mode: 'increment',
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
@@ -170,7 +316,16 @@ export function stockDetailPageData(store) {
|
|||||||
|
|
||||||
const { params } = getRouteContext();
|
const { params } = getRouteContext();
|
||||||
await runAsyncState(this.state, async () => {
|
await runAsyncState(this.state, async () => {
|
||||||
this.entry = await getStockEntry(store, params.id);
|
const [entry, locations] = await Promise.all([
|
||||||
|
getStockEntry(store, params.id),
|
||||||
|
fetchLocations(store).catch(() => ({ flat: [] })),
|
||||||
|
]);
|
||||||
|
this.entry = entry;
|
||||||
|
this.locationPathByUuid = Object.fromEntries(
|
||||||
|
(locations.flat || [])
|
||||||
|
.filter((location) => location.uuid_b64)
|
||||||
|
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
|
||||||
|
);
|
||||||
this.adjustment.level = this.entry?.level || 'plenty';
|
this.adjustment.level = this.entry?.level || 'plenty';
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
@@ -207,7 +362,7 @@ export function stockDetailPageData(store) {
|
|||||||
await runAsyncState(this.adjustmentState, async () => {
|
await runAsyncState(this.adjustmentState, async () => {
|
||||||
if (this.adjustment.level === 'gone') {
|
if (this.adjustment.level === 'gone') {
|
||||||
const entryName = this.entry.name;
|
const entryName = this.entry.name;
|
||||||
await deleteStockItem(store, this.entry.uuid_b64);
|
await useStockItem(store, this.entry.uuid_b64);
|
||||||
store.addAlert({ type: 'success', message: `${entryName} was marked gone.` });
|
store.addAlert({ type: 'success', message: `${entryName} was marked gone.` });
|
||||||
window.__loncApp.navigate('/stock');
|
window.__loncApp.navigate('/stock');
|
||||||
return;
|
return;
|
||||||
@@ -225,16 +380,155 @@ export function stockDetailPageData(store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await runAsyncState(this.adjustmentState, async () => {
|
await runAsyncState(this.adjustmentState, async () => {
|
||||||
await deleteStockItem(store, this.entry.uuid_b64);
|
const result = await useStockItem(store, this.entry.uuid_b64);
|
||||||
store.addAlert({ type: 'success', message: `${this.entry.name} was marked gone.` });
|
const alreadyGone = result.status === 'already_gone';
|
||||||
|
store.addAlert({
|
||||||
|
type: alreadyGone ? 'info' : 'success',
|
||||||
|
message: alreadyGone
|
||||||
|
? `${this.entry.name} was already out of stock.`
|
||||||
|
: `${this.entry.name} was marked gone.`,
|
||||||
|
});
|
||||||
window.__loncApp.navigate('/stock');
|
window.__loncApp.navigate('/stock');
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
|
async printLabel() {
|
||||||
|
if (!this.entry?.uuid_b64) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.printFeedback = {
|
||||||
|
type: '',
|
||||||
|
message: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await runAsyncState(this.printState, async () => {
|
||||||
|
try {
|
||||||
|
await printItemLabel(store, this.entry.uuid_b64);
|
||||||
|
this.printFeedback = {
|
||||||
|
type: 'success',
|
||||||
|
message: 'Label printed successfully.',
|
||||||
|
};
|
||||||
|
store.addAlert({
|
||||||
|
type: 'success',
|
||||||
|
message: `${this.entry.name} label sent to printer.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const parsed = formatPrintErrorMessage(error);
|
||||||
|
this.printFeedback = {
|
||||||
|
type: 'warning',
|
||||||
|
message: parsed,
|
||||||
|
};
|
||||||
|
store.addAlert({
|
||||||
|
type: 'warning',
|
||||||
|
message: `Could not print ${this.entry.name} label: ${parsed}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
quickAdjust(step) {
|
quickAdjust(step) {
|
||||||
const current = Number(this.adjustment.quantity || 0);
|
const current = Number(this.adjustment.quantity || 0);
|
||||||
this.adjustment.quantity = String(Math.max(current + step, 0));
|
this.adjustment.quantity = String(Math.max(current + step, 0));
|
||||||
},
|
},
|
||||||
formatDate,
|
formatDate,
|
||||||
|
expirationFor(entry) {
|
||||||
|
return expirationInfo(entry);
|
||||||
|
},
|
||||||
|
expirationBadgeClass(entry) {
|
||||||
|
const key = this.expirationFor(entry).key;
|
||||||
|
if (key === 'expired') {
|
||||||
|
return 'text-bg-danger';
|
||||||
|
}
|
||||||
|
if (key === 'use-first') {
|
||||||
|
return 'text-bg-warning';
|
||||||
|
}
|
||||||
|
if (key === 'upcoming') {
|
||||||
|
return 'text-bg-secondary';
|
||||||
|
}
|
||||||
|
if (key === 'within-date') {
|
||||||
|
return 'text-bg-success';
|
||||||
|
}
|
||||||
|
return 'text-bg-light border';
|
||||||
|
},
|
||||||
|
locationLabel(entry) {
|
||||||
|
const locationUuid = entry?.location_initial_uuid_b64;
|
||||||
|
if (!locationUuid) {
|
||||||
|
return 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.locationPathByUuid[locationUuid] || 'Location not resolved';
|
||||||
|
},
|
||||||
|
nutriScoreLabel(entry) {
|
||||||
|
const value = entry?.nutriscore_grade;
|
||||||
|
if (!value) {
|
||||||
|
return 'Not available';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value).toUpperCase();
|
||||||
|
},
|
||||||
|
nutritionFactsRows(entry) {
|
||||||
|
const facts = entry?.nutrition_facts;
|
||||||
|
if (!facts || typeof facts !== 'object' || Array.isArray(facts)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredOrder = [
|
||||||
|
'per',
|
||||||
|
'serving_size',
|
||||||
|
'energy_kj',
|
||||||
|
'energy_kcal',
|
||||||
|
'fat',
|
||||||
|
'saturated_fat',
|
||||||
|
'carbohydrates',
|
||||||
|
'sugars',
|
||||||
|
'fibers',
|
||||||
|
'proteins',
|
||||||
|
'salt',
|
||||||
|
'sodium',
|
||||||
|
];
|
||||||
|
const rankByKey = new Map(preferredOrder.map((key, index) => [key, index]));
|
||||||
|
|
||||||
|
return Object.entries(facts)
|
||||||
|
.sort(([leftKey], [rightKey]) => {
|
||||||
|
const leftRank = rankByKey.has(leftKey) ? rankByKey.get(leftKey) : Number.POSITIVE_INFINITY;
|
||||||
|
const rightRank = rankByKey.has(rightKey) ? rankByKey.get(rightKey) : Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
if (leftRank !== rightRank) {
|
||||||
|
return leftRank - rightRank;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftKey.localeCompare(rightKey);
|
||||||
|
})
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: this.nutritionLabel(key),
|
||||||
|
value: this.formatNutritionValue(value),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
nutritionLabel(key) {
|
||||||
|
const labels = {
|
||||||
|
per: 'Per',
|
||||||
|
serving_size: 'Serving size',
|
||||||
|
energy_kj: 'Energy (kJ)',
|
||||||
|
energy_kcal: 'Energy (kcal)',
|
||||||
|
fat: 'Fat',
|
||||||
|
saturated_fat: 'Saturated fat',
|
||||||
|
carbohydrates: 'Carbohydrates',
|
||||||
|
sugars: 'Sugars',
|
||||||
|
fibers: 'Fibers',
|
||||||
|
proteins: 'Proteins',
|
||||||
|
salt: 'Salt',
|
||||||
|
sodium: 'Sodium',
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[key] || key.replace(/_/g, ' ');
|
||||||
|
},
|
||||||
|
formatNutritionValue(value) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return 'n/a';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
},
|
||||||
formatQuantity(entry) {
|
formatQuantity(entry) {
|
||||||
return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
|
return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
deleteStockItem,
|
|
||||||
listGroupedStockEntries,
|
listGroupedStockEntries,
|
||||||
listStockEntries,
|
listStockEntries,
|
||||||
updateStockItem,
|
updateStockItem,
|
||||||
|
useStockItem,
|
||||||
} from '../../api/stock.js';
|
} from '../../api/stock.js';
|
||||||
import { fetchLocations } from '../../api/locations.js';
|
import { fetchLocations } from '../../api/locations.js';
|
||||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||||
@@ -660,7 +660,7 @@ export function renderStockListPage() {
|
|||||||
|
|
||||||
<div class="grouped-stock-items">
|
<div class="grouped-stock-items">
|
||||||
<template x-for="item in group.items" :key="item.id">
|
<template x-for="item in group.items" :key="item.id">
|
||||||
<a class="grouped-stock-item text-decoration-none" :class="groupedItemClass(item)" :href="detailHref(item)">
|
<div class="grouped-stock-item" :class="groupedItemClass(item)">
|
||||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-2">
|
<div class="d-flex flex-column flex-lg-row justify-content-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold" x-text="item.name"></div>
|
<div class="fw-semibold" x-text="item.name"></div>
|
||||||
@@ -669,6 +669,7 @@ export function renderStockListPage() {
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="small text-decoration-none fw-semibold" :href="detailHref(item)">View item</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-3 small text-body-secondary grouped-stock-item-meta">
|
<div class="d-flex flex-wrap gap-3 small text-body-secondary grouped-stock-item-meta">
|
||||||
<span x-text="quantityLabel(item)"></span>
|
<span x-text="quantityLabel(item)"></span>
|
||||||
@@ -678,9 +679,13 @@ export function renderStockListPage() {
|
|||||||
<span x-text="formatDate(item.expire_date)"></span>
|
<span x-text="formatDate(item.expire_date)"></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="font-monospace" x-text="shortId(item)"></span>
|
<span class="font-monospace" x-text="shortId(item)"></span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger grouped-stock-mark-gone" type="button" @click="markGoneFromGroup(item, group)">Mark gone</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
<template x-if="editErrors[item.id]">
|
||||||
|
<div class="small text-danger mt-2" x-text="editErrors[item.id]"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1204,12 +1209,12 @@ export function stockListPageData(store) {
|
|||||||
},
|
},
|
||||||
formatDate,
|
formatDate,
|
||||||
async updateBinary(entry, level) {
|
async updateBinary(entry, level) {
|
||||||
await this.deleteEntry(entry);
|
await this.useEntry(entry);
|
||||||
},
|
},
|
||||||
async saveLevel(entry) {
|
async saveLevel(entry) {
|
||||||
const level = this.editForms[entry.id]?.level || 'plenty';
|
const level = this.editForms[entry.id]?.level || 'plenty';
|
||||||
if (level === 'gone') {
|
if (level === 'gone') {
|
||||||
await this.deleteEntry(entry);
|
await this.useEntry(entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1229,7 +1234,27 @@ export function stockListPageData(store) {
|
|||||||
}, { quantity });
|
}, { quantity });
|
||||||
},
|
},
|
||||||
async markGone(entry) {
|
async markGone(entry) {
|
||||||
await this.deleteEntry(entry);
|
await this.useEntry(entry);
|
||||||
|
},
|
||||||
|
async markGoneFromGroup(item, group) {
|
||||||
|
this.editErrors[item.id] = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await useStockItem(store, item.uuid_b64);
|
||||||
|
const alreadyGone = result.status === 'already_gone';
|
||||||
|
this.removeGroupedItem(group.id, item.id);
|
||||||
|
this.entries = this.entries.filter((candidate) => candidate.id !== item.id);
|
||||||
|
delete this.editForms[item.id];
|
||||||
|
delete this.editErrors[item.id];
|
||||||
|
store.addAlert({
|
||||||
|
type: alreadyGone ? 'info' : 'success',
|
||||||
|
message: alreadyGone
|
||||||
|
? `${item.name} was already out of stock and removed from the group.`
|
||||||
|
: `${item.name} was marked gone and removed from the group.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.editErrors[item.id] = error.message || 'Mark gone failed.';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async saveEntryUpdate(entry, payload, localPatch) {
|
async saveEntryUpdate(entry, payload, localPatch) {
|
||||||
this.editErrors[entry.id] = '';
|
this.editErrors[entry.id] = '';
|
||||||
@@ -1245,20 +1270,23 @@ export function stockListPageData(store) {
|
|||||||
this.editErrors[entry.id] = error.message || 'Update failed.';
|
this.editErrors[entry.id] = error.message || 'Update failed.';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async deleteEntry(entry) {
|
async useEntry(entry) {
|
||||||
this.editErrors[entry.id] = '';
|
this.editErrors[entry.id] = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteStockItem(store, entry.uuid_b64);
|
const result = await useStockItem(store, entry.uuid_b64);
|
||||||
this.entries = this.entries.filter((candidate) => candidate.id !== entry.id);
|
this.entries = this.entries.filter((candidate) => candidate.id !== entry.id);
|
||||||
delete this.editForms[entry.id];
|
delete this.editForms[entry.id];
|
||||||
delete this.editErrors[entry.id];
|
delete this.editErrors[entry.id];
|
||||||
|
const alreadyGone = result.status === 'already_gone';
|
||||||
store.addAlert({
|
store.addAlert({
|
||||||
type: 'success',
|
type: alreadyGone ? 'info' : 'success',
|
||||||
message: `${entry.name} was marked gone and removed from the list.`,
|
message: alreadyGone
|
||||||
|
? `${entry.name} was already out of stock and removed from the list.`
|
||||||
|
: `${entry.name} was marked gone and removed from the list.`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.editErrors[entry.id] = error.message || 'Delete failed.';
|
this.editErrors[entry.id] = error.message || 'Mark gone failed.';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
replaceEntry(entryId, nextEntry) {
|
replaceEntry(entryId, nextEntry) {
|
||||||
@@ -1270,5 +1298,24 @@ export function stockListPageData(store) {
|
|||||||
quantity: nextEntry.quantity ?? '',
|
quantity: nextEntry.quantity ?? '',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
removeGroupedItem(groupId, itemId) {
|
||||||
|
this.groupedEntries = this.groupedEntries
|
||||||
|
.map((group) => {
|
||||||
|
if (group.id !== groupId) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = (group.items || []).filter((candidate) => candidate.id !== itemId);
|
||||||
|
if (!nextItems.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
items: nextItems,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,45 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-label-submit .input-group-text {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-actions-row .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-label-submit {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-label-submit .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-action-btn {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.label-actions-row {
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-actions-primary {
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -642,6 +681,11 @@ button.legend-card:focus-visible {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grouped-stock-mark-gone {
|
||||||
|
align-self: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.grouped-stock-item-subline {
|
.grouped-stock-item-subline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -48,17 +48,15 @@ describe('api/client', () => {
|
|||||||
it('returns configured path constants', () => {
|
it('returns configured path constants', () => {
|
||||||
expect(getPath('items')).toBe('kitchen/items');
|
expect(getPath('items')).toBe('kitchen/items');
|
||||||
expect(getPath('userApplication')).toBe('user/application/');
|
expect(getPath('userApplication')).toBe('user/application/');
|
||||||
|
expect(getPath('changes')).toBe('kitchen/changes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds kitchen urls with encoded path segments and query values', () => {
|
it('builds database-scoped kitchen urls with encoded query values', () => {
|
||||||
const store = createStore({
|
const store = createStore({
|
||||||
config: {
|
config: {
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
database: 'my db',
|
database: 'my db',
|
||||||
},
|
},
|
||||||
activeKitchen: {
|
|
||||||
id: 'kitchen/01',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
|
const url = buildKitchenApiUrl(store, 'kitchen/items/grouped', {
|
||||||
@@ -68,7 +66,7 @@ describe('api/client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(url).toBe(
|
expect(url).toBe(
|
||||||
'https://api.example.com/my%20db/kitchen/kitchen%2F01/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=1',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +95,7 @@ describe('api/client', () => {
|
|||||||
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/kitchen-1/kitchen/items?label=1');
|
expect(url).toBe('/kitchen-db/kitchen/items?label=1');
|
||||||
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');
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { formatPrintErrorMessage } from '../../src/api/labels.js';
|
||||||
|
|
||||||
|
describe('api/labels formatPrintErrorMessage', () => {
|
||||||
|
it('maps printer_unavailable payload to user-friendly message', () => {
|
||||||
|
const message = formatPrintErrorMessage({
|
||||||
|
status: 503,
|
||||||
|
payload: {
|
||||||
|
code: 'printer_unavailable',
|
||||||
|
message: 'Backend says unavailable',
|
||||||
|
details: { printer: 'Office Zebra' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(message).toBe('Printer is unavailable. (printer: Office Zebra)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to generic message when payload is missing', () => {
|
||||||
|
const message = formatPrintErrorMessage(new Error('Something failed'));
|
||||||
|
expect(message).toBe('Something failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const apiRequestMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../src/api/client.js', () => ({
|
||||||
|
getPath(key) {
|
||||||
|
const paths = {
|
||||||
|
items: 'kitchen/items',
|
||||||
|
changes: 'kitchen/changes',
|
||||||
|
};
|
||||||
|
return paths[key];
|
||||||
|
},
|
||||||
|
apiRequest: (...args) => apiRequestMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
applyItemUpsert,
|
||||||
|
listKitchenChanges,
|
||||||
|
previewItemUpsert,
|
||||||
|
useStockItem,
|
||||||
|
} = await import('../../src/api/stock.js');
|
||||||
|
|
||||||
|
describe('api/stock', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiRequestMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listKitchenChanges returns normalized changes payload', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
|
since: 'cursor-1',
|
||||||
|
next_cursor: 'cursor-2',
|
||||||
|
changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await listKitchenChanges({ config: { database: 'db' } }, { limit: 10 });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
since: 'cursor-1',
|
||||||
|
nextCursor: 'cursor-2',
|
||||||
|
changes: [{ type: 'item', action: 'upsert', timestamp: '2026-04-10T10:00:00Z' }],
|
||||||
|
});
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/changes',
|
||||||
|
{ query: { since: undefined, limit: 10 } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listKitchenChanges falls back to empty shape when changes are missing', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
const result = await listKitchenChanges({ config: { database: 'db' } }, {});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
since: null,
|
||||||
|
nextCursor: null,
|
||||||
|
changes: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previewItemUpsert normalizes preview response', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
|
status: 'ok',
|
||||||
|
mode: 'preview',
|
||||||
|
operation: 'update',
|
||||||
|
match_type: 'uuid_b64',
|
||||||
|
matched_item: { uuid_b64: 'abc', name: 'Rice' },
|
||||||
|
payload: { name: 'Rice' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await previewItemUpsert({ config: { database: 'db' } }, { item: { name: 'Rice' } });
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
mode: 'preview',
|
||||||
|
operation: 'update',
|
||||||
|
matchType: 'uuid_b64',
|
||||||
|
matchedItem: { uuid_b64: 'abc', name: 'Rice' },
|
||||||
|
item: null,
|
||||||
|
payload: { name: 'Rice' },
|
||||||
|
});
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/upsert',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { item: { name: 'Rice' } },
|
||||||
|
query: { mode: 'preview' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyItemUpsert normalizes apply response', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce({
|
||||||
|
status: 'ok',
|
||||||
|
mode: 'apply',
|
||||||
|
operation: 'create',
|
||||||
|
match_type: null,
|
||||||
|
item: { uuid_b64: 'new1', name: 'Beans' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await applyItemUpsert({ config: { database: 'db' } }, { item: { name: 'Beans' } });
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
status: 'ok',
|
||||||
|
mode: 'apply',
|
||||||
|
operation: 'create',
|
||||||
|
matchType: null,
|
||||||
|
matchedItem: null,
|
||||||
|
item: { uuid_b64: 'new1', name: 'Beans' },
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useStockItem returns used on 204', async () => {
|
||||||
|
apiRequestMock.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'used' });
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledWith(
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1/use',
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useStockItem returns already_gone on 409', async () => {
|
||||||
|
apiRequestMock.mockRejectedValueOnce({ status: 409, message: 'Item is out of stock.' });
|
||||||
|
|
||||||
|
const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'already_gone' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useStockItem falls back to delete on 404/405', async () => {
|
||||||
|
apiRequestMock
|
||||||
|
.mockRejectedValueOnce({ status: 404 })
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const result = await useStockItem({ config: { database: 'db' } }, 'item-1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'fallback_delete' });
|
||||||
|
expect(apiRequestMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
{ config: { database: 'db' } },
|
||||||
|
'kitchen/items/item-1',
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('useStockItem does not fallback on unrelated client errors', async () => {
|
||||||
|
apiRequestMock.mockRejectedValueOnce({ status: 422, message: 'validation_error' });
|
||||||
|
|
||||||
|
await expect(useStockItem({ config: { database: 'db' } }, 'item-1')).rejects.toMatchObject({
|
||||||
|
status: 422,
|
||||||
|
message: 'validation_error',
|
||||||
|
});
|
||||||
|
expect(apiRequestMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const listKitchenChangesMock = vi.fn();
|
||||||
|
const getStockEntryMock = vi.fn();
|
||||||
|
const fetchLocationsMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/stock.js', () => ({
|
||||||
|
listKitchenChanges: (...args) => listKitchenChangesMock(...args),
|
||||||
|
getStockEntry: (...args) => getStockEntryMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/locations.js', () => ({
|
||||||
|
fetchLocations: (...args) => fetchLocationsMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { dashboardPageData, renderDashboardPage } = await import('../../../src/features/dashboard/dashboard-page.js');
|
||||||
|
|
||||||
|
describe('features/dashboard/dashboard-page', () => {
|
||||||
|
it('renders dashboard with recent changes section', () => {
|
||||||
|
const html = renderDashboardPage();
|
||||||
|
expect(html).toContain('Recent changes');
|
||||||
|
expect(html).toContain('x-data="dashboardPage()"');
|
||||||
|
expect(html).toContain('Saved means the backend created or updated a record.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads recent changes on init and renders item-focused state lines', async () => {
|
||||||
|
listKitchenChangesMock.mockResolvedValueOnce({
|
||||||
|
since: null,
|
||||||
|
nextCursor: null,
|
||||||
|
changes: [{
|
||||||
|
type: 'item',
|
||||||
|
action: 'upsert',
|
||||||
|
timestamp: '2026-04-10T10:00:00Z',
|
||||||
|
item: {
|
||||||
|
uuid_b64: 'u1',
|
||||||
|
name: 'Rice',
|
||||||
|
stock_type: 'measured',
|
||||||
|
quantity: 3,
|
||||||
|
uom_symbol: 'kg',
|
||||||
|
level: 'good',
|
||||||
|
expire_date: '2026-04-21',
|
||||||
|
location_initial_uuid_b64: 'loc1',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
fetchLocationsMock.mockResolvedValueOnce({
|
||||||
|
flat: [{ uuid_b64: 'loc1', pathLabel: 'Pantry / Shelf A' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
isConnected: true,
|
||||||
|
setActiveKitchen: vi.fn(),
|
||||||
|
addAlert: vi.fn(),
|
||||||
|
};
|
||||||
|
const data = dashboardPageData(store);
|
||||||
|
|
||||||
|
await data.init();
|
||||||
|
|
||||||
|
expect(listKitchenChangesMock).toHaveBeenCalledWith(store, { limit: 10 });
|
||||||
|
expect(data.recentChanges).toHaveLength(1);
|
||||||
|
expect(data.changesState.error).toBe('');
|
||||||
|
expect(data.changeHeadline(data.recentChanges[0])).toBe('Item saved: Rice');
|
||||||
|
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 3 kg');
|
||||||
|
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Shelf A');
|
||||||
|
expect(getStockEntryMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves stock event item context via item lookup when needed', async () => {
|
||||||
|
listKitchenChangesMock.mockResolvedValueOnce({
|
||||||
|
since: null,
|
||||||
|
nextCursor: null,
|
||||||
|
changes: [{
|
||||||
|
type: 'stock',
|
||||||
|
action: 'upsert',
|
||||||
|
timestamp: '2026-04-10T10:00:00Z',
|
||||||
|
stock: {
|
||||||
|
item_uuid_b64: 'item-uuid-1',
|
||||||
|
quantity: 0.5,
|
||||||
|
uom_symbol: 'kg',
|
||||||
|
level: 'some',
|
||||||
|
location_uuid_b64: 'loc2',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
getStockEntryMock.mockResolvedValueOnce({
|
||||||
|
uuid_b64: 'item-uuid-1',
|
||||||
|
name: 'Flour',
|
||||||
|
stock_type: 'measured',
|
||||||
|
expire_date: '2026-05-02',
|
||||||
|
});
|
||||||
|
fetchLocationsMock.mockResolvedValueOnce({
|
||||||
|
flat: [{ uuid_b64: 'loc2', pathLabel: 'Pantry / Bin 2' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = dashboardPageData({
|
||||||
|
isConnected: true,
|
||||||
|
setActiveKitchen: vi.fn(),
|
||||||
|
addAlert: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await data.refreshChanges();
|
||||||
|
|
||||||
|
expect(data.changeHeadline(data.recentChanges[0])).toBe('Stock saved: Flour');
|
||||||
|
expect(data.changeStateLine(data.recentChanges[0])).toContain('Quantity: 0.5 kg');
|
||||||
|
expect(data.changeStateLine(data.recentChanges[0])).toContain('Level: Some');
|
||||||
|
expect(data.changeStateLine(data.recentChanges[0])).toContain('Location: Pantry / Bin 2');
|
||||||
|
expect(getStockEntryMock).toHaveBeenCalledWith(expect.anything(), 'item-uuid-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps empty state when API returns no changes', async () => {
|
||||||
|
listKitchenChangesMock.mockResolvedValueOnce({
|
||||||
|
since: null,
|
||||||
|
nextCursor: null,
|
||||||
|
changes: [],
|
||||||
|
});
|
||||||
|
fetchLocationsMock.mockResolvedValueOnce({ flat: [] });
|
||||||
|
|
||||||
|
const data = dashboardPageData({
|
||||||
|
isConnected: true,
|
||||||
|
setActiveKitchen: vi.fn(),
|
||||||
|
addAlert: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await data.refreshChanges();
|
||||||
|
|
||||||
|
expect(data.recentChanges).toEqual([]);
|
||||||
|
expect(data.changesState.error).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures refresh errors in async state', async () => {
|
||||||
|
listKitchenChangesMock.mockRejectedValueOnce(new Error('Feed unavailable'));
|
||||||
|
const data = dashboardPageData({
|
||||||
|
isConnected: true,
|
||||||
|
setActiveKitchen: vi.fn(),
|
||||||
|
addAlert: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await data.refreshChanges();
|
||||||
|
|
||||||
|
expect(data.changesState.error).toBe('Feed unavailable');
|
||||||
|
expect(data.recentChanges).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const applyItemUpsertMock = vi.fn();
|
||||||
|
const previewItemUpsertMock = vi.fn();
|
||||||
|
const printItemLabelMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/stock.js', () => ({
|
||||||
|
applyItemUpsert: (...args) => applyItemUpsertMock(...args),
|
||||||
|
previewItemUpsert: (...args) => previewItemUpsertMock(...args),
|
||||||
|
searchItemDefinitions: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/labels.js', () => ({
|
||||||
|
previewLabel: vi.fn(async () => ({ objectUrl: 'blob:preview' })),
|
||||||
|
printItemLabel: (...args) => printItemLabelMock(...args),
|
||||||
|
formatPrintErrorMessage: (error) => error?.message || 'Printing failed.',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/locations.js', () => ({
|
||||||
|
fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { labelCreatePageData } = await import('../../../src/features/labels/label-create-page.js');
|
||||||
|
|
||||||
|
describe('label create upsert-first submit', () => {
|
||||||
|
it('defaults print checkbox to enabled', () => {
|
||||||
|
const data = labelCreatePageData({
|
||||||
|
isConnected: false,
|
||||||
|
activeKitchen: { id: 1 },
|
||||||
|
addAlert: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(data.printLabelOnSave).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds upsert payload with selected template uuid', () => {
|
||||||
|
const store = {
|
||||||
|
isConnected: false,
|
||||||
|
activeKitchen: { id: 7 },
|
||||||
|
addAlert: vi.fn(),
|
||||||
|
};
|
||||||
|
const data = labelCreatePageData(store);
|
||||||
|
data.form = {
|
||||||
|
...data.form,
|
||||||
|
itemUuidB64: 'uuid-template-1',
|
||||||
|
name: 'Beans',
|
||||||
|
description: 'Dry beans',
|
||||||
|
stockType: 'measured',
|
||||||
|
quantity: '2',
|
||||||
|
uom: 'kg',
|
||||||
|
level: '',
|
||||||
|
productionDate: '2026-04-10',
|
||||||
|
expirationDate: '2026-08-10',
|
||||||
|
locationId: '',
|
||||||
|
identifierCode: '12345',
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = data.buildUpsertPayload();
|
||||||
|
|
||||||
|
expect(payload.uuid_b64).toBe('uuid-template-1');
|
||||||
|
expect(payload.identifier_code).toBe('12345');
|
||||||
|
expect(payload.item.name).toBe('Beans');
|
||||||
|
expect(payload.item.quantity_initial).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create uses applyItemUpsert and sets operation-aware success message', async () => {
|
||||||
|
applyItemUpsertMock.mockResolvedValueOnce({
|
||||||
|
operation: 'update',
|
||||||
|
item: { name: 'Rice', uuid_b64: 'uuid-rice-1' },
|
||||||
|
});
|
||||||
|
printItemLabelMock.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const addAlert = vi.fn();
|
||||||
|
const store = {
|
||||||
|
isConnected: false,
|
||||||
|
activeKitchen: { id: 3 },
|
||||||
|
addAlert,
|
||||||
|
};
|
||||||
|
const data = labelCreatePageData(store);
|
||||||
|
data.validateBeforeSubmit = () => true;
|
||||||
|
data.form = {
|
||||||
|
...data.form,
|
||||||
|
name: 'Rice',
|
||||||
|
stockType: 'binary',
|
||||||
|
locationId: '',
|
||||||
|
productionDate: '2026-04-10',
|
||||||
|
itemUuidB64: 'uuid-rice-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
await data.create();
|
||||||
|
|
||||||
|
expect(applyItemUpsertMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(applyItemUpsertMock.mock.calls[0][1].uuid_b64).toBe('uuid-rice-1');
|
||||||
|
expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-rice-1');
|
||||||
|
expect(data.successMessage).toBe('Rice was updated successfully.');
|
||||||
|
expect(addAlert).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Rice was updated successfully.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create shows parsed print issue warning when printing fails', async () => {
|
||||||
|
applyItemUpsertMock.mockResolvedValueOnce({
|
||||||
|
operation: 'create',
|
||||||
|
item: { name: 'Beans', uuid_b64: 'uuid-beans-1' },
|
||||||
|
});
|
||||||
|
printItemLabelMock.mockRejectedValueOnce(new Error('Printer is unavailable.'));
|
||||||
|
|
||||||
|
const addAlert = vi.fn();
|
||||||
|
const store = {
|
||||||
|
isConnected: false,
|
||||||
|
activeKitchen: { id: 3 },
|
||||||
|
addAlert,
|
||||||
|
};
|
||||||
|
const data = labelCreatePageData(store);
|
||||||
|
data.validateBeforeSubmit = () => true;
|
||||||
|
data.form = {
|
||||||
|
...data.form,
|
||||||
|
name: 'Beans',
|
||||||
|
stockType: 'binary',
|
||||||
|
locationId: '',
|
||||||
|
productionDate: '2026-04-10',
|
||||||
|
itemUuidB64: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await data.create();
|
||||||
|
|
||||||
|
expect(data.printIssue).toBe('Printer is unavailable.');
|
||||||
|
expect(addAlert).toHaveBeenCalledWith({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Beans was created, but printing has an issue: Printer is unavailable.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const useStockItemMock = vi.fn();
|
||||||
|
const getStockEntryMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/stock.js', () => ({
|
||||||
|
useStockItem: (...args) => useStockItemMock(...args),
|
||||||
|
getStockEntry: (...args) => getStockEntryMock(...args),
|
||||||
|
adjustStockEntry: vi.fn(),
|
||||||
|
listStockEntries: vi.fn(),
|
||||||
|
listGroupedStockEntries: vi.fn(),
|
||||||
|
updateStockItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/locations.js', () => ({
|
||||||
|
fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js');
|
||||||
|
const { stockListPageData } = await import('../../../src/features/stock/stock-list-page.js');
|
||||||
|
|
||||||
|
describe('stock mark-gone behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useStockItemMock.mockReset();
|
||||||
|
getStockEntryMock.mockReset();
|
||||||
|
globalThis.window = {
|
||||||
|
__loncApp: {
|
||||||
|
navigate: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stock detail markGone uses /use and shows info for already gone', async () => {
|
||||||
|
useStockItemMock.mockResolvedValueOnce({ status: 'already_gone' });
|
||||||
|
const addAlert = vi.fn();
|
||||||
|
const data = stockDetailPageData({ addAlert });
|
||||||
|
data.entry = { uuid_b64: 'item-1', name: 'Rice' };
|
||||||
|
|
||||||
|
await data.markGone();
|
||||||
|
|
||||||
|
expect(useStockItemMock).toHaveBeenCalledWith({ addAlert }, 'item-1');
|
||||||
|
expect(addAlert).toHaveBeenCalledWith({
|
||||||
|
type: 'info',
|
||||||
|
message: 'Rice was already out of stock.',
|
||||||
|
});
|
||||||
|
expect(globalThis.window.__loncApp.navigate).toHaveBeenCalledWith('/stock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stock list markGone removes entry and uses /use path', async () => {
|
||||||
|
useStockItemMock.mockResolvedValueOnce({ status: 'used' });
|
||||||
|
const addAlert = vi.fn();
|
||||||
|
const data = stockListPageData({ addAlert, isConnected: false });
|
||||||
|
data.entries = [{ id: 1, uuid_b64: 'item-1', name: 'Flour' }];
|
||||||
|
data.editForms = { 1: { level: 'plenty', quantity: 1 } };
|
||||||
|
data.editErrors = {};
|
||||||
|
|
||||||
|
await data.markGone(data.entries[0]);
|
||||||
|
|
||||||
|
expect(useStockItemMock).toHaveBeenCalledWith({ addAlert, isConnected: false }, 'item-1');
|
||||||
|
expect(data.entries).toEqual([]);
|
||||||
|
expect(addAlert).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Flour was marked gone and removed from the list.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const printItemLabelMock = vi.fn();
|
||||||
|
const formatPrintErrorMessageMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/labels.js', () => ({
|
||||||
|
printItemLabel: (...args) => printItemLabelMock(...args),
|
||||||
|
formatPrintErrorMessage: (...args) => formatPrintErrorMessageMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/stock.js', () => ({
|
||||||
|
getStockEntry: vi.fn(),
|
||||||
|
adjustStockEntry: vi.fn(),
|
||||||
|
useStockItem: vi.fn(),
|
||||||
|
listStockEntries: vi.fn(async () => []),
|
||||||
|
listGroupedStockEntries: vi.fn(async () => []),
|
||||||
|
updateStockItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/api/locations.js', () => ({
|
||||||
|
fetchLocations: vi.fn(async () => ({ flat: [], tree: [] })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { stockDetailPageData } = await import('../../../src/features/stock/stock-detail-page.js');
|
||||||
|
|
||||||
|
describe('stock print label actions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
printItemLabelMock.mockReset();
|
||||||
|
formatPrintErrorMessageMock.mockReset();
|
||||||
|
globalThis.window = {
|
||||||
|
__loncApp: {
|
||||||
|
navigate: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete globalThis.window;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints from stock detail and shows success alert', async () => {
|
||||||
|
printItemLabelMock.mockResolvedValueOnce(null);
|
||||||
|
const addAlert = vi.fn();
|
||||||
|
const store = { addAlert };
|
||||||
|
const data = stockDetailPageData(store);
|
||||||
|
data.entry = { uuid_b64: 'uuid-1', name: 'Rice' };
|
||||||
|
|
||||||
|
await data.printLabel();
|
||||||
|
|
||||||
|
expect(printItemLabelMock).toHaveBeenCalledWith(store, 'uuid-1');
|
||||||
|
expect(addAlert).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Rice label sent to printer.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows parsed warning when detail printing fails', async () => {
|
||||||
|
printItemLabelMock.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
formatPrintErrorMessageMock.mockReturnValueOnce('Printer unavailable.');
|
||||||
|
const addAlert = vi.fn();
|
||||||
|
const store = { addAlert };
|
||||||
|
const data = stockDetailPageData(store);
|
||||||
|
data.entry = { uuid_b64: 'uuid-1', name: 'Rice' };
|
||||||
|
|
||||||
|
await data.printLabel();
|
||||||
|
|
||||||
|
expect(addAlert).toHaveBeenCalledWith({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Could not print Rice label: Printer unavailable.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import packageJson from './package.json';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(packageJson.version),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 4173,
|
port: 4173,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user