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:
+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;
|
||||
}
|
||||
Reference in New Issue
Block a user