Introduce initial version of the Lonc app with core features, styling, and configurations.

- Add base app structure, including Bootstrap setup and Alpine.js integration.
- Implement authentication flow with session handling.
- Integrate stock management and label creation functionalities.
- Include responsive styling and theme using CSS variables and custom components.
- Add API clients for Tryton-based backend.
- Set up kitchen and dashboard navigation workflows.
- Configure service worker for PWA support.
This commit is contained in:
2026-04-06 09:24:22 +02:00
commit 929ee6557a
48 changed files with 4879 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { CONNECTION_STATES, TRYTON_APPLICATION } from '../app/config.js';
import { apiRequest, getPath } from './client.js';
import { listKitchens } from './kitchens.js';
function extractKey(payload) {
if (typeof payload === 'string') {
return payload;
}
return payload?.key || payload?.application_key || payload?.data?.key || null;
}
function isAuthFailure(error) {
const status = error?.cause?.status || error?.status;
return status === 401 || status === 403;
}
export async function login(store, credentials) {
const payload = await apiRequest(store, getPath('userApplication'), {
method: 'POST',
body: {
user: credentials.userLogin,
application: TRYTON_APPLICATION,
},
includeKitchen: false,
});
const applicationKey = extractKey(payload);
if (!applicationKey) {
throw new Error('User application creation did not return a key.');
}
const session = {
userLogin: credentials.userLogin,
applicationKey,
state: CONNECTION_STATES.pendingValidation,
hasValidated: false,
};
store.setSession(session);
return session;
}
export async function restoreSession(store) {
if (!store.session) {
return null;
}
try {
await verifyConnection(store);
} catch (error) {
if (!isAuthFailure(error)) {
throw error;
}
}
return store.session;
}
export async function logout(store) {
try {
if (store.session?.applicationKey && store.session?.userLogin) {
await apiRequest(store, getPath('userApplication'), {
method: 'DELETE',
body: {
user: store.session.userLogin,
key: store.session.applicationKey,
application: TRYTON_APPLICATION,
},
includeKitchen: false,
});
}
} finally {
store.clearSessionState();
}
}
export async function verifyConnection(store) {
if (!store.session?.applicationKey) {
return null;
}
try {
await listKitchens(store);
store.setSession({
...store.session,
state: CONNECTION_STATES.connected,
hasValidated: true,
});
return store.session;
} catch (error) {
if (!isAuthFailure(error)) {
throw error;
}
store.setSession({
...store.session,
state: store.session.hasValidated
? CONNECTION_STATES.invalidKey
: CONNECTION_STATES.pendingValidation,
});
throw new Error(
store.session.hasValidated
? 'The stored application key is no longer valid.'
: 'The application key is still waiting for validation in Tryton preferences.',
{
cause: error.cause || error,
},
);
}
}
+119
View File
@@ -0,0 +1,119 @@
import { API_PATHS } from '../app/config.js';
function normalizeBaseUrl(baseUrl) {
return baseUrl.trim().replace(/\/+$/, '');
}
function buildUrl({ baseUrl, database, kitchenId, path, query = {}, includeKitchen = true }) {
const cleanBaseUrl = normalizeBaseUrl(baseUrl);
const encodedDatabase = encodeURIComponent(database);
const encodedPath = path.replace(/^\/+/, '');
const kitchenSegment =
includeKitchen && kitchenId
? `/kitchen/${encodeURIComponent(String(kitchenId))}`
: '';
const url = new URL(
`${cleanBaseUrl}/${encodedDatabase}${kitchenSegment}/${encodedPath}`,
);
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
return url.toString();
}
async function parseResponse(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
if (contentType.includes('image/')) {
return response.blob();
}
if (response.status === 204) {
return null;
}
return response.text();
}
function normalizeError(response, payload) {
const message =
payload?.message ||
payload?.error ||
`Request failed with status ${response.status}.`;
return new Error(message, {
cause: {
status: response.status,
details: payload?.errors || payload?.details || null,
},
});
}
export async function apiRequest(store, path, options = {}) {
const { config, session, activeKitchen } = store;
if (!config.baseUrl || !config.database) {
throw new Error('Server URL and database name are required.');
}
const headers = new Headers(options.headers || {});
headers.set('Accept', options.accept || 'application/json');
if (options.body && !options.isFormData) {
headers.set('Content-Type', 'application/json');
}
if (session?.applicationKey) {
headers.set('Authorization', `Bearer ${session.applicationKey}`);
}
const response = await fetch(
buildUrl({
baseUrl: config.baseUrl,
database: config.database,
kitchenId: activeKitchen?.id,
path,
query: options.query,
includeKitchen: options.includeKitchen !== false,
}),
{
method: options.method || 'GET',
headers,
body:
options.body && !options.isFormData
? JSON.stringify(options.body)
: options.body || undefined,
},
);
const payload = await parseResponse(response);
if (!response.ok) {
throw normalizeError(response, payload);
}
return payload;
}
export function getPath(key) {
return API_PATHS[key];
}
export function buildKitchenApiUrl(store, path, query = {}) {
return buildUrl({
baseUrl: store.config.baseUrl,
database: store.config.database,
kitchenId: store.activeKitchen?.id,
path,
query,
includeKitchen: true,
});
}
+12
View File
@@ -0,0 +1,12 @@
import { apiRequest, getPath } from './client.js';
export async function listKitchens(store) {
const payload = await apiRequest(store, getPath('kitchens'), {
includeKitchen: false,
});
if (Array.isArray(payload)) {
return payload;
}
return payload?.data || payload?.kitchens || [];
}
+55
View File
@@ -0,0 +1,55 @@
import { apiRequest, getPath } from './client.js';
export function normalizeLabelImagePayload(payload) {
if (!payload) {
return null;
}
if (payload instanceof Blob) {
return {
objectUrl: URL.createObjectURL(payload),
contentType: payload.type,
};
}
if (payload?.imageUrl) {
return {
objectUrl: payload.imageUrl,
contentType: payload.contentType || 'image/png',
};
}
if (payload?.imageSvg) {
const blob = new Blob([payload.imageSvg], { type: 'image/svg+xml' });
return {
objectUrl: URL.createObjectURL(blob),
contentType: 'image/svg+xml',
};
}
if (payload?.label) {
return {
objectUrl: `data:image/png;base64,${payload.label}`,
contentType: 'image/png',
};
}
return null;
}
export async function previewLabel(store, body) {
const payload = await apiRequest(store, getPath('items'), {
method: 'POST',
body,
accept: 'image/svg+xml, image/png, application/json',
includeKitchen: false,
query: { label: 1, preview: 1 },
});
const image = normalizeLabelImagePayload(payload);
if (image) {
return image;
}
throw new Error('Label preview response did not include an image.');
}
+37
View File
@@ -0,0 +1,37 @@
import { apiRequest, getPath } from './client.js';
function flattenNodes(nodes, trail = [], lineage = []) {
return nodes.flatMap((node) => {
const currentTrail = [...trail, node.name];
const currentLineage = [...lineage, node.uuid_b64];
const current = {
id: node.id,
name: node.name,
type: node.type,
uuid: node.uuid,
uuid_b64: node.uuid_b64,
pathLabel: currentTrail.join(' / '),
depth: trail.length,
lineage_uuid_b64: currentLineage,
};
const children = Array.isArray(node.locations)
? flattenNodes(node.locations, currentTrail, currentLineage)
: [];
return [current, ...children];
});
}
export async function fetchLocations(store) {
const payload = await apiRequest(store, getPath('locations'), {
includeKitchen: false,
});
const tree = Array.isArray(payload)
? payload
: payload?.data || payload?.locations || [];
return {
tree,
flat: flattenNodes(tree),
};
}
+74
View File
@@ -0,0 +1,74 @@
import { apiRequest, getPath } from './client.js';
export async function searchItemDefinitions(store, query) {
if (query.trim().length <= 2) {
return [];
}
const payload = await apiRequest(store, getPath('items'), {
includeKitchen: false,
query: { search_name: query },
});
if (Array.isArray(payload)) {
return payload;
}
return payload?.data || payload?.items || [];
}
export async function listStockEntries(store, filters = {}) {
const payload = await apiRequest(store, getPath('items'), {
includeKitchen: false,
});
if (Array.isArray(payload)) {
return payload;
}
return payload?.data || payload?.entries || payload?.items || [];
}
export async function getStockEntry(store, stockId) {
const payload = await apiRequest(store, `${getPath('stockEntries')}/${stockId}`);
return payload?.data || payload?.entry || payload;
}
export async function createStockEntry(store, body) {
const payload = await apiRequest(store, getPath('items'), {
method: 'POST',
body,
includeKitchen: false,
query: { label: 1 },
});
return payload?.data || payload?.entry || payload;
}
export async function updateStockItem(store, uuidB64, body) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}/stock`, {
method: 'POST',
body,
includeKitchen: false,
});
return payload?.data || payload?.entry || payload;
}
export async function deleteStockItem(store, uuidB64) {
const payload = await apiRequest(store, `${getPath('items')}/${uuidB64}`, {
method: 'DELETE',
includeKitchen: false,
});
return payload?.data || payload?.entry || payload;
}
export async function adjustStockEntry(store, stockId, body) {
const payload = await apiRequest(
store,
`${getPath('stockEntries')}/${stockId}/adjust`,
{
method: 'POST',
body,
},
);
return payload?.data || payload?.entry || payload;
}