2026-04-10 15:43:39 +02:00
|
|
|
import { fetchLocations } from '../../api/locations.js';
|
|
|
|
|
import { getStockEntry, listKitchenChanges } from '../../api/stock.js';
|
|
|
|
|
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
|
|
|
|
|
|
2026-04-06 09:24:22 +02:00
|
|
|
export function renderDashboardPage() {
|
|
|
|
|
return `
|
2026-04-10 15:43:39 +02:00
|
|
|
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()">
|
2026-04-06 09:24:22 +02:00
|
|
|
<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>
|
2026-05-01 23:32:13 +02:00
|
|
|
<a href="#/scan" class="btn btn-outline-primary btn-lg">Scan item</a>
|
2026-04-06 09:24:22 +02:00
|
|
|
<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">
|
2026-05-01 23:32:13 +02:00
|
|
|
<a class="quick-card" href="#/scan">
|
|
|
|
|
<span class="quick-card-label">Scanning</span>
|
|
|
|
|
<strong>Use, spoil, or inspect</strong>
|
|
|
|
|
<span class="text-body-secondary">Scan a label or barcode and act on the matching item.</span>
|
2026-04-06 09:24:22 +02:00
|
|
|
</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>
|
2026-04-10 15:43:39 +02:00
|
|
|
|
|
|
|
|
<div class="row g-4 mt-1">
|
|
|
|
|
<div class="col-12">
|
|
|
|
|
<div class="card border-0 shadow-sm">
|
|
|
|
|
<div class="card-body p-4">
|
|
|
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="h5 mb-1">Recent changes</h2>
|
|
|
|
|
<p class="text-body-secondary mb-0 small">Latest item and stock updates from the kitchen change feed.</p>
|
|
|
|
|
<p class="text-body-secondary mb-0 small">Saved means the backend created or updated a record.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn-outline-secondary btn-sm" @click="refreshChanges()" :disabled="changesState.isLoading">
|
|
|
|
|
<span x-show="!changesState.isLoading">Refresh</span>
|
|
|
|
|
<span x-show="changesState.isLoading">Refreshing...</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<template x-if="changesState.error">
|
|
|
|
|
<div class="alert alert-warning mb-0" x-text="changesState.error"></div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-if="!changesState.error && changesState.isLoading && !recentChanges.length">
|
|
|
|
|
<div class="text-body-secondary">Loading changes...</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-if="!changesState.error && !changesState.isLoading && !recentChanges.length">
|
|
|
|
|
<div class="text-body-secondary">No recent changes yet.</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template x-if="recentChanges.length">
|
|
|
|
|
<div class="list-group list-group-flush">
|
|
|
|
|
<template x-for="(change, index) in recentChanges" :key="change.timestamp || index">
|
|
|
|
|
<div class="list-group-item px-0">
|
|
|
|
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
|
|
|
<div class="fw-semibold" x-text="changeHeadline(change)"></div>
|
|
|
|
|
<div class="small text-body-secondary" x-text="formatChangeTimestamp(change.timestamp)"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="small text-body-secondary mt-1" x-text="changeStateLine(change)"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-06 09:24:22 +02:00
|
|
|
</section>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function dashboardPageData(store) {
|
|
|
|
|
return {
|
|
|
|
|
showKitchenPicker: false,
|
2026-04-10 15:43:39 +02:00
|
|
|
changesState: createAsyncState(),
|
|
|
|
|
recentChanges: [],
|
|
|
|
|
locationLabelByUuid: {},
|
|
|
|
|
itemByUuid: {},
|
|
|
|
|
async init() {
|
|
|
|
|
if (!store.isConnected) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.refreshChanges();
|
|
|
|
|
},
|
|
|
|
|
async refreshChanges() {
|
|
|
|
|
await runAsyncState(this.changesState, async () => {
|
|
|
|
|
const payload = await listKitchenChanges(store, { limit: 10 });
|
|
|
|
|
this.recentChanges = payload.changes;
|
|
|
|
|
await this.loadContextForChanges(payload.changes);
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
},
|
|
|
|
|
async loadContextForChanges(changes) {
|
|
|
|
|
const stockItemUuids = Array.from(new Set(
|
|
|
|
|
changes
|
|
|
|
|
.map((change) => change?.stock?.item_uuid_b64)
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
));
|
|
|
|
|
const missingItemUuids = stockItemUuids.filter((uuid) => !this.itemByUuid[uuid]);
|
|
|
|
|
|
|
|
|
|
if (missingItemUuids.length) {
|
|
|
|
|
const results = await Promise.allSettled(
|
2026-04-12 22:46:28 +02:00
|
|
|
missingItemUuids.map(async (uuid) => {
|
|
|
|
|
try {
|
|
|
|
|
return await getStockEntry(store, uuid);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const status = error?.status || error?.cause?.status;
|
|
|
|
|
if (status !== 404) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getStockEntry(store, uuid, { allowInactive: true });
|
|
|
|
|
}
|
|
|
|
|
}),
|
2026-04-10 15:43:39 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
results.forEach((result) => {
|
|
|
|
|
if (result.status !== 'fulfilled' || !result.value?.uuid_b64) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.itemByUuid[result.value.uuid_b64] = result.value;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Object.keys(this.locationLabelByUuid).length) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const { flat } = await fetchLocations(store);
|
|
|
|
|
this.locationLabelByUuid = Object.fromEntries(
|
|
|
|
|
flat
|
|
|
|
|
.filter((location) => location.uuid_b64)
|
|
|
|
|
.map((location) => [location.uuid_b64, location.pathLabel || location.name]),
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
this.locationLabelByUuid = {};
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-06 09:24:22 +02:00
|
|
|
setKitchen(kitchen) {
|
|
|
|
|
store.setActiveKitchen(kitchen);
|
|
|
|
|
this.showKitchenPicker = false;
|
|
|
|
|
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
|
2026-04-10 15:43:39 +02:00
|
|
|
this.locationLabelByUuid = {};
|
|
|
|
|
this.itemByUuid = {};
|
|
|
|
|
this.refreshChanges();
|
|
|
|
|
},
|
|
|
|
|
resolveItemForChange(change) {
|
|
|
|
|
if (change?.item?.uuid_b64) {
|
|
|
|
|
return change.item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stockItemUuid = change?.stock?.item_uuid_b64;
|
|
|
|
|
if (!stockItemUuid) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.itemByUuid[stockItemUuid] || null;
|
|
|
|
|
},
|
|
|
|
|
humanStockType(value) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
|
|
|
},
|
|
|
|
|
formatQuantity(quantity, uomSymbol) {
|
|
|
|
|
if (quantity === null || quantity === undefined || quantity === '') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${quantity}${uomSymbol ? ` ${uomSymbol}` : ''}`;
|
|
|
|
|
},
|
|
|
|
|
formatLevel(level) {
|
|
|
|
|
if (!level) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return level.charAt(0).toUpperCase() + level.slice(1);
|
|
|
|
|
},
|
|
|
|
|
formatShortDate(value) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return date.toLocaleDateString();
|
|
|
|
|
},
|
|
|
|
|
resolveLocationLabel(change, item) {
|
|
|
|
|
const locationUuid =
|
|
|
|
|
change?.stock?.location_uuid_b64 ||
|
|
|
|
|
item?.location_initial_uuid_b64 ||
|
|
|
|
|
null;
|
|
|
|
|
if (!locationUuid) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.locationLabelByUuid[locationUuid] || locationUuid;
|
|
|
|
|
},
|
|
|
|
|
changeHeadline(change) {
|
|
|
|
|
const item = this.resolveItemForChange(change);
|
|
|
|
|
const itemName = item?.name || 'Unknown item';
|
|
|
|
|
const type = String(change?.type || 'change');
|
|
|
|
|
const action = String(change?.action || 'updated');
|
|
|
|
|
|
|
|
|
|
if (action === 'upsert' && type === 'item') {
|
|
|
|
|
return `Item saved: ${itemName}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action === 'upsert' && type === 'stock') {
|
|
|
|
|
return `Stock saved: ${itemName}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${type} ${action}: ${itemName}`;
|
|
|
|
|
},
|
|
|
|
|
changeStateLine(change) {
|
|
|
|
|
const item = this.resolveItemForChange(change);
|
|
|
|
|
const stock = change?.stock || {};
|
|
|
|
|
const state = [];
|
|
|
|
|
|
|
|
|
|
const stockType = this.humanStockType(item?.stock_type);
|
|
|
|
|
if (stockType) {
|
|
|
|
|
state.push(`Type: ${stockType}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const quantity = this.formatQuantity(
|
|
|
|
|
stock.quantity ?? item?.quantity,
|
|
|
|
|
stock.uom_symbol || item?.uom_symbol,
|
|
|
|
|
);
|
|
|
|
|
if (quantity) {
|
|
|
|
|
state.push(`Quantity: ${quantity}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const level = this.formatLevel(stock.level || item?.level);
|
|
|
|
|
if (level) {
|
|
|
|
|
state.push(`Level: ${level}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const expiry = this.formatShortDate(item?.expire_date);
|
|
|
|
|
if (expiry) {
|
|
|
|
|
state.push(`Expires: ${expiry}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const location = this.resolveLocationLabel(change, item);
|
|
|
|
|
if (location) {
|
|
|
|
|
state.push(`Location: ${location}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!state.length) {
|
|
|
|
|
return 'Saved (created or updated).';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return state.join(' • ');
|
|
|
|
|
},
|
|
|
|
|
formatChangeTimestamp(value) {
|
|
|
|
|
if (!value) {
|
|
|
|
|
return 'Unknown time';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const date = new Date(value);
|
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return date.toLocaleString();
|
2026-04-06 09:24:22 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|