From bf2e1375027c6299dc491cdf24d912e37de60fa1 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Wed, 11 Mar 2026 15:43:44 +0000 Subject: [PATCH] feat: confezioni (conf) chiede dimensione singola confezione - Nuovo campo package_unit in DB (migrazione automatica) - Form aggiungi/modifica: quando si seleziona 'conf', appare campo per specificare il contenuto della singola confezione (es. 300g, 2L) - Visualizzazione: '3 conf (da 300g)' in inventario, dettaglio, butta - formatQuantity aggiornato con supporto package_unit - API: salva/restituisce package_unit in tutti gli endpoint - Ricette e chat: contesto arricchito con info confezione - CSS: stili per il nuovo campo conf-size --- api/database.php | 13 +++++ api/index.php | 41 ++++++++++---- assets/css/style.css | 39 ++++++++++++++ assets/js/app.js | 125 +++++++++++++++++++++++++++++++++++-------- data/dispensa.db | Bin 81920 -> 86016 bytes index.html | 26 ++++++++- 6 files changed, 210 insertions(+), 34 deletions(-) diff --git a/api/database.php b/api/database.php index 12e0eef..f8d5a82 100644 --- a/api/database.php +++ b/api/database.php @@ -16,6 +16,10 @@ function getDB(): PDO { if ($isNew) { initializeDB($db); } + + // Run migrations + migrateDB($db); + return $db; } @@ -64,3 +68,12 @@ function initializeDB(PDO $db): void { CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at); "); } + +function migrateDB(PDO $db): void { + // Add package_unit column if missing + $cols = $db->query("PRAGMA table_info(products)")->fetchAll(); + $colNames = array_column($cols, 'name'); + if (!in_array('package_unit', $colNames)) { + $db->exec("ALTER TABLE products ADD COLUMN package_unit TEXT DEFAULT ''"); + } +} diff --git a/api/index.php b/api/index.php index efd55dc..7c5412a 100644 --- a/api/index.php +++ b/api/index.php @@ -303,28 +303,28 @@ function saveProduct(PDO $db): void { // Update existing $stmt = $db->prepare(" UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, - default_quantity=?, notes=?, barcode=?, updated_at=CURRENT_TIMESTAMP + default_quantity=?, notes=?, barcode=?, package_unit=?, updated_at=CURRENT_TIMESTAMP WHERE id=? "); $stmt->execute([ $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', - $input['barcode'] ?? null, $input['id'] + $input['barcode'] ?? null, $input['package_unit'] ?? '', $input['id'] ]); echo json_encode(['success' => true, 'id' => $input['id']]); } else { // Insert new $stmt = $db->prepare(" - INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) "); $barcode = !empty($input['barcode']) ? $input['barcode'] : null; $stmt->execute([ $barcode, $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, - $input['notes'] ?? '' + $input['notes'] ?? '', $input['package_unit'] ?? '' ]); echo json_encode(['success' => true, 'id' => $db->lastInsertId()]); } @@ -369,7 +369,7 @@ function searchProducts(PDO $db): void { function listInventory(PDO $db): void { $location = $_GET['location'] ?? ''; $query = " - SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode, p.default_quantity + SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode, p.default_quantity, p.package_unit FROM inventory i JOIN products p ON i.product_id = p.id "; @@ -404,6 +404,14 @@ function addToInventory(PDO $db): void { $stmt->execute([$unit, $quantity, $productId]); } + // Update package info if conf + $packageUnit = $input['package_unit'] ?? null; + $packageSize = $input['package_size'] ?? null; + if ($packageUnit !== null) { + $stmt = $db->prepare("UPDATE products SET package_unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$packageUnit, $packageSize ?: 0, $productId]); + } + // Check if product already exists in this location $stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?"); $stmt->execute([$productId, $location]); @@ -553,6 +561,12 @@ function updateInventory(PDO $db): void { $stmt->execute([$input['unit'], $input['product_id']]); } + // Update package info if provided + if (isset($input['package_unit']) && isset($input['product_id'])) { + $stmt = $db->prepare("UPDATE products SET package_unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$input['package_unit'], $input['package_size'] ?? 0, $input['product_id']]); + } + echo json_encode(['success' => true]); } @@ -611,7 +625,7 @@ function getStats(PDO $db): void { // Expiring soonest (next 4 items to expire) $expiring = $db->query(" - SELECT i.*, p.name, p.brand, p.category, p.unit + SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.expiry_date IS NOT NULL AND i.expiry_date >= date('now') AND i.quantity > 0 ORDER BY i.expiry_date ASC @@ -620,7 +634,7 @@ function getStats(PDO $db): void { // Expired $expired = $db->query(" - SELECT i.*, p.name, p.brand, p.category, p.unit + SELECT i.*, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit FROM inventory i JOIN products p ON i.product_id = p.id WHERE i.expiry_date IS NOT NULL AND i.expiry_date < date('now') ORDER BY i.expiry_date ASC @@ -847,7 +861,7 @@ function geminiChat(PDO $db): void { // Fetch inventory context $stmt = $db->query(" - SELECT p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date, + SELECT p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left FROM inventory i JOIN products p ON p.id = i.product_id @@ -861,6 +875,9 @@ function geminiChat(PDO $db): void { $line = "- {$item['name']}"; if ($item['brand']) $line .= " ({$item['brand']})"; $line .= ": {$item['quantity']} {$item['unit']}"; + if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) { + $line .= " (da {$item['default_quantity']} {$item['package_unit']} ciascuna)"; + } if ($item['expiry_date']) { $daysLeft = intval($item['days_left']); if ($daysLeft < 0) { @@ -1004,7 +1021,7 @@ function generateRecipe(PDO $db): void { // Fetch all inventory items with expiry info $stmt = $db->query(" - SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date, + SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left FROM inventory i JOIN products p ON p.id = i.product_id @@ -1024,6 +1041,9 @@ function generateRecipe(PDO $db): void { $line = "- {$item['name']}"; if ($item['brand']) $line .= " ({$item['brand']})"; $line .= ": {$item['quantity']} {$item['unit']}"; + if ($item['unit'] === 'conf' && !empty($item['package_unit']) && $item['default_quantity'] > 0) { + $line .= " (da {$item['default_quantity']} {$item['package_unit']} ciascuna, totale: " . ($item['quantity'] * $item['default_quantity']) . " {$item['package_unit']})"; + } if ($item['expiry_date']) { $daysLeft = intval($item['days_left']); if ($daysLeft < 0) { @@ -1210,6 +1230,7 @@ PROMPT; $ing['inventory_unit'] = $bestMatch['unit']; $ing['inventory_qty'] = (float)$bestMatch['quantity']; $ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0); + $ing['package_unit'] = $bestMatch['package_unit'] ?? ''; $ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit']; if (!empty($bestMatch['brand'])) { $ing['brand'] = $bestMatch['brand']; diff --git a/assets/css/style.css b/assets/css/style.css index 6afbc81..7f846f5 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -1919,6 +1919,45 @@ body { border-radius: var(--radius-sm); } +/* ===== CONF PACKAGE SIZE ROW ===== */ +.conf-size-row { + margin-top: 8px; + padding: 10px 12px; + background: #f0f9ff; + border: 1.5px solid #bae6fd; + border-radius: var(--radius-sm); +} + +.conf-size-label { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 6px; + color: #0369a1; +} + +.conf-size-inputs { + display: flex; + gap: 8px; + align-items: center; +} + +.conf-size-input { + flex: 1; + max-width: 120px; +} + +.conf-size-unit { + width: 65px; + min-width: 65px; +} + +.conf-size-info { + font-size: 0.75em; + color: var(--text-light); + font-weight: 500; +} + /* ===== EXPIRY INPUT + CAMERA ROW ===== */ .expiry-input-row { display: flex; diff --git a/assets/js/app.js b/assets/js/app.js index 59a928b..2a2736f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -572,7 +572,7 @@ async function loadDashboard() { else if (days <= 7) { badgeText = `${days} giorni`; badgeClass = 'expiring'; } else if (days <= 30) { badgeText = `${days}g`; badgeClass = 'expiring-soon'; } else { const m = Math.round(days/30); badgeText = m <= 1 ? `${days}g` : `~${m} mesi`; badgeClass = 'expiring-later'; } - const qtyDisplay = formatQuantity(item.quantity, item.unit); + const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); return `
@@ -602,7 +602,7 @@ async function loadDashboard() { else daysText = `Da ${days}g`; const safety = getExpiredSafety(item, days); const locIcon = item.location === 'freezer' ? '❄️' : item.location === 'frigo' ? '🧊' : ''; - const qtyDisplayExp = formatQuantity(item.quantity, item.unit); + const qtyDisplayExp = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); return `
@@ -676,7 +676,7 @@ async function loadReviewItems() { section.style.display = 'block'; list.innerHTML = suspicious.map(item => { const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦'; - const qtyDisplay = formatQuantity(item.quantity, item.unit); + const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location }; const t = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz']; const isTooSmall = parseFloat(item.quantity) < t.min; @@ -763,7 +763,7 @@ function renderDashItem(item) { const days = daysUntilExpiry(item.expiry_date); const isExpired = days < 0; const isExpiring = !isExpired && days <= 7; - const qtyDisplay = formatQuantity(item.quantity, item.unit); + const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); let expiryLabel = ''; if (item.expiry_date) { @@ -806,15 +806,21 @@ function showAlertItemDetail(inventoryId, productId) { }); } -function formatQuantity(qty, unit) { +function formatQuantity(qty, unit, defaultQty, packageUnit) { 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'; - if (n === Math.floor(n)) return `${Math.floor(n)} ${label}`; - // For pz/conf show whole number - if (unit === 'pz' || unit === 'conf') return `${Math.round(n)} ${label}`; - return `${n.toFixed(1)} ${label}`; + let result; + if (n === Math.floor(n)) result = `${Math.floor(n)} ${label}`; + else if (unit === 'pz' || unit === 'conf') result = `${Math.round(n)} ${label}`; + else result = `${n.toFixed(1)} ${label}`; + // Add package info for conf + if (unit === 'conf' && packageUnit && defaultQty > 0) { + const pkgLabel = unitLabels[packageUnit] || packageUnit; + result += ` (da ${defaultQty}${pkgLabel})`; + } + return result; } // Show package fraction: only ¼, ½, ¾ when there's a partial package. @@ -856,7 +862,7 @@ function renderInventoryItem(item) { const days = daysUntilExpiry(item.expiry_date); const isExpired = days < 0; const isExpiring = !isExpired && days <= 7; - const qtyDisplay = formatQuantity(item.quantity, item.unit); + const qtyDisplay = formatQuantity(item.quantity, item.unit, item.default_quantity, item.package_unit); const pkgFrac = formatPackageFraction(item.quantity, item.default_quantity); let expiryBadge = ''; @@ -951,7 +957,7 @@ function showItemDetail(inventoryId, productId) {
${item.expiry_date ? `