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