Files
EverShelf/assets/js/app.js
T
dadaloop82 706c8aeae0 Fix: camera scadenza più piccola con zoom 2x, quantità non si resetta più, nomi prodotti in lingua corretta
- Ridotto il frame della camera scadenza a 180px con zoom 2x e crop centrale
- Fix: premere 'Appena comprato' / 'Ce l'avevo già' non modifica più la quantità
- Fix: nomi prodotti in script non-latino vengono sostituiti con alternative leggibili
- Corretto nome prodotto 'Celozrnný toastový chléb' → 'Sandwich American Style'
- Migliorato ordine fallback nomi: product_name_it → generic_name_it → product_name
2026-03-10 11:20:43 +00:00

1746 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dispensa Manager - Main Application JS
* Complete pantry management with barcode scanning and AI identification
*/
// ===== CONFIGURATION =====
const API_BASE = 'api/index.php';
const LOCATIONS = {
'dispensa': { icon: '🗄️', label: 'Dispensa' },
'frigo': { icon: '🧊', label: 'Frigo' },
'freezer': { icon: '❄️', label: 'Freezer' },
'altro': { icon: '📦', label: 'Altro' },
};
const CATEGORY_ICONS = {
'latticini': '🥛', 'carne': '🥩', 'pesce': '🐟', 'frutta': '🍎',
'verdura': '🥬', 'pasta': '🍝', 'pane': '🍞', 'surgelati': '🧊',
'bevande': '🥤', 'condimenti': '🧂', 'snack': '🍪', 'conserve': '🥫',
'cereali': '🌾', 'igiene': '🧴', 'pulizia': '🧹', 'altro': '📦'
};
// Auto-detect location based on category and product name
const CATEGORY_LOCATION = {
'latticini': 'frigo', 'carne': 'frigo', 'pesce': 'frigo',
'frutta': 'frigo', 'verdura': 'frigo', 'surgelati': 'freezer',
'pasta': 'dispensa', 'pane': 'dispensa', 'bevande': 'dispensa',
'condimenti': 'dispensa', 'snack': 'dispensa', 'conserve': 'dispensa',
'cereali': 'dispensa', 'igiene': 'altro', 'pulizia': 'altro', 'altro': 'dispensa'
};
// Detect best unit/quantity from Open Food Facts quantity_info string
function detectUnitAndQuantity(quantityInfo) {
if (!quantityInfo) return { unit: 'pz', quantity: 1 };
const q = quantityInfo.toLowerCase().trim();
// Match patterns like "500 g", "1 l", "750 ml", "1.5 kg", "6 x 1l"
const multiMatch = q.match(/(\d+)\s*x\s*([\d.,]+)\s*(ml|l|g|kg|cl)/i);
if (multiMatch) {
return { unit: multiMatch[3] === 'cl' ? 'ml' : multiMatch[3], quantity: parseInt(multiMatch[1]), perUnit: multiMatch[2] + multiMatch[3] };
}
const match = q.match(/([\d.,]+)\s*(kg|g|l|ml|cl)/i);
if (match) {
let unit = match[2].toLowerCase();
let val = parseFloat(match[1].replace(',', '.'));
if (unit === 'cl') { unit = 'ml'; val *= 10; }
return { unit, quantity: 1, weight: val + unit };
}
return { unit: 'pz', quantity: 1 };
}
// Estimate expiry days based on category/product type
const EXPIRY_DAYS = {
'latticini': 7, 'carne': 4, 'pesce': 3, 'frutta': 7, 'verdura': 7,
'pasta': 730, 'pane': 4, 'surgelati': 180, 'bevande': 365, 'condimenti': 365,
'snack': 180, 'conserve': 730, 'cereali': 365, 'igiene': 1095, 'pulizia': 1095, 'altro': 180
};
// More specific expiry by product name keywords
function estimateExpiryDays(product) {
const name = (product.name || '').toLowerCase();
const cat = (product.category || '').toLowerCase();
// Specific product overrides
if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) return 7;
if (/latte\s+uht|latte\s+a\s+lunga/.test(name)) return 90;
if (/yogurt/.test(name)) return 21;
if (/mozzarella|burrata|stracciatella/.test(name)) return 5;
if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 10;
if (/parmigiano|grana|pecorino|provolone/.test(name)) return 60;
if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 7;
if (/prosciutto\s+crudo|salame|bresaola|speck/.test(name)) return 30;
if (/uova/.test(name)) return 28;
if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) return 5;
if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) return 14;
if (/insalata|rucola|spinaci\s+freschi/.test(name)) return 5;
if (/pollo|tacchino|maiale|manzo|vitello/.test(name)) return 3;
if (/salmone|tonno\s+fresco|pesce/.test(name) && !/tonno\s+in\s+scatola|tonno\s+rio/.test(name)) return 2;
if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 1095;
if (/surgelat|frozen|findus|4\s*salti/.test(name)) return 180;
if (/gelato/.test(name)) return 365;
if (/succo|spremuta/.test(name)) return 7;
if (/birra|vino/.test(name)) return 365;
if (/acqua/.test(name)) return 365;
if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) return 180;
if (/nutella|marmellata|miele/.test(name)) return 365;
if (/passata|pelati|pomodor/.test(name)) return 730;
if (/olio|aceto/.test(name)) return 548;
// Fallback to category
for (const [key, days] of Object.entries(EXPIRY_DAYS)) {
if (cat.includes(key)) return days;
}
return 180; // generic default
}
function formatEstimatedExpiry(days) {
if (days <= 7) return `~${days} giorni`;
if (days <= 30) return `~${Math.round(days / 7)} settimane`;
if (days <= 365) return `~${Math.round(days / 30)} mesi`;
return `~${Math.round(days / 365)} anni`;
}
function addDays(days) {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().split('T')[0];
}
// Guess location from product name keywords (fallback if no category)
function guessLocationFromName(name) {
const n = (name || '').toLowerCase();
// Frigo keywords
if (/latte|yogurt|formaggio|mozzarella|burro|panna|uova|prosciutto|salame|wurstel|ricotta|mascarpone|gorgonzola|insalata|rucola|spinaci|pollo|manzo|maiale|salmone|tonno fresco|bresaola/.test(n)) return 'frigo';
// Freezer keywords
if (/surgel|frozen|gelato|ghiaccioli|bastoncini|findus|4 salti|pizza surgel|verdure surgel|minestrone surg/.test(n)) return 'freezer';
// Dispensa keywords
if (/pasta|riso|farina|zucchero|sale|olio|aceto|biscott|cracker|grissini|caffè|tè|the |tea |tonno|pelati|passata|legumi|ceci|fagioli|lenticchie|cereali|muesli|marmell|nutella|miele|cioccolat/.test(n)) return 'dispensa';
return null; // unknown
}
function guessLocation(product) {
// 1. Category-based
if (product.category) {
const cat = product.category.toLowerCase().replace(/^en:/, '').split(',')[0].trim();
// Check our map
for (const [key, loc] of Object.entries(CATEGORY_LOCATION)) {
if (cat.includes(key)) return loc;
}
// Open Food Facts categories
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin/i.test(cat)) return 'frigo';
if (/meat|viande|carne|fish|poisson|pesce/i.test(cat)) return 'frigo';
if (/frozen|surgelé|surgel/i.test(cat)) return 'freezer';
if (/fruit|vegetable|verdur|frutta/i.test(cat)) return 'frigo';
if (/beverage|drink|boisson|bevand/i.test(cat)) return 'dispensa';
if (/pasta|cereal|grain|bread|biscuit|snack|sauce|condiment|conserv|can/i.test(cat)) return 'dispensa';
}
// 2. Name-based fallback
const nameLoc = guessLocationFromName(product.name);
if (nameLoc) return nameLoc;
// 3. Default
return 'dispensa';
}
// ===== STATE =====
let currentProduct = null;
let currentInventory = [];
let currentLocation = '';
let scannerStream = null;
let quaggaRunning = false;
let aiStream = null;
// ===== API HELPER =====
async function api(action, params = {}, method = 'GET', body = null) {
let url = `${API_BASE}?action=${action}`;
if (method === 'GET') {
Object.entries(params).forEach(([k, v]) => {
url += `&${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
});
}
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
return res.json();
}
// ===== PAGE NAVIGATION =====
function showPage(pageId, param = null) {
// Hide all pages
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
// Show target page
const page = document.getElementById(`page-${pageId}`);
if (page) page.classList.add('active');
// Update nav
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
const navBtn = document.querySelector(`.nav-btn[data-page="${pageId}"]`);
if (navBtn) navBtn.classList.add('active');
// Page-specific init
switch(pageId) {
case 'dashboard': loadDashboard(); break;
case 'inventory':
if (param !== null) {
currentLocation = param;
filterLocation(param);
}
loadInventory();
break;
case 'scan': initScanner(); break;
case 'products': loadAllProducts(); break;
case 'ai': initAICamera(); break;
}
// Stop scanner when leaving scan page
if (pageId !== 'scan' && pageId !== 'ai') {
stopScanner();
}
// Scroll to top
window.scrollTo(0, 0);
}
// ===== DASHBOARD =====
async function loadDashboard() {
try {
const [summaryData, statsData, invData] = await Promise.all([
api('inventory_summary'),
api('stats'),
api('inventory_list')
]);
// Update stat cards
const summary = summaryData.summary || [];
let total = 0;
['dispensa', 'frigo', 'freezer'].forEach(loc => {
const s = summary.find(x => x.location === loc);
const count = s ? s.product_count : 0;
document.getElementById(`stat-${loc}`).textContent = count;
total += count;
});
// Add non-standard locations
summary.forEach(s => {
if (!['dispensa', 'frigo', 'freezer'].includes(s.location)) {
total += s.product_count;
}
});
document.getElementById('stat-total').textContent = total || summary.reduce((a, s) => a + s.product_count, 0);
// Expiring items
const expiringSection = document.getElementById('alert-expiring');
const expiringList = document.getElementById('expiring-list');
if (statsData.expiring_soon && statsData.expiring_soon.length > 0) {
expiringSection.style.display = 'block';
expiringList.innerHTML = statsData.expiring_soon.map(item => `
<div class="alert-item">
<span>${item.name}${item.brand ? ' - ' + item.brand : ''}</span>
<span>${formatDate(item.expiry_date)}</span>
</div>
`).join('');
} else {
expiringSection.style.display = 'none';
}
// Expired items
const expiredSection = document.getElementById('alert-expired');
const expiredList = document.getElementById('expired-list');
if (statsData.expired && statsData.expired.length > 0) {
expiredSection.style.display = 'block';
expiredList.innerHTML = statsData.expired.map(item => `
<div class="alert-item">
<span>${item.name}${item.brand ? ' - ' + item.brand : ''}</span>
<span>${formatDate(item.expiry_date)}</span>
</div>
`).join('');
} else {
expiredSection.style.display = 'none';
}
// Full inventory grouped by location
const allItems = invData.inventory || [];
const grouped = { dispensa: [], frigo: [], freezer: [], altro: [] };
allItems.forEach(item => {
const loc = grouped[item.location] !== undefined ? item.location : 'altro';
grouped[loc].push(item);
});
for (const [loc, items] of Object.entries(grouped)) {
const section = document.getElementById(`dash-section-${loc}`);
const container = document.getElementById(`dash-inv-${loc}`);
if (items.length === 0) {
section.style.display = 'none';
} else {
section.style.display = 'block';
container.innerHTML = items.map(item => renderDashItem(item)).join('');
}
}
} catch (err) {
console.error('Dashboard load error:', err);
}
}
function renderDashItem(item) {
const catIcon = CATEGORY_ICONS[item.category] || '📦';
const isExpired = item.expiry_date && new Date(item.expiry_date) < new Date();
const isExpiring = item.expiry_date && !isExpired && new Date(item.expiry_date) <= new Date(Date.now() + 7 * 86400000);
const qtyDisplay = formatQuantity(item.quantity, item.unit);
return `
<div class="inventory-item compact-item" onclick="dashItemTap(${item.id}, ${item.product_id})">
<div class="inv-image">
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(item.name)}</div>
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
</div>
<div class="inv-qty-right">
<span class="inv-qty-value">${qtyDisplay}</span>
${item.expiry_date ? `<span class="inv-expiry-small ${isExpired ? 'expired' : isExpiring ? 'expiring' : ''}">${isExpired ? '⚠️' : ''} ${formatDate(item.expiry_date)}</span>` : ''}
</div>
</div>`;
}
function dashItemTap(inventoryId, productId) {
// Load full inventory so modal works
api('inventory_list').then(data => {
currentInventory = data.inventory || [];
showItemDetail(inventoryId, productId);
});
}
function formatQuantity(qty, unit) {
if (!qty && qty !== 0) return '';
const n = parseFloat(qty);
const unitLabels = { 'pz': 'pz', 'kg': 'kg', 'g': 'g', 'l': 'L', 'ml': 'ml', 'conf': 'conf' };
const label = unitLabels[unit] || unit || 'pz';
// Format nicely
if (n === Math.floor(n)) return `${Math.floor(n)} ${label}`;
return `${n.toFixed(1)} ${label}`;
}
// ===== INVENTORY =====
async function loadInventory() {
try {
const data = await api('inventory_list', currentLocation ? { location: currentLocation } : {});
currentInventory = data.inventory || [];
renderInventory(currentInventory);
} catch (err) {
console.error('Inventory load error:', err);
}
}
function renderInventory(items) {
const container = document.getElementById('inventory-list');
if (items.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><p>Nessun prodotto qui.<br>Scansiona un prodotto per aggiungerlo!</p></div>';
return;
}
container.innerHTML = items.map(item => {
const catIcon = CATEGORY_ICONS[item.category] || '📦';
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const isExpired = item.expiry_date && new Date(item.expiry_date) < new Date();
const isExpiring = item.expiry_date && !isExpired && new Date(item.expiry_date) <= new Date(Date.now() + 7 * 86400000);
const qtyDisplay = formatQuantity(item.quantity, item.unit);
return `
<div class="inventory-item" onclick="showItemDetail(${item.id}, ${item.product_id})">
<div class="inv-image">
${item.image_url ? `<img src="${escapeHtml(item.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(item.name)}</div>
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
<div class="inv-meta">
<span class="inv-badge badge-location">${locInfo.icon} ${locInfo.label}</span>
<span class="inv-badge badge-qty">${qtyDisplay}</span>
${item.expiry_date ? `<span class="inv-badge ${isExpired ? 'badge-expired' : isExpiring ? 'badge-expiry' : ''}">${isExpired ? '⚠️ ' : ''}${formatDate(item.expiry_date)}</span>` : ''}
</div>
</div>
</div>`;
}).join('');
}
function filterLocation(loc) {
currentLocation = loc;
document.querySelectorAll('.location-tabs .tab').forEach(t => {
t.classList.toggle('active', t.dataset.loc === loc);
});
loadInventory();
}
function filterInventory() {
const q = document.getElementById('inventory-search').value.toLowerCase();
if (!q) {
renderInventory(currentInventory);
return;
}
const filtered = currentInventory.filter(i =>
i.name.toLowerCase().includes(q) ||
(i.brand && i.brand.toLowerCase().includes(q)) ||
(i.barcode && i.barcode.includes(q))
);
renderInventory(filtered);
}
// ===== ITEM DETAIL MODAL =====
function showItemDetail(inventoryId, productId) {
const item = currentInventory.find(i => i.id === inventoryId);
if (!item) return;
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const catIcon = CATEGORY_ICONS[item.category] || '📦';
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${escapeHtml(item.name)}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="product-preview-small" style="margin-bottom:12px">
${item.image_url ?
`<img src="${escapeHtml(item.image_url)}" alt="" style="width:60px;height:60px;border-radius:10px;object-fit:cover">` :
`<span style="font-size:2.5rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(item.name)}</h3>
<p>${item.brand ? escapeHtml(item.brand) : ''}</p>
</div>
</div>
<div class="modal-detail">
<div class="modal-detail-row">
<span class="modal-detail-label">📍 Posizione</span>
<span class="modal-detail-value">${locInfo.icon} ${locInfo.label}</span>
</div>
<div class="modal-detail-row">
<span class="modal-detail-label">📦 Quantità</span>
<span class="modal-detail-value">${item.quantity} ${item.unit}</span>
</div>
${item.expiry_date ? `
<div class="modal-detail-row">
<span class="modal-detail-label">📅 Scadenza</span>
<span class="modal-detail-value">${formatDate(item.expiry_date)}</span>
</div>` : ''}
${item.barcode ? `
<div class="modal-detail-row">
<span class="modal-detail-label">🔖 Barcode</span>
<span class="modal-detail-value">${item.barcode}</span>
</div>` : ''}
<div class="modal-detail-row">
<span class="modal-detail-label">📅 Aggiunto</span>
<span class="modal-detail-value">${formatDateTime(item.added_at)}</span>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-danger flex-1" onclick="quickUse(${item.product_id}, '${item.location}')">📤 Usa</button>
<button class="btn btn-primary flex-1" onclick="editInventoryItem(${inventoryId})">✏️ Modifica</button>
<button class="btn btn-secondary" onclick="deleteInventoryItem(${inventoryId})" style="padding:12px">🗑️</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
function closeModal() {
document.getElementById('modal-overlay').style.display = 'none';
}
async function quickUse(productId, location) {
closeModal();
currentProduct = { id: productId };
// Get product info
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
}
document.getElementById('use-location').value = location;
// Mark active location button
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
const locBtns = document.querySelectorAll('#page-use .loc-btn');
locBtns.forEach(b => {
if (b.textContent.toLowerCase().includes(location)) b.classList.add('active');
});
renderUsePreview();
showPage('use');
}
async function deleteInventoryItem(id) {
if (confirm('Vuoi davvero rimuovere questo prodotto dall\'inventario?')) {
await api('inventory_delete', {}, 'POST', { id });
closeModal();
showToast('Prodotto rimosso', 'success');
loadInventory();
}
}
function editInventoryItem(id) {
const item = currentInventory.find(i => i.id === id);
if (!item) return;
closeModal();
// Show a simple edit modal
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>Modifica ${escapeHtml(item.name)}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<form class="form" onsubmit="submitEditInventory(event, ${id})">
<div class="form-group">
<label>📦 Quantità</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', -1)"></button>
<input type="number" id="edit-qty" value="${item.quantity}" min="0" step="any" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('edit-qty', 1)">+</button>
</div>
</div>
<div class="form-group">
<label>📍 Posizione</label>
<div class="location-selector">
${Object.entries(LOCATIONS).map(([k, v]) => `
<button type="button" class="loc-btn ${item.location === k ? 'active' : ''}"
onclick="this.parentElement.querySelectorAll('.loc-btn').forEach(b=>b.classList.remove('active'));this.classList.add('active');document.getElementById('edit-loc').value='${k}'">${v.icon} ${v.label}</button>
`).join('')}
</div>
<input type="hidden" id="edit-loc" value="${item.location}">
</div>
<div class="form-group">
<label>📅 Scadenza</label>
<input type="date" id="edit-expiry" value="${item.expiry_date || ''}" class="form-input">
</div>
<button type="submit" class="btn btn-large btn-primary full-width">💾 Salva</button>
</form>
`;
document.getElementById('modal-overlay').style.display = 'flex';
}
async function submitEditInventory(e, id) {
e.preventDefault();
const qty = parseFloat(document.getElementById('edit-qty').value);
const loc = document.getElementById('edit-loc').value;
const expiry = document.getElementById('edit-expiry').value || null;
await api('inventory_update', {}, 'POST', { id, quantity: qty, location: loc, expiry_date: expiry });
closeModal();
showToast('Aggiornato!', 'success');
loadInventory();
}
// ===== BARCODE SCANNER =====
async function initScanner() {
const video = document.getElementById('scanner-video');
const viewport = document.getElementById('scanner-viewport');
try {
// Stop any existing stream
stopScanner();
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
scannerStream = stream;
video.srcObject = stream;
await video.play();
// Start Quagga for barcode detection
startQuagga(video);
} catch (err) {
console.error('Camera error:', err);
document.getElementById('scan-result').style.display = 'block';
document.getElementById('scan-result').innerHTML = `
<p style="color: var(--danger)">⚠️ Impossibile accedere alla fotocamera.</p>
<p style="font-size:0.85rem; color: var(--text-light); margin-top:8px">
Assicurati di usare HTTPS e di aver concesso i permessi della fotocamera.<br>
Puoi inserire il barcode manualmente o usare l'identificazione AI.
</p>
`;
}
}
function startQuagga(videoEl) {
if (quaggaRunning) return;
const canvas = document.getElementById('scanner-canvas');
const ctx = canvas.getContext('2d');
let scanning = true;
quaggaRunning = true;
let lastDetected = '';
let detectCount = 0;
function scanFrame() {
if (!scanning || !scannerStream) return;
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
ctx.drawImage(videoEl, 0, 0);
try {
Quagga.decodeSingle({
src: canvas.toDataURL('image/jpeg', 0.8),
numOfWorkers: 0,
inputStream: { size: 800 },
decoder: {
readers: [
'ean_reader',
'ean_8_reader',
'code_128_reader',
'code_39_reader',
'upc_reader',
'upc_e_reader'
]
},
locate: true
}, function(result) {
if (result && result.codeResult) {
const code = result.codeResult.code;
if (code === lastDetected) {
detectCount++;
} else {
lastDetected = code;
detectCount = 1;
}
// Require 2 consecutive reads for reliability
if (detectCount >= 2) {
scanning = false;
quaggaRunning = false;
onBarcodeDetected(code);
return;
}
}
if (scanning) {
setTimeout(scanFrame, 300);
}
});
} catch (e) {
if (scanning) setTimeout(scanFrame, 500);
}
}
// Start scanning after a small delay
setTimeout(scanFrame, 500);
}
function stopScanner() {
quaggaRunning = false;
if (scannerStream) {
scannerStream.getTracks().forEach(t => t.stop());
scannerStream = null;
}
const video = document.getElementById('scanner-video');
if (video) video.srcObject = null;
// Also stop AI camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
const aiVideo = document.getElementById('ai-video');
if (aiVideo) aiVideo.srcObject = null;
}
async function onBarcodeDetected(barcode) {
showLoading(true);
// Vibrate if available
if (navigator.vibrate) navigator.vibrate(100);
try {
// First check local DB
const localResult = await api('search_barcode', { barcode });
if (localResult.found) {
currentProduct = localResult.product;
showLoading(false);
stopScanner();
showProductAction();
return;
}
// Lookup in external DB
const lookupResult = await api('lookup_barcode', { barcode });
if (lookupResult.found && lookupResult.product) {
const p = lookupResult.product;
// Detect unit and quantity from quantity_info
const detected = detectUnitAndQuantity(p.quantity_info);
// Build rich notes with all available info
const notesParts = [];
if (p.quantity_info) notesParts.push(`Peso: ${p.quantity_info}`);
if (p.nutriscore) notesParts.push(`Nutriscore: ${p.nutriscore.toUpperCase()}`);
if (p.nova_group) notesParts.push(`NOVA: ${p.nova_group}`);
if (p.ecoscore) notesParts.push(`Ecoscore: ${p.ecoscore.toUpperCase()}`);
if (p.origin) notesParts.push(`Origine: ${p.origin}`);
if (p.labels) notesParts.push(`Etichette: ${p.labels}`);
// Save to local DB
const saveResult = await api('product_save', {}, 'POST', {
barcode: barcode,
name: p.name || 'Prodotto sconosciuto',
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
notes: notesParts.join(' · '),
});
if (saveResult.id) {
currentProduct = {
id: saveResult.id,
barcode: barcode,
name: p.name || 'Prodotto sconosciuto',
brand: p.brand || '',
category: p.category || '',
image_url: p.image_url || '',
unit: detected.unit,
default_quantity: detected.quantity,
weight_info: p.quantity_info || '',
nutriscore: p.nutriscore || '',
ingredients: p.ingredients || '',
allergens: p.allergens || '',
conservation: p.conservation || '',
origin: p.origin || '',
nova_group: p.nova_group || '',
ecoscore: p.ecoscore || '',
labels: p.labels || '',
stores: p.stores || '',
};
showLoading(false);
stopScanner();
showProductAction();
return;
}
}
// Not found - ask user to add manually
showLoading(false);
stopScanner();
showToast('Prodotto non trovato. Inseriscilo manualmente.', 'error');
startManualEntry(barcode);
} catch (err) {
showLoading(false);
console.error('Barcode lookup error:', err);
showToast('Errore nella ricerca. Riprova.', 'error');
}
}
function startManualEntry(barcode = '') {
stopScanner();
// Reset form
document.getElementById('pf-id').value = '';
document.getElementById('pf-name').value = '';
document.getElementById('pf-brand').value = '';
document.getElementById('pf-category').value = '';
document.getElementById('pf-unit').value = 'pz';
document.getElementById('pf-defqty').value = '1';
document.getElementById('pf-notes').value = '';
document.getElementById('pf-barcode').value = barcode || '';
document.getElementById('pf-image').value = '';
document.getElementById('pf-image-preview').style.display = 'none';
document.getElementById('product-form-title').textContent = 'Nuovo Prodotto';
// Reset manual-edit tracking flags
document.getElementById('pf-category').dataset.manuallySet = 'false';
document.getElementById('pf-defqty').dataset.manuallySet = 'false';
// Track if user manually changes the quantity field
const qtyInput = document.getElementById('pf-defqty');
qtyInput.removeEventListener('input', markQtyManuallySet);
qtyInput.addEventListener('input', markQtyManuallySet);
// Auto-detect name → category when typing
const nameInput = document.getElementById('pf-name');
nameInput.removeEventListener('input', autoDetectCategory);
nameInput.addEventListener('input', autoDetectCategory);
showPage('product-form');
}
function markQtyManuallySet() {
document.getElementById('pf-defqty').dataset.manuallySet = 'true';
}
function autoDetectCategory() {
const name = document.getElementById('pf-name').value.toLowerCase();
if (name.length < 3) return;
const catSelect = document.getElementById('pf-category');
// Don't override if user already manually selected something
if (catSelect.dataset.manuallySet === 'true') return;
// Keywords → category mapping
const keyword2cat = {
'latte': 'latticini', 'yogurt': 'latticini', 'formaggio': 'latticini', 'mozzarella': 'latticini',
'burro': 'latticini', 'panna': 'latticini', 'ricotta': 'latticini', 'mascarpone': 'latticini',
'gorgonzola': 'latticini', 'parmigiano': 'latticini', 'grana': 'latticini', 'burrata': 'latticini',
'stracchino': 'latticini', 'uova': 'latticini',
'pollo': 'carne', 'manzo': 'carne', 'maiale': 'carne', 'vitello': 'carne', 'tacchino': 'carne',
'prosciutto': 'carne', 'salame': 'carne', 'bresaola': 'carne', 'mortadella': 'carne',
'wurstel': 'carne', 'macinato': 'carne', 'speck': 'carne',
'salmone': 'pesce', 'tonno': 'pesce', 'sgombro': 'pesce', 'pesce': 'pesce', 'merluzzo': 'pesce',
'mela': 'frutta', 'mele': 'frutta', 'banana': 'frutta', 'arancia': 'frutta', 'pera': 'frutta',
'fragola': 'frutta', 'uva': 'frutta', 'kiwi': 'frutta', 'limone': 'frutta',
'insalata': 'verdura', 'pomodor': 'verdura', 'zucchin': 'verdura', 'patat': 'verdura',
'cipoll': 'verdura', 'carota': 'verdura', 'spinaci': 'verdura', 'rucola': 'verdura',
'peperoni': 'verdura', 'melanzane': 'verdura', 'broccoli': 'verdura',
'pasta': 'pasta', 'spaghetti': 'pasta', 'penne': 'pasta', 'fusilli': 'pasta', 'riso': 'pasta',
'farina': 'pasta', 'rigatoni': 'pasta', 'farfalle': 'pasta',
'pane': 'pane', 'fette biscottate': 'pane', 'pancarrè': 'pane', 'pan carrè': 'pane',
'grissini': 'pane', 'crackers': 'pane', 'cracker': 'pane',
'surgelat': 'surgelati', 'findus': 'surgelati', 'gelato': 'surgelati',
'acqua': 'bevande', 'succo': 'bevande', 'birra': 'bevande', 'vino': 'bevande',
'coca cola': 'bevande', 'aranciata': 'bevande', 'tè': 'bevande', 'caffè': 'bevande',
'olio': 'condimenti', 'aceto': 'condimenti', 'sale': 'condimenti', 'pepe': 'condimenti',
'maionese': 'condimenti', 'ketchup': 'condimenti', 'senape': 'condimenti', 'zucchero': 'condimenti',
'biscott': 'snack', 'cioccolat': 'snack', 'nutella': 'snack', 'merendine': 'snack',
'patatine': 'snack', 'caramelle': 'snack',
'pelati': 'conserve', 'passata': 'conserve', 'legumi': 'conserve', 'ceci': 'conserve',
'fagioli': 'conserve', 'lenticchie': 'conserve', 'marmellata': 'conserve', 'miele': 'conserve',
'cereali': 'cereali', 'muesli': 'cereali', 'fiocchi': 'cereali',
};
for (const [keyword, cat] of Object.entries(keyword2cat)) {
if (name.includes(keyword)) {
catSelect.value = cat;
onCategoryChange(true);
return;
}
}
}
function onCategoryChange(fromAutoDetect = false) {
const cat = document.getElementById('pf-category').value;
const unitSelect = document.getElementById('pf-unit');
const qtyInput = document.getElementById('pf-defqty');
// If user manually changed category via dropdown, don't auto-fill qty/unit
if (!fromAutoDetect) {
// Mark qty as "set" so future auto-detects won't overwrite either
qtyInput.dataset.manuallySet = 'true';
return;
}
// Auto-detect from name: suggest default unit/qty based on category
// BUT only if user hasn't manually changed the quantity field
const catDefaults = {
'latticini': { unit: 'pz', qty: 1 },
'carne': { unit: 'g', qty: 500 },
'pesce': { unit: 'g', qty: 300 },
'frutta': { unit: 'kg', qty: 1 },
'verdura': { unit: 'kg', qty: 0.5 },
'pasta': { unit: 'g', qty: 500 },
'pane': { unit: 'pz', qty: 1 },
'surgelati': { unit: 'g', qty: 450 },
'bevande': { unit: 'l', qty: 1 },
'condimenti': { unit: 'pz', qty: 1 },
'snack': { unit: 'g', qty: 250 },
'conserve': { unit: 'g', qty: 400 },
'cereali': { unit: 'g', qty: 500 },
'igiene': { unit: 'pz', qty: 1 },
'pulizia': { unit: 'pz', qty: 1 },
};
if (catDefaults[cat]) {
// Only auto-fill unit/qty if user hasn't manually touched them
if (qtyInput.dataset.manuallySet !== 'true') {
unitSelect.value = catDefaults[cat].unit;
qtyInput.value = catDefaults[cat].qty;
}
}
}
async function submitProduct(e) {
e.preventDefault();
showLoading(true);
const productData = {
id: document.getElementById('pf-id').value || null,
name: document.getElementById('pf-name').value,
brand: document.getElementById('pf-brand').value,
category: document.getElementById('pf-category').value,
unit: document.getElementById('pf-unit').value,
default_quantity: parseFloat(document.getElementById('pf-defqty').value) || 1,
notes: document.getElementById('pf-notes').value,
barcode: document.getElementById('pf-barcode').value || null,
image_url: document.getElementById('pf-image').value || '',
};
try {
const result = await api('product_save', {}, 'POST', productData);
if (result.success) {
currentProduct = { ...productData, id: result.id };
showLoading(false);
showToast('Prodotto salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== PRODUCT ACTION (IN/OUT) =====
function showProductAction() {
if (!currentProduct) return;
const catIcon = CATEGORY_ICONS[currentProduct.category] || '📦';
const nutriscoreColors = { a: '#1e8f4e', b: '#60ac0e', c: '#eeae0e', d: '#ff6f1e', e: '#e63e11' };
let detailsHtml = '';
// Weight / quantity info
if (currentProduct.weight_info) {
detailsHtml += `<div class="product-detail-tag">⚖️ ${escapeHtml(currentProduct.weight_info)}</div>`;
}
// Nutriscore badge
if (currentProduct.nutriscore) {
const ns = currentProduct.nutriscore.toLowerCase();
const nsColor = nutriscoreColors[ns] || '#999';
detailsHtml += `<div class="product-detail-tag" style="background:${nsColor};color:#fff;font-weight:600">Nutri-Score ${ns.toUpperCase()}</div>`;
}
// NOVA group
if (currentProduct.nova_group) {
const novaLabels = { '1': 'Non trasformato', '2': 'Ingrediente culinario', '3': 'Trasformato', '4': 'Ultra-trasformato' };
detailsHtml += `<div class="product-detail-tag">🏭 NOVA ${currentProduct.nova_group}${novaLabels[currentProduct.nova_group] ? ' - ' + novaLabels[currentProduct.nova_group] : ''}</div>`;
}
// Ecoscore
if (currentProduct.ecoscore) {
const es = currentProduct.ecoscore.toLowerCase();
const esColor = nutriscoreColors[es] || '#999';
detailsHtml += `<div class="product-detail-tag" style="background:${esColor};color:#fff;font-weight:600">🌍 Eco-Score ${es.toUpperCase()}</div>`;
}
// Origin
if (currentProduct.origin) {
detailsHtml += `<div class="product-detail-tag">📍 ${escapeHtml(currentProduct.origin)}</div>`;
}
// Labels (bio, DOP, etc.)
if (currentProduct.labels) {
detailsHtml += `<div class="product-detail-tag">🏷️ ${escapeHtml(currentProduct.labels)}</div>`;
}
// Allergens
let allergensHtml = '';
if (currentProduct.allergens) {
allergensHtml = `<div class="product-allergens">⚠️ <strong>Allergeni:</strong> ${escapeHtml(currentProduct.allergens)}</div>`;
}
// Ingredients (collapsible)
let ingredientsHtml = '';
if (currentProduct.ingredients) {
const ingredShort = currentProduct.ingredients.length > 120
? currentProduct.ingredients.substring(0, 120) + '...'
: currentProduct.ingredients;
ingredientsHtml = `
<details class="product-ingredients">
<summary>📋 Ingredienti</summary>
<p>${escapeHtml(currentProduct.ingredients)}</p>
</details>
`;
}
// Conservation
let conservationHtml = '';
if (currentProduct.conservation) {
conservationHtml = `<div class="product-conservation">🧊 ${escapeHtml(currentProduct.conservation)}</div>`;
}
document.getElementById('action-product-preview').innerHTML = `
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span class="product-preview-emoji">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>${currentProduct.brand ? `<strong>${escapeHtml(currentProduct.brand)}</strong>` : ''}</p>
${currentProduct.barcode ? `<p style="font-size:0.75rem;color:var(--text-muted)">📊 ${currentProduct.barcode}</p>` : ''}
</div>
`;
// Show extra product info section below preview
let extraInfoEl = document.getElementById('action-product-details');
if (!extraInfoEl) {
const container = document.getElementById('action-product-preview').parentElement;
extraInfoEl = document.createElement('div');
extraInfoEl.id = 'action-product-details';
// Insert after preview, before action buttons
const actionBtns = document.querySelector('#page-action .action-buttons');
actionBtns.parentElement.insertBefore(extraInfoEl, actionBtns);
}
if (detailsHtml || allergensHtml || ingredientsHtml || conservationHtml) {
extraInfoEl.innerHTML = `
<div class="product-details-card">
${detailsHtml ? `<div class="product-detail-tags">${detailsHtml}</div>` : ''}
${allergensHtml}
${ingredientsHtml}
${conservationHtml}
</div>
`;
extraInfoEl.style.display = 'block';
} else {
extraInfoEl.style.display = 'none';
extraInfoEl.innerHTML = '';
}
showPage('action');
}
// ===== ADD TO INVENTORY =====
function showAddForm() {
const catIcon = CATEGORY_ICONS[currentProduct.category] || '📦';
document.getElementById('add-product-preview').innerHTML = `
${currentProduct.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span style="font-size:2rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct.name)}</h3>
<p>${currentProduct.brand ? escapeHtml(currentProduct.brand) : ''}</p>
${currentProduct.weight_info ? `<p style="font-size:0.8rem;color:var(--text-light)">${escapeHtml(currentProduct.weight_info)}</p>` : ''}
</div>
`;
// Set unit selector
const unit = currentProduct.unit || 'pz';
const unitSelect = document.getElementById('add-unit');
unitSelect.value = unit;
document.getElementById('add-quantity').value = currentProduct.default_quantity || 1;
document.getElementById('add-quantity').dataset.manuallySet = 'false';
// Track manual edits to quantity in add form
const addQtyInput = document.getElementById('add-quantity');
addQtyInput.removeEventListener('input', markAddQtyManuallySet);
addQtyInput.addEventListener('input', markAddQtyManuallySet);
// Show weight info if product has it
const weightInfoEl = document.getElementById('add-weight-info');
if (currentProduct.weight_info) {
weightInfoEl.textContent = `📦 Confezione: ${currentProduct.weight_info}`;
weightInfoEl.style.display = 'block';
} else {
weightInfoEl.style.display = 'none';
}
// Set qty step based on selected unit
updateAddQtyStep();
// Auto-detect location
const autoLoc = guessLocation(currentProduct);
document.getElementById('add-location').value = autoLoc;
// Highlight correct location button
document.querySelectorAll('#page-add .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#page-add .loc-btn').forEach(b => {
const btnText = b.textContent.toLowerCase();
if (btnText.includes(autoLoc)) b.classList.add('active');
});
// Show the purchase-type selector
const expirySection = document.getElementById('add-expiry-section');
const estimatedDays = estimateExpiryDays(currentProduct);
const estimatedDate = addDays(estimatedDays);
const estimateLabel = formatEstimatedExpiry(estimatedDays);
expirySection.innerHTML = `
<label>🛒 Questo prodotto è...</label>
<div class="purchase-type-selector">
<button type="button" class="purchase-type-btn active" onclick="selectPurchaseType(this, 'new', '${estimatedDate}', '${escapeHtml(estimateLabel)}')">
🆕 Appena comprato
</button>
<button type="button" class="purchase-type-btn" onclick="selectPurchaseType(this, 'existing', '', '')">
📦 Ce l'avevo già
</button>
</div>
<div id="expiry-detail" class="expiry-detail">
<div class="expiry-estimate">
<span class="expiry-estimate-label">Scadenza stimata: <strong>${estimateLabel}</strong></span>
<span class="expiry-estimate-date">${formatDate(estimatedDate)}</span>
</div>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="${estimatedDate}">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="Scansiona data scadenza">📷</button>
</div>
<p class="form-hint">📝 Puoi modificare la data o scansionarla con la fotocamera</p>
</div>
`;
showPage('add');
}
function onAddUnitChange() {
updateAddQtyStep();
// If switching units, suggest a sensible quantity
// BUT only if the user hasn't manually changed the quantity in this form
const unit = document.getElementById('add-unit').value;
const qtyInput = document.getElementById('add-quantity');
if (qtyInput.dataset.manuallySet === 'true') return; // User already edited qty, don't overwrite
const currentQty = parseFloat(qtyInput.value) || 1;
// Convert between related units if logical
if (unit === 'g' && currentQty <= 10) qtyInput.value = currentProduct.weight_info ? parseFloat(currentProduct.weight_info) || 250 : 250;
if (unit === 'kg' && currentQty > 100) qtyInput.value = (currentQty / 1000).toFixed(1);
if (unit === 'ml' && currentQty <= 10) qtyInput.value = 500;
if (unit === 'l' && currentQty > 100) qtyInput.value = (currentQty / 1000).toFixed(1);
if (unit === 'pz' && currentQty > 100) qtyInput.value = 1;
if (unit === 'conf' && currentQty > 10) qtyInput.value = 1;
}
function updateAddQtyStep() {
const qtyInput = document.getElementById('add-quantity');
const unit = document.getElementById('add-unit').value;
qtyInput.step = 'any';
if (unit === 'g' || unit === 'ml') {
qtyInput.min = '1';
} else if (unit === 'kg' || unit === 'l') {
qtyInput.min = '0.1';
} else {
qtyInput.min = '1';
}
}
function markAddQtyManuallySet() {
document.getElementById('add-quantity').dataset.manuallySet = 'true';
}
function adjustAddQty(delta) {
const qtyInput = document.getElementById('add-quantity');
qtyInput.dataset.manuallySet = 'true'; // +/- buttons count as manual edit
const unit = document.getElementById('add-unit').value;
let val = parseFloat(qtyInput.value) || 0;
let step;
if (unit === 'kg' || unit === 'l') {
step = val < 1 ? 0.1 : 0.5;
} else if (unit === 'g' || unit === 'ml') {
step = val < 50 ? 1 : (val < 500 ? 10 : 50);
} else {
step = 1;
}
val = Math.max(parseFloat(qtyInput.min) || 0.1, val + delta * step);
// Round nicely
if (step >= 1) val = Math.round(val);
else val = Math.round(val * 10) / 10;
qtyInput.value = val;
}
function selectPurchaseType(btn, type, estimatedDate, estimateLabel) {
btn.parentElement.querySelectorAll('.purchase-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const detailDiv = document.getElementById('expiry-detail');
// Save current quantity before switching, so we can preserve it
const currentQty = document.getElementById('add-quantity').value;
if (type === 'new') {
detailDiv.innerHTML = `
<div class="expiry-estimate">
<span class="expiry-estimate-label">Scadenza stimata: <strong>${estimateLabel}</strong></span>
<span class="expiry-estimate-date">${formatDate(estimatedDate)}</span>
</div>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="${estimatedDate}">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="Scansiona data scadenza">📷</button>
</div>
<p class="form-hint">📝 Puoi modificare la data o scansionarla con la fotocamera</p>
`;
// Restore quantity - switching purchase type should NOT change it
document.getElementById('add-quantity').value = currentQty;
} else {
detailDiv.innerHTML = `
<div class="form-group">
<label>📅 Quando scade?</label>
<div class="expiry-input-row">
<input type="date" id="add-expiry" class="form-input" value="">
<button type="button" class="btn btn-accent btn-scan-expiry" onclick="scanExpiryWithAI()" title="Scansiona data scadenza">📷</button>
</div>
<p class="form-hint">Inserisci la data di scadenza o scansionala</p>
</div>
<div class="form-group">
<label>📦 Quantità rimasta</label>
<p class="form-hint" style="margin-bottom:6px">Quanto è rimasto approssimativamente?</p>
<div class="remaining-options">
<button type="button" class="remaining-btn" onclick="setRemainingPct(1)">🟢 Pieno</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.75)">🟡 ¾</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.5)">🟠 Metà</button>
<button type="button" class="remaining-btn" onclick="setRemainingPct(0.25)">🔴 ¼</button>
</div>
</div>
`;
// DON'T auto-set remaining percentage - keep the quantity the user already entered
}
}
function setRemainingPct(pct) {
document.querySelectorAll('.remaining-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
const baseQty = currentProduct.default_quantity || 1;
const unit = currentProduct.unit || 'pz';
let adjustedQty;
if (unit === 'pz' || unit === 'conf') {
adjustedQty = Math.max(1, Math.round(baseQty * pct));
} else {
adjustedQty = Math.round(baseQty * pct * 10) / 10;
}
document.getElementById('add-quantity').value = adjustedQty;
}
function selectLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('add-location').value = loc;
}
async function submitAdd(e) {
e.preventDefault();
showLoading(true);
try {
const selectedUnit = document.getElementById('add-unit').value;
const productUnit = currentProduct.unit || 'pz';
const result = await api('inventory_add', {}, 'POST', {
product_id: currentProduct.id,
quantity: parseFloat(document.getElementById('add-quantity').value) || 1,
location: document.getElementById('add-location').value,
expiry_date: document.getElementById('add-expiry').value || null,
unit: selectedUnit !== productUnit ? selectedUnit : null,
});
showLoading(false);
if (result.success) {
showToast(`✅ ${currentProduct.name} aggiunto!`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== USE FROM INVENTORY =====
function showUseForm() {
renderUsePreview();
document.getElementById('use-quantity').value = 1;
document.getElementById('use-location').value = 'dispensa';
// Reset location buttons
document.querySelectorAll('#page-use .loc-btn').forEach(b => b.classList.remove('active'));
document.querySelector('#page-use .loc-btn').classList.add('active');
loadUseInventoryInfo();
showPage('use');
}
function renderUsePreview() {
const catIcon = CATEGORY_ICONS[currentProduct?.category] || '📦';
document.getElementById('use-product-preview').innerHTML = `
${currentProduct?.image_url ?
`<img src="${escapeHtml(currentProduct.image_url)}" alt="">` :
`<span style="font-size:2rem">${catIcon}</span>`
}
<div class="product-preview-info">
<h3>${escapeHtml(currentProduct?.name || '')}</h3>
<p>${currentProduct?.brand ? escapeHtml(currentProduct.brand) : ''}</p>
</div>
`;
}
async function loadUseInventoryInfo() {
try {
const data = await api('inventory_list');
const items = (data.inventory || []).filter(i => i.product_id == currentProduct.id);
const infoEl = document.getElementById('use-inventory-info');
if (items.length > 0) {
infoEl.innerHTML = '<strong>📦 Disponibile:</strong> ' + items.map(i => {
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
return `${loc.icon} ${loc.label}: ${i.quantity} ${i.unit}`;
}).join(' · ');
} else {
infoEl.innerHTML = '⚠️ Prodotto non presente nell\'inventario.';
}
} catch(e) {
console.error(e);
}
}
function selectUseLocation(btn, loc) {
btn.parentElement.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('use-location').value = loc;
}
async function submitUseAll() {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
use_all: true,
location: document.getElementById('use-location').value,
});
showLoading(false);
if (result.success) {
showToast(`📤 ${currentProduct.name} terminato!`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
async function submitUse(e) {
e.preventDefault();
showLoading(true);
try {
const qty = parseFloat(document.getElementById('use-quantity').value) || 1;
const result = await api('inventory_use', {}, 'POST', {
product_id: currentProduct.id,
quantity: qty,
location: document.getElementById('use-location').value,
});
showLoading(false);
if (result.success) {
showToast(`📤 Usato ${qty} di ${currentProduct.name}. Rimasti: ${result.remaining}`, 'success');
showPage('dashboard');
} else {
showToast(result.error || 'Errore', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== AI IDENTIFICATION =====
async function captureForAI() {
stopScanner();
showPage('ai');
}
async function initAICamera() {
const video = document.getElementById('ai-video');
const captureDiv = document.getElementById('ai-capture');
const previewDiv = document.getElementById('ai-preview');
const captureBtn = document.getElementById('ai-capture-btn');
const analyzeBtn = document.getElementById('ai-analyze-btn');
const retakeBtn = document.getElementById('ai-retake-btn');
const resultDiv = document.getElementById('ai-result');
captureDiv.style.display = 'block';
previewDiv.style.display = 'none';
captureBtn.style.display = 'block';
analyzeBtn.style.display = 'none';
retakeBtn.style.display = 'none';
resultDiv.style.display = 'none';
try {
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
}
aiStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
video.srcObject = aiStream;
await video.play();
} catch (err) {
console.error('AI Camera error:', err);
showToast('Impossibile accedere alla fotocamera', 'error');
}
}
function takePhotoForAI() {
const video = document.getElementById('ai-video');
const canvas = document.getElementById('ai-canvas');
const img = document.getElementById('ai-image');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (aiStream) {
aiStream.getTracks().forEach(t => t.stop());
aiStream = null;
}
video.srcObject = null;
document.getElementById('ai-capture').style.display = 'none';
document.getElementById('ai-preview').style.display = 'block';
document.getElementById('ai-capture-btn').style.display = 'none';
document.getElementById('ai-analyze-btn').style.display = 'block';
document.getElementById('ai-retake-btn').style.display = 'block';
}
function retakePhotoAI() {
document.getElementById('ai-result').style.display = 'none';
initAICamera();
}
async function analyzeWithAI() {
const resultDiv = document.getElementById('ai-result');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<p>🤖 Analisi in corso...</p><div class="loading-spinner" style="margin:12px auto"></div>';
const canvas = document.getElementById('ai-canvas');
const imageData = canvas.toDataURL('image/jpeg', 0.7);
// We'll use a free approach: analyze image colors and shapes locally
// and try to identify using image analysis heuristics
const ctx = canvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Simple color analysis to guess product type
let r = 0, g = 0, b = 0;
const pixels = imgData.data;
const count = pixels.length / 4;
for (let i = 0; i < pixels.length; i += 16) { // sample every 4th pixel
r += pixels[i];
g += pixels[i + 1];
b += pixels[i + 2];
}
const samples = count / 4;
r = Math.round(r / samples);
g = Math.round(g / samples);
b = Math.round(b / samples);
// Provide a manual identification form since free AI APIs are limited
resultDiv.innerHTML = `
<h4>🤖 Identificazione Prodotto</h4>
<p style="font-size:0.85rem;color:var(--text-light);margin:8px 0">
L'analisi automatica ha dei limiti senza API a pagamento.
Puoi descrivere il prodotto qui sotto e lo salveremo nel database.
</p>
<form class="form" onsubmit="submitAIProduct(event)" style="margin-top:12px">
<div class="form-group">
<label>🏷️ Che prodotto è? *</label>
<input type="text" id="ai-product-name" class="form-input" required
placeholder="Es: Yogurt greco, Pasta Barilla..." autofocus>
</div>
<div class="form-group">
<label>🏢 Marca (se visibile)</label>
<input type="text" id="ai-product-brand" class="form-input" placeholder="Es: Müller, Barilla...">
</div>
<div class="form-group">
<label>📂 Categoria</label>
<select id="ai-product-category" class="form-input">
<option value="">-- Seleziona --</option>
${Object.entries(CATEGORY_ICONS).map(([k, v]) => `<option value="${k}">${v} ${k.charAt(0).toUpperCase() + k.slice(1)}</option>`).join('')}
</select>
</div>
<button type="submit" class="btn btn-large btn-accent full-width">✅ Salva e Continua</button>
</form>
`;
}
async function submitAIProduct(e) {
e.preventDefault();
showLoading(true);
const name = document.getElementById('ai-product-name').value;
const brand = document.getElementById('ai-product-brand').value;
const category = document.getElementById('ai-product-category').value;
// Save the captured image as base64 (we could save to file, but for simplicity use image_url)
const canvas = document.getElementById('ai-canvas');
// For a lightweight approach, don't store the actual image data in DB
try {
const result = await api('product_save', {}, 'POST', {
name, brand, category,
unit: 'pz',
default_quantity: 1,
});
if (result.success) {
currentProduct = { id: result.id, name, brand, category, unit: 'pz', default_quantity: 1 };
showLoading(false);
showToast('Prodotto identificato e salvato!', 'success');
showProductAction();
} else {
showLoading(false);
showToast(result.error || 'Errore nel salvataggio', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore di connessione', 'error');
}
}
// ===== ALL PRODUCTS =====
async function loadAllProducts() {
try {
const data = await api('products_list');
renderProductsList(data.products || []);
} catch (err) {
console.error(err);
}
}
async function searchAllProducts() {
const q = document.getElementById('products-search').value;
if (q.length < 2) {
loadAllProducts();
return;
}
const data = await api('products_search', { q });
renderProductsList(data.products || []);
}
function renderProductsList(products) {
const container = document.getElementById('products-list');
if (products.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📦</div><p>Nessun prodotto nel database.<br>Scansiona un prodotto per iniziare!</p></div>';
return;
}
container.innerHTML = products.map(p => {
const catIcon = CATEGORY_ICONS[p.category] || '📦';
return `
<div class="product-item" onclick="selectProductForAction(${p.id})">
<div class="inv-image">
${p.image_url ? `<img src="${escapeHtml(p.image_url)}" alt="" onerror="this.parentElement.innerHTML='${catIcon}'">` : catIcon}
</div>
<div class="inv-info">
<div class="inv-name">${escapeHtml(p.name)}</div>
${p.brand ? `<div class="inv-brand">${escapeHtml(p.brand)}</div>` : ''}
<div class="inv-meta">
${p.barcode ? `<span class="inv-badge" style="background:#f3f4f6;color:#374151">📊 ${p.barcode}</span>` : ''}
<span class="inv-badge" style="background:#f3f4f6;color:#374151">${catIcon} ${p.category || 'Non categorizzato'}</span>
</div>
</div>
</div>`;
}).join('');
}
async function selectProductForAction(productId) {
showLoading(true);
try {
const data = await api('product_get', { id: productId });
if (data.product) {
currentProduct = data.product;
showLoading(false);
showProductAction();
} else {
showLoading(false);
showToast('Prodotto non trovato', 'error');
}
} catch (err) {
showLoading(false);
showToast('Errore', 'error');
}
}
// ===== UTILITY FUNCTIONS =====
// ===== SCAN EXPIRY DATE WITH CAMERA + GEMINI AI =====
let expiryStream = null;
async function scanExpiryWithAI() {
// Create modal for camera capture
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>📷 Scansiona Data Scadenza</h3>
<button class="modal-close" onclick="closeExpiryScanner()">✕</button>
</div>
<div class="expiry-scanner">
<div id="expiry-cam-container" style="height:180px;overflow:hidden;border-radius:10px;position:relative">
<video id="expiry-video" autoplay playsinline style="width:100%;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) scale(2);transform-origin:center center"></video>
<canvas id="expiry-canvas" style="display:none"></canvas>
<div style="position:absolute;inset:0;border:2px dashed rgba(255,255,255,0.5);border-radius:10px;pointer-events:none"></div>
</div>
<div id="expiry-preview-container" style="display:none;height:180px;overflow:hidden;border-radius:10px">
<img id="expiry-preview-img" src="" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:10px">
</div>
<p class="form-hint" style="text-align:center;margin:6px 0;font-size:0.8rem">Inquadra la data di scadenza stampata sul prodotto</p>
<div id="expiry-scan-status" style="display:none;text-align:center;padding:8px">
<div class="loading-spinner" style="margin:0 auto 6px"></div>
<p>🤖 Analisi AI in corso...</p>
</div>
<div class="expiry-scanner-actions">
<button class="btn btn-large btn-accent full-width" id="expiry-capture-btn" onclick="captureExpiry()">📸 Scatta Foto</button>
<button class="btn btn-large btn-secondary full-width" id="expiry-retake-btn" onclick="retakeExpiry()" style="display:none">🔄 Riscatta</button>
</div>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
// Start camera
try {
expiryStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
});
const video = document.getElementById('expiry-video');
video.srcObject = expiryStream;
await video.play();
} catch (err) {
console.error('Expiry camera error:', err);
document.getElementById('expiry-cam-container').innerHTML = `
<p style="color:var(--danger);text-align:center;padding:20px">⚠️ Impossibile accedere alla fotocamera</p>
`;
}
}
function closeExpiryScanner() {
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
closeModal();
}
function captureExpiry() {
const video = document.getElementById('expiry-video');
const canvas = document.getElementById('expiry-canvas');
const img = document.getElementById('expiry-preview-img');
// Crop to center 50% (matching the 2x zoom view) for better AI accuracy
const sw = video.videoWidth / 2;
const sh = video.videoHeight / 2;
const sx = (video.videoWidth - sw) / 2;
const sy = (video.videoHeight - sh) / 2;
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, sx, sy, sw, sh, 0, 0, sw, sh);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
img.src = dataUrl;
// Stop camera
if (expiryStream) {
expiryStream.getTracks().forEach(t => t.stop());
expiryStream = null;
}
video.srcObject = null;
document.getElementById('expiry-cam-container').style.display = 'none';
document.getElementById('expiry-preview-container').style.display = 'block';
document.getElementById('expiry-capture-btn').style.display = 'none';
document.getElementById('expiry-retake-btn').style.display = 'block';
// Auto-analyze
analyzeExpiryImage(dataUrl);
}
function retakeExpiry() {
document.getElementById('expiry-cam-container').style.display = 'block';
document.getElementById('expiry-preview-container').style.display = 'none';
document.getElementById('expiry-capture-btn').style.display = 'block';
document.getElementById('expiry-retake-btn').style.display = 'none';
document.getElementById('expiry-scan-status').style.display = 'none';
// Restart camera
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }
}).then(stream => {
expiryStream = stream;
const video = document.getElementById('expiry-video');
video.srcObject = stream;
video.play();
}).catch(err => console.error(err));
}
async function analyzeExpiryImage(dataUrl) {
const statusDiv = document.getElementById('expiry-scan-status');
statusDiv.style.display = 'block';
statusDiv.innerHTML = '<div class="loading-spinner" style="margin:0 auto 8px"></div><p>🤖 Analisi AI in corso...</p>';
try {
// Remove data:image/jpeg;base64, prefix
const base64 = dataUrl.split(',')[1];
const result = await api('gemini_expiry', {}, 'POST', { image: base64 });
if (result.success && result.expiry_date) {
// Auto-fill the expiry date
const expiryInput = document.getElementById('add-expiry');
if (expiryInput) {
expiryInput.value = result.expiry_date;
}
statusDiv.innerHTML = `<p style="color:var(--success);font-weight:600">✅ Data trovata: ${formatDate(result.expiry_date)}</p>`;
// Close modal after delay
setTimeout(() => closeExpiryScanner(), 1500);
} else if (result.error === 'no_api_key') {
statusDiv.innerHTML = `<p style="color:var(--warning)">⚠️ Chiave API Gemini non configurata.<br><small>Aggiungi GEMINI_API_KEY nel file .env sul server.</small></p>`;
} else {
statusDiv.innerHTML = `<p style="color:var(--danger)">❌ Non riesco a leggere la data. ${result.raw_text ? '<br><small>Letto: ' + escapeHtml(result.raw_text) + '</small>' : ''}</p>
<button class="btn btn-secondary" onclick="retakeExpiry()" style="margin-top:8px">🔄 Riprova</button>`;
}
} catch (err) {
console.error('Expiry AI error:', err);
statusDiv.innerHTML = `<p style="color:var(--danger)">❌ Errore di connessione. Riprova.</p>
<button class="btn btn-secondary" onclick="retakeExpiry()" style="margin-top:8px">🔄 Riprova</button>`;
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short', year: 'numeric' });
}
function formatDateTime(dtStr) {
if (!dtStr) return '';
const d = new Date(dtStr.replace(' ', 'T'));
return d.toLocaleDateString('it-IT', { day: '2-digit', month: 'short' }) + ' ' +
d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
}
function adjustQty(inputId, delta) {
const input = document.getElementById(inputId);
let val = parseFloat(input.value) || 0;
val = Math.max(0.1, val + delta);
input.value = Math.round(val * 10) / 10;
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'flex' : 'none';
}
function showToast(message, type = '') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show ' + type;
setTimeout(() => {
toast.className = 'toast';
}, 3000);
}
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
showPage('dashboard');
});