Introduce initial version of the Lonc app with core features, styling, and configurations.
- Add base app structure, including Bootstrap setup and Alpine.js integration. - Implement authentication flow with session handling. - Integrate stock management and label creation functionalities. - Include responsive styling and theme using CSS variables and custom components. - Add API clients for Tryton-based backend. - Set up kitchen and dashboard navigation workflows. - Configure service worker for PWA support.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import { login, verifyConnection } from '../../api/auth.js';
|
||||
import { CONNECTION_STATES } from '../../app/config.js';
|
||||
import { navigate } from '../../app/router.js';
|
||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||
|
||||
export function renderLoginPage() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-10 col-lg-7 col-xl-5">
|
||||
<div class="card border-0 shadow-lg">
|
||||
<div class="card-body p-4 p-lg-5" x-data="loginPage()" x-init="init()">
|
||||
<div class="mb-4">
|
||||
<p class="eyebrow mb-2">Kitchen User Application</p>
|
||||
<h1 class="h3 mb-2">Connect Lonc to Tryton</h1>
|
||||
<p class="text-body-secondary mb-0">
|
||||
Lonc uses a Tryton user application key for <code>kitchen</code>, not a normal session login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="vstack gap-3" @submit.prevent="submit()">
|
||||
<div>
|
||||
<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 />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="database">Database name</label>
|
||||
<input id="database" class="form-control" type="text" x-model="form.database" placeholder="kitchen" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="user-login">User login</label>
|
||||
<input id="user-login" class="form-control" type="text" x-model="form.userLogin" autocomplete="username" required />
|
||||
<div class="form-text">
|
||||
This requests a pending application key that must be approved in Tryton client preferences.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="state.error">
|
||||
<div class="alert alert-danger mb-0" x-text="state.error"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="!hasStoredKey">
|
||||
<button class="btn btn-primary btn-lg" type="submit" :disabled="state.isLoading">
|
||||
<span x-show="!state.isLoading">Create application key</span>
|
||||
<span x-show="state.isLoading">Creating key...</span>
|
||||
</button>
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<template x-if="hasStoredKey">
|
||||
<div class="mt-4 pt-4 border-top">
|
||||
<div class="alert mb-3" :class="statusAlertClass()">
|
||||
<div class="fw-semibold mb-1" x-text="statusTitle()"></div>
|
||||
<div x-text="statusMessage()"></div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-body-tertiary border-0 mb-3">
|
||||
<div class="card-body">
|
||||
<div class="fw-semibold mb-2">Application key to approve in Tryton</div>
|
||||
<div class="small text-body-secondary mb-2">
|
||||
Match this key with the pending key shown in Tryton client preferences.
|
||||
</div>
|
||||
<input
|
||||
class="form-control font-monospace small"
|
||||
type="text"
|
||||
readonly
|
||||
:value="maskedApplicationKey()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="small text-body-secondary ps-3 mb-4">
|
||||
<li>Create the key from this screen.</li>
|
||||
<li>Open Tryton client preferences.</li>
|
||||
<li>Validate the pending key for the <code>kitchen</code> application.</li>
|
||||
<li>Return here and verify the connection.</li>
|
||||
</ol>
|
||||
|
||||
<template x-if="verifyState.error">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<span x-text="verifyState.error"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
@click="verifyState.error = ''"
|
||||
></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary" type="button" @click="checkConnection()" :disabled="verifyState.isLoading">
|
||||
<span x-show="!verifyState.isLoading">Verify connection</span>
|
||||
<span x-show="verifyState.isLoading">Checking...</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" @click="disconnect()">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function loginPageData(store) {
|
||||
return {
|
||||
store,
|
||||
state: createAsyncState(),
|
||||
verifyState: createAsyncState(),
|
||||
sessionState: store.session?.state || CONNECTION_STATES.notConnected,
|
||||
applicationKey: store.session?.applicationKey || '',
|
||||
form: {
|
||||
baseUrl: store.config.baseUrl || '',
|
||||
database: store.config.database || '',
|
||||
userLogin: store.session?.userLogin || '',
|
||||
},
|
||||
get hasStoredKey() {
|
||||
return Boolean(this.applicationKey);
|
||||
},
|
||||
init() {
|
||||
this.syncFromStore();
|
||||
if (store.isConnected) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasStoredKey) {
|
||||
this.tryAutoVerify();
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
await runAsyncState(this.state, async () => {
|
||||
this.verifyState.error = '';
|
||||
store.setConfig({
|
||||
baseUrl: this.form.baseUrl.trim(),
|
||||
database: this.form.database.trim(),
|
||||
});
|
||||
|
||||
await login(store, {
|
||||
userLogin: this.form.userLogin.trim(),
|
||||
});
|
||||
this.syncFromStore();
|
||||
|
||||
store.addAlert({
|
||||
type: 'info',
|
||||
message: `Got secret key starting ${store.session.applicationKey.slice(0, 8)}.... Approve this key in Tryton preferences.`,
|
||||
});
|
||||
});
|
||||
},
|
||||
async checkConnection() {
|
||||
await runAsyncState(this.verifyState, async () => {
|
||||
await window.__loncApp.verifyConnection();
|
||||
this.syncFromStore();
|
||||
if (store.isConnected) {
|
||||
store.addAlert({ type: 'success', message: 'Kitchen application key is active.' });
|
||||
navigate('/');
|
||||
}
|
||||
}).catch(() => {
|
||||
this.verifyState.error =
|
||||
'Failed to verify connection. Please verify the application key in Tryton first.';
|
||||
});
|
||||
},
|
||||
async tryAutoVerify() {
|
||||
await runAsyncState(this.verifyState, async () => {
|
||||
try {
|
||||
await verifyConnection(store);
|
||||
} catch {
|
||||
this.syncFromStore();
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncFromStore();
|
||||
if (store.isConnected) {
|
||||
await window.__loncApp.refreshKitchens();
|
||||
navigate('/');
|
||||
}
|
||||
}).catch(() => {});
|
||||
},
|
||||
async disconnect() {
|
||||
await window.__loncApp.logout();
|
||||
this.syncFromStore();
|
||||
this.sessionState = CONNECTION_STATES.notConnected;
|
||||
this.applicationKey = '';
|
||||
this.verifyState.error = '';
|
||||
},
|
||||
statusTitle() {
|
||||
return {
|
||||
[CONNECTION_STATES.pendingValidation]: 'Validation still pending',
|
||||
[CONNECTION_STATES.connected]: 'Connected',
|
||||
[CONNECTION_STATES.invalidKey]: 'Stored key is invalid',
|
||||
}[this.sessionState] || 'Not connected';
|
||||
},
|
||||
statusMessage() {
|
||||
return {
|
||||
[CONNECTION_STATES.pendingValidation]:
|
||||
'The application key was created successfully. Copy or compare it with the pending key in Tryton client preferences, approve it there, then verify the connection here.',
|
||||
[CONNECTION_STATES.connected]:
|
||||
'The kitchen user application key is active and ready for protected requests.',
|
||||
[CONNECTION_STATES.invalidKey]:
|
||||
'This key worked before but is no longer accepted. Disconnect and create a new one.',
|
||||
}[this.sessionState] || 'Create a new application key to connect.';
|
||||
},
|
||||
statusAlertClass() {
|
||||
return {
|
||||
[CONNECTION_STATES.pendingValidation]: 'alert-warning',
|
||||
[CONNECTION_STATES.connected]: 'alert-success',
|
||||
[CONNECTION_STATES.invalidKey]: 'alert-danger',
|
||||
}[this.sessionState] || 'alert-secondary';
|
||||
},
|
||||
maskedApplicationKey() {
|
||||
const key = this.applicationKey;
|
||||
return key ? `${key.slice(0, 16)}...` : '';
|
||||
},
|
||||
syncFromStore() {
|
||||
this.sessionState = store.session?.state || CONNECTION_STATES.notConnected;
|
||||
this.applicationKey = store.session?.applicationKey || '';
|
||||
this.form.userLogin = store.session?.userLogin || this.form.userLogin;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export function renderSettingsPage() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4" x-data="settingsPage()">
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<p class="eyebrow mb-2">Client Settings</p>
|
||||
<h1 class="h3 mb-1">Connection & workspace</h1>
|
||||
<p class="text-body-secondary mb-0">
|
||||
These values are stored locally and reused to start the Tryton user application flow.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="vstack gap-3" @submit.prevent="save()">
|
||||
<div>
|
||||
<label class="form-label">Tryton server URL</label>
|
||||
<input class="form-control" type="url" x-model="form.baseUrl" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Database name</label>
|
||||
<input class="form-control" type="text" x-model="form.database" required />
|
||||
</div>
|
||||
<button class="btn btn-primary align-self-start" type="submit">Save settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="h5">Integration notes</h2>
|
||||
<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>Kitchen-scoped requests are built as <code>/{database}/kitchen/{kitchenId}/...</code>.</li>
|
||||
<li>Label preview accepts image blobs, image URLs, or SVG payloads.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function settingsPageData(store) {
|
||||
return {
|
||||
form: {
|
||||
baseUrl: store.config.baseUrl || '',
|
||||
database: store.config.database || '',
|
||||
},
|
||||
save() {
|
||||
store.setConfig({
|
||||
baseUrl: this.form.baseUrl.trim(),
|
||||
database: this.form.database.trim(),
|
||||
});
|
||||
|
||||
store.addAlert({ type: 'success', message: 'Settings saved locally.' });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export function renderDashboardPage() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()">
|
||||
<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">
|
||||
<p class="eyebrow mb-2">Kitchen stock management</p>
|
||||
<h1 class="display-6 mb-3">Keep labels, stock, and adjustments in one focused workflow.</h1>
|
||||
<p class="lead text-body-secondary mb-4">
|
||||
This MVP is shaped for fast household operations on a phone or desktop, with the Tryton backend staying in charge of business logic.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="#/labels/new" class="btn btn-primary btn-lg">Create label</a>
|
||||
<a href="#/stock" class="btn btn-outline-primary btn-lg">Browse stock</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="glass-panel p-4">
|
||||
<div class="small text-uppercase text-body-secondary mb-2">Active kitchen</div>
|
||||
<div class="h4 mb-2" x-text="$store.app.activeKitchen?.name || 'Select a kitchen'"></div>
|
||||
<div class="text-body-secondary mb-3">
|
||||
Switch without signing out when you need to work across kitchens in the same Tryton database.
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" @click="showKitchenPicker = !showKitchenPicker">Switch kitchen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="showKitchenPicker">
|
||||
<div class="mb-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h5 mb-0">Choose kitchen</h2>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="showKitchenPicker = false">Close</button>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<template x-for="kitchen in $store.app.kitchens" :key="kitchen.id">
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<button class="btn kitchen-card w-100 text-start" @click="setKitchen(kitchen)">
|
||||
<div class="fw-semibold" x-text="kitchen.name"></div>
|
||||
<div class="small text-body-secondary" x-text="kitchen.description || 'Kitchen workspace'"></div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<a class="quick-card" href="#/labels/new">
|
||||
<span class="quick-card-label">Labels</span>
|
||||
<strong>New label & stock entry</strong>
|
||||
<span class="text-body-secondary">Search an item, fill the batch fields, preview, then create.</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<a class="quick-card" href="#/stock">
|
||||
<span class="quick-card-label">Stock</span>
|
||||
<strong>Overview & filters</strong>
|
||||
<span class="text-body-secondary">Browse current entries with mobile-friendly cards and table view.</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<a class="quick-card" href="#/stock">
|
||||
<span class="quick-card-label">Adjustments</span>
|
||||
<strong>Fast quantity updates</strong>
|
||||
<span class="text-body-secondary">Apply increments, decrements, or exact counts with clear feedback.</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-3">
|
||||
<a class="quick-card" href="#/settings">
|
||||
<span class="quick-card-label">Settings</span>
|
||||
<strong>Connection details</strong>
|
||||
<span class="text-body-secondary">Update the Tryton base URL and database without leaving the app.</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function dashboardPageData(store) {
|
||||
return {
|
||||
showKitchenPicker: false,
|
||||
setKitchen(kitchen) {
|
||||
store.setActiveKitchen(kitchen);
|
||||
this.showKitchenPicker = false;
|
||||
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export function renderKitchenSelector() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card border-0 shadow-sm" x-data="kitchenSelector()" x-init="init()">
|
||||
<div class="card-body p-4 p-lg-5">
|
||||
<p class="eyebrow mb-2">Kitchen Context</p>
|
||||
<h1 class="h3 mb-2">Pick the active kitchen</h1>
|
||||
<p class="text-body-secondary mb-4">
|
||||
Every stock, label, and location request uses this kitchen context until you switch.
|
||||
</p>
|
||||
|
||||
<template x-if="state.error">
|
||||
<div class="alert alert-danger" x-text="state.error"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="state.isLoading">
|
||||
<div class="alert alert-secondary mb-0">Loading available kitchens...</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!state.isLoading && !state.error && !kitchens.length">
|
||||
<div class="alert alert-warning mb-0">
|
||||
No kitchens were returned by the server for this user application key.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="row g-3">
|
||||
<template x-for="kitchen in kitchens" :key="kitchen.id">
|
||||
<div class="col-12 col-md-6">
|
||||
<button class="btn kitchen-card w-100 text-start p-4" @click="select(kitchen)">
|
||||
<div class="fw-semibold fs-5 mb-1" x-text="kitchen.name"></div>
|
||||
<div class="text-body-secondary" x-text="kitchen.description || kitchen.uuid_b64 || 'Kitchen workspace'"></div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function kitchenSelectorData(store) {
|
||||
return {
|
||||
state: {
|
||||
isLoading: false,
|
||||
error: '',
|
||||
},
|
||||
get kitchens() {
|
||||
return store.kitchens;
|
||||
},
|
||||
async init() {
|
||||
if (!store.kitchens.length) {
|
||||
try {
|
||||
this.state.isLoading = true;
|
||||
await window.__loncApp.refreshKitchens();
|
||||
} catch (error) {
|
||||
this.state.error = error.message;
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
select(kitchen) {
|
||||
store.setActiveKitchen(kitchen);
|
||||
store.addAlert({ type: 'success', message: `Active kitchen set to ${kitchen.name}.` });
|
||||
window.__loncApp.router.render();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
import { createStockEntry, searchItemDefinitions } from '../../api/stock.js';
|
||||
import { fetchLocations } from '../../api/locations.js';
|
||||
import { previewLabel } from '../../api/labels.js';
|
||||
import { STORAGE_KEYS } from '../../app/config.js';
|
||||
import { debounce, normalizeValidationError } from '../shared/form-utils.js';
|
||||
import { loadStoredValue, saveStoredValue } from '../shared/storage.js';
|
||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||
|
||||
const STOCK_TYPE_OPTIONS = [
|
||||
{ value: 'measured', label: 'Measured' },
|
||||
{ value: 'descriptive', label: 'Descriptive' },
|
||||
{ value: 'binary', label: 'Binary' },
|
||||
];
|
||||
|
||||
const STOCK_LEVEL_OPTIONS = [
|
||||
{ value: 'plenty', label: 'Plenty (> 75%)' },
|
||||
{ value: 'good', label: 'Good (> 50%)' },
|
||||
{ value: 'some', label: 'Some (> 25%)' },
|
||||
{ value: 'low', label: 'Low (> 10%)' },
|
||||
{ value: 'trace', label: 'Trace (<= 10%)' },
|
||||
{ value: 'gone', label: 'Gone (~= 0%)' },
|
||||
];
|
||||
|
||||
export function renderLabelCreatePage() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5" x-data="labelCreatePage()" x-init="init()">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end mb-4">
|
||||
<div>
|
||||
<p class="eyebrow mb-2">Label Creation</p>
|
||||
<h1 class="h3 mb-1">Create a stock label and entry</h1>
|
||||
<p class="text-body-secondary mb-0">
|
||||
Active kitchen:
|
||||
<span class="fw-semibold text-body" x-text="$store.app.activeKitchen?.name"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="small text-body-secondary">
|
||||
Drafts are stored locally so small navigation changes do not wipe form input.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<form class="vstack gap-3" @submit.prevent="create()" autocomplete="off">
|
||||
<div class="position-relative search-field-with-clear">
|
||||
<label class="form-label">Search item definitions</label>
|
||||
<input class="form-control pe-5" type="text" x-model="form.search" @input="onSearchInput()" placeholder="Search by item name" autocomplete="off" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link text-body-secondary clear-field-button search-clear-button"
|
||||
x-show="form.search || form.itemId"
|
||||
@click="clearItemSearch()"
|
||||
aria-label="Clear item search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<template x-if="suggestions.length">
|
||||
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1">
|
||||
<template x-for="item in suggestions" :key="item.id">
|
||||
<button class="list-group-item list-group-item-action" type="button" @click="pickSuggestion(item)">
|
||||
<div class="fw-semibold" x-text="item.name"></div>
|
||||
<div class="small text-body-secondary" x-text="item.description || 'Existing item definition'"></div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-8 position-relative text-field-with-clear">
|
||||
<label class="form-label">Title / name</label>
|
||||
<input class="form-control pe-5" type="text" x-model="form.name" required />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link text-body-secondary clear-field-button inline-clear-button"
|
||||
x-show="form.name"
|
||||
@click="form.name = ''"
|
||||
aria-label="Clear title"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 position-relative text-field-with-clear">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control pe-5" rows="2" x-model="form.description"></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link text-body-secondary clear-field-button textarea-clear-button"
|
||||
x-show="form.description"
|
||||
@click="form.description = ''"
|
||||
aria-label="Clear description"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">Stock type</label>
|
||||
<select class="form-select" x-model="form.stockType" x-ref="stockTypeSelect" autocomplete="off">
|
||||
<template x-for="option in stockTypeOptions" :key="option.value">
|
||||
<option :value="option.value" x-text="option.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 grouped-field-with-clear" x-show="form.stockType === 'measured'">
|
||||
<div class="grouped-field-footer mb-1">
|
||||
<label class="form-label mb-0">Quantity</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link text-body-secondary p-0"
|
||||
x-show="form.quantity || form.uom"
|
||||
@click="clearQuantityFields()"
|
||||
>
|
||||
Clear quantity
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-7">
|
||||
<input class="form-control" type="number" step="0.01" min="0" x-model="form.quantity" />
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<input class="form-control" type="text" x-model="form.uom" placeholder="g" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-7">
|
||||
<div class="small text-body-secondary">Amount</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<div class="small text-body-secondary">Unit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6" x-show="form.stockType === 'descriptive'">
|
||||
<label class="form-label">Stock level</label>
|
||||
<select class="form-select" x-model="form.level" x-ref="stockLevelSelect">
|
||||
<option value="">Select level</option>
|
||||
<template x-for="option in stockLevelOptions" :key="option.value">
|
||||
<option :value="option.value" x-text="option.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 grouped-field-with-clear">
|
||||
<div class="grouped-field-footer mb-1">
|
||||
<label class="form-label mb-0">Energy</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link text-body-secondary p-0"
|
||||
x-show="form.energy !== '' || form.energyUnit"
|
||||
@click="clearEnergyFields()"
|
||||
>
|
||||
Clear energy
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-5">
|
||||
<input class="form-control" type="number" step="0.01" min="0" x-model="form.energy" />
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<input class="form-control" type="text" x-model="form.energyUnit" placeholder="kcal (100g/ml)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-5">
|
||||
<div class="small text-body-secondary">Value</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div class="small text-body-secondary">Unit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Storage location</label>
|
||||
<div
|
||||
class="position-relative location-field-with-clear"
|
||||
x-ref="locationPicker"
|
||||
@focusin="locationPickerOpen = true"
|
||||
@focusout="handleLocationFocusOut($event)"
|
||||
>
|
||||
<input
|
||||
class="form-control pe-5"
|
||||
type="text"
|
||||
x-model="locationSearch"
|
||||
@input="onLocationInput()"
|
||||
@keydown.escape="locationPickerOpen = false"
|
||||
@click="openLocationPicker()"
|
||||
placeholder="Search location"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link text-body-secondary clear-field-button location-clear-button"
|
||||
x-show="locationSearch || form.locationId"
|
||||
@click="clearLocation()"
|
||||
aria-label="Clear selected location"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<input type="hidden" x-model="form.locationId" />
|
||||
<template x-if="locationPickerOpen">
|
||||
<div class="list-group shadow-sm position-absolute start-0 end-0 z-3 mt-1 location-picker">
|
||||
<template x-if="filteredLocations.length">
|
||||
<div>
|
||||
<template x-for="location in filteredLocations" :key="location.id">
|
||||
<button
|
||||
class="list-group-item list-group-item-action"
|
||||
type="button"
|
||||
@click="pickLocation(location)"
|
||||
:style="locationItemStyle(location)"
|
||||
>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span
|
||||
class="location-level-badge"
|
||||
x-show="location.depth"
|
||||
x-text="locationLevelLabel(location)"
|
||||
></span>
|
||||
<div class="fw-semibold" x-text="location.name"></div>
|
||||
</div>
|
||||
<div class="small text-body-secondary" x-text="locationPathHint(location)"></div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!filteredLocations.length">
|
||||
<div class="list-group-item text-body-secondary">
|
||||
No matching locations found.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="form-text" x-show="selectedLocationPath" x-text="selectedLocationPath"></div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Production date</label>
|
||||
<input class="form-control" type="date" x-model="form.productionDate" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6 grouped-field-with-clear">
|
||||
<div class="grouped-field-footer mb-1">
|
||||
<label class="form-label mb-0">Expiration</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-link text-body-secondary p-0"
|
||||
x-show="form.expirationDate || form.expireDays"
|
||||
@click="clearExpirationDate()"
|
||||
>
|
||||
Clear expiration date
|
||||
</button>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-5">
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
x-model="form.expireDays"
|
||||
@input="syncExpireDateFromDays()"
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<input
|
||||
class="form-control"
|
||||
type="date"
|
||||
x-model="form.expirationDate"
|
||||
@input="syncExpireDaysFromDate()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-5">
|
||||
<div class="small text-body-secondary">Days</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div class="small text-body-secondary">Date</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Enter either days or a date. The other field updates automatically.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="submitError">
|
||||
<div class="alert alert-danger mb-0">
|
||||
<div class="fw-semibold mb-1">Could not save this stock entry.</div>
|
||||
<div x-text="submitError"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="successMessage">
|
||||
<div class="alert alert-success mb-0" x-text="successMessage"></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">
|
||||
<span x-show="!previewState.isLoading">Preview label</span>
|
||||
<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">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary" type="button" @click="reset()">Clear form</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-5" x-show="previewUrl">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Label preview</h2>
|
||||
<p class="text-body-secondary small mb-0">PNG and SVG responses are both supported.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="previewState.error">
|
||||
<div class="alert alert-warning" x-text="previewState.error"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="previewUrl">
|
||||
<div class="preview-frame">
|
||||
<img class="img-fluid rounded-3 border" :src="previewUrl" alt="Generated label preview" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function todayIsoDate() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addDaysToIsoDate(isoDate, days) {
|
||||
const [year, month, day] = isoDate.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
date.setDate(date.getDate() + days);
|
||||
const nextYear = date.getFullYear();
|
||||
const nextMonth = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const nextDay = String(date.getDate()).padStart(2, '0');
|
||||
return `${nextYear}-${nextMonth}-${nextDay}`;
|
||||
}
|
||||
|
||||
function diffDays(fromIsoDate, toIsoDate) {
|
||||
const [fromYear, fromMonth, fromDay] = fromIsoDate.split('-').map(Number);
|
||||
const [toYear, toMonth, toDay] = toIsoDate.split('-').map(Number);
|
||||
const fromDate = new Date(fromYear, fromMonth - 1, fromDay);
|
||||
const toDate = new Date(toYear, toMonth - 1, toDay);
|
||||
const millisecondsPerDay = 24 * 60 * 60 * 1000;
|
||||
return Math.round((toDate - fromDate) / millisecondsPerDay);
|
||||
}
|
||||
|
||||
function createDefaultForm() {
|
||||
return {
|
||||
itemId: '',
|
||||
search: '',
|
||||
name: '',
|
||||
description: '',
|
||||
quantity: '',
|
||||
uom: 'g',
|
||||
stockType: 'binary',
|
||||
level: 'plenty',
|
||||
energy: '',
|
||||
energyUnit: 'kcal (100g/ml)',
|
||||
productionDate: todayIsoDate(),
|
||||
expireDays: '',
|
||||
expirationDate: '',
|
||||
locationId: '',
|
||||
};
|
||||
}
|
||||
|
||||
function loadLabelDraft() {
|
||||
const draft = loadStoredValue(STORAGE_KEYS.labelDraft, createDefaultForm());
|
||||
|
||||
return {
|
||||
...createDefaultForm(),
|
||||
...draft,
|
||||
itemId: '',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraftPayload(form) {
|
||||
return {
|
||||
...form,
|
||||
itemId: '',
|
||||
search: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function labelCreatePageData(store) {
|
||||
return {
|
||||
previewState: createAsyncState(),
|
||||
createState: createAsyncState(),
|
||||
stockTypeOptions: STOCK_TYPE_OPTIONS,
|
||||
stockLevelOptions: STOCK_LEVEL_OPTIONS,
|
||||
suggestions: [],
|
||||
locations: [],
|
||||
locationSearch: '',
|
||||
locationPickerOpen: false,
|
||||
previewUrl: '',
|
||||
successMessage: '',
|
||||
submitError: '',
|
||||
fieldErrors: {},
|
||||
form: {
|
||||
...loadLabelDraft(),
|
||||
},
|
||||
async init() {
|
||||
await this.loadLocations();
|
||||
this.$watch('form', () => this.persistDraft(), { deep: true });
|
||||
this.$watch('form.stockType', (value) => {
|
||||
this.syncStockTypeState(value);
|
||||
this.syncStockTypeSelect();
|
||||
this.syncStockLevelSelect();
|
||||
});
|
||||
this.$watch('form.level', () => this.syncStockLevelSelect());
|
||||
this.syncStockTypeState(this.form.stockType);
|
||||
this.syncStockTypeSelect();
|
||||
this.syncStockLevelSelect();
|
||||
this.searchDebounced = debounce(async () => {
|
||||
if (this.form.search.trim().length <= 2) {
|
||||
this.suggestions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.suggestions = await searchItemDefinitions(store, this.form.search.trim());
|
||||
}, 250);
|
||||
},
|
||||
async loadLocations() {
|
||||
try {
|
||||
const { flat } = await fetchLocations(store);
|
||||
this.locations = flat;
|
||||
this.syncLocationSelection();
|
||||
} catch (error) {
|
||||
store.addAlert({
|
||||
type: 'warning',
|
||||
message: `Locations could not be loaded: ${error.message}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSearchInput() {
|
||||
this.persistDraft();
|
||||
this.searchDebounced();
|
||||
},
|
||||
pickSuggestion(item) {
|
||||
this.form.itemId = item.id;
|
||||
this.form.search = item.name;
|
||||
this.form.name = item.name;
|
||||
this.form.description = item.description || this.form.description;
|
||||
this.form.uom = item.uom_symbol || this.form.uom;
|
||||
this.form.quantity =
|
||||
item.quantity === 0 || item.quantity
|
||||
? String(item.quantity)
|
||||
: this.form.quantity;
|
||||
this.form.stockType = item.stock_type || this.form.stockType;
|
||||
this.form.level = item.level || this.form.level;
|
||||
this.form.expirationDate = item.expire_date || this.form.expirationDate;
|
||||
this.applyItemLocation(item.location_initial_uuid_b64);
|
||||
this.syncExpireDaysFromDate();
|
||||
this.suggestions = [];
|
||||
this.persistDraft();
|
||||
},
|
||||
clearItemSearch() {
|
||||
this.form.itemId = '';
|
||||
this.form.search = '';
|
||||
this.suggestions = [];
|
||||
this.persistDraft();
|
||||
},
|
||||
persistDraft() {
|
||||
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
||||
},
|
||||
get filteredLocations() {
|
||||
const query = this.locationSearch.trim().toLowerCase();
|
||||
const selectedLabel = this.selectedLocation
|
||||
? this.selectedLocation.name.toLowerCase()
|
||||
: '';
|
||||
|
||||
if (selectedLabel && query === selectedLabel) {
|
||||
return this.locations;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return this.locations;
|
||||
}
|
||||
|
||||
return this.locations.filter((location) =>
|
||||
`${location.name} ${location.pathLabel}`.toLowerCase().includes(query),
|
||||
);
|
||||
},
|
||||
get selectedLocation() {
|
||||
return this.locations.find(
|
||||
(location) => String(location.id) === String(this.form.locationId),
|
||||
) || null;
|
||||
},
|
||||
get selectedLocationPath() {
|
||||
return this.selectedLocation?.pathLabel || '';
|
||||
},
|
||||
pickLocation(location) {
|
||||
this.form.locationId = String(location.id);
|
||||
this.locationSearch = location.name;
|
||||
this.locationPickerOpen = false;
|
||||
this.persistDraft();
|
||||
},
|
||||
clearLocation() {
|
||||
this.form.locationId = '';
|
||||
this.locationSearch = '';
|
||||
this.locationPickerOpen = true;
|
||||
this.persistDraft();
|
||||
},
|
||||
openLocationPicker() {
|
||||
this.locationPickerOpen = true;
|
||||
},
|
||||
onLocationInput() {
|
||||
this.locationPickerOpen = true;
|
||||
if (this.selectedLocation && this.locationSearch !== this.selectedLocation.name) {
|
||||
this.form.locationId = '';
|
||||
}
|
||||
},
|
||||
handleLocationFocusOut(event) {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (nextTarget && this.$refs.locationPicker?.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.locationPickerOpen = false;
|
||||
},
|
||||
syncLocationSelection() {
|
||||
if (!this.form.locationId) {
|
||||
this.locationSearch = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.locations.find(
|
||||
(location) => String(location.id) === String(this.form.locationId),
|
||||
);
|
||||
|
||||
this.locationSearch = selected ? selected.name : '';
|
||||
},
|
||||
applyItemLocation(locationUuidB64) {
|
||||
if (!locationUuidB64) {
|
||||
return;
|
||||
}
|
||||
|
||||
const location = this.locations.find(
|
||||
(entry) => entry.uuid_b64 === locationUuidB64,
|
||||
);
|
||||
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.locationId = String(location.id);
|
||||
this.locationSearch = location.name;
|
||||
},
|
||||
syncExpireDateFromDays() {
|
||||
if (this.form.expireDays === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Number(this.form.expireDays);
|
||||
if (Number.isNaN(days) || days < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDate = this.form.productionDate || todayIsoDate();
|
||||
this.form.expirationDate = addDaysToIsoDate(baseDate, days);
|
||||
},
|
||||
syncExpireDaysFromDate() {
|
||||
if (!this.form.expirationDate) {
|
||||
this.form.expireDays = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDate = this.form.productionDate || todayIsoDate();
|
||||
const days = diffDays(baseDate, this.form.expirationDate);
|
||||
this.form.expireDays = days >= 0 ? String(days) : '';
|
||||
},
|
||||
clearExpirationDate() {
|
||||
this.form.expirationDate = '';
|
||||
this.form.expireDays = '';
|
||||
},
|
||||
clearEnergyFields() {
|
||||
this.form.energy = '';
|
||||
this.form.energyUnit = 'kcal (100g/ml)';
|
||||
},
|
||||
clearQuantityFields() {
|
||||
this.form.quantity = '';
|
||||
this.form.uom = 'g';
|
||||
},
|
||||
syncStockTypeState(stockType) {
|
||||
if (stockType === 'measured') {
|
||||
this.form.level = '';
|
||||
if (this.form.uom === '') {
|
||||
this.form.uom = 'g';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.form.quantity = '';
|
||||
this.form.uom = 'g';
|
||||
|
||||
if (stockType === 'binary' && this.form.level !== 'plenty') {
|
||||
this.form.level = 'plenty';
|
||||
return;
|
||||
}
|
||||
|
||||
if (stockType === 'descriptive' && (!this.form.level || this.form.level === '')) {
|
||||
this.form.level = 'plenty';
|
||||
}
|
||||
},
|
||||
syncStockTypeSelect() {
|
||||
if (this.$refs.stockTypeSelect) {
|
||||
this.$refs.stockTypeSelect.value = this.form.stockType;
|
||||
}
|
||||
},
|
||||
syncStockLevelSelect() {
|
||||
if (this.$refs.stockLevelSelect) {
|
||||
this.$refs.stockLevelSelect.value = this.form.level || 'plenty';
|
||||
}
|
||||
},
|
||||
buildPayload() {
|
||||
const quantity =
|
||||
this.form.stockType === 'measured'
|
||||
? this.form.quantity === ''
|
||||
? null
|
||||
: Number(this.form.quantity)
|
||||
: this.form.stockType === 'binary'
|
||||
? 1
|
||||
: null;
|
||||
|
||||
return {
|
||||
item_id: this.form.itemId || null,
|
||||
name: this.form.name.trim(),
|
||||
description: this.form.description.trim(),
|
||||
quantity_initial: quantity,
|
||||
uom_symbol: this.form.stockType === 'measured' ? this.form.uom.trim() : null,
|
||||
calories: this.form.energy === '' ? null : Number(this.form.energy),
|
||||
calories_unit: this.form.energyUnit.trim() || null,
|
||||
stock_type: this.form.stockType,
|
||||
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,
|
||||
kitchen_id: store.activeKitchen?.id || null,
|
||||
};
|
||||
},
|
||||
async preview() {
|
||||
await runAsyncState(this.previewState, async () => {
|
||||
this.successMessage = '';
|
||||
const result = await previewLabel(store, this.buildPayload());
|
||||
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
}
|
||||
this.previewUrl = result.objectUrl;
|
||||
this.persistDraft();
|
||||
});
|
||||
},
|
||||
async create() {
|
||||
this.submitError = '';
|
||||
this.fieldErrors = {};
|
||||
|
||||
await runAsyncState(this.createState, async () => {
|
||||
try {
|
||||
const entry = await createStockEntry(store, this.buildPayload());
|
||||
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
}
|
||||
this.previewUrl = '';
|
||||
this.successMessage = `${entry.name || this.form.name} was created successfully.`;
|
||||
store.addAlert({
|
||||
type: 'success',
|
||||
message: `${entry.name || this.form.name} was created successfully.`,
|
||||
});
|
||||
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
|
||||
} catch (error) {
|
||||
this.fieldErrors = normalizeValidationError(error.cause);
|
||||
this.submitError = error.message;
|
||||
throw error;
|
||||
}
|
||||
}).catch(() => {});
|
||||
},
|
||||
reset(revokePreview = true) {
|
||||
this.form = createDefaultForm();
|
||||
this.syncStockTypeState(this.form.stockType);
|
||||
this.suggestions = [];
|
||||
this.locationSearch = '';
|
||||
this.locationPickerOpen = false;
|
||||
this.successMessage = '';
|
||||
this.submitError = '';
|
||||
this.fieldErrors = {};
|
||||
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
|
||||
if (revokePreview && this.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(this.previewUrl);
|
||||
}
|
||||
this.previewUrl = '';
|
||||
},
|
||||
locationItemStyle(location) {
|
||||
const depth = location.depth || 0;
|
||||
return `padding-left: calc(1rem + ${depth} * 1rem);`;
|
||||
},
|
||||
locationLevelLabel(location) {
|
||||
return `L${(location.depth || 0) + 1}`;
|
||||
},
|
||||
locationPathHint(location) {
|
||||
if (!location.depth) {
|
||||
return location.type || 'Top-level location';
|
||||
}
|
||||
|
||||
const segments = location.pathLabel.split(' / ');
|
||||
return `Inside ${segments.slice(0, -1).join(' / ')}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { alertsData } from './shared/alerts.js';
|
||||
import { loginPageData } from './auth/login-page.js';
|
||||
import { settingsPageData } from './auth/settings-page.js';
|
||||
import { dashboardPageData } from './dashboard/dashboard-page.js';
|
||||
import { kitchenSelectorData } from './kitchens/kitchen-selector.js';
|
||||
import { labelCreatePageData } from './labels/label-create-page.js';
|
||||
import { stockDetailPageData } from './stock/stock-detail-page.js';
|
||||
import { stockListPageData } from './stock/stock-list-page.js';
|
||||
|
||||
export function registerFeatureData(Alpine, store) {
|
||||
Alpine.data('alertsData', () => alertsData(store));
|
||||
Alpine.data('loginPage', () => loginPageData(store));
|
||||
Alpine.data('settingsPage', () => settingsPageData(store));
|
||||
Alpine.data('dashboardPage', () => dashboardPageData(store));
|
||||
Alpine.data('kitchenSelector', () => kitchenSelectorData(store));
|
||||
Alpine.data('labelCreatePage', () => labelCreatePageData(store));
|
||||
Alpine.data('stockListPage', () => stockListPageData(store));
|
||||
Alpine.data('stockDetailPage', () => stockDetailPageData(store));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function alertsData(store) {
|
||||
return {
|
||||
get alerts() {
|
||||
return store.alerts;
|
||||
},
|
||||
dismiss(alertId) {
|
||||
store.removeAlert(alertId);
|
||||
},
|
||||
badgeClass(type) {
|
||||
return {
|
||||
info: 'bg-info-subtle text-info-emphasis',
|
||||
success: 'bg-success-subtle text-success-emphasis',
|
||||
warning: 'bg-warning-subtle text-warning-emphasis',
|
||||
danger: 'bg-danger-subtle text-danger-emphasis',
|
||||
}[type] || 'bg-secondary-subtle text-secondary-emphasis';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function formatDate(value) {
|
||||
if (!value) {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function fieldError(errors, field) {
|
||||
return errors[field] || '';
|
||||
}
|
||||
|
||||
export function normalizeValidationError(error) {
|
||||
if (error?.details && typeof error.details === 'object') {
|
||||
return error.details;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function debounce(callback, wait = 300) {
|
||||
let timeoutId;
|
||||
|
||||
return (...args) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function loadStoredValue(key, fallback = null) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredValue(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Ignore storage failures so the app still works in constrained browsers.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredValue(key) {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore storage failures so the app still works in constrained browsers.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function createAsyncState() {
|
||||
return {
|
||||
isLoading: false,
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAsyncState(target, callback) {
|
||||
target.isLoading = true;
|
||||
target.error = '';
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} catch (error) {
|
||||
target.error = error.message || 'Something went wrong.';
|
||||
throw error;
|
||||
} finally {
|
||||
target.isLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { adjustStockEntry, getStockEntry } from '../../api/stock.js';
|
||||
import { getRouteContext } from '../../app/router.js';
|
||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||
import { formatDate } from '../shared/date-utils.js';
|
||||
|
||||
export function renderStockDetailPage() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5" x-data="stockDetailPage()" x-init="init()">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center mb-4">
|
||||
<div>
|
||||
<a href="#/stock" class="link-secondary text-decoration-none small">← Back to stock</a>
|
||||
<h1 class="h3 mb-1 mt-2">Stock detail</h1>
|
||||
<p class="text-body-secondary mb-0">Inspect the entry and update its quantity without leaving the workflow.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="state.error">
|
||||
<div class="alert alert-danger" x-text="state.error"></div>
|
||||
</template>
|
||||
|
||||
<template x-if="entry">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-3 mb-4">
|
||||
<div>
|
||||
<p class="eyebrow mb-2">Entry</p>
|
||||
<h2 class="h4 mb-1" x-text="entry.name"></h2>
|
||||
<p class="text-body-secondary mb-0" x-text="entry.description || 'No description'"></p>
|
||||
</div>
|
||||
<span class="badge rounded-pill text-bg-light border align-self-start" x-text="entry.status || 'ok'"></span>
|
||||
</div>
|
||||
|
||||
<dl class="row mb-0 detail-grid">
|
||||
<dt class="col-5">Kitchen</dt>
|
||||
<dd class="col-7" x-text="$store.app.activeKitchen?.name || 'Unknown'"></dd>
|
||||
<dt class="col-5">Quantity</dt>
|
||||
<dd class="col-7" x-text="formatQuantity(entry)"></dd>
|
||||
<dt class="col-5">Location</dt>
|
||||
<dd class="col-7" x-text="entry.location_name || 'Unassigned'"></dd>
|
||||
<dt class="col-5">Production date</dt>
|
||||
<dd class="col-7" x-text="formatDate(entry.production_date)"></dd>
|
||||
<dt class="col-5">Expiration date</dt>
|
||||
<dd class="col-7" x-text="formatDate(entry.expiration_date)"></dd>
|
||||
<dt class="col-5">Stock type</dt>
|
||||
<dd class="col-7" x-text="entry.stock_type || 'Stored'"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="eyebrow mb-2">Adjustment</p>
|
||||
<h2 class="h5 mb-3">Update current stock level</h2>
|
||||
|
||||
<form class="vstack gap-3" @submit.prevent="submitAdjustment()">
|
||||
<div>
|
||||
<label class="form-label">Adjustment mode</label>
|
||||
<select class="form-select" x-model="adjustment.mode">
|
||||
<option value="increment">Add quantity</option>
|
||||
<option value="decrement">Subtract quantity</option>
|
||||
<option value="set">Set exact quantity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Quantity</label>
|
||||
<input class="form-control" type="number" min="0" step="0.01" x-model="adjustment.quantity" required />
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(1)">+1</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(-1)">-1</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @click="quickAdjust(0.5)">+0.5</button>
|
||||
</div>
|
||||
|
||||
<template x-if="adjustmentState.error">
|
||||
<div class="alert alert-danger mb-0" x-text="adjustmentState.error"></div>
|
||||
</template>
|
||||
|
||||
<button class="btn btn-primary" type="submit" :disabled="adjustmentState.isLoading">
|
||||
<span x-show="!adjustmentState.isLoading">Save adjustment</span>
|
||||
<span x-show="adjustmentState.isLoading">Saving...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function stockDetailPageData(store) {
|
||||
return {
|
||||
state: createAsyncState(),
|
||||
adjustmentState: createAsyncState(),
|
||||
entry: null,
|
||||
adjustment: {
|
||||
mode: 'increment',
|
||||
quantity: '1',
|
||||
},
|
||||
async init() {
|
||||
const { params } = getRouteContext();
|
||||
await runAsyncState(this.state, async () => {
|
||||
this.entry = await getStockEntry(store, params.id);
|
||||
}).catch(() => {});
|
||||
},
|
||||
async submitAdjustment() {
|
||||
if (!this.entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runAsyncState(this.adjustmentState, async () => {
|
||||
this.entry = await adjustStockEntry(store, this.entry.id, {
|
||||
mode: this.adjustment.mode,
|
||||
quantity: Number(this.adjustment.quantity),
|
||||
});
|
||||
store.addAlert({ type: 'success', message: 'Stock quantity updated.' });
|
||||
}).catch(() => {});
|
||||
},
|
||||
quickAdjust(step) {
|
||||
const current = Number(this.adjustment.quantity || 0);
|
||||
this.adjustment.quantity = String(Math.max(current + step, 0));
|
||||
},
|
||||
formatDate,
|
||||
formatQuantity(entry) {
|
||||
return `${entry.quantity ?? 0} ${entry.uom || ''}`.trim();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { deleteStockItem, listStockEntries, updateStockItem } from '../../api/stock.js';
|
||||
import { fetchLocations } from '../../api/locations.js';
|
||||
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
||||
import { formatDate } from '../shared/date-utils.js';
|
||||
|
||||
const LEVEL_LABELS = {
|
||||
plenty: 'Plenty',
|
||||
good: 'Good',
|
||||
some: 'Some',
|
||||
low: 'Low',
|
||||
trace: 'Trace',
|
||||
gone: 'Gone',
|
||||
};
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: 'plenty', label: 'Plenty' },
|
||||
{ value: 'good', label: 'Good' },
|
||||
{ value: 'some', label: 'Some' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'trace', label: 'Trace' },
|
||||
{ value: 'gone', label: 'Gone' },
|
||||
];
|
||||
|
||||
const EXPIRATION_LEGEND = [
|
||||
{ key: 'expired', label: 'Expired', description: 'The expiration date has already passed.' },
|
||||
{ key: 'use-first', label: 'Use first', description: 'Still within date, but should be prioritized soonest for consumption.' },
|
||||
{ key: 'upcoming', label: 'Upcoming expiration', description: 'Within date, but approaching expiration in the near term.' },
|
||||
{ key: 'within-date', label: 'Within date', description: 'Still within the expected shelf-life window.' },
|
||||
{ key: 'none', label: 'No expiration', description: 'No expiration date is assigned.' },
|
||||
];
|
||||
|
||||
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] = 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',
|
||||
sortRank: 4,
|
||||
};
|
||||
}
|
||||
|
||||
const today = todayAtMidnight();
|
||||
const expireDate = parseDateValue(entry.expire_date);
|
||||
const expireIn =
|
||||
typeof entry.expire_in === 'number'
|
||||
? entry.expire_in
|
||||
: Math.round((expireDate - today) / (24 * 60 * 60 * 1000));
|
||||
|
||||
if (expireIn < 0) {
|
||||
return {
|
||||
key: 'expired',
|
||||
label: 'Expired',
|
||||
detail: `Expired ${Math.abs(expireIn)} day${Math.abs(expireIn) === 1 ? '' : 's'} ago`,
|
||||
sortRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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'}`,
|
||||
sortRank: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (expireIn <= 7) {
|
||||
return {
|
||||
key: 'upcoming',
|
||||
label: 'Upcoming expiration',
|
||||
detail: `Expires in ${expireIn} days`,
|
||||
sortRank: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'within-date',
|
||||
label: 'Within date',
|
||||
detail: `Expires in ${expireIn} days`,
|
||||
sortRank: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function sortEntries(entries) {
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftExpiration = expirationInfo(left);
|
||||
const rightExpiration = expirationInfo(right);
|
||||
|
||||
if (leftExpiration.sortRank !== rightExpiration.sortRank) {
|
||||
return leftExpiration.sortRank - rightExpiration.sortRank;
|
||||
}
|
||||
|
||||
const leftExpire = left.expire_date || '9999-12-31';
|
||||
const rightExpire = right.expire_date || '9999-12-31';
|
||||
if (leftExpire !== rightExpire) {
|
||||
return leftExpire.localeCompare(rightExpire);
|
||||
}
|
||||
|
||||
return (left.name || '').localeCompare(right.name || '');
|
||||
});
|
||||
}
|
||||
|
||||
function quantityLabel(entry) {
|
||||
if (entry.stock_type === 'binary') {
|
||||
return entry.level === 'gone' ? 'Gone' : 'Available';
|
||||
}
|
||||
|
||||
const numeric = entry.quantity ?? null;
|
||||
const uom = entry.uom_symbol || '';
|
||||
const measured = numeric !== null && numeric !== undefined ? `${numeric} ${uom}`.trim() : '';
|
||||
const level = entry.level ? LEVEL_LABELS[entry.level] || entry.level : '';
|
||||
|
||||
if (entry.stock_type === 'descriptive') {
|
||||
return level || 'No stock level';
|
||||
}
|
||||
|
||||
if (measured && level) {
|
||||
return `${measured} • ${level}`;
|
||||
}
|
||||
|
||||
return measured || level || 'No quantity';
|
||||
}
|
||||
|
||||
function resolveLocationLabel(entry, locationMap) {
|
||||
if (!entry.location_initial_uuid_b64) {
|
||||
return 'No location assigned';
|
||||
}
|
||||
|
||||
return locationMap[entry.location_initial_uuid_b64] || 'Location not resolved';
|
||||
}
|
||||
|
||||
function searchBlob(entry, locationMap) {
|
||||
return [
|
||||
entry.name,
|
||||
entry.description,
|
||||
entry.level,
|
||||
entry.stock_type,
|
||||
resolveLocationLabel(entry, locationMap),
|
||||
entry.uuid_b64,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function renderStockListPage() {
|
||||
return `
|
||||
<section class="container-xxl py-4 py-lg-5" x-data="stockListPage()" x-init="init()">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end mb-4">
|
||||
<div>
|
||||
<p class="eyebrow mb-2">Stock Review</p>
|
||||
<h1 class="h3 mb-1">Review stock and act quickly</h1>
|
||||
<p class="text-body-secondary mb-0">
|
||||
Focus on expiration, stock state, and location without leaving the overview.
|
||||
</p>
|
||||
</div>
|
||||
<a href="#/labels/new" class="btn btn-primary">New stock label</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Search stock</label>
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
placeholder="Search by item, description, location, or id"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="stock-filter-toolbar">
|
||||
<details class="stock-filter-details w-100">
|
||||
<summary class="btn btn-outline-secondary">More filters</summary>
|
||||
<div class="stock-filter-panel mt-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Expiration filter</label>
|
||||
<select class="form-select" x-model="filters.expiration">
|
||||
<option value="">All expiration states</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="use-first">Use first</option>
|
||||
<option value="upcoming">Upcoming expiration</option>
|
||||
<option value="within-date">Within date</option>
|
||||
<option value="none">No expiration</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Location</label>
|
||||
<select class="form-select" x-model="filters.location">
|
||||
<option value="">All locations</option>
|
||||
<template x-for="location in locations" :key="location.id">
|
||||
<option :value="location.uuid_b64" x-text="location.pathLabel"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<button class="btn btn-outline-secondary stock-filter-clear" type="button" @click="clearFilters()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="card border-0 shadow-sm mb-4 stock-guide">
|
||||
<summary class="card-body p-4 d-flex justify-content-between align-items-center gap-3 stock-guide-summary">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Expiration overview</h2>
|
||||
<p class="text-body-secondary small mb-0">
|
||||
Show what each expiration color means.
|
||||
</p>
|
||||
</div>
|
||||
<div class="small text-body-secondary">
|
||||
<span class="fw-semibold text-body" x-text="filteredEntries.length"></span>
|
||||
item(s) visible
|
||||
</div>
|
||||
</summary>
|
||||
<div class="card-body pt-0 px-4 pb-4">
|
||||
<div class="row g-3">
|
||||
<template x-for="stateInfo in expirationLegend" :key="stateInfo.key">
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="legend-card h-100" :class="legendClass(stateInfo.key)">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-1">
|
||||
<div class="fw-semibold" x-text="stateInfo.label"></div>
|
||||
<div class="small fw-semibold" x-text="expirationCount(stateInfo.key)"></div>
|
||||
</div>
|
||||
<div class="small" x-text="stateInfo.description"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<template x-if="state.isLoading">
|
||||
<div class="alert alert-secondary">Loading stock review...</div>
|
||||
</template>
|
||||
|
||||
<template x-if="state.error">
|
||||
<div class="alert alert-danger d-flex justify-content-between align-items-center gap-3">
|
||||
<span x-text="state.error"></span>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" @click="init()">Retry</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!state.isLoading && !state.error && !filteredEntries.length">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4 text-center">
|
||||
<h2 class="h5">No stock items to show</h2>
|
||||
<p class="text-body-secondary mb-0">
|
||||
Try clearing the filters or create a new stock label.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!state.isLoading && !state.error && filteredEntries.length">
|
||||
<div>
|
||||
<div class="d-none d-xl-block">
|
||||
<div class="card border-0 shadow-sm overflow-hidden">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0 stock-review-table">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Expiration</th>
|
||||
<th>Quantity / level</th>
|
||||
<th>Location</th>
|
||||
<th>Dates</th>
|
||||
<th>Quick actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="entry in filteredEntries" :key="entry.id">
|
||||
<tr :class="rowClass(entry)">
|
||||
<td>
|
||||
<div class="fw-semibold" x-text="entry.name"></div>
|
||||
<div class="small text-body-secondary" x-text="entry.description || 'No description'"></div>
|
||||
<div class="small font-monospace text-body-secondary" x-text="shortId(entry)"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
|
||||
</div>
|
||||
<div class="small text-body-secondary" x-text="expirationFor(entry).detail"></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold" x-text="quantityLabel(entry)"></div>
|
||||
<div class="small text-body-secondary" x-text="stockTypeDetail(entry)"></div>
|
||||
</td>
|
||||
<td x-text="locationLabel(entry)"></td>
|
||||
<td>
|
||||
<div class="small"><span class="text-body-secondary">Made:</span> <span x-text="formatDate(entry.date)"></span></div>
|
||||
<div class="small"><span class="text-body-secondary">Expires:</span> <span x-text="formatDate(entry.expire_date)"></span></div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="quick-edit-stack">
|
||||
<template x-if="entry.stock_type === 'binary'">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" @click="updateBinary(entry, 'gone')">Mark gone</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="entry.stock_type === 'descriptive'">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<select class="form-select form-select-sm quick-select" x-model="editForms[entry.id].level">
|
||||
<template x-for="option in levelOptions" :key="option.value">
|
||||
<option :value="option.value" x-text="option.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="entry.stock_type === 'measured'">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<input class="form-control form-control-sm quick-number" type="number" step="0.01" min="0" x-model="editForms[entry.id].quantity" />
|
||||
<button class="btn btn-sm btn-primary" type="button" @click="saveQuantity(entry)">Save qty</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" @click="markMeasuredGone(entry)">Gone</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editErrors[entry.id]">
|
||||
<div class="small text-danger" x-text="editErrors[entry.id]"></div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-3 d-xl-none">
|
||||
<template x-for="entry in filteredEntries" :key="entry.id">
|
||||
<div class="card border-0 shadow-sm stock-review-card" :class="rowClass(entry)">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between gap-3 align-items-start mb-3">
|
||||
<div>
|
||||
<div class="fw-semibold fs-5" x-text="entry.name"></div>
|
||||
<div class="text-body-secondary small" x-text="entry.description || 'No description'"></div>
|
||||
<div class="text-body-secondary small font-monospace" x-text="shortId(entry)"></div>
|
||||
</div>
|
||||
<span class="badge rounded-pill" :class="badgeClass(entry)" x-text="expirationFor(entry).label"></span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 small mb-3">
|
||||
<div class="col-6">
|
||||
<div class="text-body-secondary">Quantity / level</div>
|
||||
<div class="fw-semibold" x-text="quantityLabel(entry)"></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-body-secondary">Location</div>
|
||||
<div class="fw-semibold" x-text="locationLabel(entry)"></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-body-secondary">Production date</div>
|
||||
<div x-text="formatDate(entry.date)"></div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-body-secondary">Expiration</div>
|
||||
<div x-text="expirationFor(entry).detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-edit-stack">
|
||||
<template x-if="entry.stock_type === 'binary'">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" @click="updateBinary(entry, 'gone')">Mark gone</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="entry.stock_type === 'descriptive'">
|
||||
<div class="d-grid gap-2">
|
||||
<select class="form-select form-select-sm" x-model="editForms[entry.id].level">
|
||||
<template x-for="option in levelOptions" :key="option.value">
|
||||
<option :value="option.value" x-text="option.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" type="button" @click="saveLevel(entry)">Save stock level</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="entry.stock_type === 'measured'">
|
||||
<div class="d-grid gap-2">
|
||||
<input class="form-control form-control-sm" type="number" step="0.01" min="0" x-model="editForms[entry.id].quantity" />
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-sm btn-primary" type="button" @click="saveQuantity(entry)">Save quantity</button>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button" @click="markMeasuredGone(entry)">Gone</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editErrors[entry.id]">
|
||||
<div class="small text-danger mt-2" x-text="editErrors[entry.id]"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function stockListPageData(store) {
|
||||
return {
|
||||
state: createAsyncState(),
|
||||
entries: [],
|
||||
locations: [],
|
||||
locationMap: {},
|
||||
locationDescendants: {},
|
||||
editForms: {},
|
||||
editErrors: {},
|
||||
levelOptions: LEVEL_OPTIONS,
|
||||
expirationLegend: EXPIRATION_LEGEND,
|
||||
filters: {
|
||||
search: '',
|
||||
expiration: '',
|
||||
location: '',
|
||||
},
|
||||
async init() {
|
||||
await Promise.all([this.loadLocations(), this.loadEntries()]);
|
||||
},
|
||||
async loadEntries() {
|
||||
await runAsyncState(this.state, async () => {
|
||||
const loadedEntries = await listStockEntries(store);
|
||||
this.entries = sortEntries(loadedEntries);
|
||||
this.editForms = Object.fromEntries(
|
||||
this.entries.map((entry) => [
|
||||
entry.id,
|
||||
{
|
||||
level: entry.level || 'plenty',
|
||||
quantity: entry.quantity ?? '',
|
||||
},
|
||||
]),
|
||||
);
|
||||
this.editErrors = {};
|
||||
}).catch(() => {});
|
||||
},
|
||||
async loadLocations() {
|
||||
try {
|
||||
const { flat } = await fetchLocations(store);
|
||||
this.locations = flat;
|
||||
this.locationMap = Object.fromEntries(
|
||||
flat.map((location) => [location.uuid_b64, location.pathLabel]),
|
||||
);
|
||||
this.locationDescendants = Object.fromEntries(
|
||||
flat.map((location) => [
|
||||
location.uuid_b64,
|
||||
flat
|
||||
.filter((candidate) => candidate.lineage_uuid_b64.includes(location.uuid_b64))
|
||||
.map((candidate) => candidate.uuid_b64),
|
||||
]),
|
||||
);
|
||||
} catch {
|
||||
this.locations = [];
|
||||
this.locationMap = {};
|
||||
this.locationDescendants = {};
|
||||
}
|
||||
},
|
||||
clearFilters() {
|
||||
this.filters = {
|
||||
search: '',
|
||||
expiration: '',
|
||||
location: '',
|
||||
};
|
||||
},
|
||||
get filteredEntries() {
|
||||
return this.entries.filter((entry) => {
|
||||
if (
|
||||
this.filters.search &&
|
||||
!searchBlob(entry, this.locationMap).includes(this.filters.search.toLowerCase())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.filters.expiration &&
|
||||
expirationInfo(entry).key !== this.filters.expiration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.filters.location &&
|
||||
!this.locationMatchesFilter(entry.location_initial_uuid_b64, this.filters.location)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
},
|
||||
expirationFor(entry) {
|
||||
return expirationInfo(entry);
|
||||
},
|
||||
rowClass(entry) {
|
||||
return `expiration-${expirationInfo(entry).key}`;
|
||||
},
|
||||
badgeClass(entry) {
|
||||
return `expiration-badge-${expirationInfo(entry).key}`;
|
||||
},
|
||||
legendClass(key) {
|
||||
return `legend-${key}`;
|
||||
},
|
||||
expirationCount(key) {
|
||||
return this.entries.filter((entry) => expirationInfo(entry).key === key).length;
|
||||
},
|
||||
shortId(entry) {
|
||||
return entry.uuid_b64 ? entry.uuid_b64.slice(0, 10) : 'No id';
|
||||
},
|
||||
locationLabel(entry) {
|
||||
return resolveLocationLabel(entry, this.locationMap);
|
||||
},
|
||||
locationMatchesFilter(entryLocationUuid, selectedLocationUuid) {
|
||||
if (!selectedLocationUuid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowed = this.locationDescendants[selectedLocationUuid] || [selectedLocationUuid];
|
||||
return allowed.includes(entryLocationUuid);
|
||||
},
|
||||
quantityLabel,
|
||||
stockTypeDetail(entry) {
|
||||
if (entry.stock_type === 'binary') {
|
||||
return 'Binary stock';
|
||||
}
|
||||
if (entry.stock_type === 'descriptive') {
|
||||
return `Level: ${LEVEL_LABELS[entry.level] || 'Not set'}`;
|
||||
}
|
||||
return entry.uom_symbol ? `Measured in ${entry.uom_symbol}` : 'Measured stock';
|
||||
},
|
||||
formatDate,
|
||||
async updateBinary(entry, level) {
|
||||
await this.deleteEntry(entry);
|
||||
},
|
||||
async saveLevel(entry) {
|
||||
const level = this.editForms[entry.id]?.level || 'plenty';
|
||||
await this.saveEntryUpdate(entry, {
|
||||
level,
|
||||
}, { level });
|
||||
},
|
||||
async saveQuantity(entry) {
|
||||
const quantity = Number(this.editForms[entry.id]?.quantity);
|
||||
if (Number.isNaN(quantity) || quantity < 0) {
|
||||
this.editErrors[entry.id] = 'Enter a valid quantity first.';
|
||||
return;
|
||||
}
|
||||
|
||||
await this.saveEntryUpdate(entry, {
|
||||
quantity,
|
||||
}, { quantity });
|
||||
},
|
||||
async markMeasuredGone(entry) {
|
||||
await this.deleteEntry(entry);
|
||||
},
|
||||
async saveEntryUpdate(entry, payload, localPatch) {
|
||||
this.editErrors[entry.id] = '';
|
||||
|
||||
try {
|
||||
const updated = await updateStockItem(store, entry.uuid_b64, payload);
|
||||
this.replaceEntry(entry.id, { ...entry, ...localPatch, ...updated });
|
||||
store.addAlert({
|
||||
type: 'success',
|
||||
message: `${entry.name} updated successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.editErrors[entry.id] = error.message || 'Update failed.';
|
||||
}
|
||||
},
|
||||
async deleteEntry(entry) {
|
||||
this.editErrors[entry.id] = '';
|
||||
|
||||
try {
|
||||
await deleteStockItem(store, entry.uuid_b64);
|
||||
this.entries = this.entries.filter((candidate) => candidate.id !== entry.id);
|
||||
delete this.editForms[entry.id];
|
||||
delete this.editErrors[entry.id];
|
||||
store.addAlert({
|
||||
type: 'success',
|
||||
message: `${entry.name} was marked gone and removed from the list.`,
|
||||
});
|
||||
} catch (error) {
|
||||
this.editErrors[entry.id] = error.message || 'Delete failed.';
|
||||
}
|
||||
},
|
||||
replaceEntry(entryId, nextEntry) {
|
||||
this.entries = sortEntries(
|
||||
this.entries.map((entry) => (entry.id === entryId ? nextEntry : entry)),
|
||||
);
|
||||
this.editForms[entryId] = {
|
||||
level: nextEntry.level || 'plenty',
|
||||
quantity: nextEntry.quantity ?? '',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user