Refactor API client and stock management logic for improved clarity, error handling, and support for additional stock types.
This commit is contained in:
@@ -67,7 +67,7 @@ Then configure your web server to serve `/var/www/lonc` as a static site.
|
|||||||
|
|
||||||
The application does not require build-time environment variables for the Tryton connection. Users configure the following in the login screen:
|
The application does not require build-time environment variables for the Tryton connection. Users configure the following in the login screen:
|
||||||
|
|
||||||
- Tryton server base URL
|
- Tryton server base URL (optional, leave empty for same-origin deployment)
|
||||||
- database name
|
- database name
|
||||||
- user login
|
- user login
|
||||||
|
|
||||||
@@ -77,6 +77,8 @@ Authentication is done with Tryton user application keys for the `kitchen` appli
|
|||||||
|
|
||||||
If the frontend and Tryton backend are served from different origins, the Tryton server must allow cross-origin requests from the frontend origin.
|
If the frontend and Tryton backend are served from different origins, the Tryton server must allow cross-origin requests from the frontend origin.
|
||||||
|
|
||||||
|
If Lonc is served by the same nginx origin as the API, leave the server URL empty in the app settings so requests stay same-origin and avoid unnecessary browser CORS checks.
|
||||||
|
|
||||||
At minimum, production should ensure:
|
At minimum, production should ensure:
|
||||||
|
|
||||||
- `Authorization` headers are accepted for API requests
|
- `Authorization` headers are accepted for API requests
|
||||||
@@ -143,8 +145,6 @@ Expected shapes today:
|
|||||||
`/{database}/kitchen/{kitchen_id}/{resource}`
|
`/{database}/kitchen/{kitchen_id}/{resource}`
|
||||||
- User application key management uses:
|
- User application key management uses:
|
||||||
`/{database}/user/application/`
|
`/{database}/user/application/`
|
||||||
- Non-kitchen-scoped authenticated resources currently assume:
|
|
||||||
`/{database}/{resource}`
|
|
||||||
|
|
||||||
- `POST /{database}/user/application/`
|
- `POST /{database}/user/application/`
|
||||||
Sends `{ user, application: "kitchen" }` and returns the application key as a JSON string.
|
Sends `{ user, application: "kitchen" }` and returns the application key as a JSON string.
|
||||||
@@ -155,14 +155,20 @@ Expected shapes today:
|
|||||||
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
|
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
|
||||||
- `GET /{database}/kitchen/items?search_name=...`
|
- `GET /{database}/kitchen/items?search_name=...`
|
||||||
Returns item definitions for autocomplete.
|
Returns item definitions for autocomplete.
|
||||||
|
- `GET /{database}/kitchen/items`
|
||||||
|
Returns the current stock review list.
|
||||||
|
- `GET /{database}/kitchen/items/{uuid_b64}`
|
||||||
|
Returns one item detail payload.
|
||||||
- `POST /{database}/kitchen/items?label=1`
|
- `POST /{database}/kitchen/items?label=1`
|
||||||
Creates a stock item plus label-related output on the backend side.
|
Creates a stock item plus label-related output on the backend side.
|
||||||
- `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`
|
||||||
|
Updates measured or descriptive stock state using `{ quantity }` or `{ level }`.
|
||||||
|
- `DELETE /{database}/kitchen/items/{uuid_b64}`
|
||||||
|
Marks an individual stock item gone.
|
||||||
- `GET /{database}/kitchen/locations`
|
- `GET /{database}/kitchen/locations`
|
||||||
Returns a nested location tree.
|
Returns a nested location tree.
|
||||||
- `GET /{database}/kitchen/{kitchen_id}/stock`, `GET /{database}/kitchen/{kitchen_id}/stock/:id`, `POST /{database}/kitchen/{kitchen_id}/stock/:id/adjust`
|
|
||||||
Back the stock overview, creation, and adjustment workflows.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
+197
-44
@@ -1,70 +1,202 @@
|
|||||||
import { API_PATHS } from '../app/config.js';
|
import { API_PATHS } from '../app/config.js';
|
||||||
|
|
||||||
function normalizeBaseUrl(baseUrl) {
|
function normalizeBaseUrl(baseUrl) {
|
||||||
return baseUrl.trim().replace(/\/+$/, '');
|
return String(baseUrl || '').trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOriginBaseUrl(baseUrl) {
|
||||||
|
if (!baseUrl) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(baseUrl, window.location.origin).origin === window.location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPathname({ database, kitchenId, path, includeKitchen = true }) {
|
||||||
|
const encodedDatabase = encodeURIComponent(database);
|
||||||
|
const encodedPathSegments = String(path || '')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => encodeURIComponent(segment));
|
||||||
|
const segments = [encodedDatabase];
|
||||||
|
|
||||||
|
if (includeKitchen && kitchenId) {
|
||||||
|
segments.push('kitchen', encodeURIComponent(String(kitchenId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
segments.push(...encodedPathSegments);
|
||||||
|
|
||||||
|
return `/${segments.join('/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
|
function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
|
||||||
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
const encodedDatabase = encodeURIComponent(database);
|
const pathname = buildPathname({
|
||||||
const encodedPath = path.replace(/^\/+/, '');
|
database,
|
||||||
const kitchenSegment =
|
kitchenId,
|
||||||
includeKitchen && kitchenId
|
path,
|
||||||
? `/kitchen/${encodeURIComponent(String(kitchenId))}`
|
includeKitchen,
|
||||||
: '';
|
});
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
const url = new URL(
|
|
||||||
`${cleanBaseUrl}/${encodedDatabase}${kitchenSegment}/${encodedPath}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
Object.entries(query).forEach(([key, value]) => {
|
Object.entries(query).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
url.searchParams.set(key, String(value));
|
searchParams.set(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return url.toString();
|
const queryString = searchParams.toString();
|
||||||
|
const relativeUrl = queryString ? `${pathname}?${queryString}` : pathname;
|
||||||
|
|
||||||
|
if (!cleanBaseUrl || isSameOriginBaseUrl(cleanBaseUrl)) {
|
||||||
|
return relativeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(relativeUrl, `${cleanBaseUrl}/`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonResponse(response) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseResponse(response) {
|
async function parseResponse(response) {
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
return response.json();
|
return parseJsonResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentType.includes('image/')) {
|
if (contentType.includes('image/')) {
|
||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.text();
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeError(response, payload) {
|
function flattenErrorObject(value) {
|
||||||
const message =
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
payload?.message ||
|
return null;
|
||||||
payload?.error ||
|
}
|
||||||
`Request failed with status ${response.status}.`;
|
|
||||||
|
|
||||||
return new Error(message, {
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, entry]) => {
|
||||||
|
if (Array.isArray(entry)) {
|
||||||
|
return [key, entry.join(', ')];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [key, String(entry)];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(response, payload) {
|
||||||
|
if (typeof payload === 'string' && payload.trim()) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload) && payload.length) {
|
||||||
|
return payload.map((entry) => String(entry)).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
const directMessage =
|
||||||
|
payload.message ||
|
||||||
|
payload.error ||
|
||||||
|
payload.detail ||
|
||||||
|
payload.description ||
|
||||||
|
payload.title;
|
||||||
|
|
||||||
|
if (typeof directMessage === 'string' && directMessage.trim()) {
|
||||||
|
return directMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldErrors = flattenErrorObject(payload.errors || payload.details);
|
||||||
|
if (fieldErrors) {
|
||||||
|
return Object.entries(fieldErrors)
|
||||||
|
.map(([field, message]) => `${field}: ${message}`)
|
||||||
|
.join(' | ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Request failed with status ${response.status}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeError(response, payload, request) {
|
||||||
|
const details = flattenErrorObject(payload?.errors || payload?.details) || null;
|
||||||
|
const message =
|
||||||
|
extractErrorMessage(response, payload);
|
||||||
|
|
||||||
|
const error = new Error(message, {
|
||||||
cause: {
|
cause: {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
details: payload?.errors || payload?.details || null,
|
details,
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
payload,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
error.name = 'ApiRequestError';
|
||||||
|
error.status = response.status;
|
||||||
|
error.url = request.url;
|
||||||
|
error.method = request.method;
|
||||||
|
error.details = details;
|
||||||
|
error.payload = payload;
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logApiFailure(message, context) {
|
||||||
|
console.error(message, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest(store, path, options = {}) {
|
export async function apiRequest(store, path, options = {}) {
|
||||||
const { config, session, activeKitchen } = store;
|
const { config, session, activeKitchen } = store;
|
||||||
|
|
||||||
if (!config.baseUrl || !config.database) {
|
if (!config.database) {
|
||||||
throw new Error('Server URL and database name are required.');
|
throw new Error('Database name is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const method = options.method || 'GET';
|
||||||
|
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 || {});
|
const headers = new Headers(options.headers || {});
|
||||||
headers.set('Accept', options.accept || 'application/json');
|
headers.set('Accept', options.accept || 'application/json');
|
||||||
|
|
||||||
@@ -76,28 +208,49 @@ export async function apiRequest(store, path, options = {}) {
|
|||||||
headers.set('Authorization', `Bearer ${session.applicationKey}`);
|
headers.set('Authorization', `Bearer ${session.applicationKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
let response;
|
||||||
buildUrl({
|
try {
|
||||||
baseUrl: config.baseUrl,
|
response = await fetch(url, {
|
||||||
database: config.database,
|
method,
|
||||||
kitchenId: activeKitchen?.id,
|
headers,
|
||||||
path,
|
credentials: options.credentials || 'same-origin',
|
||||||
query: options.query,
|
body:
|
||||||
includeKitchen: options.includeKitchen !== false,
|
options.body && !options.isFormData
|
||||||
}),
|
? JSON.stringify(options.body)
|
||||||
{
|
: options.body || undefined,
|
||||||
method: options.method || 'GET',
|
});
|
||||||
headers,
|
} catch (error) {
|
||||||
body:
|
const networkError = new Error(
|
||||||
options.body && !options.isFormData
|
`Network request failed for ${method} ${url}. Check the server URL, nginx proxy, and CORS configuration.`,
|
||||||
? JSON.stringify(options.body)
|
{
|
||||||
: options.body || undefined,
|
cause: {
|
||||||
},
|
url,
|
||||||
);
|
method,
|
||||||
|
originalError: error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
networkError.name = 'ApiNetworkError';
|
||||||
|
networkError.url = url;
|
||||||
|
networkError.method = method;
|
||||||
|
logApiFailure('API network request failed', {
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
throw networkError;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = await parseResponse(response);
|
const payload = await parseResponse(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw normalizeError(response, payload);
|
const apiError = normalizeError(response, payload, { url, method });
|
||||||
|
logApiFailure('API request returned an error response', {
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
status: response.status,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
throw apiError;
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
+17
-14
@@ -1,5 +1,9 @@
|
|||||||
import { apiRequest, getPath } from './client.js';
|
import { apiRequest, getPath } from './client.js';
|
||||||
|
|
||||||
|
function unwrapEntryPayload(payload) {
|
||||||
|
return payload?.data || payload?.entry || payload?.item || payload;
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchItemDefinitions(store, query) {
|
export async function searchItemDefinitions(store, query) {
|
||||||
if (query.trim().length <= 2) {
|
if (query.trim().length <= 2) {
|
||||||
return [];
|
return [];
|
||||||
@@ -30,8 +34,10 @@ export async function listStockEntries(store, filters = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getStockEntry(store, stockId) {
|
export async function getStockEntry(store, stockId) {
|
||||||
const payload = await apiRequest(store, `${getPath('stockEntries')}/${stockId}`);
|
const payload = await apiRequest(store, `${getPath('items')}/${stockId}`, {
|
||||||
return payload?.data || payload?.entry || payload;
|
includeKitchen: false,
|
||||||
|
});
|
||||||
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createStockEntry(store, body) {
|
export async function createStockEntry(store, body) {
|
||||||
@@ -41,7 +47,7 @@ export async function createStockEntry(store, body) {
|
|||||||
includeKitchen: false,
|
includeKitchen: false,
|
||||||
query: { label: 1 },
|
query: { label: 1 },
|
||||||
});
|
});
|
||||||
return payload?.data || payload?.entry || payload;
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateStockItem(store, uuidB64, body) {
|
export async function updateStockItem(store, uuidB64, body) {
|
||||||
@@ -50,7 +56,7 @@ export async function updateStockItem(store, uuidB64, body) {
|
|||||||
body,
|
body,
|
||||||
includeKitchen: false,
|
includeKitchen: false,
|
||||||
});
|
});
|
||||||
return payload?.data || payload?.entry || payload;
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteStockItem(store, uuidB64) {
|
export async function deleteStockItem(store, uuidB64) {
|
||||||
@@ -58,17 +64,14 @@ export async function deleteStockItem(store, uuidB64) {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
includeKitchen: false,
|
includeKitchen: false,
|
||||||
});
|
});
|
||||||
return payload?.data || payload?.entry || payload;
|
return unwrapEntryPayload(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function adjustStockEntry(store, stockId, body) {
|
export async function adjustStockEntry(store, stockId, body) {
|
||||||
const payload = await apiRequest(
|
const payload = await apiRequest(store, `${getPath('items')}/${stockId}/stock`, {
|
||||||
store,
|
method: 'POST',
|
||||||
`${getPath('stockEntries')}/${stockId}/adjust`,
|
body,
|
||||||
{
|
includeKitchen: false,
|
||||||
method: 'POST',
|
});
|
||||||
body,
|
return unwrapEntryPayload(payload);
|
||||||
},
|
|
||||||
);
|
|
||||||
return payload?.data || payload?.entry || payload;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export function renderLoginPage() {
|
|||||||
<form class="vstack gap-3" @submit.prevent="submit()">
|
<form class="vstack gap-3" @submit.prevent="submit()">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="base-url">Tryton server URL</label>
|
<label class="form-label" for="base-url">Tryton server URL</label>
|
||||||
<input id="base-url" class="form-control" type="url" x-model="form.baseUrl" placeholder="https://tryton.example.com" required />
|
<input id="base-url" class="form-control" type="url" x-model="form.baseUrl" placeholder="https://tryton.example.com" />
|
||||||
|
<div class="form-text">
|
||||||
|
Leave this empty to use the same origin as the current Lonc page.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label" for="database">Database name</label>
|
<label class="form-label" for="database">Database name</label>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export function renderSettingsPage() {
|
|||||||
<form class="vstack gap-3" @submit.prevent="save()">
|
<form class="vstack gap-3" @submit.prevent="save()">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Tryton server URL</label>
|
<label class="form-label">Tryton server URL</label>
|
||||||
<input class="form-control" type="url" x-model="form.baseUrl" required />
|
<input class="form-control" type="url" x-model="form.baseUrl" />
|
||||||
|
<div class="form-text">
|
||||||
|
Leave empty to use the same origin as this deployed Lonc frontend.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Database name</label>
|
<label class="form-label">Database name</label>
|
||||||
@@ -36,6 +39,7 @@ export function renderSettingsPage() {
|
|||||||
<h2 class="h5">Integration notes</h2>
|
<h2 class="h5">Integration notes</h2>
|
||||||
<ul class="text-body-secondary small ps-3 mb-0">
|
<ul class="text-body-secondary small ps-3 mb-0">
|
||||||
<li>Connection uses Tryton user application keys for the <code>kitchen</code> application.</li>
|
<li>Connection uses Tryton user application keys for the <code>kitchen</code> application.</li>
|
||||||
|
<li>Leaving the server URL empty makes API calls use same-origin relative paths.</li>
|
||||||
<li>Kitchen-scoped requests are built as <code>/{database}/kitchen/{kitchenId}/...</code>.</li>
|
<li>Kitchen-scoped requests are built as <code>/{database}/kitchen/{kitchenId}/...</code>.</li>
|
||||||
<li>Label preview accepts image blobs, image URLs, or SVG payloads.</li>
|
<li>Label preview accepts image blobs, image URLs, or SVG payloads.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -687,7 +687,7 @@ export function labelCreatePageData(store) {
|
|||||||
});
|
});
|
||||||
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.fieldErrors = normalizeValidationError(error.cause);
|
this.fieldErrors = normalizeValidationError(error);
|
||||||
this.submitError = error.message;
|
this.submitError = error.message;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ export function fieldError(errors, field) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeValidationError(error) {
|
export function normalizeValidationError(error) {
|
||||||
if (error?.details && typeof error.details === 'object') {
|
const details =
|
||||||
return error.details;
|
error?.details ||
|
||||||
|
error?.cause?.details ||
|
||||||
|
error?.payload?.errors ||
|
||||||
|
error?.payload?.details ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (details && typeof details === 'object') {
|
||||||
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { adjustStockEntry, getStockEntry } from '../../api/stock.js';
|
import {
|
||||||
|
adjustStockEntry,
|
||||||
|
deleteStockItem,
|
||||||
|
getStockEntry,
|
||||||
|
} from '../../api/stock.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';
|
||||||
@@ -38,11 +42,11 @@ 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_name || 'Unassigned'"></dd>
|
<dd class="col-7" x-text="entry.location_initial_uuid_b64 || 'Unassigned'"></dd>
|
||||||
<dt class="col-5">Production date</dt>
|
<dt class="col-5">Production date</dt>
|
||||||
<dd class="col-7" x-text="formatDate(entry.production_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.expiration_date)"></dd>
|
<dd class="col-7" x-text="formatDate(entry.expire_date)"></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>
|
||||||
@@ -56,34 +60,78 @@ export function renderStockDetailPage() {
|
|||||||
<p class="eyebrow mb-2">Adjustment</p>
|
<p class="eyebrow mb-2">Adjustment</p>
|
||||||
<h2 class="h5 mb-3">Update current stock level</h2>
|
<h2 class="h5 mb-3">Update current stock level</h2>
|
||||||
|
|
||||||
<form class="vstack gap-3" @submit.prevent="submitAdjustment()">
|
<template x-if="entry.stock_type === 'measured'">
|
||||||
<div>
|
<form class="vstack gap-3" @submit.prevent="submitMeasuredAdjustment()">
|
||||||
<label class="form-label">Adjustment mode</label>
|
<div>
|
||||||
<select class="form-select" x-model="adjustment.mode">
|
<label class="form-label">Adjustment mode</label>
|
||||||
<option value="increment">Add quantity</option>
|
<select class="form-select" x-model="adjustment.mode">
|
||||||
<option value="decrement">Subtract quantity</option>
|
<option value="increment">Add quantity</option>
|
||||||
<option value="set">Set exact quantity</option>
|
<option value="decrement">Subtract quantity</option>
|
||||||
</select>
|
<option value="set">Set exact quantity</option>
|
||||||
</div>
|
</select>
|
||||||
<div>
|
</div>
|
||||||
<label class="form-label">Quantity</label>
|
<div>
|
||||||
<input class="form-control" type="number" min="0" step="0.01" x-model="adjustment.quantity" required />
|
<label class="form-label">Quantity</label>
|
||||||
</div>
|
<input class="form-control" type="number" min="0" step="0.01" x-model="adjustment.quantity" required />
|
||||||
<div class="d-flex flex-wrap gap-2">
|
</div>
|
||||||
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(1)">+1</button>
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(-1)">-1</button>
|
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(1)">+1</button>
|
||||||
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(0.5)">+0.5</button>
|
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(-1)">-1</button>
|
||||||
</div>
|
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(0.5)">+0.5</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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 adjustment</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>
|
||||||
</form>
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="entry.stock_type === 'descriptive'">
|
||||||
|
<form class="vstack gap-3" @submit.prevent="submitLevelAdjustment()">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Stock level</label>
|
||||||
|
<select class="form-select" x-model="adjustment.level">
|
||||||
|
<option value="plenty">Plenty</option>
|
||||||
|
<option value="good">Good</option>
|
||||||
|
<option value="some">Some</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="trace">Trace</option>
|
||||||
|
<option value="gone">Gone</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="adjustmentState.error">
|
||||||
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
||||||
|
<span x-show="!adjustmentState.isLoading">Save stock level</span>
|
||||||
|
<span x-show="adjustmentState.isLoading">Saving...</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="entry.stock_type === 'binary'">
|
||||||
|
<div class="vstack gap-3">
|
||||||
|
<p class="text-body-secondary mb-0">
|
||||||
|
Binary stock items can be marked gone from this screen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template x-if="adjustmentState.error">
|
||||||
|
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button class="btn btn-outline-danger align-self-start" type="button" @click="markGone()" :disabled="adjustmentState.isLoading">
|
||||||
|
<span x-show="!adjustmentState.isLoading">Mark gone</span>
|
||||||
|
<span x-show="adjustmentState.isLoading">Removing...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,33 +149,70 @@ export function stockDetailPageData(store) {
|
|||||||
adjustment: {
|
adjustment: {
|
||||||
mode: 'increment',
|
mode: 'increment',
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
|
level: 'plenty',
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
const { params } = getRouteContext();
|
const { params } = getRouteContext();
|
||||||
await runAsyncState(this.state, async () => {
|
await runAsyncState(this.state, async () => {
|
||||||
this.entry = await getStockEntry(store, params.id);
|
this.entry = await getStockEntry(store, params.id);
|
||||||
|
this.adjustment.level = this.entry?.level || 'plenty';
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
async submitAdjustment() {
|
async submitMeasuredAdjustment() {
|
||||||
if (!this.entry) {
|
if (!this.entry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await runAsyncState(this.adjustmentState, async () => {
|
await runAsyncState(this.adjustmentState, async () => {
|
||||||
this.entry = await adjustStockEntry(store, this.entry.id, {
|
const requestedQuantity = Number(this.adjustment.quantity);
|
||||||
mode: this.adjustment.mode,
|
if (Number.isNaN(requestedQuantity) || requestedQuantity < 0) {
|
||||||
quantity: Number(this.adjustment.quantity),
|
throw new Error('Enter a valid quantity first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentQuantity = Number(this.entry.quantity || 0);
|
||||||
|
const exactQuantity =
|
||||||
|
this.adjustment.mode === 'increment'
|
||||||
|
? currentQuantity + requestedQuantity
|
||||||
|
: this.adjustment.mode === 'decrement'
|
||||||
|
? Math.max(currentQuantity - requestedQuantity, 0)
|
||||||
|
: requestedQuantity;
|
||||||
|
|
||||||
|
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
|
||||||
|
quantity: exactQuantity,
|
||||||
});
|
});
|
||||||
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
|
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
},
|
},
|
||||||
|
async submitLevelAdjustment() {
|
||||||
|
if (!this.entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAsyncState(this.adjustmentState, async () => {
|
||||||
|
this.entry = await adjustStockEntry(store, this.entry.uuid_b64, {
|
||||||
|
level: this.adjustment.level,
|
||||||
|
});
|
||||||
|
store.addAlert({ type: 'success', message: 'Stock level updated.' });
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
async markGone() {
|
||||||
|
if (!this.entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runAsyncState(this.adjustmentState, async () => {
|
||||||
|
await deleteStockItem(store, this.entry.uuid_b64);
|
||||||
|
store.addAlert({ type: 'success', message: `${this.entry.name} was marked gone.` });
|
||||||
|
window.__loncApp.navigate('/stock');
|
||||||
|
}).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,
|
||||||
formatQuantity(entry) {
|
formatQuantity(entry) {
|
||||||
return `${entry.quantity ?? 0} ${entry.uom || ''}`.trim();
|
return `${entry.quantity ?? 0} ${entry.uom_symbol || ''}`.trim();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user