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:
2026-04-06 09:24:22 +02:00
commit 929ee6557a
48 changed files with 4879 additions and 0 deletions
+225
View File
@@ -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;
},
};
}
+65
View File
@@ -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.' });
},
};
}
+96
View File
@@ -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}.` });
},
};
}
+73
View File
@@ -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();
},
};
}
+727
View File
@@ -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"
>
&times;
</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"
>
&times;
</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"
>
&times;
</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"
>
&times;
</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(' / ')}`;
},
};
}
+19
View File
@@ -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));
}
+18
View File
@@ -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';
},
};
}
+16
View File
@@ -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);
}
+20
View File
@@ -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);
};
}
+24
View File
@@ -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.
}
}
+20
View File
@@ -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;
}
}
+133
View File
@@ -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">&larr; 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();
},
};
}
+620
View File
@@ -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 ?? '',
};
},
};
}