Implement upsert label flow and use-based mark gone handling
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-04-10 15:43:39 +02:00
parent caa6ca6ce1
commit 1dc1bb4912
24 changed files with 948 additions and 76 deletions
+232 -1
View File
@@ -1,6 +1,10 @@
import { fetchLocations } from '../../api/locations.js';
import { getStockEntry, listKitchenChanges } from '../../api/stock.js';
import { createAsyncState, runAsyncState } from '../shared/ui-state.js';
export function renderDashboardPage() {
return `
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()">
<section class="container-xxl py-4 py-lg-5" x-data="dashboardPage()" x-init="init()">
<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">
@@ -80,6 +84,52 @@ export function renderDashboardPage() {
</a>
</div>
</div>
<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>
</section>
`;
}
@@ -87,10 +137,191 @@ export function renderDashboardPage() {
export function dashboardPageData(store) {
return {
showKitchenPicker: false,
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(
missingItemUuids.map((uuid) => getStockEntry(store, uuid)),
);
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 = {};
}
},
setKitchen(kitchen) {
store.setActiveKitchen(kitchen);
this.showKitchenPicker = false;
store.addAlert({ type: 'success', message: `Working in ${kitchen.name}.` });
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();
},
};
}