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
+100 -7
View File
@@ -1,4 +1,8 @@
import { createStockEntry, searchItemDefinitions } from '../../api/stock.js';
import {
applyItemUpsert,
previewItemUpsert,
searchItemDefinitions,
} from '../../api/stock.js';
import { fetchLocations } from '../../api/locations.js';
import { previewLabel } from '../../api/labels.js';
import { STORAGE_KEYS } from '../../app/config.js';
@@ -400,6 +404,14 @@ export function renderLabelCreatePage() {
<div class="alert alert-success mb-0" x-text="successMessage"></div>
</template>
<template x-if="upsertPreview && !upsertPreview.error">
<div class="alert alert-info mb-0 py-2" x-text="upsertPreviewSummary()"></div>
</template>
<template x-if="upsertPreview?.error">
<div class="alert alert-warning mb-0 py-2" x-text="upsertPreview.error"></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">
@@ -407,7 +419,7 @@ export function renderLabelCreatePage() {
<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">Save stock entry</span>
<span x-show="createState.isLoading">Saving...</span>
</button>
</div>
@@ -478,6 +490,10 @@ function diffDays(fromIsoDate, toIsoDate) {
function createDefaultForm() {
return {
itemId: '',
itemUuidB64: '',
identifierCode: '',
externalSource: '',
externalId: '',
search: '',
name: '',
description: '',
@@ -505,6 +521,10 @@ function loadLabelDraft() {
? ''
: draft.quantity,
itemId: '',
itemUuidB64: '',
identifierCode: '',
externalSource: '',
externalId: '',
search: '',
};
}
@@ -513,6 +533,10 @@ function buildDraftPayload(form) {
return {
...form,
itemId: '',
itemUuidB64: '',
identifierCode: '',
externalSource: '',
externalId: '',
search: '',
};
}
@@ -536,6 +560,7 @@ export function labelCreatePageData(store) {
successMessage: '',
submitError: '',
fieldErrors: {},
upsertPreview: null,
form: {
...loadLabelDraft(),
},
@@ -590,6 +615,14 @@ export function labelCreatePageData(store) {
}
},
onSearchInput() {
this.upsertPreview = null;
if (this.form.itemUuidB64 || this.form.itemId) {
this.form.itemId = '';
this.form.itemUuidB64 = '';
this.form.identifierCode = '';
this.form.externalSource = '';
this.form.externalId = '';
}
this.persistDraft();
this.searchDebounced();
},
@@ -603,6 +636,10 @@ export function labelCreatePageData(store) {
: null;
this.form.itemId = item.id;
this.form.itemUuidB64 = item.uuid_b64 || '';
this.form.identifierCode = item.identifier_code || '';
this.form.externalSource = item.external_source || '';
this.form.externalId = item.external_id || '';
this.form.search = item.name;
this.form.name = item.name;
this.form.description = item.description || this.form.description;
@@ -623,7 +660,12 @@ export function labelCreatePageData(store) {
},
clearItemSearch() {
this.form.itemId = '';
this.form.itemUuidB64 = '';
this.form.identifierCode = '';
this.form.externalSource = '';
this.form.externalId = '';
this.form.search = '';
this.upsertPreview = null;
this.suggestions = [];
this.persistDraft();
},
@@ -908,6 +950,8 @@ export function labelCreatePageData(store) {
: null
: Number(this.form.quantity);
const selectedLocationUuidB64 = this.selectedLocation?.uuid_b64 || null;
return {
item_id: this.form.itemId || null,
name: this.form.name.trim(),
@@ -920,13 +964,51 @@ export function labelCreatePageData(store) {
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,
location_initial: selectedLocationUuidB64,
kitchen_id: store.activeKitchen?.id || null,
};
},
buildUpsertPayload() {
const basePayload = this.buildPayload();
const itemPayload = {
name: basePayload.name,
description: basePayload.description,
quantity_initial: basePayload.quantity_initial,
uom_symbol: basePayload.uom_symbol,
calories: basePayload.calories,
calories_unit: basePayload.calories_unit,
stock_type: basePayload.stock_type,
level: basePayload.level,
date: basePayload.date,
expire_date: basePayload.expire_date,
location_initial: basePayload.location_initial,
};
return {
uuid_b64: this.form.itemUuidB64 || null,
identifier_code: this.form.identifierCode || null,
external_source: this.form.externalSource || null,
external_id: this.form.externalId || null,
item: itemPayload,
};
},
upsertPreviewSummary() {
if (!this.upsertPreview || this.upsertPreview.error) {
return '';
}
if (this.upsertPreview.operation === 'update') {
const name = this.upsertPreview.matchedItem?.name || this.form.name;
const matchType = this.upsertPreview.matchType ? ` (matched by ${this.upsertPreview.matchType})` : '';
return `Submit will update: ${name}${matchType}.`;
}
return 'Submit will create a new stock item.';
},
async preview() {
this.submitError = '';
this.fieldErrors = {};
this.upsertPreview = null;
if (!this.validateBeforeSubmit()) {
this.previewState.error = 'Please fill out the required fields before previewing the label.';
@@ -940,6 +1022,13 @@ export function labelCreatePageData(store) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = result.objectUrl;
try {
this.upsertPreview = await previewItemUpsert(store, this.buildUpsertPayload());
} catch (error) {
this.upsertPreview = {
error: error.message || 'Upsert preview failed.',
};
}
this.persistDraft();
});
},
@@ -948,22 +1037,25 @@ export function labelCreatePageData(store) {
this.fieldErrors = {};
if (!this.validateBeforeSubmit()) {
this.submitError = 'Please fill out the required fields before creating the stock entry.';
this.submitError = 'Please fill out the required fields before saving the stock entry.';
return;
}
await runAsyncState(this.createState, async () => {
try {
const entry = await createStockEntry(store, this.buildPayload());
const entry = await applyItemUpsert(store, this.buildUpsertPayload());
if (this.previewUrl && this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);
}
this.previewUrl = '';
this.successMessage = `${entry.name || this.form.name} was created successfully.`;
const entryName = entry.item?.name || this.form.name;
const operationVerb = entry.operation === 'update' ? 'updated' : 'created';
this.successMessage = `${entryName} was ${operationVerb} successfully.`;
store.addAlert({
type: 'success',
message: `${entry.name || this.form.name} was created successfully.`,
message: `${entryName} was ${operationVerb} successfully.`,
});
this.upsertPreview = entry;
saveStoredValue(STORAGE_KEYS.labelDraft, buildDraftPayload(this.form));
} catch (error) {
this.fieldErrors = normalizeValidationError(error);
@@ -981,6 +1073,7 @@ export function labelCreatePageData(store) {
this.successMessage = '';
this.submitError = '';
this.fieldErrors = {};
this.upsertPreview = null;
saveStoredValue(STORAGE_KEYS.labelDraft, this.form);
if (revokePreview && this.previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.previewUrl);