Implement upsert label flow and use-based mark gone handling
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -22,7 +22,6 @@ export async function login(store, credentials) {
|
||||
user: credentials.userLogin,
|
||||
application: TRYTON_APPLICATION,
|
||||
},
|
||||
includeKitchen: false,
|
||||
});
|
||||
|
||||
const applicationKey = extractKey(payload);
|
||||
@@ -66,7 +65,6 @@ export async function logout(store) {
|
||||
key: store.session.applicationKey,
|
||||
application: TRYTON_APPLICATION,
|
||||
},
|
||||
includeKitchen: false,
|
||||
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 rawPath = String(path || '').replace(/^\/+/, '');
|
||||
const keepTrailingSlash = rawPath.endsWith('/');
|
||||
@@ -30,23 +30,17 @@ function buildPathname({ database, kitchenId, path, includeKitchen = true }) {
|
||||
.map((segment) => encodeURIComponent(segment));
|
||||
const segments = [encodedDatabase];
|
||||
|
||||
if (includeKitchen && kitchenId) {
|
||||
segments.push('kitchen', encodeURIComponent(String(kitchenId)));
|
||||
}
|
||||
|
||||
segments.push(...encodedPathSegments);
|
||||
|
||||
const pathname = `/${segments.join('/')}`;
|
||||
return keepTrailingSlash ? `${pathname}/` : pathname;
|
||||
}
|
||||
|
||||
function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
|
||||
function buildUrl({ baseUrl, database, path, query = {} }) {
|
||||
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
const pathname = buildPathname({
|
||||
database,
|
||||
kitchenId,
|
||||
path,
|
||||
includeKitchen,
|
||||
});
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
@@ -191,6 +185,10 @@ function isKitchensPath(path) {
|
||||
return String(path || '').replace(/^\/+/, '').replace(/\/+$/, '') === API_PATHS.kitchens;
|
||||
}
|
||||
|
||||
function isKitchenApiPath(path) {
|
||||
return String(path || '').replace(/^\/+/, '').startsWith('kitchen/');
|
||||
}
|
||||
|
||||
function shouldInvalidateValidatedSession(store, path, options = {}) {
|
||||
if (options.skipAuthFailureHandler) {
|
||||
return false;
|
||||
@@ -202,15 +200,16 @@ function shouldInvalidateValidatedSession(store, path, options = {}) {
|
||||
|
||||
return (
|
||||
isKitchensPath(path) ||
|
||||
options.includeKitchen !== false ||
|
||||
isKitchenApiPath(path) ||
|
||||
path === API_PATHS.items ||
|
||||
path === API_PATHS.locations ||
|
||||
path === API_PATHS.changes ||
|
||||
String(path || '').startsWith(`${API_PATHS.items}/`)
|
||||
);
|
||||
}
|
||||
|
||||
export async function apiRequest(store, path, options = {}) {
|
||||
const { config, session, activeKitchen } = store;
|
||||
const { config, session } = store;
|
||||
|
||||
if (!config.database) {
|
||||
throw new Error('Database name is required.');
|
||||
@@ -220,10 +219,8 @@ export async function apiRequest(store, path, options = {}) {
|
||||
const url = buildUrl({
|
||||
baseUrl: config.baseUrl,
|
||||
database: config.database,
|
||||
kitchenId: activeKitchen?.id,
|
||||
path,
|
||||
query: options.query,
|
||||
includeKitchen: options.includeKitchen !== false,
|
||||
});
|
||||
const headers = new Headers(options.headers || {});
|
||||
headers.set('Accept', options.accept || 'application/json');
|
||||
@@ -304,9 +301,7 @@ export function buildKitchenApiUrl(store, path, query = {}) {
|
||||
return buildUrl({
|
||||
baseUrl: store.config.baseUrl,
|
||||
database: store.config.database,
|
||||
kitchenId: store.activeKitchen?.id,
|
||||
path,
|
||||
query,
|
||||
includeKitchen: true,
|
||||
});
|
||||
}
|
||||
|
||||
+1
-3
@@ -1,9 +1,7 @@
|
||||
import { apiRequest, getPath } from './client.js';
|
||||
|
||||
export async function listKitchens(store) {
|
||||
const payload = await apiRequest(store, getPath('kitchens'), {
|
||||
includeKitchen: false,
|
||||
});
|
||||
const payload = await apiRequest(store, getPath('kitchens'));
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ export async function previewLabel(store, body) {
|
||||
method: 'POST',
|
||||
body,
|
||||
accept: 'image/svg+xml, image/png, application/json',
|
||||
includeKitchen: false,
|
||||
query: { label: 1, preview: 1 },
|
||||
});
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ export async function fetchLocations(store) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
const payload = await apiRequest(store, getPath('locations'), {
|
||||
includeKitchen: false,
|
||||
});
|
||||
const payload = await apiRequest(store, getPath('locations'));
|
||||
const tree = Array.isArray(payload)
|
||||
? payload
|
||||
: payload?.data || payload?.locations || [];
|
||||
|
||||
+70
-12
@@ -10,7 +10,6 @@ export async function searchItemDefinitions(store, query) {
|
||||
}
|
||||
|
||||
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||
includeKitchen: false,
|
||||
query: { search_name: query, expanded: 0 },
|
||||
});
|
||||
|
||||
@@ -22,9 +21,7 @@ export async function searchItemDefinitions(store, query) {
|
||||
}
|
||||
|
||||
export async function listStockEntries(store, filters = {}) {
|
||||
const payload = await apiRequest(store, getPath('items'), {
|
||||
includeKitchen: false,
|
||||
});
|
||||
const payload = await apiRequest(store, getPath('items'));
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
@@ -35,7 +32,6 @@ export async function listStockEntries(store, filters = {}) {
|
||||
|
||||
export async function listGroupedStockEntries(store) {
|
||||
const payload = await apiRequest(store, `${getPath('items')}/grouped`, {
|
||||
includeKitchen: false,
|
||||
query: { expanded: 1 },
|
||||
});
|
||||
|
||||
@@ -47,9 +43,7 @@ export async function listGroupedStockEntries(store) {
|
||||
}
|
||||
|
||||
export async function getStockEntry(store, stockId) {
|
||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
|
||||
includeKitchen: false,
|
||||
});
|
||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`);
|
||||
return unwrapEntryPayload(payload);
|
||||
}
|
||||
|
||||
@@ -57,17 +51,47 @@ export async function createStockEntry(store, body) {
|
||||
const payload = await apiRequest(store, getPath('items'), {
|
||||
method: 'POST',
|
||||
body,
|
||||
includeKitchen: false,
|
||||
query: { label: 1, print: 1 },
|
||||
});
|
||||
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) {
|
||||
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
includeKitchen: false,
|
||||
});
|
||||
return unwrapEntryPayload(payload);
|
||||
}
|
||||
@@ -75,16 +99,50 @@ export async function updateStockItem(store, uuidB64, body) {
|
||||
export async function deleteStockItem(store, uuidB64) {
|
||||
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
|
||||
method: 'DELETE',
|
||||
includeKitchen: false,
|
||||
});
|
||||
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) {
|
||||
const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
includeKitchen: false,
|
||||
});
|
||||
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 { 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 { createAppStore } from './store.js';
|
||||
import { appShell } from '../components/app-shell.js';
|
||||
@@ -30,7 +30,11 @@ export function bootstrapApp() {
|
||||
registerFeatureData(Alpine, store);
|
||||
|
||||
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);
|
||||
const navRoot = document.querySelector('#app-nav');
|
||||
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
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 CONNECTION_STATES = {
|
||||
@@ -24,8 +25,8 @@ export const API_PATHS = {
|
||||
userApplication: 'user/application/',
|
||||
kitchens: 'kitchen/kitchens',
|
||||
items: 'kitchen/items',
|
||||
stockEntries: 'stock',
|
||||
locations: 'kitchen/locations',
|
||||
changes: 'kitchen/changes',
|
||||
};
|
||||
|
||||
export const ROUTES = {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { navBar } from './nav-bar.js';
|
||||
|
||||
export function appShell(appName) {
|
||||
export function appShell(appName, appVersion, runtimeMode) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return `
|
||||
<div class="app-shell d-flex flex-column min-vh-100">
|
||||
<div id="app-nav">
|
||||
${navBar(appName)}
|
||||
</div>
|
||||
<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()">
|
||||
<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">
|
||||
|
||||
@@ -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() {
|
||||
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="row align-items-center g-4">
|
||||
<div class="col-12 col-lg-7">
|
||||
@@ -80,6 +84,52 @@ export function renderDashboardPage() {
|
||||
</a>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
@@ -87,10 +137,191 @@ export function renderDashboardPage() {
|
||||
export function dashboardPageData(store) {
|
||||
return {
|
||||
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) {
|
||||
store.setActiveKitchen(kitchen);
|
||||
this.showKitchenPicker = false;
|
||||
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,4 +1,8 @@
|
||||
import { createStockEntry, searchItemDefinitions } from '../../api/stock.js';
|
||||
import {
|
||||
applyItemUpsert,
|
||||
previewItemUpsert,
|
||||
searchItemDefinitions,
|
||||
} from '../../api/stock.js';
|
||||
import { fetchLocations } from '../../api/locations.js';
|
||||
import { previewLabel } from '../../api/labels.js';
|
||||
import { STORAGE_KEYS } from '../../app/config.js';
|
||||
@@ -400,6 +404,14 @@ export function renderLabelCreatePage() {
|
||||
<div class="alert alert-success mb-0" x-text="successMessage"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="upsertPreview && !upsertPreview.error">
|
||||
<div class="alert alert-info mb-0 py-2" x-text="upsertPreviewSummary()"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="upsertPreview?.error">
|
||||
<div class="alert alert-warning mb-0 py-2" x-text="upsertPreview.error"></div>
|
||||
</template>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-outline-primary" type="button" @click="preview()" :disabled="previewState.isLoading">
|
||||
@@ -407,7 +419,7 @@ export function renderLabelCreatePage() {
|
||||
<span x-show="previewState.isLoading">Rendering preview...</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" type="submit" :disabled="createState.isLoading">
|
||||
<span x-show="!createState.isLoading">Create stock entry</span>
|
||||
<span x-show="!createState.isLoading">Save stock entry</span>
|
||||
<span x-show="createState.isLoading">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -478,6 +490,10 @@ function diffDays(fromIsoDate, toIsoDate) {
|
||||
function createDefaultForm() {
|
||||
return {
|
||||
itemId: '',
|
||||
itemUuidB64: '',
|
||||
identifierCode: '',
|
||||
externalSource: '',
|
||||
externalId: '',
|
||||
search: '',
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -505,6 +521,10 @@ function loadLabelDraft() {
|
||||
? ''
|
||||
: draft.quantity,
|
||||
itemId: '',
|
||||
itemUuidB64: '',
|
||||
identifierCode: '',
|
||||
externalSource: '',
|
||||
externalId: '',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
@@ -513,6 +533,10 @@ function buildDraftPayload(form) {
|
||||
return {
|
||||
...form,
|
||||
itemId: '',
|
||||
itemUuidB64: '',
|
||||
identifierCode: '',
|
||||
externalSource: '',
|
||||
externalId: '',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
@@ -536,6 +560,7 @@ export function labelCreatePageData(store) {
|
||||
successMessage: '',
|
||||
submitError: '',
|
||||
fieldErrors: {},
|
||||
upsertPreview: null,
|
||||
form: {
|
||||
...loadLabelDraft(),
|
||||
},
|
||||
@@ -590,6 +615,14 @@ export function labelCreatePageData(store) {
|
||||
}
|
||||
},
|
||||
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.searchDebounced();
|
||||
},
|
||||
@@ -603,6 +636,10 @@ export function labelCreatePageData(store) {
|
||||
: null;
|
||||
|
||||
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.name = item.name;
|
||||
this.form.description = item.description || this.form.description;
|
||||
@@ -623,7 +660,12 @@ export function labelCreatePageData(store) {
|
||||
},
|
||||
clearItemSearch() {
|
||||
this.form.itemId = '';
|
||||
this.form.itemUuidB64 = '';
|
||||
this.form.identifierCode = '';
|
||||
this.form.externalSource = '';
|
||||
this.form.externalId = '';
|
||||
this.form.search = '';
|
||||
this.upsertPreview = null;
|
||||
this.suggestions = [];
|
||||
this.persistDraft();
|
||||
},
|
||||
@@ -908,6 +950,8 @@ export function labelCreatePageData(store) {
|
||||
: null
|
||||
: Number(this.form.quantity);
|
||||
|
||||
const selectedLocationUuidB64 = this.selectedLocation?.uuid_b64 || null;
|
||||
|
||||
return {
|
||||
item_id: this.form.itemId || null,
|
||||
name: this.form.name.trim(),
|
||||
@@ -920,13 +964,51 @@ export function labelCreatePageData(store) {
|
||||
level: this.form.stockType === 'measured' ? null : this.form.level || null,
|
||||
date: this.form.productionDate || null,
|
||||
expire_date: this.form.expirationDate || null,
|
||||
location_initial: this.form.locationId || null,
|
||||
location_initial: selectedLocationUuidB64,
|
||||
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.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() {
|
||||
this.submitError = '';
|
||||
this.fieldErrors = {};
|
||||
this.upsertPreview = null;
|
||||
|
||||
if (!this.validateBeforeSubmit()) {
|
||||
this.previewState.error = 'Please fill out the required fields before previewing the label.';
|
||||
@@ -940,6 +1022,13 @@ export function labelCreatePageData(store) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
}
|
||||
this.previewUrl = result.objectUrl;
|
||||
try {
|
||||
this.upsertPreview = await previewItemUpsert(store, this.buildUpsertPayload());
|
||||
} catch (error) {
|
||||
this.upsertPreview = {
|
||||
error: error.message || 'Upsert preview failed.',
|
||||
};
|
||||
}
|
||||
this.persistDraft();
|
||||
});
|
||||
},
|
||||
@@ -948,22 +1037,25 @@ export function labelCreatePageData(store) {
|
||||
this.fieldErrors = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await runAsyncState(this.createState, async () => {
|
||||
try {
|
||||
const entry = await createStockEntry(store, this.buildPayload());
|
||||
const entry = await applyItemUpsert(store, this.buildUpsertPayload());
|
||||
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
}
|
||||
this.previewUrl = '';
|
||||
this.successMessage = `${entry.name || this.form.name} was created successfully.`;
|
||||
const entryName = entry.item?.name || this.form.name;
|
||||
const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
|
||||
this.successMessage = `${entryName} was ${operationVerb} successfully.`;
|
||||
store.addAlert({
|
||||
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));
|
||||
} catch (error) {
|
||||
this.fieldErrors = normalizeValidationError(error);
|
||||
@@ -981,6 +1073,7 @@ export function labelCreatePageData(store) {
|
||||
this.successMessage = '';
|
||||
this.submitError = '';
|
||||
this.fieldErrors = {};
|
||||
this.upsertPreview = null;
|
||||
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
|
||||
if (revokePreview && this.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
adjustStockEntry,
|
||||
deleteStockItem,
|
||||
getStockEntry,
|
||||
useStockItem,
|
||||
} from '../../api/stock.js';
|
||||
import { getRouteContext } from '../../app/router.js';
|
||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||
@@ -207,7 +207,7 @@ export function stockDetailPageData(store) {
|
||||
await runAsyncState(this.adjustmentState, async () => {
|
||||
if (this.adjustment.level === 'gone') {
|
||||
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.` });
|
||||
window.__loncApp.navigate('/stock');
|
||||
return;
|
||||
@@ -225,8 +225,14 @@ export function stockDetailPageData(store) {
|
||||
}
|
||||
|
||||
await runAsyncState(this.adjustmentState, async () => {
|
||||
await deleteStockItem(store, this.entry.uuid_b64);
|
||||
store.addAlert({ type: 'success', message: `${this.entry.name} was marked gone.` });
|
||||
const result = await useStockItem(store, this.entry.uuid_b64);
|
||||
const alreadyGone = result.status === 'already_gone';
|
||||
store.addAlert({
|
||||
type: alreadyGone ? 'info' : 'success',
|
||||
message: alreadyGone
|
||||
? `${this.entry.name} was already out of stock.`
|
||||
: `${this.entry.name} was marked gone.`,
|
||||
});
|
||||
window.__loncApp.navigate('/stock');
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
deleteStockItem,
|
||||
listGroupedStockEntries,
|
||||
listStockEntries,
|
||||
updateStockItem,
|
||||
useStockItem,
|
||||
} from '../../api/stock.js';
|
||||
import { fetchLocations } from '../../api/locations.js';
|
||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||
@@ -1204,12 +1204,12 @@ export function stockListPageData(store) {
|
||||
},
|
||||
formatDate,
|
||||
async updateBinary(entry, level) {
|
||||
await this.deleteEntry(entry);
|
||||
await this.useEntry(entry);
|
||||
},
|
||||
async saveLevel(entry) {
|
||||
const level = this.editForms[entry.id]?.level || 'plenty';
|
||||
if (level === 'gone') {
|
||||
await this.deleteEntry(entry);
|
||||
await this.useEntry(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1229,7 +1229,7 @@ export function stockListPageData(store) {
|
||||
}, { quantity });
|
||||
},
|
||||
async markGone(entry) {
|
||||
await this.deleteEntry(entry);
|
||||
await this.useEntry(entry);
|
||||
},
|
||||
async saveEntryUpdate(entry, payload, localPatch) {
|
||||
this.editErrors[entry.id] = '';
|
||||
@@ -1245,20 +1245,23 @@ export function stockListPageData(store) {
|
||||
this.editErrors[entry.id] = error.message || 'Update failed.';
|
||||
}
|
||||
},
|
||||
async deleteEntry(entry) {
|
||||
async useEntry(entry) {
|
||||
this.editErrors[entry.id] = '';
|
||||
|
||||
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);
|
||||
delete this.editForms[entry.id];
|
||||
delete this.editErrors[entry.id];
|
||||
const alreadyGone = result.status === 'already_gone';
|
||||
store.addAlert({
|
||||
type: 'success',
|
||||
message: `${entry.name} was marked gone and removed from the list.`,
|
||||
type: alreadyGone ? 'info' : 'success',
|
||||
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) {
|
||||
this.editErrors[entry.id] = error.message || 'Delete failed.';
|
||||
this.editErrors[entry.id] = error.message || 'Mark gone failed.';
|
||||
}
|
||||
},
|
||||
replaceEntry(entryId, nextEntry) {
|
||||
|
||||
@@ -26,6 +26,11 @@ body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
|
||||
Reference in New Issue
Block a user