feat: lista spesa con tab Da comprare/In previsione, tag, frequenza, tap-to-scan
- Counter nei tab aggiornati dinamicamente - Auto-aggiunta prodotti CRITICI a Bring! al caricamento (1x per sessione) - Badge urgenza e frequenza sugli item in lista (cross-ref smart shopping) - Tag locali per item (Urgente/Priorità/Verificare) con menu dropdown - Ordinamento automatico per frequenza utilizzo (item più usati in cima) - Tap su un item → scanner barcode, con banner 'Trovato! Rimuovi dalla lista' - Fix pctLeft: usa max(1, qty) come fallback refQty per evitare falsi alert - Fix daysLeft capped a 365gg per pulire stringhe di previsione - Back button on action page → torna a shopping se aperto da lista
This commit is contained in:
@@ -147,4 +147,241 @@ function migrateDB(PDO $db): void {
|
|||||||
if (!in_array('vacuum_sealed', $invColNames)) {
|
if (!in_array('vacuum_sealed', $invColNames)) {
|
||||||
$db->exec("ALTER TABLE inventory ADD COLUMN vacuum_sealed INTEGER DEFAULT 0");
|
$db->exec("ALTER TABLE inventory ADD COLUMN vacuum_sealed INTEGER DEFAULT 0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add opened_at column to inventory if missing
|
||||||
|
if (!in_array('opened_at', $invColNames)) {
|
||||||
|
$db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL");
|
||||||
|
// Backfill: detect already-opened items and set opened_at + recalculate expiry
|
||||||
|
backfillOpenedItems($db);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration v2: recalculate sealed fridge item expiry (fridge extends shelf life)
|
||||||
|
$migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn();
|
||||||
|
if (!$migrated) {
|
||||||
|
recalcSealedFridgeExpiry($db);
|
||||||
|
$db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill opened_at for existing inventory items that appear to be opened.
|
||||||
|
* An item is considered opened if:
|
||||||
|
* - conf unit with fractional quantity
|
||||||
|
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
|
||||||
|
* Uses updated_at as the approximate opened_at date.
|
||||||
|
* Recalculates expiry_date based on opened shelf life from opened_at.
|
||||||
|
*/
|
||||||
|
function backfillOpenedItems(PDO $db): void {
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT i.id, i.quantity, i.location, i.updated_at, i.expiry_date, i.vacuum_sealed,
|
||||||
|
p.name, p.category, p.unit, p.default_quantity
|
||||||
|
FROM inventory i
|
||||||
|
JOIN products p ON i.product_id = p.id
|
||||||
|
WHERE i.quantity > 0
|
||||||
|
");
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$isOpened = false;
|
||||||
|
$unit = $row['unit'] ?: 'pz';
|
||||||
|
$qty = (float)$row['quantity'];
|
||||||
|
$defQty = (float)($row['default_quantity'] ?: 0);
|
||||||
|
|
||||||
|
if ($unit === 'conf') {
|
||||||
|
$frac = $qty - floor($qty + 0.001);
|
||||||
|
if ($frac > 0.001) $isOpened = true;
|
||||||
|
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $qty < $defQty - 0.001) {
|
||||||
|
$isOpened = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isOpened) continue;
|
||||||
|
|
||||||
|
$openedAt = $row['updated_at'];
|
||||||
|
$openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']);
|
||||||
|
if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5);
|
||||||
|
|
||||||
|
// Calculate new expiry from opened_at
|
||||||
|
$newExpiry = date('Y-m-d', strtotime($openedAt . " +{$openedDays} days"));
|
||||||
|
|
||||||
|
$upd = $db->prepare("UPDATE inventory SET opened_at = ?, expiry_date = ? WHERE id = ?");
|
||||||
|
$upd->execute([$openedAt, $newExpiry, $row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate shelf life in days for an opened product.
|
||||||
|
* Much shorter than sealed shelf life.
|
||||||
|
*/
|
||||||
|
function estimateOpenedExpiryDaysPHP(string $name, string $category, string $location): int {
|
||||||
|
$n = mb_strtolower($name);
|
||||||
|
$cat = mb_strtolower($category);
|
||||||
|
$loc = mb_strtolower($location);
|
||||||
|
|
||||||
|
// Freezer: opened items still last a long time
|
||||||
|
if ($loc === 'freezer') return 90;
|
||||||
|
// Dispensa: opened dry goods
|
||||||
|
if ($loc === 'dispensa') return 30;
|
||||||
|
|
||||||
|
// Specific product overrides (fridge)
|
||||||
|
if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) return 3;
|
||||||
|
if (preg_match('/latte\s+uht|latte\s+a\s+lunga/', $n)) return 5;
|
||||||
|
if (preg_match('/latte/', $n)) return 4;
|
||||||
|
if (preg_match('/yogurt/', $n)) return 3;
|
||||||
|
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 2;
|
||||||
|
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
||||||
|
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
||||||
|
if (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) return 21;
|
||||||
|
if (preg_match('/formaggio/', $n)) return 10;
|
||||||
|
if (preg_match('/burro/', $n)) return 21;
|
||||||
|
if (preg_match('/panna/', $n)) return 3;
|
||||||
|
if (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) return 3;
|
||||||
|
if (preg_match('/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/', $n)) return 7;
|
||||||
|
if (preg_match('/pollo|tacchino|maiale|manzo|vitello/', $n)) return 2;
|
||||||
|
if (preg_match('/salmone|tonno\s+fresco|pesce/', $n)) return 2;
|
||||||
|
if (preg_match('/passata|pelati|polpa|sugo/', $n)) return 5;
|
||||||
|
if (preg_match('/marmellata|confettura/', $n)) return 30;
|
||||||
|
if (preg_match('/miele/', $n)) return 180;
|
||||||
|
if (preg_match('/nutella/', $n)) return 60;
|
||||||
|
if (preg_match('/succo|spremuta/', $n)) return 4;
|
||||||
|
if (preg_match('/olio|aceto/', $n)) return 90;
|
||||||
|
if (preg_match('/vino|birra/', $n)) return 5;
|
||||||
|
if (preg_match('/limone|limmi/', $n)) return 21;
|
||||||
|
if (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) return 3;
|
||||||
|
if (preg_match('/insalata|rucola|spinaci/', $n)) return 3;
|
||||||
|
|
||||||
|
// Category fallbacks
|
||||||
|
if (preg_match('/dairy|latticin|lait|dairies/', $cat)) return 5;
|
||||||
|
if (preg_match('/meat|carne|meats/', $cat)) return 3;
|
||||||
|
if (preg_match('/fish|pesce/', $cat)) return 2;
|
||||||
|
if (preg_match('/fruit|frutta/', $cat)) return 5;
|
||||||
|
if (preg_match('/verdur|vegetable|plant-based/', $cat)) return 5;
|
||||||
|
if (preg_match('/conserve/', $cat)) return 5;
|
||||||
|
if (preg_match('/condimenti|sauce/', $cat)) return 21;
|
||||||
|
if (preg_match('/bevand|beverage/', $cat)) return 4;
|
||||||
|
|
||||||
|
return 5; // safe default for fridge
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate sealed shelf life in days, with fridge/freezer extensions.
|
||||||
|
* Mirrors the JS estimateExpiryDays() function.
|
||||||
|
*/
|
||||||
|
function estimateSealedExpiryDaysPHP(string $name, string $category, string $location): int {
|
||||||
|
$n = mb_strtolower($name);
|
||||||
|
$cat = mb_strtolower($category);
|
||||||
|
$loc = mb_strtolower($location);
|
||||||
|
|
||||||
|
$days = null;
|
||||||
|
|
||||||
|
// Specific product overrides
|
||||||
|
if (preg_match('/latte\s+(fresco|intero|parzial|scremato)/', $n)) $days = 7;
|
||||||
|
elseif (preg_match('/latte\s+uht|latte\s+a\s+lunga/', $n)) $days = 90;
|
||||||
|
elseif (preg_match('/yogurt/', $n)) $days = 21;
|
||||||
|
elseif (preg_match('/mozzarella|burrata|stracciatella/', $n)) $days = 5;
|
||||||
|
elseif (preg_match('/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) $days = 10;
|
||||||
|
elseif (preg_match('/parmigiano|grana|pecorino|provolone/', $n)) $days = 60;
|
||||||
|
elseif (preg_match('/burro/', $n)) $days = 60;
|
||||||
|
elseif (preg_match('/panna/', $n)) $days = 14;
|
||||||
|
elseif (preg_match('/prosciutto\s+cotto|mortadella|wurstel/', $n)) $days = 7;
|
||||||
|
elseif (preg_match('/prosciutto\s+crudo|salame|bresaola|speck/', $n)) $days = 30;
|
||||||
|
elseif (preg_match('/nduja/', $n)) $days = 90;
|
||||||
|
elseif (preg_match('/uova/', $n)) $days = 28;
|
||||||
|
elseif (preg_match('/pane\s+fresco|pane\s+in\s+cassetta/', $n)) $days = 5;
|
||||||
|
elseif (preg_match('/pane\s+confezionato|pan\s+carr|pancarrè/', $n)) $days = 14;
|
||||||
|
elseif (preg_match('/insalata|rucola|spinaci\s+freschi/', $n)) $days = 5;
|
||||||
|
elseif (preg_match('/pollo|tacchino|maiale|manzo|vitello|sovracosci|cosci/', $n)) $days = 3;
|
||||||
|
elseif (preg_match('/salmone|tonno\s+fresco|pesce/', $n) && !preg_match('/tonno\s+in\s+scatola|tonno\s+rio/', $n)) $days = 2;
|
||||||
|
elseif (preg_match('/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/', $n)) $days = 1095;
|
||||||
|
elseif (preg_match('/surgelat|frozen|findus|4\s*salti/', $n)) $days = 180;
|
||||||
|
elseif (preg_match('/gelato/', $n)) $days = 365;
|
||||||
|
elseif (preg_match('/succo|spremuta/', $n)) $days = 7;
|
||||||
|
elseif (preg_match('/birra|vino/', $n)) $days = 365;
|
||||||
|
elseif (preg_match('/acqua/', $n)) $days = 365;
|
||||||
|
elseif (preg_match('/mela|mele\b/', $n)) $days = 7;
|
||||||
|
elseif (preg_match('/arancia|arance|mandarini|agrumi/', $n)) $days = 7;
|
||||||
|
elseif (preg_match('/banana|banane/', $n)) $days = 5;
|
||||||
|
elseif (preg_match('/pera|pere\b|fragola|fragole|uva|kiwi/', $n)) $days = 5;
|
||||||
|
elseif (preg_match('/carota|carote|zucchina|zucchine|peperoni|melanzane/', $n)) $days = 7;
|
||||||
|
elseif (preg_match('/broccoli|cavolfiore|cavolo|spinaci|bietola/', $n)) $days = 5;
|
||||||
|
elseif (preg_match('/cipolla|cipolle/', $n)) $days = 10;
|
||||||
|
elseif (preg_match('/patata|patate/', $n)) $days = 14;
|
||||||
|
elseif (preg_match('/biscott|cracker|grissini|fette\s+biscott/', $n)) $days = 180;
|
||||||
|
elseif (preg_match('/nutella|marmellata|miele/', $n)) $days = 365;
|
||||||
|
elseif (preg_match('/passata|pelati|pomodor/', $n)) $days = 730;
|
||||||
|
elseif (preg_match('/olio|aceto/', $n)) $days = 548;
|
||||||
|
|
||||||
|
if ($days === null) {
|
||||||
|
// Category fallbacks
|
||||||
|
$catMap = [
|
||||||
|
'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,
|
||||||
|
];
|
||||||
|
$days = 180;
|
||||||
|
foreach ($catMap as $key => $d) {
|
||||||
|
if (strpos($cat, $key) !== false) { $days = $d; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fridge extends shelf life for produce and short-lived items
|
||||||
|
if ($loc === 'frigo') {
|
||||||
|
if (preg_match('/mela|mele/', $n)) $days = max($days, 28);
|
||||||
|
elseif (preg_match('/arancia|arance|agrumi|mandarini|limone|limoni/', $n)) $days = max($days, 21);
|
||||||
|
elseif (preg_match('/carota|carote/', $n)) $days = max($days, 21);
|
||||||
|
elseif (preg_match('/cipolla/', $n)) $days = max($days, 14);
|
||||||
|
elseif (preg_match('/patata|patate/', $n)) $days = max($days, 21);
|
||||||
|
elseif (preg_match('/pera|pere/', $n)) $days = max($days, 21);
|
||||||
|
elseif (preg_match('/kiwi/', $n)) $days = max($days, 28);
|
||||||
|
elseif (preg_match('/uva/', $n)) $days = max($days, 14);
|
||||||
|
elseif (preg_match('/fragola|fragole/', $n)) $days = max($days, 7);
|
||||||
|
elseif (preg_match('/peperoni/', $n)) $days = max($days, 14);
|
||||||
|
elseif (preg_match('/zucchina|zucchine/', $n)) $days = max($days, 14);
|
||||||
|
elseif (preg_match('/melanzane/', $n)) $days = max($days, 14);
|
||||||
|
elseif (preg_match('/broccoli|cavolfiore|cavolo/', $n)) $days = max($days, 10);
|
||||||
|
elseif ($days <= 7 && preg_match('/frutta|fruit|verdur|vegetable|plant-based/', $cat)) {
|
||||||
|
$days = (int)round($days * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freezer extends shelf life significantly
|
||||||
|
if ($loc === 'freezer' && $days < 180) {
|
||||||
|
if ($days <= 4) $days = 120;
|
||||||
|
elseif ($days <= 14) $days = 75;
|
||||||
|
elseif ($days <= 30) $days = 120;
|
||||||
|
else $days = max($days, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate expiry for sealed (non-opened) fridge items with new fridge-aware logic.
|
||||||
|
*/
|
||||||
|
function recalcSealedFridgeExpiry(PDO $db): void {
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT i.id, i.added_at, i.vacuum_sealed, i.opened_at, i.expiry_date,
|
||||||
|
p.name, p.category
|
||||||
|
FROM inventory i
|
||||||
|
JOIN products p ON i.product_id = p.id
|
||||||
|
WHERE i.location = 'frigo' AND i.opened_at IS NULL AND i.quantity > 0
|
||||||
|
");
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$days = estimateSealedExpiryDaysPHP($row['name'], $row['category'], 'frigo');
|
||||||
|
if ($row['vacuum_sealed']) $days = getVacuumExpiryDaysPHP($days);
|
||||||
|
$newExpiry = date('Y-m-d', strtotime($row['added_at'] . " +{$days} days"));
|
||||||
|
// Only extend expiry, never shorten it
|
||||||
|
if ($row['expiry_date'] && $newExpiry <= $row['expiry_date']) continue;
|
||||||
|
$upd = $db->prepare("UPDATE inventory SET expiry_date = ? WHERE id = ?");
|
||||||
|
$upd->execute([$newExpiry, $row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVacuumExpiryDaysPHP(int $baseDays): int {
|
||||||
|
if ($baseDays <= 7) return (int)round($baseDays * 3);
|
||||||
|
if ($baseDays <= 14) return (int)round($baseDays * 3);
|
||||||
|
if ($baseDays <= 30) return (int)round($baseDays * 2.5);
|
||||||
|
if ($baseDays <= 90) return (int)round($baseDays * 2.5);
|
||||||
|
return (int)round($baseDays * 1.5);
|
||||||
}
|
}
|
||||||
|
|||||||
+255
-5
@@ -115,6 +115,9 @@ try {
|
|||||||
case 'bring_suggest':
|
case 'bring_suggest':
|
||||||
bringSuggestItems($db);
|
bringSuggestItems($db);
|
||||||
break;
|
break;
|
||||||
|
case 'smart_shopping':
|
||||||
|
smartShopping($db);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'save_settings':
|
case 'save_settings':
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -468,7 +471,7 @@ function listInventory(PDO $db): void {
|
|||||||
$location = $_GET['location'] ?? '';
|
$location = $_GET['location'] ?? '';
|
||||||
$query = "
|
$query = "
|
||||||
SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode, p.default_quantity, p.package_unit,
|
SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode, p.default_quantity, p.package_unit,
|
||||||
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed
|
COALESCE(i.vacuum_sealed, 0) as vacuum_sealed, i.opened_at
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
JOIN products p ON i.product_id = p.id
|
JOIN products p ON i.product_id = p.id
|
||||||
";
|
";
|
||||||
@@ -624,7 +627,7 @@ function useFromInventory(PDO $db): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY (quantity != CAST(CAST(quantity AS INTEGER) AS REAL)) DESC, quantity ASC");
|
$stmt = $db->prepare("SELECT id, quantity, opened_at, vacuum_sealed FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY (quantity != CAST(CAST(quantity AS INTEGER) AS REAL)) DESC, quantity ASC");
|
||||||
$stmt->execute([$productId, $location]);
|
$stmt->execute([$productId, $location]);
|
||||||
$existing = $stmt->fetch();
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
@@ -640,7 +643,7 @@ function useFromInventory(PDO $db): void {
|
|||||||
|
|
||||||
// Auto-split conf products: separate whole confs from opened (fractional) part
|
// Auto-split conf products: separate whole confs from opened (fractional) part
|
||||||
$openedId = null;
|
$openedId = null;
|
||||||
$stmt2 = $db->prepare("SELECT unit, default_quantity, package_unit FROM products WHERE id = ?");
|
$stmt2 = $db->prepare("SELECT name, category, unit, default_quantity, package_unit FROM products WHERE id = ?");
|
||||||
$stmt2->execute([$productId]);
|
$stmt2->execute([$productId]);
|
||||||
$prodInfo = $stmt2->fetch();
|
$prodInfo = $stmt2->fetch();
|
||||||
|
|
||||||
@@ -662,8 +665,13 @@ function useFromInventory(PDO $db): void {
|
|||||||
|
|
||||||
$newFraction = round($fraction - $quantity, 6);
|
$newFraction = round($fraction - $quantity, 6);
|
||||||
if ($newFraction > 0.001) {
|
if ($newFraction > 0.001) {
|
||||||
$stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
|
// Opened item: calculate shorter shelf life from now
|
||||||
$stmt3->execute([$productId, $location, $newFraction, $origRow['expiry_date'], $origRow['vacuum_sealed'] ?? 0]);
|
$vacuum = (int)($origRow['vacuum_sealed'] ?? 0);
|
||||||
|
$openedDays = estimateOpenedExpiryDaysPHP($prodInfo['name'] ?? '', $prodInfo['category'] ?? '', $location);
|
||||||
|
if ($vacuum) $openedDays = (int)round($openedDays * 1.5);
|
||||||
|
$openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days"));
|
||||||
|
$stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)");
|
||||||
|
$stmt3->execute([$productId, $location, $newFraction, $openedExpiry, $vacuum]);
|
||||||
$openedId = (int)$db->lastInsertId();
|
$openedId = (int)$db->lastInsertId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,10 +691,35 @@ function useFromInventory(PDO $db): void {
|
|||||||
if ($newQty <= 0) {
|
if ($newQty <= 0) {
|
||||||
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
|
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
|
||||||
$stmt->execute([$existing['id']]);
|
$stmt->execute([$existing['id']]);
|
||||||
|
} else {
|
||||||
|
// Check if item is now opened (first use reduces quantity)
|
||||||
|
$wasOpened = !empty($existing['opened_at']);
|
||||||
|
$isNowOpened = false;
|
||||||
|
$unit = $prodInfo['unit'] ?? 'pz';
|
||||||
|
$defQty = (float)($prodInfo['default_quantity'] ?? 0);
|
||||||
|
if ($unit === 'conf') {
|
||||||
|
$w = floor($newQty + 0.001);
|
||||||
|
$f = round($newQty - $w, 6);
|
||||||
|
if ($f > 0.001) $isNowOpened = true;
|
||||||
|
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) {
|
||||||
|
$isNowOpened = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isNowOpened && !$wasOpened) {
|
||||||
|
// First time opened: recalculate expiry with shorter shelf life
|
||||||
|
$pName = $prodInfo['name'] ?? '';
|
||||||
|
$pCat = $prodInfo['category'] ?? '';
|
||||||
|
$vacuum = (int)($existing['vacuum_sealed'] ?? 0);
|
||||||
|
$openedDays = estimateOpenedExpiryDaysPHP($pName, $pCat, $location);
|
||||||
|
if ($vacuum) $openedDays = (int)round($openedDays * 1.5);
|
||||||
|
$openedExpiry = date('Y-m-d', strtotime("+{$openedDays} days"));
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||||
} else {
|
} else {
|
||||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
$stmt->execute([$newQty, $existing['id']]);
|
$stmt->execute([$newQty, $existing['id']]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log transaction
|
// Log transaction
|
||||||
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
||||||
@@ -2160,6 +2193,223 @@ function bringCleanSpecs(): void {
|
|||||||
echo json_encode(['success' => true, 'cleaned' => $cleaned]);
|
echo json_encode(['success' => true, 'cleaned' => $cleaned]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smart Shopping List: analyzes usage frequency, stock levels, expiry to produce
|
||||||
|
* intelligent urgency-ranked shopping recommendations.
|
||||||
|
*/
|
||||||
|
function smartShopping(PDO $db): void {
|
||||||
|
$now = time();
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
|
||||||
|
// 1. Get all products with their inventory and transaction history
|
||||||
|
$products = $db->query("
|
||||||
|
SELECT p.id, p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit
|
||||||
|
FROM products p
|
||||||
|
ORDER BY p.name
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
// 2. Get all inventory grouped by product
|
||||||
|
$invStmt = $db->query("
|
||||||
|
SELECT i.product_id, SUM(i.quantity) as total_qty,
|
||||||
|
MIN(i.expiry_date) as nearest_expiry,
|
||||||
|
GROUP_CONCAT(DISTINCT i.location) as locations,
|
||||||
|
MAX(i.opened_at) as opened_at
|
||||||
|
FROM inventory i
|
||||||
|
WHERE i.quantity > 0
|
||||||
|
GROUP BY i.product_id
|
||||||
|
");
|
||||||
|
$inventory = [];
|
||||||
|
foreach ($invStmt->fetchAll() as $inv) {
|
||||||
|
$inventory[$inv['product_id']] = $inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get transaction stats per product
|
||||||
|
$txStmt = $db->query("
|
||||||
|
SELECT product_id,
|
||||||
|
COUNT(CASE WHEN type IN ('out','waste') THEN 1 END) as use_count,
|
||||||
|
SUM(CASE WHEN type IN ('out','waste') THEN quantity ELSE 0 END) as total_used,
|
||||||
|
COUNT(CASE WHEN type = 'in' THEN 1 END) as buy_count,
|
||||||
|
SUM(CASE WHEN type = 'in' THEN quantity ELSE 0 END) as total_bought,
|
||||||
|
MIN(CASE WHEN type = 'in' THEN created_at END) as first_in,
|
||||||
|
MAX(CASE WHEN type = 'in' THEN created_at END) as last_in,
|
||||||
|
MAX(CASE WHEN type IN ('out','waste') THEN created_at END) as last_out
|
||||||
|
FROM transactions
|
||||||
|
GROUP BY product_id
|
||||||
|
");
|
||||||
|
$txData = [];
|
||||||
|
foreach ($txStmt->fetchAll() as $tx) {
|
||||||
|
$txData[$tx['product_id']] = $tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch current Bring! list to know what's already there
|
||||||
|
$bringItems = [];
|
||||||
|
try {
|
||||||
|
$auth = bringAuth();
|
||||||
|
if ($auth) {
|
||||||
|
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
|
||||||
|
if ($listData && isset($listData['purchase'])) {
|
||||||
|
foreach ($listData['purchase'] as $bi) {
|
||||||
|
$bringItems[mb_strtolower(bringToItalian($bi['name'] ?? ''))] = true;
|
||||||
|
$bringItems[mb_strtolower($bi['name'] ?? '')] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception $e) { /* ignore */ }
|
||||||
|
|
||||||
|
// 5. Analyze each product
|
||||||
|
$items = [];
|
||||||
|
foreach ($products as $p) {
|
||||||
|
$pid = $p['id'];
|
||||||
|
$inv = $inventory[$pid] ?? null;
|
||||||
|
$tx = $txData[$pid] ?? null;
|
||||||
|
|
||||||
|
// Skip products never bought/used and not in inventory
|
||||||
|
if (!$tx && !$inv) continue;
|
||||||
|
|
||||||
|
$qty = $inv ? (float)$inv['total_qty'] : 0;
|
||||||
|
$unit = $p['unit'] ?: 'pz';
|
||||||
|
$defQty = (float)($p['default_quantity'] ?: 0);
|
||||||
|
$isOpened = $inv && !empty($inv['opened_at']);
|
||||||
|
|
||||||
|
// --- Usage frequency ---
|
||||||
|
$useCount = $tx ? (int)$tx['use_count'] : 0;
|
||||||
|
$buyCount = $tx ? (int)$tx['buy_count'] : 0;
|
||||||
|
$totalUsed = $tx ? (float)$tx['total_used'] : 0;
|
||||||
|
$totalBought = $tx ? (float)$tx['total_bought'] : 0;
|
||||||
|
|
||||||
|
// Days since first purchase
|
||||||
|
$firstIn = $tx && $tx['first_in'] ? strtotime($tx['first_in']) : null;
|
||||||
|
$lastIn = $tx && $tx['last_in'] ? strtotime($tx['last_in']) : null;
|
||||||
|
$lastOut = $tx && $tx['last_out'] ? strtotime($tx['last_out']) : null;
|
||||||
|
$daysSinceFirst = $firstIn ? max(1, ($now - $firstIn) / 86400) : 999;
|
||||||
|
|
||||||
|
// Average daily consumption rate
|
||||||
|
$dailyRate = $daysSinceFirst < 999 && $totalUsed > 0 ? $totalUsed / $daysSinceFirst : 0;
|
||||||
|
|
||||||
|
// Days of stock remaining
|
||||||
|
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
|
||||||
|
|
||||||
|
// --- Expiry check ---
|
||||||
|
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
||||||
|
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
||||||
|
$isExpired = $daysToExpiry < 0;
|
||||||
|
$isExpiringSoon = !$isExpired && $daysToExpiry <= 3;
|
||||||
|
|
||||||
|
// --- Stock level assessment ---
|
||||||
|
// percentage_left: how much is left vs typical purchase size
|
||||||
|
// Use average of totalBought/buyCount if available, else default_quantity, else best-guess from defQty or 1
|
||||||
|
$refQty = $totalBought > 0 && $buyCount > 0
|
||||||
|
? $totalBought / $buyCount
|
||||||
|
: ($defQty > 0 ? $defQty : max(1, $qty)); // avoid inflating pctLeft for products with no history
|
||||||
|
$pctLeft = $refQty > 0 ? min(200, ($qty / $refQty) * 100) : ($qty > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
// Cap daysLeft at a reasonable ceiling to avoid 999-day noise in reason strings
|
||||||
|
$daysLeft = min($daysLeft, 365);
|
||||||
|
|
||||||
|
// --- Determine urgency ---
|
||||||
|
$urgency = 'none'; // none, low, medium, high, critical
|
||||||
|
$reasons = [];
|
||||||
|
$score = 0;
|
||||||
|
|
||||||
|
// Out of stock
|
||||||
|
if ($qty <= 0) {
|
||||||
|
if ($useCount >= 3 || $buyCount >= 2) {
|
||||||
|
$urgency = 'critical';
|
||||||
|
$reasons[] = 'Esaurito';
|
||||||
|
$score += 100;
|
||||||
|
if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; }
|
||||||
|
} else {
|
||||||
|
// Rarely used — not urgent even if finished
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Almost finished
|
||||||
|
if ($qty > 0 && $pctLeft <= 15) {
|
||||||
|
$urgency = 'high';
|
||||||
|
$reasons[] = 'Quasi finito (' . round($pctLeft) . '%)';
|
||||||
|
$score += 80;
|
||||||
|
} elseif ($qty > 0 && $pctLeft <= 30) {
|
||||||
|
if ($dailyRate > 0 && $daysLeft <= 5) {
|
||||||
|
$urgency = 'high';
|
||||||
|
$reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg';
|
||||||
|
$score += 75;
|
||||||
|
} elseif ($dailyRate > 0 && $daysLeft <= 10) {
|
||||||
|
$urgency = 'medium';
|
||||||
|
$reasons[] = 'Finisce tra ~' . round($daysLeft) . 'gg';
|
||||||
|
$score += 50;
|
||||||
|
} else {
|
||||||
|
$urgency = 'low';
|
||||||
|
$reasons[] = 'Scorta bassa (' . round($pctLeft) . '%)';
|
||||||
|
$score += 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiring soon or expired (needs replacement)
|
||||||
|
if ($isExpired && $qty > 0) {
|
||||||
|
$urgency = 'critical';
|
||||||
|
$reasons[] = 'Scaduto!';
|
||||||
|
$score += 90;
|
||||||
|
} elseif ($isExpiringSoon && $qty > 0) {
|
||||||
|
if ($urgency === 'none') $urgency = 'medium';
|
||||||
|
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
|
||||||
|
$score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequently used but stock getting low (predictive)
|
||||||
|
if ($urgency === 'none' && $dailyRate > 0 && $daysLeft <= 14 && $useCount >= 3) {
|
||||||
|
$urgency = 'low';
|
||||||
|
$reasons[] = 'Previsto esaurimento tra ~' . round($daysLeft) . 'gg';
|
||||||
|
$score += 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opened item with fast consumption
|
||||||
|
if ($isOpened && $urgency === 'none' && $dailyRate > 0 && $daysLeft <= 7) {
|
||||||
|
$urgency = 'low';
|
||||||
|
$reasons[] = 'Aperto, finisce presto';
|
||||||
|
$score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($urgency === 'none') continue;
|
||||||
|
|
||||||
|
// Boost score for very frequent items
|
||||||
|
if ($useCount >= 8) $score += 15;
|
||||||
|
elseif ($useCount >= 5) $score += 10;
|
||||||
|
|
||||||
|
// Is already on Bring?
|
||||||
|
$onBring = isset($bringItems[mb_strtolower($p['name'])]);
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'product_id' => $pid,
|
||||||
|
'name' => $p['name'],
|
||||||
|
'brand' => $p['brand'] ?: '',
|
||||||
|
'category' => $p['category'] ?: '',
|
||||||
|
'unit' => $unit,
|
||||||
|
'current_qty' => round($qty, 1),
|
||||||
|
'default_qty' => $defQty,
|
||||||
|
'package_unit' => $p['package_unit'] ?: '',
|
||||||
|
'pct_left' => round($pctLeft),
|
||||||
|
'use_count' => $useCount,
|
||||||
|
'buy_count' => $buyCount,
|
||||||
|
'daily_rate' => round($dailyRate, 2),
|
||||||
|
'days_left' => round($daysLeft),
|
||||||
|
'expiry_date' => $expiryDate,
|
||||||
|
'days_to_expiry' => round($daysToExpiry),
|
||||||
|
'is_opened' => $isOpened,
|
||||||
|
'urgency' => $urgency,
|
||||||
|
'reasons' => $reasons,
|
||||||
|
'score' => $score,
|
||||||
|
'on_bring' => $onBring,
|
||||||
|
'locations' => $inv ? $inv['locations'] : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending (most urgent first)
|
||||||
|
usort($items, fn($a, $b) => $b['score'] - $a['score']);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'items' => $items], JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
function bringSuggestItems(PDO $db): void {
|
function bringSuggestItems(PDO $db): void {
|
||||||
$env = loadEnvVars();
|
$env = loadEnvVars();
|
||||||
$apiKey = $env['GEMINI_API_KEY'] ?? '';
|
$apiKey = $env['GEMINI_API_KEY'] ?? '';
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:active {
|
.stat-card:active {
|
||||||
@@ -1155,6 +1156,204 @@ body {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== SHOPPING TABS ===== */
|
||||||
|
.shopping-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 8px;
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(45, 80, 22, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-tab-count {
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-tab:not(.active) .shopping-tab-count {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel-shopping {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel-shopping.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOPPING ITEM ENHANCEMENTS ===== */
|
||||||
|
.shopping-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-scan-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic small item badge (sinv = shopping inventory) */
|
||||||
|
.sinv-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-critical { background: #fee2e2; color: #dc2626; }
|
||||||
|
.badge-high { background: #ffedd5; color: #c2410c; }
|
||||||
|
.badge-medium { background: #fef9c3; color: #a16207; }
|
||||||
|
.badge-low { background: #dcfce7; color: #15803d; }
|
||||||
|
|
||||||
|
.badge-freq-high { background: #fce7f3; color: #be185d; }
|
||||||
|
.badge-freq-med { background: #ede9fe; color: #6d28d9; }
|
||||||
|
.badge-freq-low { background: #f1f5f9; color: #64748b; }
|
||||||
|
|
||||||
|
.badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; }
|
||||||
|
.badge-local-tag:hover { background: #bae6fd; }
|
||||||
|
|
||||||
|
/* Tag add button */
|
||||||
|
.shopping-item-tag-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.shopping-item-tag-btn:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Tag menu dropdown */
|
||||||
|
.shopping-tag-menu-container {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-tag-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-tag-add {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-tag-add:hover, .badge-tag-add.active {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
border-color: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOPPING SCAN TARGET BANNER ===== */
|
||||||
|
.shopping-scan-target-banner {
|
||||||
|
background: linear-gradient(135deg, #FFF9C4, #FFF3CD);
|
||||||
|
border: 1.5px solid #F59E0B;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-scan-target-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stb-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #92400e;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stb-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-scan-target-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stb-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.bring-loading {
|
.bring-loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
@@ -1213,6 +1412,7 @@ body {
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
/* tap-to-scan handled by JS; cursor/active defined in ENHANCEMENTS block above */
|
||||||
}
|
}
|
||||||
|
|
||||||
.shopping-item-icon {
|
.shopping-item-icon {
|
||||||
@@ -1453,6 +1653,244 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== SMART SHOPPING ===== */
|
||||||
|
.smart-shopping {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-shopping h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-shopping h3 .shopping-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-filter-row::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-filter {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-filter.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item:active {
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-check {
|
||||||
|
margin-top: 3px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-brand {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-reasons {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-reasons span + span::before {
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-urgency-badge,
|
||||||
|
.smart-freq-badge,
|
||||||
|
.smart-pred-badge,
|
||||||
|
.smart-bring-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-urgency-badge {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-freq-badge {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-freq-badge.freq-high {
|
||||||
|
background: #fce7f3;
|
||||||
|
color: #be185d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-freq-badge.freq-med {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-freq-badge.freq-low {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-pred-badge {
|
||||||
|
background: #fefce8;
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-pred-badge.pred-urgent {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-pred-badge.pred-soon {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-bring-badge {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-item-stock {
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-qty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-stock-bar {
|
||||||
|
width: 44px;
|
||||||
|
height: 5px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-stock-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-urgent {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== PRODUCT PREVIEW ===== */
|
/* ===== PRODUCT PREVIEW ===== */
|
||||||
.product-preview, .product-preview-small {
|
.product-preview, .product-preview-small {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -2137,6 +2575,16 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.opened-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
color: #d97706;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== REMAINING QUANTITY OPTIONS ===== */
|
/* ===== REMAINING QUANTITY OPTIONS ===== */
|
||||||
.remaining-options {
|
.remaining-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -3763,3 +4211,34 @@ body {
|
|||||||
.screensaver-fact.visible {
|
.screensaver-fact.visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.screensaver-shortcuts {
|
||||||
|
position: absolute;
|
||||||
|
bottom: max(32px, env(safe-area-inset-bottom, 32px));
|
||||||
|
right: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
.screensaver-shortcut-btn {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255,255,255,0.25);
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.screensaver-shortcut-btn:active {
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|||||||
+510
-13
@@ -274,8 +274,11 @@ function estimateExpiryDays(product, location) {
|
|||||||
else if (/mozzarella|burrata|stracciatella/.test(name)) days = 5;
|
else if (/mozzarella|burrata|stracciatella/.test(name)) days = 5;
|
||||||
else if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) days = 10;
|
else if (/formaggio\s+(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) days = 10;
|
||||||
else if (/parmigiano|grana|pecorino|provolone/.test(name)) days = 60;
|
else if (/parmigiano|grana|pecorino|provolone/.test(name)) days = 60;
|
||||||
|
else if (/burro/.test(name)) days = 60;
|
||||||
|
else if (/panna/.test(name)) days = 14;
|
||||||
else if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) days = 7;
|
else if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) days = 7;
|
||||||
else if (/prosciutto\s+crudo|salame|bresaola|speck/.test(name)) days = 30;
|
else if (/prosciutto\s+crudo|salame|bresaola|speck/.test(name)) days = 30;
|
||||||
|
else if (/nduja/.test(name)) days = 90;
|
||||||
else if (/uova/.test(name)) days = 28;
|
else if (/uova/.test(name)) days = 28;
|
||||||
else if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) days = 5;
|
else if (/pane\s+fresco|pane\s+in\s+cassetta/.test(name)) days = 5;
|
||||||
else if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) days = 14;
|
else if (/pane\s+confezionato|pan\s+carr|pancarrè/.test(name)) days = 14;
|
||||||
@@ -288,6 +291,14 @@ function estimateExpiryDays(product, location) {
|
|||||||
else if (/succo|spremuta/.test(name)) days = 7;
|
else if (/succo|spremuta/.test(name)) days = 7;
|
||||||
else if (/birra|vino/.test(name)) days = 365;
|
else if (/birra|vino/.test(name)) days = 365;
|
||||||
else if (/acqua/.test(name)) days = 365;
|
else if (/acqua/.test(name)) days = 365;
|
||||||
|
else if (/mela|mele\b/.test(name)) days = 7;
|
||||||
|
else if (/arancia|arance|mandarini|agrumi/.test(name)) days = 7;
|
||||||
|
else if (/banana|banane/.test(name)) days = 5;
|
||||||
|
else if (/pera|pere\b|fragola|fragole|uva|kiwi/.test(name)) days = 5;
|
||||||
|
else if (/carota|carote|zucchina|zucchine|peperoni|melanzane/.test(name)) days = 7;
|
||||||
|
else if (/broccoli|cavolfiore|cavolo|spinaci|bietola/.test(name)) days = 5;
|
||||||
|
else if (/cipolla|cipolle/.test(name)) days = 10;
|
||||||
|
else if (/patata|patate/.test(name)) days = 14;
|
||||||
else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180;
|
else if (/biscott|cracker|grissini|fette\s+biscott/.test(name)) days = 180;
|
||||||
else if (/nutella|marmellata|miele/.test(name)) days = 365;
|
else if (/nutella|marmellata|miele/.test(name)) days = 365;
|
||||||
else if (/passata|pelati|pomodor/.test(name)) days = 730;
|
else if (/passata|pelati|pomodor/.test(name)) days = 730;
|
||||||
@@ -300,6 +311,28 @@ function estimateExpiryDays(product, location) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fridge extends shelf life for produce and short-lived items (sealed only)
|
||||||
|
if (loc === 'frigo') {
|
||||||
|
// Specific fridge-friendly produce overrides
|
||||||
|
if (/mela|mele/.test(name)) days = Math.max(days, 28);
|
||||||
|
else if (/arancia|arance|agrumi|mandarini|limone|limoni/.test(name)) days = Math.max(days, 21);
|
||||||
|
else if (/carota|carote/.test(name)) days = Math.max(days, 21);
|
||||||
|
else if (/cipolla/.test(name)) days = Math.max(days, 14);
|
||||||
|
else if (/patata|patate/.test(name)) days = Math.max(days, 21);
|
||||||
|
else if (/pera|pere/.test(name)) days = Math.max(days, 21);
|
||||||
|
else if (/kiwi/.test(name)) days = Math.max(days, 28);
|
||||||
|
else if (/uva/.test(name)) days = Math.max(days, 14);
|
||||||
|
else if (/fragola|fragole/.test(name)) days = Math.max(days, 7);
|
||||||
|
else if (/peperoni/.test(name)) days = Math.max(days, 14);
|
||||||
|
else if (/zucchina|zucchine/.test(name)) days = Math.max(days, 14);
|
||||||
|
else if (/melanzane/.test(name)) days = Math.max(days, 14);
|
||||||
|
else if (/broccoli|cavolfiore|cavolo/.test(name)) days = Math.max(days, 10);
|
||||||
|
// General fridge bonus: fruits and vegs that aren't already long
|
||||||
|
else if (days <= 7 && (/frutta|fruit/.test(cat) || /verdur|vegetable|plant-based/.test(cat))) {
|
||||||
|
days = Math.round(days * 2); // ~double shelf life in fridge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Freezer extends shelf life significantly
|
// Freezer extends shelf life significantly
|
||||||
if (loc === 'freezer' && days < 180) {
|
if (loc === 'freezer' && days < 180) {
|
||||||
// Fresh meat/fish: 3-6 months in freezer
|
// Fresh meat/fish: 3-6 months in freezer
|
||||||
@@ -322,6 +355,60 @@ function formatEstimatedExpiry(days) {
|
|||||||
return `~${Math.round(days / 365)} anni`;
|
return `~${Math.round(days / 365)} anni`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate shelf life in days for an OPENED product.
|
||||||
|
* Much shorter than sealed shelf life — based on typical "once opened, consume within X days".
|
||||||
|
*/
|
||||||
|
function estimateOpenedExpiryDays(product, location) {
|
||||||
|
const name = (product.name || '').toLowerCase();
|
||||||
|
const cat = (product.category || '').toLowerCase();
|
||||||
|
const loc = (location || '').toLowerCase();
|
||||||
|
|
||||||
|
// Freezer: opened items still last a long time
|
||||||
|
if (loc === 'freezer') return 90;
|
||||||
|
// Dispensa: opened dry goods
|
||||||
|
if (loc === 'dispensa') return 30;
|
||||||
|
|
||||||
|
// Specific product overrides (fridge)
|
||||||
|
if (/latte\s+(fresco|intero|parzial|scremato)/.test(name)) return 3;
|
||||||
|
if (/latte\s+uht|latte\s+a\s+lunga/.test(name)) return 5;
|
||||||
|
if (/latte/.test(name)) return 4;
|
||||||
|
if (/yogurt/.test(name)) return 3;
|
||||||
|
if (/mozzarella|burrata|stracciatella/.test(name)) return 2;
|
||||||
|
if (/philadelphia|spalmabile/.test(name)) return 7;
|
||||||
|
if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5;
|
||||||
|
if (/parmigiano|grana|pecorino|provolone/.test(name)) return 21;
|
||||||
|
if (/formaggio/.test(name)) return 10;
|
||||||
|
if (/burro/.test(name)) return 21;
|
||||||
|
if (/panna/.test(name)) return 3;
|
||||||
|
if (/prosciutto\s+cotto|mortadella|wurstel/.test(name)) return 3;
|
||||||
|
if (/prosciutto\s+crudo|salame|bresaola|speck|pancetta|nduja/.test(name)) return 7;
|
||||||
|
if (/pollo|tacchino|maiale|manzo|vitello/.test(name)) return 2;
|
||||||
|
if (/salmone|tonno\s+fresco|pesce/.test(name)) return 2;
|
||||||
|
if (/passata|pelati|polpa|sugo/.test(name)) return 5;
|
||||||
|
if (/marmellata|confettura/.test(name)) return 30;
|
||||||
|
if (/miele/.test(name)) return 180;
|
||||||
|
if (/nutella/.test(name)) return 60;
|
||||||
|
if (/succo|spremuta/.test(name)) return 4;
|
||||||
|
if (/olio|aceto/.test(name)) return 90;
|
||||||
|
if (/vino|birra/.test(name)) return 5;
|
||||||
|
if (/limone|limmi/.test(name)) return 21;
|
||||||
|
if (/tonno\s+in\s+scatola|tonno\s+rio|sgombro\s+in/.test(name)) return 3;
|
||||||
|
if (/insalata|rucola|spinaci/.test(name)) return 3;
|
||||||
|
|
||||||
|
// Category fallbacks
|
||||||
|
if (/dairy|latticin|lait|dairies/.test(cat)) return 5;
|
||||||
|
if (/meat|carne|meats/.test(cat)) return 3;
|
||||||
|
if (/fish|pesce/.test(cat)) return 2;
|
||||||
|
if (/fruit|frutta/.test(cat)) return 5;
|
||||||
|
if (/verdur|vegetable|plant-based/.test(cat)) return 5;
|
||||||
|
if (/conserve/.test(cat)) return 5;
|
||||||
|
if (/condimenti|sauce/.test(cat)) return 21;
|
||||||
|
if (/bevand|beverage/.test(cat)) return 4;
|
||||||
|
|
||||||
|
return 5; // safe default for fridge
|
||||||
|
}
|
||||||
|
|
||||||
function addDays(days) {
|
function addDays(days) {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setDate(d.getDate() + days);
|
d.setDate(d.getDate() + days);
|
||||||
@@ -1270,6 +1357,7 @@ function renderInventoryItem(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const vacuumBadge = item.vacuum_sealed ? '<span class="vacuum-badge">🫙 Sotto vuoto</span>' : '';
|
const vacuumBadge = item.vacuum_sealed ? '<span class="vacuum-badge">🫙 Sotto vuoto</span>' : '';
|
||||||
|
const openedBadge = item.opened_at ? '<span class="opened-badge">📭 Aperto</span>' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="inventory-item" onclick="showItemDetail(${item.id}, ${item.product_id})">
|
<div class="inventory-item" onclick="showItemDetail(${item.id}, ${item.product_id})">
|
||||||
@@ -1282,6 +1370,7 @@ function renderInventoryItem(item) {
|
|||||||
<div class="inv-meta">
|
<div class="inv-meta">
|
||||||
<span class="inv-badge badge-location">${locInfo.icon} ${locInfo.label}</span>
|
<span class="inv-badge badge-location">${locInfo.icon} ${locInfo.label}</span>
|
||||||
${expiryBadge}
|
${expiryBadge}
|
||||||
|
${openedBadge}
|
||||||
${vacuumBadge}
|
${vacuumBadge}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1366,6 +1455,11 @@ function showItemDetail(inventoryId, productId) {
|
|||||||
<span class="modal-detail-label">🫙 Conservazione</span>
|
<span class="modal-detail-label">🫙 Conservazione</span>
|
||||||
<span class="modal-detail-value">Sotto vuoto</span>
|
<span class="modal-detail-value">Sotto vuoto</span>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
${item.opened_at ? `
|
||||||
|
<div class="modal-detail-row">
|
||||||
|
<span class="modal-detail-label">📭 Stato</span>
|
||||||
|
<span class="modal-detail-value">Aperto dal ${formatDateTime(item.opened_at)}</span>
|
||||||
|
</div>` : ''}
|
||||||
${item.barcode ? `
|
${item.barcode ? `
|
||||||
<div class="modal-detail-row">
|
<div class="modal-detail-row">
|
||||||
<span class="modal-detail-label">🔖 Barcode</span>
|
<span class="modal-detail-label">🔖 Barcode</span>
|
||||||
@@ -1437,7 +1531,10 @@ function recalcEditExpiry(locInputId, vacuumInputId, expiryInputId) {
|
|||||||
if (!product) return;
|
if (!product) return;
|
||||||
const loc = document.getElementById(locInputId)?.value || '';
|
const loc = document.getElementById(locInputId)?.value || '';
|
||||||
const isVacuum = document.getElementById(vacuumInputId)?.checked;
|
const isVacuum = document.getElementById(vacuumInputId)?.checked;
|
||||||
let days = estimateExpiryDays(product, loc);
|
// Use opened shelf life if item is already opened
|
||||||
|
let days = product._isOpened
|
||||||
|
? estimateOpenedExpiryDays(product, loc)
|
||||||
|
: estimateExpiryDays(product, loc);
|
||||||
if (isVacuum) days = getVacuumExpiryDays(days);
|
if (isVacuum) days = getVacuumExpiryDays(days);
|
||||||
const newDate = addDays(days);
|
const newDate = addDays(days);
|
||||||
const expiryInput = document.getElementById(expiryInputId);
|
const expiryInput = document.getElementById(expiryInputId);
|
||||||
@@ -1456,7 +1553,7 @@ function editInventoryItem(id) {
|
|||||||
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
|
const confSizeVal = (isConf && item.default_quantity > 0) ? item.default_quantity : '';
|
||||||
const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g';
|
const confUnitVal = (isConf && item.package_unit) ? item.package_unit : 'g';
|
||||||
|
|
||||||
window._editingProduct = { name: item.name, category: item.category || '' };
|
window._editingProduct = { name: item.name, category: item.category || '', _isOpened: !!item.opened_at };
|
||||||
|
|
||||||
// Rebuild modal content for editing (don't close and reopen - just replace content)
|
// Rebuild modal content for editing (don't close and reopen - just replace content)
|
||||||
document.getElementById('modal-content').innerHTML = `
|
document.getElementById('modal-content').innerHTML = `
|
||||||
@@ -2594,6 +2691,29 @@ function showProductAction() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update back button: go back to shopping if came from shopping list scan
|
||||||
|
const backBtn = document.getElementById('action-back-btn');
|
||||||
|
if (backBtn) backBtn.onclick = _spesaScanTarget ? () => { _spesaScanTarget = null; showPage('shopping'); } : () => showPage('scan');
|
||||||
|
|
||||||
|
// Show "shopping target" banner if we came from the shopping list
|
||||||
|
const banner = document.getElementById('shopping-scan-target-banner');
|
||||||
|
if (banner && _spesaScanTarget) {
|
||||||
|
const targetName = _spesaScanTarget.name;
|
||||||
|
banner.style.display = 'block';
|
||||||
|
banner.innerHTML = `
|
||||||
|
<div class="shopping-scan-target-info">
|
||||||
|
<span class="stb-label">🛒 Stai cercando</span>
|
||||||
|
<span class="stb-name">${escapeHtml(targetName)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="shopping-scan-target-actions">
|
||||||
|
<button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()">✅ Trovato! Rimuovi dalla lista</button>
|
||||||
|
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>showPage('scan')">✕ Annulla</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (banner) {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
showPage('action');
|
showPage('action');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3624,9 +3744,9 @@ async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
|
|||||||
showLoading(true);
|
showLoading(true);
|
||||||
try {
|
try {
|
||||||
if (openedId) {
|
if (openedId) {
|
||||||
// Move only the specific opened row
|
// Move only the specific opened row — use opened shelf life
|
||||||
const product = { name: currentProduct?.name || '', category: currentProduct?.category || '' };
|
const product = { name: currentProduct?.name || '', category: currentProduct?.category || '' };
|
||||||
let days = estimateExpiryDays(product, toLoc);
|
let days = estimateOpenedExpiryDays(product, toLoc);
|
||||||
await api('inventory_update', {}, 'POST', {
|
await api('inventory_update', {}, 'POST', {
|
||||||
id: openedId,
|
id: openedId,
|
||||||
location: toLoc,
|
location: toLoc,
|
||||||
@@ -4048,6 +4168,92 @@ let shoppingListUUID = '';
|
|||||||
let shoppingItems = [];
|
let shoppingItems = [];
|
||||||
let suggestionItems = [];
|
let suggestionItems = [];
|
||||||
let shoppingPrices = {}; // { itemName: { product, searched: true } }
|
let shoppingPrices = {}; // { itemName: { product, searched: true } }
|
||||||
|
let _spesaScanTarget = null; // { name, rawName, idx } when tapping item to scan
|
||||||
|
|
||||||
|
// ===== SHOPPING TABS =====
|
||||||
|
function switchShoppingTab(tab) {
|
||||||
|
document.querySelectorAll('.shopping-tab').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-panel-shopping').forEach(p => p.classList.remove('active'));
|
||||||
|
document.getElementById(`tab-${tab}`)?.classList.add('active');
|
||||||
|
document.getElementById(`tab-panel-${tab}`)?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShoppingTabCounts() {
|
||||||
|
const acquistoCount = shoppingItems.length;
|
||||||
|
const previsioneCount = smartShoppingItems.filter(i => !i.on_bring).length;
|
||||||
|
const acqEl = document.getElementById('tab-count-acquisto');
|
||||||
|
const prevEl = document.getElementById('tab-count-previsione');
|
||||||
|
if (acqEl) acqEl.textContent = acquistoCount;
|
||||||
|
if (prevEl) prevEl.textContent = previsioneCount;
|
||||||
|
document.getElementById('shopping-tabs')?.style.setProperty('display', 'flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LOCAL SHOPPING TAGS =====
|
||||||
|
function getShoppingTags(itemName) {
|
||||||
|
try {
|
||||||
|
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
|
||||||
|
return tags[itemName.toLowerCase()] || [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShoppingTag(itemIdx, tag) {
|
||||||
|
const item = shoppingItems[itemIdx];
|
||||||
|
if (!item) return;
|
||||||
|
const key = item.name.toLowerCase();
|
||||||
|
try {
|
||||||
|
const tags = JSON.parse(localStorage.getItem('shopping_tags') || '{}');
|
||||||
|
const existing = tags[key] || [];
|
||||||
|
const pos = existing.indexOf(tag);
|
||||||
|
if (pos >= 0) existing.splice(pos, 1);
|
||||||
|
else existing.push(tag);
|
||||||
|
if (existing.length) tags[key] = existing;
|
||||||
|
else delete tags[key];
|
||||||
|
localStorage.setItem('shopping_tags', JSON.stringify(tags));
|
||||||
|
renderShoppingItems();
|
||||||
|
} catch (e) { console.error('toggleShoppingTag', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SCAN FROM SHOPPING LIST =====
|
||||||
|
function openScanForItem(idx) {
|
||||||
|
const item = shoppingItems[idx];
|
||||||
|
if (!item) return;
|
||||||
|
_spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx };
|
||||||
|
showPage('scan');
|
||||||
|
showToast(`📷 Scansiona: ${item.name}`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmShoppingItemFound() {
|
||||||
|
if (!_spesaScanTarget) return;
|
||||||
|
const { name, rawName } = _spesaScanTarget;
|
||||||
|
_spesaScanTarget = null;
|
||||||
|
document.getElementById('shopping-scan-target-banner').style.display = 'none';
|
||||||
|
try {
|
||||||
|
const r = await api('bring_remove', {}, 'POST', { name, rawName, listUUID: shoppingListUUID });
|
||||||
|
if (r.success) {
|
||||||
|
const idx = shoppingItems.findIndex(i => i.name.toLowerCase() === name.toLowerCase());
|
||||||
|
if (idx >= 0) shoppingItems.splice(idx, 1);
|
||||||
|
showToast(`✅ ${name} rimosso dalla lista!`, 'success');
|
||||||
|
loadShoppingCount();
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('confirmShoppingItemFound', e); }
|
||||||
|
showPage('shopping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== AUTO-ADD CRITICAL ITEMS TO BRING! =====
|
||||||
|
async function autoAddCriticalItems() {
|
||||||
|
if (sessionStorage.getItem('_autoAddedCritical')) return;
|
||||||
|
sessionStorage.setItem('_autoAddedCritical', '1');
|
||||||
|
const critical = smartShoppingItems.filter(i => i.urgency === 'critical' && !i.on_bring);
|
||||||
|
if (critical.length === 0) return;
|
||||||
|
const itemsToAdd = critical.map(i => ({ name: i.name, specification: i.brand || '' }));
|
||||||
|
try {
|
||||||
|
const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
|
||||||
|
if (result.success && result.added > 0) {
|
||||||
|
showToast(`🔴 ${result.added} prodott${result.added === 1 ? 'o urgente aggiunto' : 'i urgenti aggiunti'} automaticamente a Bring!`, 'success');
|
||||||
|
loadShoppingList();
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_SPESA_AI_PROMPT = `Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare e una lista di prodotti trovati nel catalogo del supermercato.
|
const DEFAULT_SPESA_AI_PROMPT = `Sei un assistente per la spesa online. Ti viene dato il nome di un prodotto che l'utente vuole comprare e una lista di prodotti trovati nel catalogo del supermercato.
|
||||||
|
|
||||||
@@ -4142,6 +4348,173 @@ function estimateItemPrice(product, spec) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== SMART SHOPPING =====
|
||||||
|
let smartShoppingItems = [];
|
||||||
|
let smartShoppingFilter = 'all';
|
||||||
|
|
||||||
|
async function loadSmartShopping() {
|
||||||
|
try {
|
||||||
|
const data = await api('smart_shopping');
|
||||||
|
if (data.success && data.items && data.items.length > 0) {
|
||||||
|
smartShoppingItems = data.items;
|
||||||
|
renderSmartShopping();
|
||||||
|
document.getElementById('smart-shopping-empty').style.display = 'none';
|
||||||
|
document.getElementById('smart-shopping-content').style.display = 'block';
|
||||||
|
} else {
|
||||||
|
smartShoppingItems = [];
|
||||||
|
document.getElementById('smart-shopping-empty').style.display = 'block';
|
||||||
|
document.getElementById('smart-shopping-content').style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Smart shopping error:', e);
|
||||||
|
smartShoppingItems = [];
|
||||||
|
}
|
||||||
|
updateShoppingTabCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSmart(filter) {
|
||||||
|
smartShoppingFilter = filter;
|
||||||
|
document.querySelectorAll('.smart-filter').forEach(b => b.classList.remove('active'));
|
||||||
|
document.querySelector(`.smart-filter[data-filter="${filter}"]`)?.classList.add('active');
|
||||||
|
renderSmartShopping();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSmartShopping() {
|
||||||
|
const container = document.getElementById('smart-items');
|
||||||
|
const countEl = document.getElementById('smart-count');
|
||||||
|
const actionsEl = document.getElementById('smart-actions');
|
||||||
|
|
||||||
|
let items = smartShoppingItems;
|
||||||
|
if (smartShoppingFilter !== 'all') {
|
||||||
|
items = items.filter(i => i.urgency === smartShoppingFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
countEl.textContent = items.length;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state" style="padding:16px"><p>Nessun prodotto in questa categoria</p></div>';
|
||||||
|
actionsEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urgencyConfig = {
|
||||||
|
critical: { color: '#ef4444', bg: 'rgba(239,68,68,0.08)', icon: '🔴', label: 'Urgente' },
|
||||||
|
high: { color: '#f97316', bg: 'rgba(249,115,22,0.08)', icon: '🟠', label: 'Presto' },
|
||||||
|
medium: { color: '#eab308', bg: 'rgba(234,179,8,0.08)', icon: '🟡', label: 'Pianifica' },
|
||||||
|
low: { color: '#22c55e', bg: 'rgba(34,197,94,0.08)', icon: '🟢', label: 'Previsione' },
|
||||||
|
};
|
||||||
|
|
||||||
|
container.innerHTML = items.map((item, idx) => {
|
||||||
|
const u = urgencyConfig[item.urgency] || urgencyConfig.low;
|
||||||
|
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
|
||||||
|
const checked = !item.on_bring ? 'checked' : '';
|
||||||
|
const globalIdx = smartShoppingItems.indexOf(item);
|
||||||
|
|
||||||
|
// Stock bar
|
||||||
|
const pct = Math.min(100, Math.max(0, item.pct_left));
|
||||||
|
const barColor = pct <= 15 ? '#ef4444' : pct <= 30 ? '#f97316' : pct <= 50 ? '#eab308' : '#22c55e';
|
||||||
|
|
||||||
|
// Quantity display
|
||||||
|
let qtyText = '';
|
||||||
|
if (item.current_qty > 0) {
|
||||||
|
qtyText = `${item.current_qty} ${item.unit}`;
|
||||||
|
if (item.pct_left < 100) qtyText += ` (${pct}%)`;
|
||||||
|
} else {
|
||||||
|
qtyText = 'Esaurito';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage frequency badge
|
||||||
|
let freqBadge = '';
|
||||||
|
if (item.use_count >= 8) freqBadge = '<span class="smart-freq-badge freq-high">📈 Uso frequente</span>';
|
||||||
|
else if (item.use_count >= 4) freqBadge = '<span class="smart-freq-badge freq-med">📊 Uso regolare</span>';
|
||||||
|
else if (item.use_count >= 2) freqBadge = '<span class="smart-freq-badge freq-low">📉 Uso occasionale</span>';
|
||||||
|
|
||||||
|
// Days left prediction
|
||||||
|
let predBadge = '';
|
||||||
|
if (item.days_left <= 3 && item.days_left > 0 && item.current_qty > 0) {
|
||||||
|
predBadge = `<span class="smart-pred-badge pred-urgent">⏳ ~${item.days_left}gg rimasti</span>`;
|
||||||
|
} else if (item.days_left <= 7 && item.days_left > 0 && item.current_qty > 0) {
|
||||||
|
predBadge = `<span class="smart-pred-badge pred-soon">⏳ ~${item.days_left}gg rimasti</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry badge
|
||||||
|
let expiryBadge = '';
|
||||||
|
if (item.days_to_expiry < 0 && item.current_qty > 0) {
|
||||||
|
expiryBadge = `<span class="smart-pred-badge pred-urgent">⚠️ Scaduto</span>`;
|
||||||
|
} else if (item.days_to_expiry <= 3 && item.days_to_expiry >= 0 && item.current_qty > 0) {
|
||||||
|
expiryBadge = `<span class="smart-pred-badge pred-urgent">⚠️ Scade tra ${item.days_to_expiry}gg</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="smart-item" style="border-left: 3px solid ${u.color}; background: ${u.bg}">
|
||||||
|
<div class="smart-item-top">
|
||||||
|
${!item.on_bring ? `<input type="checkbox" class="smart-check" data-idx="${globalIdx}" ${checked}>` : ''}
|
||||||
|
<span class="smart-item-icon">${catIcon}</span>
|
||||||
|
<div class="smart-item-info">
|
||||||
|
<div class="smart-item-name">${escapeHtml(item.name)}${item.brand ? ` <small class="smart-brand">${escapeHtml(item.brand)}</small>` : ''}</div>
|
||||||
|
<div class="smart-item-reasons">${item.reasons.map(r => `<span>${escapeHtml(r)}</span>`).join(' · ')}</div>
|
||||||
|
<div class="smart-item-badges">
|
||||||
|
<span class="smart-urgency-badge" style="color:${u.color}">${u.icon} ${u.label}</span>
|
||||||
|
${freqBadge}${predBadge}${expiryBadge}
|
||||||
|
${item.is_opened ? '<span class="smart-freq-badge freq-low">📭 Aperto</span>' : ''}
|
||||||
|
${item.on_bring ? '<span class="smart-bring-badge">🛒 Già su Bring!</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="smart-item-stock">
|
||||||
|
<span class="smart-qty">${qtyText}</span>
|
||||||
|
${item.current_qty > 0 ? `<div class="smart-stock-bar"><div class="smart-stock-fill" style="width:${pct}%;background:${barColor}"></div></div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Show/hide add button based on checkable items
|
||||||
|
const hasCheckable = items.some(i => !i.on_bring);
|
||||||
|
actionsEl.style.display = hasCheckable ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSmartToBring() {
|
||||||
|
const checks = document.querySelectorAll('.smart-check:checked');
|
||||||
|
if (checks.length === 0) {
|
||||||
|
showToast('Seleziona almeno un prodotto', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToAdd = [];
|
||||||
|
checks.forEach(cb => {
|
||||||
|
const idx = parseInt(cb.dataset.idx);
|
||||||
|
const item = smartShoppingItems[idx];
|
||||||
|
if (item) {
|
||||||
|
itemsToAdd.push({
|
||||||
|
name: item.name,
|
||||||
|
specification: item.brand || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api('bring_add', {}, 'POST', {
|
||||||
|
items: itemsToAdd,
|
||||||
|
listUUID: shoppingListUUID,
|
||||||
|
});
|
||||||
|
showLoading(false);
|
||||||
|
if (result.success) {
|
||||||
|
const msg = result.added > 0
|
||||||
|
? `🛒 ${result.added} prodotti aggiunti a Bring!${result.skipped > 0 ? ` (${result.skipped} già presenti)` : ''}`
|
||||||
|
: `Tutti i prodotti erano già su Bring!`;
|
||||||
|
showToast(msg, result.added > 0 ? 'success' : 'info');
|
||||||
|
// Reload to refresh badges
|
||||||
|
loadShoppingList();
|
||||||
|
} else {
|
||||||
|
showToast(result.error || 'Errore', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showLoading(false);
|
||||||
|
showToast('Errore di connessione', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load just the shopping count for dashboard stat card
|
// Load just the shopping count for dashboard stat card
|
||||||
async function loadShoppingCount() {
|
async function loadShoppingCount() {
|
||||||
try {
|
try {
|
||||||
@@ -4154,6 +4527,20 @@ async function loadShoppingCount() {
|
|||||||
} catch {
|
} catch {
|
||||||
document.getElementById('stat-spesa').textContent = '-';
|
document.getElementById('stat-spesa').textContent = '-';
|
||||||
}
|
}
|
||||||
|
// Smart urgency badge
|
||||||
|
try {
|
||||||
|
const smart = await api('smart_shopping');
|
||||||
|
const urgentEl = document.getElementById('stat-urgent');
|
||||||
|
if (smart.success && smart.items) {
|
||||||
|
const urgent = smart.items.filter(i => i.urgency === 'critical' || i.urgency === 'high').length;
|
||||||
|
if (urgent > 0) {
|
||||||
|
urgentEl.textContent = `⚠ ${urgent}`;
|
||||||
|
urgentEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
urgentEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadShoppingList() {
|
async function loadShoppingList() {
|
||||||
@@ -4194,6 +4581,12 @@ async function loadShoppingList() {
|
|||||||
renderShoppingItems();
|
renderShoppingItems();
|
||||||
currentEl.style.display = 'block';
|
currentEl.style.display = 'block';
|
||||||
|
|
||||||
|
// Load smart shopping predictions (auto-add critical after loading)
|
||||||
|
loadSmartShopping().then(() => autoAddCriticalItems());
|
||||||
|
|
||||||
|
// Show tabs once data is ready
|
||||||
|
updateShoppingTabCounts();
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Bring! error:', err);
|
console.error('Bring! error:', err);
|
||||||
statusEl.style.display = 'block';
|
statusEl.style.display = 'block';
|
||||||
@@ -4205,7 +4598,19 @@ async function renderShoppingItems() {
|
|||||||
const container = document.getElementById('shopping-items');
|
const container = document.getElementById('shopping-items');
|
||||||
const countEl = document.getElementById('shopping-count');
|
const countEl = document.getElementById('shopping-count');
|
||||||
|
|
||||||
|
// Sort shoppingItems in-place by use_count (cross-reference smartShoppingItems), most frequent first
|
||||||
|
shoppingItems.sort((a, b) => {
|
||||||
|
const smartA = smartShoppingItems.find(s => s.name.toLowerCase() === a.name.toLowerCase());
|
||||||
|
const smartB = smartShoppingItems.find(s => s.name.toLowerCase() === b.name.toLowerCase());
|
||||||
|
const freqA = smartA ? smartA.use_count : 0;
|
||||||
|
const freqB = smartB ? smartB.use_count : 0;
|
||||||
|
return freqB - freqA;
|
||||||
|
});
|
||||||
|
|
||||||
countEl.textContent = shoppingItems.length;
|
countEl.textContent = shoppingItems.length;
|
||||||
|
// Update tab count too
|
||||||
|
const tabCount = document.getElementById('tab-count-acquisto');
|
||||||
|
if (tabCount) tabCount.textContent = shoppingItems.length;
|
||||||
|
|
||||||
if (shoppingItems.length === 0) {
|
if (shoppingItems.length === 0) {
|
||||||
container.innerHTML = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>Lista della spesa vuota!<br>Usa il pulsante sotto per generare suggerimenti.</p></div>';
|
container.innerHTML = '<div class="empty-state" style="padding:20px"><div class="empty-state-icon">✅</div><p>Lista della spesa vuota!<br>Usa il pulsante sotto per generare suggerimenti.</p></div>';
|
||||||
@@ -4235,6 +4640,40 @@ async function renderShoppingItems() {
|
|||||||
const priceKey = item.name.toLowerCase();
|
const priceKey = item.name.toLowerCase();
|
||||||
const priceData = shoppingPrices[priceKey];
|
const priceData = shoppingPrices[priceKey];
|
||||||
|
|
||||||
|
// Cross-reference with smart shopping for urgency + frequency
|
||||||
|
const smartData = smartShoppingItems.find(s => s.name.toLowerCase() === item.name.toLowerCase());
|
||||||
|
const localTags = getShoppingTags(item.name);
|
||||||
|
// Urgency/frequency badges
|
||||||
|
let urgencyBadge = '';
|
||||||
|
if (smartData) {
|
||||||
|
const urgencyMap = {
|
||||||
|
critical: { icon: '🔴', label: 'Urgente', cls: 'badge-critical' },
|
||||||
|
high: { icon: '🟠', label: 'Presto', cls: 'badge-high' },
|
||||||
|
medium: { icon: '🟡', label: 'Presto', cls: 'badge-medium' },
|
||||||
|
low: { icon: '🟢', label: 'Ok', cls: 'badge-low' },
|
||||||
|
};
|
||||||
|
const u = urgencyMap[smartData.urgency];
|
||||||
|
if (u) urgencyBadge = `<span class="sinv-badge ${u.cls}">${u.icon} ${u.label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let freqBadge = '';
|
||||||
|
if (smartData && smartData.use_count >= 8) freqBadge = `<span class="sinv-badge badge-freq-high">📈 ${smartData.use_count}x</span>`;
|
||||||
|
else if (smartData && smartData.use_count >= 4) freqBadge = `<span class="sinv-badge badge-freq-med">📊 ${smartData.use_count}x</span>`;
|
||||||
|
else if (smartData && smartData.use_count >= 2) freqBadge = `<span class="sinv-badge badge-freq-low">📉 ${smartData.use_count}x</span>`;
|
||||||
|
|
||||||
|
// Local tags
|
||||||
|
const TAG_LABELS = { urgente: '🔴 Urgente', prio: '⭐ Priorità', check: '✅ Verificare' };
|
||||||
|
const localTagHtml = localTags.map(t =>
|
||||||
|
`<span class="sinv-badge badge-local-tag" onclick="event.stopPropagation(); toggleShoppingTag(${idx}, '${t}')">${TAG_LABELS[t] || t} ✕</span>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Tag add button
|
||||||
|
const tagMenu = `<div class="shopping-tag-menu" onclick="event.stopPropagation()">
|
||||||
|
${Object.entries(TAG_LABELS).map(([k, v]) =>
|
||||||
|
`<button class="sinv-badge badge-tag-add ${localTags.includes(k) ? 'active' : ''}" onclick="toggleShoppingTag(${idx}, '${k}')">${v}</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
let detailHtml = '';
|
let detailHtml = '';
|
||||||
let priceTag = '';
|
let priceTag = '';
|
||||||
let spesaBar = '';
|
let spesaBar = '';
|
||||||
@@ -4258,37 +4697,43 @@ async function renderShoppingItems() {
|
|||||||
${promoHtml}
|
${promoHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
spesaBar = `<div class="spesa-bar">
|
spesaBar = `<div class="spesa-bar">
|
||||||
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx}, true)" title="Ricerca">🔄 Ricerca</button>
|
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx}, true)" title="Ricerca">🔄 Ricerca</button>
|
||||||
<a href="${escapeHtml(p.url)}" target="_blank" class="spesa-bar-btn" title="${escapeHtml(p.name)} - ${escapeHtml(p.brand)}">🔗 Apri</a>
|
<a href="${escapeHtml(p.url)}" target="_blank" class="spesa-bar-btn" title="${escapeHtml(p.name)} - ${escapeHtml(p.brand)}" onclick="event.stopPropagation()">🔗 Apri</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (priceData && priceData.searched && !priceData.product) {
|
} else if (priceData && priceData.searched && !priceData.product) {
|
||||||
detailHtml = `<div class="spesa-detail-left"><span class="spesa-not-found">Non trovato</span></div>`;
|
detailHtml = `<div class="spesa-detail-left"><span class="spesa-not-found">Non trovato</span></div>`;
|
||||||
spesaBar = `<div class="spesa-bar">
|
spesaBar = `<div class="spesa-bar">
|
||||||
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx}, true)" title="Riprova">🔄 Riprova</button>
|
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx}, true)" title="Riprova">🔄 Riprova</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
spesaBar = `<div class="spesa-bar">
|
spesaBar = `<div class="spesa-bar">
|
||||||
<button class="spesa-bar-btn" onclick="searchItemPrice(${idx})" title="Cerca prezzo">🔍 Cerca prezzo</button>
|
<button class="spesa-bar-btn" onclick="event.stopPropagation(); searchItemPrice(${idx})" title="Cerca prezzo">🔍 Cerca prezzo</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="shopping-item ${priceData && priceData.product && priceData.product.promo ? 'has-promo' : ''}" id="shop-item-${idx}">
|
<div class="shopping-item ${priceData && priceData.product && priceData.product.promo ? 'has-promo' : ''}" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="Tocca per scansionare">
|
||||||
<span class="shopping-item-icon">${catIcon}</span>
|
<span class="shopping-item-icon">${catIcon}</span>
|
||||||
<div class="shopping-item-body">
|
<div class="shopping-item-body">
|
||||||
<div class="shopping-item-top">
|
<div class="shopping-item-top">
|
||||||
<div class="shopping-item-info">
|
<div class="shopping-item-info">
|
||||||
<div class="shopping-item-name">${escapeHtml(item.name)}</div>
|
<div class="shopping-item-name-row">
|
||||||
|
<span class="shopping-item-name">${escapeHtml(item.name)}</span>
|
||||||
|
<span class="shopping-item-scan-hint">📷</span>
|
||||||
|
</div>
|
||||||
${item.specification ? `<div class="shopping-item-spec">${escapeHtml(item.specification)}</div>` : ''}
|
${item.specification ? `<div class="shopping-item-spec">${escapeHtml(item.specification)}</div>` : ''}
|
||||||
|
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
|
||||||
${detailHtml}
|
${detailHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="shopping-item-right">
|
<div class="shopping-item-right" onclick="event.stopPropagation()">
|
||||||
${priceTag}
|
${priceTag}
|
||||||
|
<button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="Tag">🏷️</button>
|
||||||
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="Rimuovi">✕</button>
|
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="Rimuovi">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${spesaBar}
|
${spesaBar}
|
||||||
|
<div class="shopping-tag-menu-container" style="display:none">${tagMenu}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -4296,6 +4741,15 @@ async function renderShoppingItems() {
|
|||||||
updateSpesaTotal();
|
updateSpesaTotal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleShoppingTagMenu(btn) {
|
||||||
|
const container = btn.closest('.shopping-item-body').querySelector('.shopping-tag-menu-container');
|
||||||
|
if (!container) return;
|
||||||
|
const isOpen = container.style.display !== 'none';
|
||||||
|
// Close all other menus first
|
||||||
|
document.querySelectorAll('.shopping-tag-menu-container').forEach(c => c.style.display = 'none');
|
||||||
|
container.style.display = isOpen ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
function updateSpesaTotal() {
|
function updateSpesaTotal() {
|
||||||
const banner = document.getElementById('spesa-total-banner');
|
const banner = document.getElementById('spesa-total-banner');
|
||||||
const valueEl = document.getElementById('spesa-total-value');
|
const valueEl = document.getElementById('spesa-total-value');
|
||||||
@@ -5723,7 +6177,7 @@ function updateScreensaverClock() {
|
|||||||
if (el) el.innerHTML = `${time}<div class="screensaver-date">${date}</div>`;
|
if (el) el.innerHTML = `${time}<div class="screensaver-date">${date}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissScreensaver() {
|
function dismissScreensaver(targetPage) {
|
||||||
if (!_screensaverActive) return;
|
if (!_screensaverActive) return;
|
||||||
clearInterval(_screensaverClockInterval);
|
clearInterval(_screensaverClockInterval);
|
||||||
clearInterval(_screensaverFactInterval);
|
clearInterval(_screensaverFactInterval);
|
||||||
@@ -5733,8 +6187,11 @@ function dismissScreensaver() {
|
|||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
_screensaverActive = false;
|
_screensaverActive = false;
|
||||||
_screensaverData = null;
|
_screensaverData = null;
|
||||||
// Reload all data for the current page
|
if (targetPage) {
|
||||||
|
showPage(targetPage);
|
||||||
|
} else {
|
||||||
refreshCurrentPage();
|
refreshCurrentPage();
|
||||||
|
}
|
||||||
resetInactivityTimer();
|
resetInactivityTimer();
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
@@ -6161,6 +6618,45 @@ function spesaModeAfterAdd() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _initScreensaverShortcutBtn(btnId, targetPage, longPressFn) {
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
if (!btn) return;
|
||||||
|
let ssLongPress = null;
|
||||||
|
btn.addEventListener('pointerdown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (longPressFn) {
|
||||||
|
ssLongPress = setTimeout(() => {
|
||||||
|
ssLongPress = null;
|
||||||
|
dismissScreensaver(targetPage);
|
||||||
|
setTimeout(longPressFn, 500);
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
btn.addEventListener('pointerup', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (longPressFn && ssLongPress) {
|
||||||
|
clearTimeout(ssLongPress);
|
||||||
|
ssLongPress = null;
|
||||||
|
}
|
||||||
|
dismissScreensaver(targetPage);
|
||||||
|
});
|
||||||
|
btn.addEventListener('pointerleave', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (ssLongPress) {
|
||||||
|
clearTimeout(ssLongPress);
|
||||||
|
ssLongPress = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
['click', 'touchstart', 'touchend'].forEach(evt => {
|
||||||
|
btn.addEventListener(evt, (e) => e.stopPropagation(), { passive: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initScreensaverShortcuts() {
|
||||||
|
_initScreensaverShortcutBtn('screensaver-scan-btn', 'scan', () => startSpesaMode());
|
||||||
|
_initScreensaverShortcutBtn('screensaver-recipe-btn', 'recipe', null);
|
||||||
|
}
|
||||||
|
|
||||||
function initInactivityWatcher() {
|
function initInactivityWatcher() {
|
||||||
const events = ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart'];
|
const events = ['pointerdown', 'pointermove', 'keydown', 'scroll', 'touchstart'];
|
||||||
events.forEach(evt => {
|
events.forEach(evt => {
|
||||||
@@ -6181,6 +6677,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
showPage('dashboard');
|
showPage('dashboard');
|
||||||
initInactivityWatcher();
|
initInactivityWatcher();
|
||||||
initSpesaMode();
|
initSpesaMode();
|
||||||
|
initScreensaverShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== DUPLICLICK (SPESA ONLINE) =====
|
// ===== DUPLICLICK (SPESA ONLINE) =====
|
||||||
|
|||||||
Binary file not shown.
+59
-2
@@ -9,7 +9,7 @@
|
|||||||
<title>Dispensa Manager</title>
|
<title>Dispensa Manager</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
|
||||||
<link rel="stylesheet" href="assets/css/style.css?v=20260319a">
|
<link rel="stylesheet" href="assets/css/style.css?v=20260329a">
|
||||||
<!-- QuaggaJS for barcode scanning -->
|
<!-- QuaggaJS for barcode scanning -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
<span class="stat-icon">🛒</span>
|
<span class="stat-icon">🛒</span>
|
||||||
<span class="stat-value" id="stat-spesa">-</span>
|
<span class="stat-value" id="stat-spesa">-</span>
|
||||||
<span class="stat-label">Spesa</span>
|
<span class="stat-label">Spesa</span>
|
||||||
|
<span class="stat-urgent" id="stat-urgent" style="display:none"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,9 +169,11 @@
|
|||||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||||
<section class="page" id="page-action">
|
<section class="page" id="page-action">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<button class="back-btn" onclick="showPage('scan')">← Indietro</button>
|
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')">← Indietro</button>
|
||||||
<h2>Cosa vuoi fare?</h2>
|
<h2>Cosa vuoi fare?</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Banner: shopping list scan context -->
|
||||||
|
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
|
||||||
<div class="product-preview product-preview-large" id="action-product-preview"></div>
|
<div class="product-preview product-preview-large" id="action-product-preview"></div>
|
||||||
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
||||||
<div class="action-buttons" id="action-buttons-container">
|
<div class="action-buttons" id="action-buttons-container">
|
||||||
@@ -513,6 +516,19 @@
|
|||||||
<div class="bring-status" id="bring-status">
|
<div class="bring-status" id="bring-status">
|
||||||
<div class="bring-loading">Connessione a Bring!...</div>
|
<div class="bring-loading">Connessione a Bring!...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab navigation -->
|
||||||
|
<div class="shopping-tabs" id="shopping-tabs" style="display:none">
|
||||||
|
<button class="shopping-tab active" id="tab-acquisto" onclick="switchShoppingTab('acquisto')">
|
||||||
|
🛍️ Da comprare <span class="shopping-tab-count" id="tab-count-acquisto">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="shopping-tab" id="tab-previsione" onclick="switchShoppingTab('previsione')">
|
||||||
|
🧠 In previsione <span class="shopping-tab-count" id="tab-count-previsione">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab panel: Da comprare -->
|
||||||
|
<div id="tab-panel-acquisto" class="tab-panel-shopping active">
|
||||||
<!-- Price total banner -->
|
<!-- Price total banner -->
|
||||||
<div class="spesa-total-banner" id="spesa-total-banner" style="display:none">
|
<div class="spesa-total-banner" id="spesa-total-banner" style="display:none">
|
||||||
<div class="spesa-total-row">
|
<div class="spesa-total-row">
|
||||||
@@ -549,6 +565,39 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab panel: In previsione -->
|
||||||
|
<div id="tab-panel-previsione" class="tab-panel-shopping">
|
||||||
|
<!-- Smart shopping predictions -->
|
||||||
|
<div class="smart-shopping" id="smart-shopping">
|
||||||
|
<div class="smart-shopping-empty" id="smart-shopping-empty" style="display:none">
|
||||||
|
<div class="empty-state" style="padding:30px">
|
||||||
|
<div class="empty-state-icon">🧠</div>
|
||||||
|
<p>Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="smart-shopping-content">
|
||||||
|
<div class="shopping-section-header" style="margin-bottom:4px">
|
||||||
|
<h3>🧠 Previsioni intelligenti</h3>
|
||||||
|
<span class="shopping-count" id="smart-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="smart-filter-row" id="smart-filter-row">
|
||||||
|
<button class="smart-filter active" data-filter="all" onclick="filterSmart('all')">Tutti</button>
|
||||||
|
<button class="smart-filter" data-filter="critical" onclick="filterSmart('critical')">🔴 Urgenti</button>
|
||||||
|
<button class="smart-filter" data-filter="high" onclick="filterSmart('high')">🟠 Presto</button>
|
||||||
|
<button class="smart-filter" data-filter="medium" onclick="filterSmart('medium')">🟡 Pianifica</button>
|
||||||
|
<button class="smart-filter" data-filter="low" onclick="filterSmart('low')">🟢 Previsione</button>
|
||||||
|
</div>
|
||||||
|
<div class="smart-items" id="smart-items"></div>
|
||||||
|
<div class="smart-actions" id="smart-actions" style="display:none">
|
||||||
|
<button class="btn btn-success full-width" onclick="addSmartToBring()">
|
||||||
|
🛒 Aggiungi selezionati a Bring!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||||
@@ -909,6 +958,14 @@
|
|||||||
<div class="screensaver-clock" id="screensaver-clock"></div>
|
<div class="screensaver-clock" id="screensaver-clock"></div>
|
||||||
<div class="screensaver-fact" id="screensaver-fact"></div>
|
<div class="screensaver-fact" id="screensaver-fact"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screensaver-shortcuts">
|
||||||
|
<button class="screensaver-shortcut-btn" id="screensaver-recipe-btn" title="Ricette">
|
||||||
|
🍳
|
||||||
|
</button>
|
||||||
|
<button class="screensaver-shortcut-btn" id="screensaver-scan-btn" title="Scansiona prodotto (tieni premuto per modalità spesa)">
|
||||||
|
📷
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="assets/js/app.js?v=20260319a"></script>
|
<script src="assets/js/app.js?v=20260319a"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user