feat+fix: Bring removal, multi-expiry batches, FIFO in cooking steps

BRING! REMOVAL FIX (latte/aglio not removed after shopping):
- PHP addToInventory: replace exact strcasecmp with token-based fuzzy
  matching (same logic as _productOnBring) so custom Bring item names
  and translated catalog keys both match correctly
- JS submitAdd: add client-side fallback — if PHP removal missed the item,
  use _findSimilarItem against the loaded shoppingItems and call bring_remove

MULTI-EXPIRY BATCHES (when buying conf with different expiry dates):
- Add form (unit=conf): shows '+ Lotto con scadenza diversa' button
- Each extra batch has its own qty + expiry date input with +/- controls
- On submitAdd, extra batches are submitted as additional inventory_add calls
  (separate DB rows, separate expiry dates)
- Multi-batch section hidden in 'Ce l'avevo già' mode and for non-conf units
- Re-shown/hidden when switching unit via onAddUnitChange

RECIPE COOKING STEPS - FIFO ingredient display:
- renderCookingStep: each ingredient row now shows brand chip, location chip,
  and expiry date chip (color-coded: red ≤3d, yellow ≤7d)
- PHP already selected earliest-expiry inventory entry (ORDER BY days_left ASC
  with > not >= ensures first/earliest match wins)
- CSS: .cooking-ing-meta, .cooking-ing-chip, .exp-soon, .exp-close,
  .multi-batch-row, .multi-batch-qty, .multi-batch-date, .btn-icon-sm
