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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.14 (lonc)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.14 (lonc)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.14 (lonc)" project-jdk-type="Python SDK" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lonc.iml" filepath="$PROJECT_DIR$/.idea/lonc.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+172
View File
@@ -0,0 +1,172 @@
# Lonc
Lonc is a responsive PWA frontend for household kitchen stock and labeling workflows backed by Tryton APIs.
## Stack
- Vite for a lightweight client build
- Bootstrap 5 for layout and form styling
- Alpine.js for local component state and interaction
- Plain `fetch()` service modules for backend communication
- Static manifest and service worker for PWA installability
## Scripts
- `npm install`
- `npm run dev`
- `npm run build`
- `npm run preview`
## Production installation
### Requirements
- Node.js 20 or newer
- npm 10 or newer
- A static web server for the generated `dist/` directory
- A Tryton backend that exposes the `kitchen` user application and kitchen endpoints
### Build for production
1. Install dependencies:
```bash
npm install
```
2. Create the production build:
```bash
npm run build
```
3. Deploy the generated `dist/` directory to your web server.
### Serve the built app
Lonc is a static frontend. In production you do not run a Node.js application server for it. You build the app once and serve the files from `dist/` with a normal static web server such as:
- Nginx
- Apache
- Caddy
- a CDN/static hosting platform
The frontend uses hash-based routing, so no special SPA history fallback is required for route handling.
### Example deployment flow
```bash
npm install
npm run build
rsync -av dist/ /var/www/lonc/
```
Then configure your web server to serve `/var/www/lonc` as a static site.
### Runtime configuration
The application does not require build-time environment variables for the Tryton connection. Users configure the following in the login screen:
- Tryton server base URL
- database name
- user login
Authentication is done with Tryton user application keys for the `kitchen` application, not with JSON-RPC session login.
### Reverse proxy / browser requirements
If the frontend and Tryton backend are served from different origins, the Tryton server must allow cross-origin requests from the frontend origin.
At minimum, production should ensure:
- `Authorization` headers are accepted for API requests
- CORS is configured for the frontend origin when origins differ
- HTTPS is enabled in production
### PWA notes
For installability and service worker support:
- serve `manifest.webmanifest` with an appropriate web manifest content type
- make sure `service-worker.js` is reachable from the deployed site root
- avoid aggressive caching on `index.html` during upgrades so new builds are picked up reliably
### Smoke test after deployment
After deployment, verify that:
1. the site loads from the production URL
2. login can create a Tryton user application key
3. kitchen selection loads successfully
4. stock review and label creation can reach the backend
5. the browser can install the app as a PWA
## Project structure
```text
public/
icons/
manifest.webmanifest
offline.html
service-worker.js
src/
api/
app/
components/
features/
styles/
main.js
index.html
package.json
```
## Current MVP features
- Login/configuration screen for Tryton server URL and database
- Session restore and logout shell
- Active kitchen selection and switching
- Dashboard with quick actions
- Label creation flow with item lookup, location loading, preview, and stock entry creation
- Stock list with search and filters
- Stock detail page with stock adjustment workflow
- PWA manifest, icons, service worker, and offline fallback
## Tryton integration assumptions
The frontend is intentionally organized around adapter-style API modules so the exact backend contract can be finalized without rewriting screens.
Default endpoint placeholders live in [`src/app/config.js`](/Users/blaz/PycharmProjects/lonc/src/app/config.js), and the canonical URL builder lives in [`src/api/client.js`](/Users/blaz/PycharmProjects/lonc/src/api/client.js).
Expected shapes today:
- Kitchen-scoped application resources use:
`/{database}/kitchen/{kitchen_id}/{resource}`
- User application key management uses:
`/{database}/user/application/`
- Non-kitchen-scoped authenticated resources currently assume:
`/{database}/{resource}`
- `POST /{database}/user/application/`
Sends `{ user, application: "kitchen" }` and returns the application key as a JSON string.
- `DELETE /{database}/user/application/`
Sends `{ user, key, application: "kitchen" }` and disconnects the client.
- `GET /{database}/kitchen/kitchens`
Requires `Authorization: Bearer <application_key>` and is used as the current lightweight verification call after key approval.
Returns `{ data: [...] }` or `{ kitchens: [...] }`.
- `GET /{database}/kitchen/items?search_name=...`
Returns item definitions for autocomplete.
- `POST /{database}/kitchen/items?label=1`
Creates a stock item plus label-related output on the backend side.
- `POST /{database}/kitchen/items?label=1&preview=1`
Returns an image blob, `{ imageUrl }`, or `{ imageSvg }` for in-browser preview.
- `GET /{database}/kitchen/locations`
Returns a nested location tree.
- `GET /{database}/kitchen/{kitchen_id}/stock`, `GET /{database}/kitchen/{kitchen_id}/stock/:id`, `POST /{database}/kitchen/{kitchen_id}/stock/:id/adjust`
Back the stock overview, creation, and adjustment workflows.
## Notes
- Hash-based routing is used to keep static deployment simple.
- Local storage only keeps non-sensitive app config, session payload, active kitchen, and label draft state.
- Kitchen context now lives in the URL path instead of a custom header.
- `includeKitchen: false` in the API client only removes the kitchen path segment; it does not disable bearer authentication.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="theme-color" content="#1f4b99" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<title>Lonc</title>
</head>
<body class="bg-body-tertiary">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1162
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "lonc-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"alpinejs": "^3.14.9",
"bootstrap": "^5.3.3"
},
"devDependencies": {
"vite": "^7.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="120" fill="#000" />
<path d="M170 132h44v161c0 46 19 69 57 69 38 0 57-23 57-69V132h44v164c0 35-10 64-31 86-21 22-45 33-70 33s-49-11-70-33c-21-22-31-51-31-86V132z" fill="#fff" />
<circle cx="380" cy="170" r="34" fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 337 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1f4b99" />
<stop offset="100%" stop-color="#5da9ff" />
</linearGradient>
</defs>
<rect width="512" height="512" rx="120" fill="url(#bg)" />
<path d="M170 132h44v161c0 46 19 69 57 69 38 0 57-23 57-69V132h44v164c0 35-10 64-31 86-21 22-45 33-70 33s-49-11-70-33c-21-22-31-51-31-86V132z" fill="#fff" />
<circle cx="380" cy="170" r="34" fill="#ebf3ff" />
</svg>

After

Width:  |  Height:  |  Size: 548 B

+23
View File
@@ -0,0 +1,23 @@
{
"name": "Lonc",
"short_name": "Lonc",
"description": "Kitchen stock labeling and stock management client for Tryton-backed household workflows.",
"start_url": "/#/",
"display": "standalone",
"background_color": "#f4f7fb",
"theme_color": "#1f4b99",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/icon-mask.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lonc Offline</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Avenir Next", "Segoe UI", sans-serif;
color: #1f2740;
background:
radial-gradient(circle at top left, rgba(90, 169, 255, 0.22), transparent 22%),
linear-gradient(180deg, #f8fbff 0%, #f4f7fb 42%, #eef2f8 100%);
}
main {
max-width: 28rem;
margin: 1.5rem;
padding: 2rem;
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 48px rgba(24, 42, 79, 0.08);
}
h1 {
margin-top: 0;
}
</style>
</head>
<body>
<main>
<h1>You are offline</h1>
<p>
Lonc can still open its cached shell, but Tryton-backed data and save actions need the network.
</p>
<p>Reconnect and reload when you are ready to continue.</p>
</main>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
const CACHE_NAME = 'lonc-shell-v1';
const APP_SHELL = ['/', '/index.html', '/manifest.webmanifest', '/offline.html', '/icons/icon.svg', '/icons/icon-mask.svg'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
const requestUrl = new URL(event.request.url);
if (requestUrl.origin !== self.location.origin) {
return;
}
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(async () => {
const shell = await caches.match('/index.html');
return shell || caches.match('/offline.html');
}),
);
return;
}
event.respondWith(
caches.match(event.request).then((response) => {
return (
response ||
fetch(event.request).then((networkResponse) => {
const clone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return networkResponse;
})
);
}),
);
});
+111
View File
@@ -0,0 +1,111 @@
import { CONNECTION_STATES, TRYTON_APPLICATION } from '../app/config.js';
import { apiRequest, getPath } from './client.js';
import { listKitchens } from './kitchens.js';
function extractKey(payload) {
if (typeof payload === 'string') {
return payload;
}
return payload?.key || payload?.application_key || payload?.data?.key || null;
}
function isAuthFailure(error) {
const status = error?.cause?.status || error?.status;
return status === 401 || status === 403;
}
export async function login(store, credentials) {
const payload = await apiRequest(store, getPath('userApplication'), {
method: 'POST',
body: {
user: credentials.userLogin,
application: TRYTON_APPLICATION,
},
includeKitchen: false,
});
const applicationKey = extractKey(payload);
if (!applicationKey) {
throw new Error('User application creation did not return a key.');
}
const session = {
userLogin: credentials.userLogin,
applicationKey,
state: CONNECTION_STATES.pendingValidation,
hasValidated: false,
};
store.setSession(session);
return session;
}
export async function restoreSession(store) {
if (!store.session) {
return null;
}
try {
await verifyConnection(store);
} catch (error) {
if (!isAuthFailure(error)) {
throw error;
}
}
return store.session;
}
export async function logout(store) {
try {
if (store.session?.applicationKey && store.session?.userLogin) {
await apiRequest(store, getPath('userApplication'), {
method: 'DELETE',
body: {
user: store.session.userLogin,
key: store.session.applicationKey,
application: TRYTON_APPLICATION,
},
includeKitchen: false,
});
}
} finally {
store.clearSessionState();
}
}
export async function verifyConnection(store) {
if (!store.session?.applicationKey) {
return null;
}
try {
await listKitchens(store);
store.setSession({
...store.session,
state: CONNECTION_STATES.connected,
hasValidated: true,
});
return store.session;
} catch (error) {
if (!isAuthFailure(error)) {
throw error;
}
store.setSession({
...store.session,
state: store.session.hasValidated
? CONNECTION_STATES.invalidKey
: CONNECTION_STATES.pendingValidation,
});
throw new Error(
store.session.hasValidated
? 'The stored application key is no longer valid.'
: 'The application key is still waiting for validation in Tryton preferences.',
{
cause: error.cause || error,
},
);
}
}
+119
View File
@@ -0,0 +1,119 @@
import { API_PATHS } from '../app/config.js';
function normalizeBaseUrl(baseUrl) {
return baseUrl.trim().replace(/\/+$/, '');
}
function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
const encodedDatabase = encodeURIComponent(database);
const encodedPath = path.replace(/^\/+/, '');
const kitchenSegment =
includeKitchen && kitchenId
? `/kitchen/${encodeURIComponent(String(kitchenId))}`
: '';
const url = new URL(
`${cleanBaseUrl}/${encodedDatabase}${kitchenSegment}/${encodedPath}`,
);
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
return url.toString();
}
async function parseResponse(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
if (contentType.includes('image/')) {
return response.blob();
}
if (response.status === 204) {
return null;
}
return response.text();
}
function normalizeError(response, payload) {
const message =
payload?.message ||
payload?.error ||
`Request failed with status ${response.status}.`;
return new Error(message, {
cause: {
status: response.status,
details: payload?.errors || payload?.details || null,
},
});
}
export async function apiRequest(store, path, options = {}) {
const { config, session, activeKitchen } = store;
if (!config.baseUrl || !config.database) {
throw new Error('Server URL and database name are required.');
}
const headers = new Headers(options.headers || {});
headers.set('Accept', options.accept || 'application/json');
if (options.body && !options.isFormData) {
headers.set('Content-Type', 'application/json');
}
if (session?.applicationKey) {
headers.set('Authorization', `Bearer ${session.applicationKey}`);
}
const response = await fetch(
buildUrl({
baseUrl: config.baseUrl,
database: config.database,
kitchenId: activeKitchen?.id,
path,
query: options.query,
includeKitchen: options.includeKitchen !== false,
}),
{
method: options.method || 'GET',
headers,
body:
options.body && !options.isFormData
? JSON.stringify(options.body)
: options.body || undefined,
},
);
const payload = await parseResponse(response);
if (!response.ok) {
throw normalizeError(response, payload);
}
return payload;
}
export function getPath(key) {
return API_PATHS[key];
}
export function buildKitchenApiUrl(store, path, query = {}) {
return buildUrl({
baseUrl: store.config.baseUrl,
database: store.config.database,
kitchenId: store.activeKitchen?.id,
path,
query,
includeKitchen: true,
});
}
+12
View File
@@ -0,0 +1,12 @@
import { apiRequest, getPath } from './client.js';
export async function listKitchens(store) {
const payload = await apiRequest(store, getPath('kitchens'), {
includeKitchen: false,
});
if (Array.isArray(payload)) {
return payload;
}
return payload?.data || payload?.kitchens || [];
}
+55
View File
@@ -0,0 +1,55 @@
import { apiRequest, getPath } from './client.js';
export function normalizeLabelImagePayload(payload) {
if (!payload) {
return null;
}
if (payload instanceof Blob) {
return {
objectUrl: URL.createObjectURL(payload),
contentType: payload.type,
};
}
if (payload?.imageUrl) {
return {
objectUrl: payload.imageUrl,
contentType: payload.contentType || 'image/png',
};
}
if (payload?.imageSvg) {
const blob = new Blob([payload.imageSvg], { type: 'image/svg+xml' });
return {
objectUrl: URL.createObjectURL(blob),
contentType: 'image/svg+xml',
};
}
if (payload?.label) {
return {
objectUrl: `data:image/png;base64,${payload.label}`,
contentType: 'image/png',
};
}
return null;
}
export async function previewLabel(store, body) {
const payload = await apiRequest(store, getPath('items'), {
method: 'POST',
body,
accept: 'image/svg+xml, image/png, application/json',
includeKitchen: false,
query: { label: 1, preview: 1 },
});
const image = normalizeLabelImagePayload(payload);
if (image) {
return image;
}
throw new Error('Label preview response did not include an image.');
}
+37
View File
@@ -0,0 +1,37 @@
import { apiRequest, getPath } from './client.js';
function flattenNodes(nodes, trail = [], lineage = []) {
return nodes.flatMap((node) => {
const currentTrail = [...trail, node.name];
const currentLineage = [...lineage, node.uuid_b64];
const current = {
id: node.id,
name: node.name,
type: node.type,
uuid: node.uuid,
uuid_b64: node.uuid_b64,
pathLabel: currentTrail.join(' / '),
depth: trail.length,
lineage_uuid_b64: currentLineage,
};
const children = Array.isArray(node.locations)
? flattenNodes(node.locations, currentTrail, currentLineage)
: [];
return [current, ...children];
});
}
export async function fetchLocations(store) {
const payload = await apiRequest(store, getPath('locations'), {
includeKitchen: false,
});
const tree = Array.isArray(payload)
? payload
: payload?.data || payload?.locations || [];
return {
tree,
flat: flattenNodes(tree),
};
}
+74
View File
@@ -0,0 +1,74 @@
import { apiRequest, getPath } from './client.js';
export async function searchItemDefinitions(store, query) {
if (query.trim().length <= 2) {
return [];
}
const payload = await apiRequest(store, getPath('items'), {
includeKitchen: false,
query: { search_name: query },
});
if (Array.isArray(payload)) {
return payload;
}
return payload?.data || payload?.items || [];
}
export async function listStockEntries(store, filters = {}) {
const payload = await apiRequest(store, getPath('items'), {
includeKitchen: false,
});
if (Array.isArray(payload)) {
return payload;
}
return payload?.data || payload?.entries || payload?.items || [];
}
export async function getStockEntry(store, stockId) {
const payload = await apiRequest(store, `${getPath('stockEntries')}/${stockId}`);
return payload?.data || payload?.entry || payload;
}
export async function createStockEntry(store, body) {
const payload = await apiRequest(store, getPath('items'), {
method: 'POST',
body,
includeKitchen: false,
query: { label: 1 },
});
return payload?.data || payload?.entry || payload;
}
export async function updateStockItem(store, uuidB64, body) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
method: 'POST',
body,
includeKitchen: false,
});
return payload?.data || payload?.entry || payload;
}
export async function deleteStockItem(store, uuidB64) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
method: 'DELETE',
includeKitchen: false,
});
return payload?.data || payload?.entry || payload;
}
export async function adjustStockEntry(store, stockId, body) {
const payload = await apiRequest(
store,
`${getPath('stockEntries')}/${stockId}/adjust`,
{
method: 'POST',
body,
},
);
return payload?.data || payload?.entry || payload;
}
+91
View File
@@ -0,0 +1,91 @@
import Alpine from 'alpinejs';
import { logout, restoreSession, verifyConnection } from '../api/auth.js';
import { listKitchens } from '../api/kitchens.js';
import { APP_NAME } from './config.js';
import { createRouter, navigate } from './router.js';
import { createAppStore } from './store.js';
import { appShell } from '../components/app-shell.js';
import { registerFeatureData } from '../features/register.js';
async function installServiceWorker() {
if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/service-worker.js');
}
}
export function bootstrapApp() {
const store = createAppStore();
Alpine.store('app', store);
registerFeatureData(Alpine, store);
const appRoot = document.querySelector('#app');
appRoot.innerHTML = appShell(APP_NAME);
Alpine.initTree(appRoot);
const router = createRouter({
Alpine,
store,
outlet: document.querySelector('#route-view'),
});
window.__loncApp = {
navigate,
async refreshKitchens() {
const kitchens = await listKitchens(store);
store.setKitchens(kitchens);
if (!store.activeKitchen && kitchens.length) {
store.setActiveKitchen(kitchens[0]);
}
return kitchens;
},
async restoreSession() {
try {
await restoreSession(store);
if (store.isConnected) {
await window.__loncApp.refreshKitchens();
}
} catch (error) {
if (window.location.hash !== '#/login') {
navigate('/login');
}
}
},
async verifyConnection() {
await verifyConnection(store);
if (store.isConnected) {
await window.__loncApp.refreshKitchens();
}
return store.session;
},
async logout() {
await logout(store);
navigate('/login');
},
router,
};
window.addEventListener('online', () => {
store.addAlert({ type: 'success', message: 'Connection restored.' });
});
window.addEventListener('offline', () => {
store.addAlert({
type: 'warning',
message: 'You are offline. Cached screens stay available, but API actions may fail.',
});
});
window.__loncApp
.restoreSession()
.finally(() => router.start())
.catch(() => router.start());
installServiceWorker().catch(() => {
store.addAlert({
type: 'warning',
message: 'PWA installation support could not be initialized.',
});
});
}
+39
View File
@@ -0,0 +1,39 @@
export const APP_NAME = 'Lonc';
export const TRYTON_APPLICATION = 'kitchen';
export const CONNECTION_STATES = {
notConnected: 'not_connected',
pendingValidation: 'pending_validation',
connected: 'connected',
invalidKey: 'invalid_key',
};
export const STORAGE_KEYS = {
appConfig: 'lonc.app.config',
session: 'lonc.auth.session',
activeKitchen: 'lonc.kitchen.active',
labelDraft: 'lonc.labels.draft',
};
export const DEFAULT_CONFIG = {
baseUrl: '',
database: '',
};
export const API_PATHS = {
userApplication: 'user/application/',
kitchens: 'kitchen/kitchens',
items: 'kitchen/items',
stockEntries: 'stock',
locations: 'kitchen/locations',
};
export const ROUTES = {
login: '/login',
home: '/',
stock: '/stock',
stockNew: '/stock/new',
stockDetail: '/stock/:id',
labelsNew: '/labels/new',
settings: '/settings',
};
+95
View File
@@ -0,0 +1,95 @@
import { ROUTES } from './config.js';
import { renderDashboardPage } from '../features/dashboard/dashboard-page.js';
import { renderKitchenSelector } from '../features/kitchens/kitchen-selector.js';
import { renderLoginPage } from '../features/auth/login-page.js';
import { renderLabelCreatePage } from '../features/labels/label-create-page.js';
import { renderSettingsPage } from '../features/auth/settings-page.js';
import { renderStockDetailPage } from '../features/stock/stock-detail-page.js';
import { renderStockListPage } from '../features/stock/stock-list-page.js';
const routeDefinitions = [
{ path: ROUTES.login, render: renderLoginPage, protected: false },
{ path: ROUTES.home, render: renderDashboardPage, protected: true },
{ path: ROUTES.stock, render: renderStockListPage, protected: true },
{ path: ROUTES.stockNew, render: renderLabelCreatePage, protected: true },
{ path: ROUTES.stockDetail, render: renderStockDetailPage, protected: true },
{ path: ROUTES.labelsNew, render: renderLabelCreatePage, protected: true },
{ path: ROUTES.settings, render: renderSettingsPage, protected: false },
];
function normalizeHashRoute() {
const route = window.location.hash.replace(/^#/, '') || ROUTES.home;
return route.startsWith('/') ? route : `/${route}`;
}
function matchRoute(pathname) {
for (const definition of routeDefinitions) {
const keys = [];
const pattern = definition.path.replace(/:([^/]+)/g, (_, key) => {
keys.push(key);
return '([^/]+)';
});
const regex = new RegExp(`^${pattern}$`);
const match = pathname.match(regex);
if (!match) {
continue;
}
const params = keys.reduce((accumulator, key, index) => {
accumulator[key] = decodeURIComponent(match[index + 1]);
return accumulator;
}, {});
return { ...definition, params };
}
return null;
}
export function navigate(path) {
window.location.hash = path;
}
export function getRouteContext() {
return window.__loncRouteContext || { path: ROUTES.home, params: {} };
}
export function createRouter({ Alpine, store, outlet }) {
const render = async () => {
const pathname = normalizeHashRoute();
const match = matchRoute(pathname);
if (!match) {
navigate(ROUTES.home);
return;
}
if (match.protected && !store.isConnected) {
navigate(ROUTES.login);
return;
}
if (
store.isConnected &&
!store.activeKitchen &&
pathname !== ROUTES.login &&
pathname !== ROUTES.settings
) {
outlet.innerHTML = renderKitchenSelector();
Alpine.initTree(outlet);
return;
}
window.__loncRouteContext = { path: pathname, params: match.params };
outlet.innerHTML = match.render();
Alpine.initTree(outlet);
};
window.addEventListener('hashchange', render);
return {
start: render,
render,
};
}
+104
View File
@@ -0,0 +1,104 @@
import {
CONNECTION_STATES,
DEFAULT_CONFIG,
STORAGE_KEYS,
TRYTON_APPLICATION,
} from './config.js';
import {
clearStoredValue,
loadStoredValue,
saveStoredValue,
} from '../features/shared/storage.js';
const defaultState = {
config: { ...DEFAULT_CONFIG },
session: null,
kitchens: [],
activeKitchen: null,
alerts: [],
};
export function createAppStore() {
const state = {
...defaultState,
config: loadStoredValue(STORAGE_KEYS.appConfig, { ...DEFAULT_CONFIG }),
session: loadStoredValue(STORAGE_KEYS.session, null),
activeKitchen: loadStoredValue(STORAGE_KEYS.activeKitchen, null),
alerts: [],
};
return {
...state,
get isAuthenticated() {
return Boolean(this.session?.applicationKey);
},
get isConnected() {
return this.session?.state === CONNECTION_STATES.connected;
},
get needsValidation() {
return this.session?.state === CONNECTION_STATES.pendingValidation;
},
setConfig(nextConfig) {
this.config = { ...this.config, ...nextConfig };
saveStoredValue(STORAGE_KEYS.appConfig, this.config);
},
setSession(session) {
this.session = session
? {
application: TRYTON_APPLICATION,
state: CONNECTION_STATES.notConnected,
hasValidated: false,
...session,
}
: null;
if (session) {
saveStoredValue(STORAGE_KEYS.session, this.session);
return;
}
clearStoredValue(STORAGE_KEYS.session);
},
setKitchens(kitchens) {
this.kitchens = kitchens;
if (
this.activeKitchen &&
!kitchens.some((kitchen) => kitchen.id === this.activeKitchen.id)
) {
this.activeKitchen = null;
clearStoredValue(STORAGE_KEYS.activeKitchen);
}
},
setActiveKitchen(kitchen) {
this.activeKitchen = kitchen;
if (kitchen) {
saveStoredValue(STORAGE_KEYS.activeKitchen, kitchen);
return;
}
clearStoredValue(STORAGE_KEYS.activeKitchen);
},
addAlert(alert) {
const nextAlert = {
id: crypto.randomUUID(),
type: 'info',
timeout: 5000,
...alert,
};
this.alerts = [...this.alerts, nextAlert];
if (nextAlert.timeout) {
window.setTimeout(() => this.removeAlert(nextAlert.id), nextAlert.timeout);
}
},
removeAlert(alertId) {
this.alerts = this.alerts.filter((alert) => alert.id !== alertId);
},
clearSessionState() {
this.setSession(null);
this.setKitchens([]);
this.setActiveKitchen(null);
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import { navBar } from './nav-bar.js';
export function appShell(appName) {
return `
<div class="app-shell d-flex flex-column min-vh-100">
${navBar(appName)}
<main id="route-view" class="flex-grow-1"></main>
<div class="toast-stack" x-data="alertsData()">
<template x-for="alert in alerts" :key="alert.id">
<div class="toast show align-items-center border-0 mb-2 text-bg-dark" role="status">
<div class="d-flex">
<div class="toast-body">
<span class="badge me-2 text-uppercase" :class="badgeClass(alert.type)" x-text="alert.type"></span>
<span x-text="alert.message"></span>
</div>
<button
type="button"
class="btn-close btn-close-white me-2 m-auto"
@click="dismiss(alert.id)"
aria-label="Close"
></button>
</div>
</div>
</template>
</div>
</div>
`;
}
+19
View File
@@ -0,0 +1,19 @@
export function confirmDialog(dialogId, title, body, actionLabel) {
return `
<div class="modal fade" id="${dialogId}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5">${title}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">${body}</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary">${actionLabel}</button>
</div>
</div>
</div>
</div>
`;
}
+10
View File
@@ -0,0 +1,10 @@
export function emptyState(title, body) {
return `
<div class="card border-0 shadow-sm">
<div class="card-body p-4 text-center">
<h2 class="h5">${title}</h2>
<p class="text-body-secondary mb-0">${body}</p>
</div>
</div>
`;
}
+7
View File
@@ -0,0 +1,7 @@
export function errorState(message) {
return `
<div class="alert alert-danger" role="alert">
${message}
</div>
`;
}
+8
View File
@@ -0,0 +1,8 @@
export function loadingState(message = 'Loading...') {
return `
<div class="text-center py-5">
<div class="spinner-border text-primary mb-3" role="status" aria-hidden="true"></div>
<p class="text-body-secondary mb-0">${message}</p>
</div>
`;
}
+34
View File
@@ -0,0 +1,34 @@
export function navBar(appName) {
return `
<nav class="navbar navbar-expand-lg bg-white border-bottom sticky-top shadow-sm">
<div class="container-xxl">
<a class="navbar-brand d-flex align-items-center gap-2 fw-semibold" href="#/">
<span class="brand-mark">L</span>
<span>${appName}</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#main-nav">
<span class="navbar-toggler-icon"></span>
</button>
<div id="main-nav" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="#/">Dashboard</a></li>
<li class="nav-item"><a class="nav-link" href="#/labels/new">New Label</a></li>
<li class="nav-item"><a class="nav-link" href="#/stock">Stock</a></li>
<li class="nav-item"><a class="nav-link" href="#/settings">Settings</a></li>
</ul>
<div class="d-flex flex-column flex-lg-row align-items-lg-center gap-2" x-data>
<template x-if="$store.app.activeKitchen">
<div class="small text-body-secondary">
Kitchen:
<span class="fw-semibold text-body" x-text="$store.app.activeKitchen?.name"></span>
</div>
</template>
<template x-if="$store.app.isAuthenticated">
<button class="btn btn-outline-secondary btn-sm" @click="window.__loncApp.logout()">Logout</button>
</template>
</div>
</div>
</div>
</nav>
`;
}
+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 ?? '',
};
},
};
}
+11
View File
@@ -0,0 +1,11 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import './styles/app.css';
import Alpine from 'alpinejs';
import { bootstrapApp } from './app/bootstrap.js';
window.Alpine = Alpine;
bootstrapApp(Alpine);
+405
View File
@@ -0,0 +1,405 @@
:root {
--lonc-ink: #1f2740;
--lonc-muted: #667085;
--lonc-primary: #1f4b99;
--lonc-primary-dark: #163b7c;
--lonc-accent: #ebf3ff;
--lonc-surface: #ffffff;
--lonc-background: #f4f7fb;
--lonc-border: rgba(31, 39, 64, 0.08);
--bs-primary: #1f4b99;
--bs-primary-rgb: 31, 75, 153;
--bs-body-color: #1f2740;
--bs-body-bg: #f4f7fb;
--bs-border-color: rgba(31, 39, 64, 0.08);
--bs-font-sans-serif: "Avenir Next", "Segoe UI", sans-serif;
}
body {
color: var(--lonc-ink);
background:
radial-gradient(circle at top left, rgba(90, 169, 255, 0.18), transparent 22%),
linear-gradient(180deg, #f8fbff 0%, var(--lonc-background) 42%, #eef2f8 100%);
}
.app-shell {
position: relative;
}
.brand-mark {
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.75rem;
background: linear-gradient(135deg, var(--lonc-primary), #5da9ff);
color: #fff;
font-weight: 700;
}
.eyebrow {
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 700;
color: var(--lonc-primary);
}
.hero-card {
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(235, 243, 255, 0.92)),
linear-gradient(135deg, rgba(93, 169, 255, 0.18), rgba(31, 75, 153, 0.08));
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 2rem;
box-shadow: 0 24px 48px rgba(24, 42, 79, 0.08);
}
.glass-panel {
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
}
.quick-card,
.kitchen-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
min-height: 100%;
padding: 1.25rem;
border-radius: 1.5rem;
border: 1px solid var(--lonc-border);
background: rgba(255, 255, 255, 0.88);
color: inherit;
text-decoration: none;
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.quick-card:hover,
.kitchen-card:hover,
.clickable-row:hover {
transform: translateY(-2px);
box-shadow: 0 20px 40px rgba(24, 42, 79, 0.08);
border-color: rgba(31, 75, 153, 0.2);
}
.quick-card-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--lonc-primary);
}
.preview-frame,
.empty-preview {
min-height: 24rem;
border-radius: 1.5rem;
border: 1px dashed rgba(31, 75, 153, 0.22);
background:
linear-gradient(135deg, rgba(235, 243, 255, 0.88), rgba(255, 255, 255, 0.88)),
repeating-linear-gradient(
45deg,
transparent,
transparent 12px,
rgba(31, 75, 153, 0.04) 12px,
rgba(31, 75, 153, 0.04) 24px
);
}
.preview-frame {
display: grid;
place-items: center;
padding: 1rem;
}
.empty-preview {
display: grid;
place-items: center;
text-align: center;
color: var(--lonc-muted);
padding: 2rem;
}
.status-chip {
text-transform: capitalize;
}
.detail-grid dt,
.detail-grid dd {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
margin-bottom: 0;
border-bottom: 1px solid var(--lonc-border);
}
.toast-stack {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 1090;
width: min(24rem, calc(100vw - 2rem));
}
.clear-field-button {
position: absolute;
top: 50%;
right: 0.75rem;
transform: translateY(-50%);
z-index: 5;
text-decoration: none;
font-size: 1.4rem;
line-height: 1;
padding: 0;
background: transparent;
}
.search-field-with-clear .search-clear-button {
top: calc(50% + 1rem);
}
.text-field-with-clear .inline-clear-button {
top: calc(50% + 1rem);
}
.text-field-with-clear .textarea-clear-button {
top: 2.5rem;
transform: none;
}
.location-field-with-clear .location-clear-button {
top: 50%;
}
.location-picker {
z-index: 4;
}
.location-level-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: rgba(31, 75, 153, 0.1);
color: var(--lonc-primary);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.grouped-field-with-clear {
position: relative;
}
.grouped-field-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.legend-card {
height: 100%;
padding: 1rem;
border-radius: 1rem;
border: 1px solid var(--lonc-border);
}
.legend-expired,
.expiration-expired {
background: rgba(220, 53, 69, 0.1);
}
.legend-use-first,
.expiration-use-first {
background: rgba(253, 126, 20, 0.12);
}
.legend-upcoming,
.expiration-upcoming {
background: rgba(255, 193, 7, 0.14);
}
.legend-within-date,
.expiration-within-date {
background: rgba(25, 135, 84, 0.08);
}
.legend-none,
.expiration-none {
background: rgba(108, 117, 125, 0.08);
}
.expiration-badge-expired {
background: #dc3545;
color: #fff;
}
.expiration-badge-use-first {
background: #fd7e14;
color: #fff;
}
.expiration-badge-upcoming {
background: #ffc107;
color: #2f2a12;
}
.expiration-badge-within-date {
background: #198754;
color: #fff;
}
.expiration-badge-none {
background: #6c757d;
color: #fff;
}
.stock-review-table tbody tr td {
vertical-align: top;
transition: background-color 160ms ease;
}
.stock-review-table tbody tr.expiration-expired td {
background: rgba(220, 53, 69, 0.08);
}
.stock-review-table tbody tr.expiration-use-first td {
background: rgba(253, 126, 20, 0.1);
}
.stock-review-table tbody tr.expiration-upcoming td {
background: rgba(255, 193, 7, 0.12);
}
.stock-review-table tbody tr.expiration-within-date td {
background: rgba(25, 135, 84, 0.07);
}
.stock-review-table tbody tr.expiration-none td {
background: rgba(108, 117, 125, 0.06);
}
.stock-review-table tbody tr td:first-child {
border-left: 4px solid transparent;
}
.stock-review-table tbody tr.expiration-expired td:first-child {
border-left-color: #dc3545;
}
.stock-review-table tbody tr.expiration-use-first td:first-child {
border-left-color: #fd7e14;
}
.stock-review-table tbody tr.expiration-upcoming td:first-child {
border-left-color: #ffc107;
}
.stock-review-table tbody tr.expiration-within-date td:first-child {
border-left-color: #198754;
}
.stock-review-table tbody tr.expiration-none td:first-child {
border-left-color: #6c757d;
}
.stock-review-card {
border-left: 6px solid transparent;
}
.stock-review-card.expiration-expired {
border-left-color: #dc3545;
}
.stock-review-card.expiration-use-first {
border-left-color: #fd7e14;
}
.stock-review-card.expiration-upcoming {
border-left-color: #ffc107;
}
.stock-review-card.expiration-within-date {
border-left-color: #198754;
}
.stock-review-card.expiration-none {
border-left-color: #6c757d;
}
.quick-edit-stack {
min-width: 13rem;
}
.quick-select {
min-width: 8rem;
}
.quick-number {
max-width: 6rem;
}
.stock-guide-summary {
list-style: none;
cursor: pointer;
}
.stock-guide-summary::-webkit-details-marker {
display: none;
}
.stock-filter-details summary {
list-style: none;
}
.stock-filter-details summary::-webkit-details-marker {
display: none;
}
.stock-filter-toolbar {
position: relative;
min-height: 3rem;
}
.stock-filter-panel {
width: 100%;
padding: 1rem;
border-radius: 1rem;
border: 1px solid var(--lonc-border);
background: rgba(255, 255, 255, 0.75);
}
.stock-filter-clear {
position: absolute;
top: 0;
right: 0;
min-width: 5.5rem;
}
.clickable-row {
cursor: pointer;
}
@media (max-width: 991.98px) {
.navbar {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.92) !important;
}
.hero-card,
.quick-card,
.kitchen-card,
.preview-frame,
.empty-preview {
border-radius: 1.25rem;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 4173,
},
});