This commit is contained in:
dadaloop82
2026-04-07 12:10:14 +00:00
parent 22ae3abf47
commit b7ed9899fa
6 changed files with 551 additions and 5 deletions
+120 -1
View File
@@ -3269,6 +3269,8 @@ function showAddForm() {
// Reset historical expiry for this product; will be fetched async
window._historyExpiryDays = null;
window._historyExpiryCount = 0;
// Reset extra batches from previous add
window._addExtraBatches = [];
// Store base expiry for vacuum recalculation
window._addBaseExpiryDays = estimatedDays;
@@ -3293,6 +3295,12 @@ function showAddForm() {
</div>
<p class="form-hint">📝 Puoi modificare la data o scansionarla con la fotocamera</p>
</div>
<div id="multi-batch-section" style="display:${unit === 'conf' ? 'block' : 'none'}">
<div id="multi-batch-container"></div>
<button type="button" class="btn btn-outline btn-small full-width" style="margin-top:8px" onclick="addExpiryBatch()">
📦 + Lotto con scadenza diversa
</button>
</div>
`;
showPage('add');
@@ -3406,6 +3414,10 @@ function onAddUnitChange() {
// Scroll into view so the user sees the new field
if (isConf) setTimeout(() => confRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 100);
}
// Show/hide multi-batch section (only for conf unit)
const mbSection = document.getElementById('multi-batch-section');
if (mbSection) mbSection.style.display = unit === 'conf' ? 'block' : 'none';
// If switching units, suggest a sensible quantity
// BUT only if the user hasn't manually changed the quantity in this form
@@ -3457,6 +3469,11 @@ function selectPurchaseType(btn, type) {
btn.parentElement.querySelectorAll('.purchase-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Reset extra batches when switching purchase type
window._addExtraBatches = [];
const mbContainer = document.getElementById('multi-batch-container');
if (mbContainer) mbContainer.innerHTML = '';
const detailDiv = document.getElementById('expiry-detail');
// Save current quantity before switching, so we can preserve it
@@ -3489,6 +3506,9 @@ function selectPurchaseType(btn, type) {
`;
// Restore quantity - switching purchase type should NOT change it
document.getElementById('add-quantity').value = currentQty;
// Show multi-batch section only in "new" mode (and only for conf unit)
const mbSection = document.getElementById('multi-batch-section');
if (mbSection) mbSection.style.display = (document.getElementById('add-unit')?.value === 'conf') ? 'block' : 'none';
} else {
detailDiv.innerHTML = `
<div class="form-group">
@@ -3511,6 +3531,9 @@ function selectPurchaseType(btn, type) {
</div>
`;
// DON'T auto-set remaining percentage - keep the quantity the user already entered
// Hide multi-batch section in "existing" mode
const mbSection = document.getElementById('multi-batch-section');
if (mbSection) mbSection.style.display = 'none';
}
}
@@ -3528,6 +3551,50 @@ function setRemainingPct(pct) {
document.getElementById('add-quantity').value = adjustedQty;
}
// ===== MULTI-EXPIRY BATCHES (for conf products with different expiry dates) =====
window._addExtraBatches = [];
function addExpiryBatch() {
const loc = document.getElementById('add-location')?.value || '';
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
const estimatedDate = addDays(baseDays);
window._addExtraBatches.push({ qty: 1, expiry: estimatedDate });
_rebuildMultiBatchUI();
}
function removeExpiryBatch(i) {
window._addExtraBatches.splice(i, 1);
_rebuildMultiBatchUI();
}
function adjustBatchQty(i, delta) {
window._addExtraBatches[i].qty = Math.max(1, (window._addExtraBatches[i].qty || 1) + delta);
_rebuildMultiBatchUI();
}
function _rebuildMultiBatchUI() {
const container = document.getElementById('multi-batch-container');
if (!container) return;
if (window._addExtraBatches.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = window._addExtraBatches.map((b, i) => `
<div class="multi-batch-row">
<div class="multi-batch-qty">
<button type="button" class="qty-btn" onclick="adjustBatchQty(${i}, -1)"></button>
<input type="number" class="qty-input" value="${b.qty}" min="1" step="1" style="width:60px"
onchange="window._addExtraBatches[${i}].qty = parseInt(this.value)||1">
<button type="button" class="qty-btn" onclick="adjustBatchQty(${i}, 1)">+</button>
<span class="multi-batch-unit">conf</span>
</div>
<input type="date" class="form-input multi-batch-date" value="${b.expiry}"
onchange="window._addExtraBatches[${i}].expiry = this.value">
<button type="button" class="btn-icon-sm" onclick="removeExpiryBatch(${i})" title="Rimuovi"></button>
</div>
`).join('');
}
function selectLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
@@ -3583,8 +3650,46 @@ async function submitAdd(e) {
showToast(`${currentProduct.name} aggiunto!${qtyInfo}`, 'success');
if (result.removed_from_bring) {
setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500);
} else if (shoppingItems.length > 0 && shoppingListUUID) {
// PHP matching may have missed the item (custom name / no catalog match) —
// try a client-side fuzzy remove using the already-loaded shoppingItems
const match = _findSimilarItem(currentProduct.name, shoppingItems);
if (match) {
api('bring_remove', {}, 'POST', {
name: match.name,
rawName: match.rawName || '',
listUUID: shoppingListUUID
}).then(r => {
if (r && r.success) {
shoppingItems = shoppingItems.filter(i => i !== match);
setTimeout(() => showToast('🛒 Rimosso dalla lista della spesa', 'info'), 1500);
}
}).catch(() => {});
}
}
if (!spesaModeAfterAdd()) showPage('dashboard');
// Submit extra batches (different expiry dates) in the background, silently
if ((window._addExtraBatches || []).length > 0) {
const loc = document.getElementById('add-location')?.value || result.location || 'dispensa';
const selectedUnit = document.getElementById('add-unit').value;
const productUnit = currentProduct.unit || 'pz';
const confUnit = document.getElementById('add-conf-unit')?.value || null;
const confSize = parseFloat(document.getElementById('add-conf-size')?.value) || null;
for (const batch of window._addExtraBatches) {
if (!batch.qty || batch.qty <= 0) continue;
api('inventory_add', {}, 'POST', {
product_id: currentProduct.id,
quantity: batch.qty,
location: loc,
expiry_date: batch.expiry || null,
unit: selectedUnit !== productUnit ? selectedUnit : null,
package_unit: selectedUnit === 'conf' ? confUnit : null,
package_size: selectedUnit === 'conf' ? confSize : null,
}).catch(() => {});
}
window._addExtraBatches = [];
}
} else {
showToast(result.error || 'Errore', 'error');
}
@@ -6911,11 +7016,25 @@ function renderCookingStep() {
const ingsEl = document.getElementById('cooking-step-ings');
if (ings.length > 0) {
const LOC_LABELS = { dispensa: '🏠 Dispensa', frigo: '❄️ Frigo', freezer: '🧊 Freezer' };
ingsEl.innerHTML = ings.map(ing => {
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const qtyNum = ing.qty_number || 0;
// Build info chips: brand, location, expiry
const chips = [];
if (ing.brand) chips.push(`<span class="cooking-ing-chip">${escapeHtml(ing.brand)}</span>`);
const locLabel = LOC_LABELS[ing.location] || (ing.location ? `📍 ${ing.location}` : '🏠 Dispensa');
chips.push(`<span class="cooking-ing-chip">${locLabel}</span>`);
if (ing.expiry_date) {
const daysLeft = Math.round((new Date(ing.expiry_date) - new Date()) / 86400000);
const expClass = daysLeft <= 3 ? 'exp-soon' : daysLeft <= 7 ? 'exp-close' : '';
chips.push(`<span class="cooking-ing-chip ${expClass}">📅 scade ${formatDate(ing.expiry_date)}</span>`);
}
return `<div class="cooking-ing-row">
<span class="cooking-ing-name">📦 <strong>${escapeHtml(ing.name)}</strong>: ${escapeHtml(ing.qty)}</span>
<div style="flex:1;min-width:0">
<span class="cooking-ing-name">📦 <strong>${escapeHtml(ing.name)}</strong>: ${escapeHtml(ing.qty)}</span>
<div class="cooking-ing-meta">${chips.join('')}</div>
</div>
<button class="cooking-use-btn" onclick="cookingUseIngredient(${ing._idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this)">📤 Usa</button>
</div>`;
}).join('');