Initial commit: Dispensa - home pantry management app
Features: - Barcode scanning (QuaggaJS) + Open Food Facts API lookup - Inventory management with locations (Frigo, Freezer, Dispensa) - Product database with Italian product suggestions - Expiry date estimation by category - AI expiry date reading via Gemini Vision API - Flexible unit of measure (pz, conf, g, kg, ml, L) - Nutriscore/NOVA/Ecoscore/allergens display - Mobile-first PWA with offline support - SQLite backend with PHP REST API
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# Environment variables (secrets)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
data/
|
||||||
|
|
||||||
|
# SSL CA cert (local only)
|
||||||
|
ca.crt
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Force HTTPS
|
||||||
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
# API routing
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^api/(.*)$ api/index.php?action=$1&%{QUERY_STRING} [L,QSA]
|
||||||
|
AddType application/x-x509-ca-cert .crt
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database initialization and connection for Dispensa Manager
|
||||||
|
*/
|
||||||
|
|
||||||
|
define('DB_PATH', __DIR__ . '/../data/dispensa.db');
|
||||||
|
|
||||||
|
function getDB(): PDO {
|
||||||
|
$isNew = !file_exists(DB_PATH);
|
||||||
|
$db = new PDO('sqlite:' . DB_PATH);
|
||||||
|
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
$db->exec("PRAGMA journal_mode=WAL");
|
||||||
|
$db->exec("PRAGMA foreign_keys=ON");
|
||||||
|
|
||||||
|
if ($isNew) {
|
||||||
|
initializeDB($db);
|
||||||
|
}
|
||||||
|
return $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeDB(PDO $db): void {
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
barcode TEXT UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
brand TEXT DEFAULT '',
|
||||||
|
category TEXT DEFAULT '',
|
||||||
|
image_url TEXT DEFAULT '',
|
||||||
|
unit TEXT DEFAULT 'pz',
|
||||||
|
default_quantity REAL DEFAULT 1,
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS inventory (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_id INTEGER NOT NULL,
|
||||||
|
location TEXT NOT NULL DEFAULT 'dispensa',
|
||||||
|
quantity REAL NOT NULL DEFAULT 1,
|
||||||
|
expiry_date DATE,
|
||||||
|
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK(type IN ('in', 'out')),
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
location TEXT NOT NULL DEFAULT 'dispensa',
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_barcode ON products(barcode);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_product ON inventory(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inventory_location ON inventory(location);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_product ON transactions(product_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(created_at);
|
||||||
|
");
|
||||||
|
}
|
||||||
+617
@@ -0,0 +1,617 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Dispensa Manager - Main API Router
|
||||||
|
* Handles all CRUD operations for products and inventory
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/database.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = getDB();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($action) {
|
||||||
|
// ===== PRODUCTS =====
|
||||||
|
case 'search_barcode':
|
||||||
|
searchBarcode($db);
|
||||||
|
break;
|
||||||
|
case 'lookup_barcode':
|
||||||
|
lookupBarcode();
|
||||||
|
break;
|
||||||
|
case 'product_save':
|
||||||
|
saveProduct($db);
|
||||||
|
break;
|
||||||
|
case 'product_get':
|
||||||
|
getProduct($db);
|
||||||
|
break;
|
||||||
|
case 'product_delete':
|
||||||
|
deleteProduct($db);
|
||||||
|
break;
|
||||||
|
case 'products_list':
|
||||||
|
listProducts($db);
|
||||||
|
break;
|
||||||
|
case 'products_search':
|
||||||
|
searchProducts($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== INVENTORY =====
|
||||||
|
case 'inventory_list':
|
||||||
|
listInventory($db);
|
||||||
|
break;
|
||||||
|
case 'inventory_add':
|
||||||
|
addToInventory($db);
|
||||||
|
break;
|
||||||
|
case 'inventory_use':
|
||||||
|
useFromInventory($db);
|
||||||
|
break;
|
||||||
|
case 'inventory_update':
|
||||||
|
updateInventory($db);
|
||||||
|
break;
|
||||||
|
case 'inventory_delete':
|
||||||
|
deleteInventory($db);
|
||||||
|
break;
|
||||||
|
case 'inventory_summary':
|
||||||
|
inventorySummary($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== TRANSACTIONS =====
|
||||||
|
case 'transactions_list':
|
||||||
|
listTransactions($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== STATS =====
|
||||||
|
case 'stats':
|
||||||
|
getStats($db);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// ===== AI =====
|
||||||
|
case 'gemini_expiry':
|
||||||
|
geminiReadExpiry();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PRODUCT FUNCTIONS =====
|
||||||
|
|
||||||
|
function searchBarcode(PDO $db): void {
|
||||||
|
$barcode = $_GET['barcode'] ?? '';
|
||||||
|
if (empty($barcode)) {
|
||||||
|
echo json_encode(['found' => false]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare("SELECT * FROM products WHERE barcode = ?");
|
||||||
|
$stmt->execute([$barcode]);
|
||||||
|
$product = $stmt->fetch();
|
||||||
|
if ($product) {
|
||||||
|
echo json_encode(['found' => true, 'product' => $product]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['found' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupBarcode(): void {
|
||||||
|
$barcode = $_GET['barcode'] ?? '';
|
||||||
|
if (empty($barcode)) {
|
||||||
|
echo json_encode(['found' => false, 'error' => 'No barcode provided']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Open Food Facts API (Italian version first for better localized data)
|
||||||
|
$url = "https://world.openfoodfacts.org/api/v2/product/{$barcode}.json?fields=product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores&lc=it";
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 10,
|
||||||
|
'header' => "User-Agent: DispensaManager/1.0\r\n"
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($response === false) {
|
||||||
|
echo json_encode(['found' => false, 'source' => 'openfoodfacts', 'error' => 'API request failed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
if (isset($data['status']) && $data['status'] === 1 && !empty($data['product'])) {
|
||||||
|
$p = $data['product'];
|
||||||
|
|
||||||
|
// Prefer Italian name, fall back to generic
|
||||||
|
$name = '';
|
||||||
|
if (!empty($p['product_name_it'])) {
|
||||||
|
$name = $p['product_name_it'];
|
||||||
|
} elseif (!empty($p['product_name'])) {
|
||||||
|
$name = $p['product_name'];
|
||||||
|
} elseif (!empty($p['generic_name_it'])) {
|
||||||
|
$name = $p['generic_name_it'];
|
||||||
|
} elseif (!empty($p['generic_name'])) {
|
||||||
|
$name = $p['generic_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Italian ingredients, fall back to generic
|
||||||
|
$ingredients = '';
|
||||||
|
if (!empty($p['ingredients_text_it'])) {
|
||||||
|
$ingredients = $p['ingredients_text_it'];
|
||||||
|
} elseif (!empty($p['ingredients_text'])) {
|
||||||
|
$ingredients = $p['ingredients_text'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category: prefer Italian categories_tags, fallback
|
||||||
|
$category = '';
|
||||||
|
if (!empty($p['categories_tags'])) {
|
||||||
|
// Try to find an Italian-friendly category
|
||||||
|
$category = $p['categories_tags'][0] ?? '';
|
||||||
|
} elseif (!empty($p['categories_hierarchy'])) {
|
||||||
|
$category = end($p['categories_hierarchy']);
|
||||||
|
} elseif (!empty($p['categories'])) {
|
||||||
|
$category = $p['categories'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allergens
|
||||||
|
$allergens = '';
|
||||||
|
if (!empty($p['allergens_tags'])) {
|
||||||
|
$allergens = implode(', ', array_map(function($a) {
|
||||||
|
return str_replace('en:', '', $a);
|
||||||
|
}, $p['allergens_tags']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conservation / storage
|
||||||
|
$conservation = $p['conservation_conditions_it'] ?? $p['conservation_conditions'] ?? '';
|
||||||
|
|
||||||
|
// Origin
|
||||||
|
$origin = $p['origins_it'] ?? $p['origins'] ?? $p['manufacturing_places'] ?? '';
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'found' => true,
|
||||||
|
'source' => 'openfoodfacts',
|
||||||
|
'product' => [
|
||||||
|
'name' => $name,
|
||||||
|
'brand' => $p['brands'] ?? '',
|
||||||
|
'category' => $category,
|
||||||
|
'image_url' => $p['image_front_small_url'] ?? $p['image_url'] ?? '',
|
||||||
|
'quantity_info' => $p['quantity'] ?? '',
|
||||||
|
'nutriscore' => $p['nutriscore_grade'] ?? '',
|
||||||
|
'ingredients' => $ingredients,
|
||||||
|
'allergens' => $allergens,
|
||||||
|
'conservation' => $conservation,
|
||||||
|
'origin' => $origin,
|
||||||
|
'nova_group' => $p['nova_group'] ?? '',
|
||||||
|
'ecoscore' => $p['ecoscore_grade'] ?? '',
|
||||||
|
'labels' => $p['labels'] ?? '',
|
||||||
|
'stores' => $p['stores'] ?? '',
|
||||||
|
]
|
||||||
|
];
|
||||||
|
echo json_encode($result);
|
||||||
|
} else {
|
||||||
|
// Try UPC Item DB as fallback
|
||||||
|
$url2 = "https://api.upcitemdb.com/prod/trial/lookup?upc={$barcode}";
|
||||||
|
$ctx2 = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'timeout' => 10,
|
||||||
|
'header' => "User-Agent: DispensaManager/1.0\r\n"
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
$response2 = @file_get_contents($url2, false, $ctx2);
|
||||||
|
if ($response2 !== false) {
|
||||||
|
$data2 = json_decode($response2, true);
|
||||||
|
if (!empty($data2['items'][0])) {
|
||||||
|
$item = $data2['items'][0];
|
||||||
|
echo json_encode([
|
||||||
|
'found' => true,
|
||||||
|
'source' => 'upcitemdb',
|
||||||
|
'product' => [
|
||||||
|
'name' => $item['title'] ?? '',
|
||||||
|
'brand' => $item['brand'] ?? '',
|
||||||
|
'category' => $item['category'] ?? '',
|
||||||
|
'image_url' => $item['images'][0] ?? '',
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode(['found' => false, 'source' => 'openfoodfacts']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProduct(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!$input || empty($input['name'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Product name is required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($input['id'])) {
|
||||||
|
// Update existing
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?,
|
||||||
|
default_quantity=?, notes=?, barcode=?, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=?
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$input['name'], $input['brand'] ?? '', $input['category'] ?? '',
|
||||||
|
$input['image_url'] ?? '', $input['unit'] ?? 'pz',
|
||||||
|
$input['default_quantity'] ?? 1, $input['notes'] ?? '',
|
||||||
|
$input['barcode'] ?? null, $input['id']
|
||||||
|
]);
|
||||||
|
echo json_encode(['success' => true, 'id' => $input['id']]);
|
||||||
|
} else {
|
||||||
|
// Insert new
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$barcode = !empty($input['barcode']) ? $input['barcode'] : null;
|
||||||
|
$stmt->execute([
|
||||||
|
$barcode, $input['name'], $input['brand'] ?? '',
|
||||||
|
$input['category'] ?? '', $input['image_url'] ?? '',
|
||||||
|
$input['unit'] ?? 'pz', $input['default_quantity'] ?? 1,
|
||||||
|
$input['notes'] ?? ''
|
||||||
|
]);
|
||||||
|
echo json_encode(['success' => true, 'id' => $db->lastInsertId()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProduct(PDO $db): void {
|
||||||
|
$id = $_GET['id'] ?? 0;
|
||||||
|
$stmt = $db->prepare("SELECT * FROM products WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$product = $stmt->fetch();
|
||||||
|
if ($product) {
|
||||||
|
echo json_encode(['success' => true, 'product' => $product]);
|
||||||
|
} else {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Product not found']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteProduct(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $input['id'] ?? 0;
|
||||||
|
$stmt = $db->prepare("DELETE FROM products WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listProducts(PDO $db): void {
|
||||||
|
$stmt = $db->query("SELECT * FROM products ORDER BY name ASC");
|
||||||
|
echo json_encode(['products' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchProducts(PDO $db): void {
|
||||||
|
$q = $_GET['q'] ?? '';
|
||||||
|
$stmt = $db->prepare("SELECT * FROM products WHERE name LIKE ? OR brand LIKE ? OR barcode LIKE ? ORDER BY name ASC LIMIT 20");
|
||||||
|
$like = "%{$q}%";
|
||||||
|
$stmt->execute([$like, $like, $like]);
|
||||||
|
echo json_encode(['products' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== INVENTORY FUNCTIONS =====
|
||||||
|
|
||||||
|
function listInventory(PDO $db): void {
|
||||||
|
$location = $_GET['location'] ?? '';
|
||||||
|
$query = "
|
||||||
|
SELECT i.*, p.name, p.brand, p.category, p.image_url, p.unit, p.barcode
|
||||||
|
FROM inventory i
|
||||||
|
JOIN products p ON i.product_id = p.id
|
||||||
|
";
|
||||||
|
$params = [];
|
||||||
|
if (!empty($location)) {
|
||||||
|
$query .= " WHERE i.location = ?";
|
||||||
|
$params[] = $location;
|
||||||
|
}
|
||||||
|
$query .= " ORDER BY p.name ASC";
|
||||||
|
$stmt = $db->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
echo json_encode(['inventory' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToInventory(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$productId = $input['product_id'] ?? 0;
|
||||||
|
$quantity = $input['quantity'] ?? 1;
|
||||||
|
$location = $input['location'] ?? 'dispensa';
|
||||||
|
$expiry = $input['expiry_date'] ?? null;
|
||||||
|
$unit = $input['unit'] ?? null;
|
||||||
|
|
||||||
|
if (!$productId) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Product ID required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a different unit was specified, update the product's unit
|
||||||
|
if ($unit) {
|
||||||
|
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$unit, $quantity, $productId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if product already exists in this location
|
||||||
|
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?");
|
||||||
|
$stmt->execute([$productId, $location]);
|
||||||
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Update quantity
|
||||||
|
$newQty = $existing['quantity'] + $quantity;
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$newQty, $expiry, $existing['id']]);
|
||||||
|
} else {
|
||||||
|
// Insert new inventory entry
|
||||||
|
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date) VALUES (?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$productId, $location, $quantity, $expiry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log transaction
|
||||||
|
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location) VALUES (?, 'in', ?, ?)");
|
||||||
|
$stmt->execute([$productId, $quantity, $location]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFromInventory(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$productId = $input['product_id'] ?? 0;
|
||||||
|
$quantity = $input['quantity'] ?? 0;
|
||||||
|
$useAll = $input['use_all'] ?? false;
|
||||||
|
$location = $input['location'] ?? 'dispensa';
|
||||||
|
|
||||||
|
if (!$productId) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Product ID required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0");
|
||||||
|
$stmt->execute([$productId, $location]);
|
||||||
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Product not found in inventory at this location']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($useAll) {
|
||||||
|
$quantity = $existing['quantity'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$newQty = max(0, $existing['quantity'] - $quantity);
|
||||||
|
|
||||||
|
if ($newQty <= 0) {
|
||||||
|
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
|
||||||
|
$stmt->execute([$existing['id']]);
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt->execute([$newQty, $existing['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log transaction
|
||||||
|
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location) VALUES (?, 'out', ?, ?)");
|
||||||
|
$stmt->execute([$productId, $quantity, $location]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'remaining' => $newQty]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInventory(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $input['id'] ?? 0;
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$params = [];
|
||||||
|
if (isset($input['quantity'])) { $fields[] = "quantity = ?"; $params[] = $input['quantity']; }
|
||||||
|
if (isset($input['location'])) { $fields[] = "location = ?"; $params[] = $input['location']; }
|
||||||
|
if (isset($input['expiry_date'])) { $fields[] = "expiry_date = ?"; $params[] = $input['expiry_date']; }
|
||||||
|
$fields[] = "updated_at = CURRENT_TIMESTAMP";
|
||||||
|
$params[] = $id;
|
||||||
|
|
||||||
|
$stmt = $db->prepare("UPDATE inventory SET " . implode(', ', $fields) . " WHERE id = ?");
|
||||||
|
$stmt->execute($params);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteInventory(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $input['id'] ?? 0;
|
||||||
|
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventorySummary(PDO $db): void {
|
||||||
|
$stmt = $db->query("
|
||||||
|
SELECT i.location, COUNT(DISTINCT i.product_id) as product_count,
|
||||||
|
SUM(i.quantity) as total_items
|
||||||
|
FROM inventory i
|
||||||
|
GROUP BY i.location
|
||||||
|
");
|
||||||
|
echo json_encode(['summary' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TRANSACTION FUNCTIONS =====
|
||||||
|
|
||||||
|
function listTransactions(PDO $db): void {
|
||||||
|
$limit = $_GET['limit'] ?? 50;
|
||||||
|
$productId = $_GET['product_id'] ?? '';
|
||||||
|
|
||||||
|
$query = "
|
||||||
|
SELECT t.*, p.name, p.brand, p.unit
|
||||||
|
FROM transactions t
|
||||||
|
JOIN products p ON t.product_id = p.id
|
||||||
|
";
|
||||||
|
$params = [];
|
||||||
|
if (!empty($productId)) {
|
||||||
|
$query .= " WHERE t.product_id = ?";
|
||||||
|
$params[] = $productId;
|
||||||
|
}
|
||||||
|
$query .= " ORDER BY t.created_at DESC LIMIT ?";
|
||||||
|
$params[] = (int)$limit;
|
||||||
|
|
||||||
|
$stmt = $db->prepare($query);
|
||||||
|
$stmt->execute($params);
|
||||||
|
echo json_encode(['transactions' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATS =====
|
||||||
|
|
||||||
|
function getStats(PDO $db): void {
|
||||||
|
$totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn();
|
||||||
|
$totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn();
|
||||||
|
$locations = $db->query("SELECT COUNT(DISTINCT location) FROM inventory")->fetchColumn();
|
||||||
|
$recentIn = $db->query("SELECT COUNT(*) FROM transactions WHERE type='in' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
|
||||||
|
$recentOut = $db->query("SELECT COUNT(*) FROM transactions WHERE type='out' AND created_at >= datetime('now', '-7 days')")->fetchColumn();
|
||||||
|
|
||||||
|
// Expiring soon (next 7 days)
|
||||||
|
$expiring = $db->query("
|
||||||
|
SELECT i.*, p.name, p.brand
|
||||||
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
|
WHERE i.expiry_date IS NOT NULL AND i.expiry_date <= date('now', '+7 days') AND i.expiry_date >= date('now')
|
||||||
|
ORDER BY i.expiry_date ASC
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
// Expired
|
||||||
|
$expired = $db->query("
|
||||||
|
SELECT i.*, p.name, p.brand
|
||||||
|
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||||
|
WHERE i.expiry_date IS NOT NULL AND i.expiry_date < date('now')
|
||||||
|
ORDER BY i.expiry_date ASC
|
||||||
|
")->fetchAll();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'total_products' => (int)$totalProducts,
|
||||||
|
'total_items' => (float)$totalItems,
|
||||||
|
'locations' => (int)$locations,
|
||||||
|
'recent_in' => (int)$recentIn,
|
||||||
|
'recent_out' => (int)$recentOut,
|
||||||
|
'expiring_soon' => $expiring,
|
||||||
|
'expired' => $expired,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== GEMINI AI FUNCTIONS =====
|
||||||
|
|
||||||
|
function geminiReadExpiry(): void {
|
||||||
|
// Load API key from .env
|
||||||
|
$envFile = __DIR__ . '/../.env';
|
||||||
|
$apiKey = '';
|
||||||
|
if (file_exists($envFile)) {
|
||||||
|
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (strpos($line, '#') === 0) continue;
|
||||||
|
if (strpos($line, '=') !== false) {
|
||||||
|
list($key, $val) = explode('=', $line, 2);
|
||||||
|
if (trim($key) === 'GEMINI_API_KEY') {
|
||||||
|
$apiKey = trim($val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($apiKey)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'no_api_key']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$imageBase64 = $input['image'] ?? '';
|
||||||
|
|
||||||
|
if (empty($imageBase64)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'No image provided']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Gemini API
|
||||||
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}";
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'contents' => [
|
||||||
|
[
|
||||||
|
'parts' => [
|
||||||
|
[
|
||||||
|
'text' => "Analizza questa immagine di un prodotto alimentare. Cerca la data di scadenza (\"da consumarsi entro\", \"da consumarsi preferibilmente entro\", \"scad.\", \"exp\", \"best before\", \"TMC\", o date stampate).\n\nRispondi SOLO con un JSON nel formato: {\"found\": true, \"date\": \"YYYY-MM-DD\", \"raw_text\": \"testo letto\"}\nSe non trovi una data: {\"found\": false, \"raw_text\": \"testo letto se presente\"}\n\nSe la data ha solo mese e anno (es. 03/2027), usa il primo giorno del mese. Se ha solo giorno e mese (es. 15/04), assumi l'anno corrente o il prossimo se la data è già passata."
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'inline_data' => [
|
||||||
|
'mime_type' => 'image/jpeg',
|
||||||
|
'data' => $imageBase64
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
'generationConfig' => [
|
||||||
|
'temperature' => 0.1,
|
||||||
|
'maxOutputTokens' => 256
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$jsonPayload = json_encode($payload);
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $jsonPayload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($response === false || $httpCode !== 200) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Gemini API error', 'http_code' => $httpCode]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($response, true);
|
||||||
|
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||||
|
|
||||||
|
// Parse the JSON response from Gemini
|
||||||
|
// Remove potential markdown code block wrapping
|
||||||
|
$text = preg_replace('/^```json\\s*/i', '', $text);
|
||||||
|
$text = preg_replace('/\\s*```$/i', '', $text);
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
$parsed = json_decode($text, true);
|
||||||
|
|
||||||
|
if ($parsed && !empty($parsed['found']) && !empty($parsed['date'])) {
|
||||||
|
// Validate date format
|
||||||
|
$date = $parsed['date'];
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
echo json_encode(['success' => true, 'expiry_date' => $date, 'raw_text' => $parsed['raw_text'] ?? '']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Could not parse expiry date',
|
||||||
|
'raw_text' => $parsed['raw_text'] ?? $text
|
||||||
|
]);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+1697
File diff suppressed because it is too large
Load Diff
+480
@@ -0,0 +1,480 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="theme-color" content="#2d5016">
|
||||||
|
<title>🏠 Dispensa Manager</title>
|
||||||
|
<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="stylesheet" href="assets/css/style.css">
|
||||||
|
<!-- QuaggaJS for barcode scanning -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Top Header -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<h1 class="header-title" onclick="showPage('dashboard')">🏠 Dispensa</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="app-content" id="app-content">
|
||||||
|
|
||||||
|
<!-- ===== DASHBOARD ===== -->
|
||||||
|
<section class="page active" id="page-dashboard">
|
||||||
|
<div class="dashboard-stats" id="dashboard-stats">
|
||||||
|
<div class="stat-card" onclick="showPage('inventory', 'dispensa')">
|
||||||
|
<span class="stat-icon">🗄️</span>
|
||||||
|
<span class="stat-value" id="stat-dispensa">0</span>
|
||||||
|
<span class="stat-label">Dispensa</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" onclick="showPage('inventory', 'frigo')">
|
||||||
|
<span class="stat-icon">🧊</span>
|
||||||
|
<span class="stat-value" id="stat-frigo">0</span>
|
||||||
|
<span class="stat-label">Frigo</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" onclick="showPage('inventory', 'freezer')">
|
||||||
|
<span class="stat-icon">❄️</span>
|
||||||
|
<span class="stat-value" id="stat-freezer">0</span>
|
||||||
|
<span class="stat-label">Freezer</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card" onclick="showPage('inventory', '')">
|
||||||
|
<span class="stat-icon">📦</span>
|
||||||
|
<span class="stat-value" id="stat-total">0</span>
|
||||||
|
<span class="stat-label">Totale</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert for expiring items -->
|
||||||
|
<div class="alert-section" id="alert-expiring" style="display:none">
|
||||||
|
<h3>⚠️ In Scadenza</h3>
|
||||||
|
<div id="expiring-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="alert-section alert-danger" id="alert-expired" style="display:none">
|
||||||
|
<h3>🚫 Scaduti</h3>
|
||||||
|
<div id="expired-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full inventory by location -->
|
||||||
|
<div class="section-card" id="dash-section-dispensa">
|
||||||
|
<h3 onclick="showPage('inventory','dispensa')" style="cursor:pointer">🗄️ Dispensa</h3>
|
||||||
|
<div class="inventory-list compact" id="dash-inv-dispensa"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card" id="dash-section-frigo">
|
||||||
|
<h3 onclick="showPage('inventory','frigo')" style="cursor:pointer">🧊 Frigo</h3>
|
||||||
|
<div class="inventory-list compact" id="dash-inv-frigo"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card" id="dash-section-freezer">
|
||||||
|
<h3 onclick="showPage('inventory','freezer')" style="cursor:pointer">❄️ Freezer</h3>
|
||||||
|
<div class="inventory-list compact" id="dash-inv-freezer"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-card" id="dash-section-altro">
|
||||||
|
<h3 onclick="showPage('inventory','altro')" style="cursor:pointer">📦 Altro</h3>
|
||||||
|
<div class="inventory-list compact" id="dash-inv-altro"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== INVENTORY LIST ===== -->
|
||||||
|
<section class="page" id="page-inventory">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
|
||||||
|
<h2 id="inventory-title">Inventario</h2>
|
||||||
|
</div>
|
||||||
|
<div class="location-tabs" id="location-tabs">
|
||||||
|
<button class="tab active" onclick="filterLocation('')" data-loc="">Tutti</button>
|
||||||
|
<button class="tab" onclick="filterLocation('dispensa')" data-loc="dispensa">🗄️ Dispensa</button>
|
||||||
|
<button class="tab" onclick="filterLocation('frigo')" data-loc="frigo">🧊 Frigo</button>
|
||||||
|
<button class="tab" onclick="filterLocation('freezer')" data-loc="freezer">❄️ Freezer</button>
|
||||||
|
<button class="tab" onclick="filterLocation('altro')" data-loc="altro">📦 Altro</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="inventory-search" placeholder="🔍 Cerca prodotto..." oninput="filterInventory()">
|
||||||
|
</div>
|
||||||
|
<div class="inventory-list" id="inventory-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== SCAN PAGE ===== -->
|
||||||
|
<section class="page" id="page-scan">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')">← Indietro</button>
|
||||||
|
<h2>Scansiona Prodotto</h2>
|
||||||
|
</div>
|
||||||
|
<div class="scan-container">
|
||||||
|
<div class="scanner-viewport" id="scanner-viewport">
|
||||||
|
<div class="scanner-overlay">
|
||||||
|
<div class="scanner-line"></div>
|
||||||
|
</div>
|
||||||
|
<video id="scanner-video" autoplay playsinline></video>
|
||||||
|
<canvas id="scanner-canvas" style="display:none"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||||
|
<div class="scan-actions">
|
||||||
|
<button class="btn btn-large btn-secondary" onclick="startManualEntry()">
|
||||||
|
✏️ Inserimento Manuale
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-large btn-accent" onclick="captureForAI()">
|
||||||
|
🤖 Identifica con AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="scan-hint">Inquadra il codice a barre del prodotto oppure usa i pulsanti sotto</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||||
|
<section class="page" id="page-action">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="showPage('scan')">← Indietro</button>
|
||||||
|
<h2>Cosa vuoi fare?</h2>
|
||||||
|
</div>
|
||||||
|
<div class="product-preview" id="action-product-preview"></div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
||||||
|
<span class="btn-icon">📥</span>
|
||||||
|
<span class="btn-text">AGGIUNGI<br><small>in dispensa/frigo</small></span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-huge btn-danger" onclick="showUseForm()">
|
||||||
|
<span class="btn-icon">📤</span>
|
||||||
|
<span class="btn-text">USA / CONSUMA<br><small>dalla dispensa/frigo</small></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||||
|
<section class="page" id="page-add">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
|
||||||
|
<h2>Aggiungi alla Dispensa</h2>
|
||||||
|
</div>
|
||||||
|
<div class="product-preview-small" id="add-product-preview"></div>
|
||||||
|
<form class="form" onsubmit="submitAdd(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>📍 Dove lo metti?</label>
|
||||||
|
<div class="location-selector">
|
||||||
|
<button type="button" class="loc-btn active" onclick="selectLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
||||||
|
<button type="button" class="loc-btn" onclick="selectLocation(this, 'frigo')">🧊 Frigo</button>
|
||||||
|
<button type="button" class="loc-btn" onclick="selectLocation(this, 'freezer')">❄️ Freezer</button>
|
||||||
|
<button type="button" class="loc-btn" onclick="selectLocation(this, 'altro')">📦 Altro</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="add-location" value="dispensa">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>📦 Quantità</label>
|
||||||
|
<div class="qty-unit-row">
|
||||||
|
<div class="qty-control flex-1">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustAddQty(-1)">−</button>
|
||||||
|
<input type="number" id="add-quantity" value="1" min="0.1" step="0.5" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustAddQty(1)">+</button>
|
||||||
|
</div>
|
||||||
|
<select id="add-unit" class="form-input unit-select" onchange="onAddUnitChange()">
|
||||||
|
<option value="pz">pz</option>
|
||||||
|
<option value="conf">conf</option>
|
||||||
|
<option value="g">g</option>
|
||||||
|
<option value="kg">kg</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="l">L</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="add-weight-info" class="form-hint" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="add-expiry-section">
|
||||||
|
<!-- Populated dynamically by showAddForm() -->
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-large btn-success full-width">✅ Aggiungi</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||||
|
<section class="page" id="page-use">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="showPage('action')">← Indietro</button>
|
||||||
|
<h2>Usa / Consuma</h2>
|
||||||
|
</div>
|
||||||
|
<div class="product-preview-small" id="use-product-preview"></div>
|
||||||
|
<div class="use-inventory-info" id="use-inventory-info"></div>
|
||||||
|
<form class="form" onsubmit="submitUse(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>📍 Da dove?</label>
|
||||||
|
<div class="location-selector" id="use-location-selector">
|
||||||
|
<button type="button" class="loc-btn active" onclick="selectUseLocation(this, 'dispensa')">🗄️ Dispensa</button>
|
||||||
|
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'frigo')">🧊 Frigo</button>
|
||||||
|
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'freezer')">❄️ Freezer</button>
|
||||||
|
<button type="button" class="loc-btn" onclick="selectUseLocation(this, 'altro')">📦 Altro</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="use-location" value="dispensa">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Quanto hai usato?</label>
|
||||||
|
<div class="use-options">
|
||||||
|
<button type="button" class="btn btn-large btn-danger full-width use-all-btn" onclick="submitUseAll()">
|
||||||
|
🗑️ Usato TUTTO / Finito
|
||||||
|
</button>
|
||||||
|
<div class="use-partial">
|
||||||
|
<p>Oppure specifica la quantità usata:</p>
|
||||||
|
<div class="qty-control">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('use-quantity', -0.5)">−</button>
|
||||||
|
<input type="number" id="use-quantity" value="1" min="0.1" step="0.5" class="qty-input">
|
||||||
|
<button type="button" class="qty-btn" onclick="adjustQty('use-quantity', 0.5)">+</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-large btn-warning full-width mt-2">📤 Usa questa quantità</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||||
|
<section class="page" id="page-product-form">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="showPage('scan')">← Indietro</button>
|
||||||
|
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||||
|
</div>
|
||||||
|
<form class="form" onsubmit="submitProduct(event)">
|
||||||
|
<input type="hidden" id="pf-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>🏷️ Nome Prodotto *</label>
|
||||||
|
<input type="text" id="pf-name" class="form-input" required placeholder="Es: Latte intero, Pasta penne rigate..."
|
||||||
|
list="common-products" autocomplete="off">
|
||||||
|
<datalist id="common-products">
|
||||||
|
<option value="Latte intero">
|
||||||
|
<option value="Latte parzialmente scremato">
|
||||||
|
<option value="Latte scremato">
|
||||||
|
<option value="Yogurt bianco">
|
||||||
|
<option value="Yogurt greco">
|
||||||
|
<option value="Mozzarella">
|
||||||
|
<option value="Burrata">
|
||||||
|
<option value="Parmigiano Reggiano">
|
||||||
|
<option value="Grana Padano">
|
||||||
|
<option value="Ricotta">
|
||||||
|
<option value="Mascarpone">
|
||||||
|
<option value="Burro">
|
||||||
|
<option value="Panna fresca">
|
||||||
|
<option value="Uova">
|
||||||
|
<option value="Prosciutto cotto">
|
||||||
|
<option value="Prosciutto crudo">
|
||||||
|
<option value="Bresaola">
|
||||||
|
<option value="Salame">
|
||||||
|
<option value="Mortadella">
|
||||||
|
<option value="Petto di pollo">
|
||||||
|
<option value="Macinato di manzo">
|
||||||
|
<option value="Salmone fresco">
|
||||||
|
<option value="Tonno in scatola">
|
||||||
|
<option value="Sgombro in scatola">
|
||||||
|
<option value="Pasta spaghetti">
|
||||||
|
<option value="Pasta penne rigate">
|
||||||
|
<option value="Pasta fusilli">
|
||||||
|
<option value="Riso">
|
||||||
|
<option value="Riso basmati">
|
||||||
|
<option value="Farina 00">
|
||||||
|
<option value="Pane fresco">
|
||||||
|
<option value="Pan carrè">
|
||||||
|
<option value="Fette biscottate">
|
||||||
|
<option value="Passata di pomodoro">
|
||||||
|
<option value="Pomodori pelati">
|
||||||
|
<option value="Olio extravergine d'oliva">
|
||||||
|
<option value="Aceto balsamico">
|
||||||
|
<option value="Sale fino">
|
||||||
|
<option value="Zucchero">
|
||||||
|
<option value="Caffè macinato">
|
||||||
|
<option value="Biscotti">
|
||||||
|
<option value="Nutella">
|
||||||
|
<option value="Marmellata">
|
||||||
|
<option value="Miele">
|
||||||
|
<option value="Cereali">
|
||||||
|
<option value="Lenticchie">
|
||||||
|
<option value="Ceci">
|
||||||
|
<option value="Fagioli">
|
||||||
|
<option value="Insalata mista">
|
||||||
|
<option value="Pomodori">
|
||||||
|
<option value="Zucchine">
|
||||||
|
<option value="Patate">
|
||||||
|
<option value="Cipolle">
|
||||||
|
<option value="Mele">
|
||||||
|
<option value="Banane">
|
||||||
|
<option value="Arance">
|
||||||
|
<option value="Acqua naturale">
|
||||||
|
<option value="Succo d'arancia">
|
||||||
|
<option value="Birra">
|
||||||
|
<option value="Vino rosso">
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>🏢 Marca</label>
|
||||||
|
<input type="text" id="pf-brand" class="form-input" placeholder="Es: Barilla, Granarolo, Mutti..."
|
||||||
|
list="common-brands" autocomplete="off">
|
||||||
|
<datalist id="common-brands">
|
||||||
|
<option value="Barilla">
|
||||||
|
<option value="De Cecco">
|
||||||
|
<option value="Rummo">
|
||||||
|
<option value="Voiello">
|
||||||
|
<option value="Divella">
|
||||||
|
<option value="Granarolo">
|
||||||
|
<option value="Parmalat">
|
||||||
|
<option value="Müller">
|
||||||
|
<option value="Danone">
|
||||||
|
<option value="Galbani">
|
||||||
|
<option value="Ferrero">
|
||||||
|
<option value="Lavazza">
|
||||||
|
<option value="Illy">
|
||||||
|
<option value="Mulino Bianco">
|
||||||
|
<option value="Pan di Stelle">
|
||||||
|
<option value="Mutti">
|
||||||
|
<option value="Cirio">
|
||||||
|
<option value="De Rica">
|
||||||
|
<option value="Star">
|
||||||
|
<option value="Knorr">
|
||||||
|
<option value="Findus">
|
||||||
|
<option value="4 Salti in Padella">
|
||||||
|
<option value="Rio Mare">
|
||||||
|
<option value="Valfrutta">
|
||||||
|
<option value="Auricchio">
|
||||||
|
<option value="Zanetti">
|
||||||
|
<option value="Beretta">
|
||||||
|
<option value="Rovagnati">
|
||||||
|
<option value="Amadori">
|
||||||
|
<option value="AIA">
|
||||||
|
<option value="Esselunga">
|
||||||
|
<option value="Conad">
|
||||||
|
<option value="Coop">
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>📂 Categoria</label>
|
||||||
|
<select id="pf-category" class="form-input" onchange="onCategoryChange()">
|
||||||
|
<option value="">-- Seleziona --</option>
|
||||||
|
<option value="latticini">🥛 Latticini</option>
|
||||||
|
<option value="carne">🥩 Carne</option>
|
||||||
|
<option value="pesce">🐟 Pesce</option>
|
||||||
|
<option value="frutta">🍎 Frutta</option>
|
||||||
|
<option value="verdura">🥬 Verdura</option>
|
||||||
|
<option value="pasta">🍝 Pasta & Riso</option>
|
||||||
|
<option value="pane">🍞 Pane & Forno</option>
|
||||||
|
<option value="surgelati">🧊 Surgelati</option>
|
||||||
|
<option value="bevande">🥤 Bevande</option>
|
||||||
|
<option value="condimenti">🧂 Condimenti</option>
|
||||||
|
<option value="snack">🍪 Snack & Dolci</option>
|
||||||
|
<option value="conserve">🥫 Conserve</option>
|
||||||
|
<option value="cereali">🌾 Cereali & Legumi</option>
|
||||||
|
<option value="igiene">🧴 Igiene</option>
|
||||||
|
<option value="pulizia">🧹 Pulizia Casa</option>
|
||||||
|
<option value="altro">📦 Altro</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label>📏 Unità di misura</label>
|
||||||
|
<select id="pf-unit" class="form-input">
|
||||||
|
<option value="pz">Pezzi</option>
|
||||||
|
<option value="kg">Kg</option>
|
||||||
|
<option value="g">Grammi</option>
|
||||||
|
<option value="l">Litri</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="conf">Confezione</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label>🔢 Quantità default</label>
|
||||||
|
<input type="number" id="pf-defqty" class="form-input" value="1" min="0.1" step="0.5">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>📝 Note</label>
|
||||||
|
<textarea id="pf-notes" class="form-input" rows="2" placeholder="Es: senza lattosio, bio, conservare in frigo dopo apertura..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>🔖 Barcode</label>
|
||||||
|
<input type="text" id="pf-barcode" class="form-input" placeholder="Codice a barre (se disponibile)">
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="pf-image">
|
||||||
|
<div class="product-image-preview" id="pf-image-preview" style="display:none">
|
||||||
|
<img id="pf-image-img" src="" alt="Product">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-large btn-primary full-width">💾 Salva Prodotto</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||||
|
<section class="page" id="page-products">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="showPage('dashboard')">← Indietro</button>
|
||||||
|
<h2>📦 Tutti i Prodotti</h2>
|
||||||
|
</div>
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="products-search" placeholder="🔍 Cerca prodotto..." oninput="searchAllProducts()">
|
||||||
|
</div>
|
||||||
|
<div class="products-list" id="products-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||||
|
<section class="page" id="page-ai">
|
||||||
|
<div class="page-header">
|
||||||
|
<button class="back-btn" onclick="stopScanner(); showPage('scan')">← Indietro</button>
|
||||||
|
<h2>🤖 Identificazione AI</h2>
|
||||||
|
</div>
|
||||||
|
<div class="ai-container">
|
||||||
|
<div class="ai-capture" id="ai-capture">
|
||||||
|
<video id="ai-video" autoplay playsinline></video>
|
||||||
|
<canvas id="ai-canvas" style="display:none"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="ai-preview" id="ai-preview" style="display:none">
|
||||||
|
<img id="ai-image" src="" alt="Captured">
|
||||||
|
</div>
|
||||||
|
<div class="ai-actions">
|
||||||
|
<button class="btn btn-large btn-accent" onclick="takePhotoForAI()" id="ai-capture-btn">
|
||||||
|
📸 Scatta Foto
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-large btn-primary" onclick="analyzeWithAI()" id="ai-analyze-btn" style="display:none">
|
||||||
|
🤖 Analizza con AI
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-large btn-secondary" onclick="retakePhotoAI()" id="ai-retake-btn" style="display:none">
|
||||||
|
🔄 Riscatta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-result" id="ai-result" style="display:none"></div>
|
||||||
|
<p class="scan-hint">Scatta una foto del prodotto e l'AI cercherà di identificarlo</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<button class="nav-btn" onclick="showPage('dashboard')" data-page="dashboard">
|
||||||
|
<span class="nav-icon">🏠</span>
|
||||||
|
<span class="nav-label">Home</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" onclick="showPage('inventory', '')" data-page="inventory">
|
||||||
|
<span class="nav-icon">📋</span>
|
||||||
|
<span class="nav-label">Inventario</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn scan-btn" onclick="showPage('scan')" data-page="scan">
|
||||||
|
<span class="nav-icon-large">📷</span>
|
||||||
|
<span class="nav-label">Scansiona</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" onclick="showPage('products')" data-page="products">
|
||||||
|
<span class="nav-icon">📦</span>
|
||||||
|
<span class="nav-label">Prodotti</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Toast notification -->
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div class="loading-overlay" id="loading" style="display:none">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Caricamento...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for product details from inventory -->
|
||||||
|
<div class="modal-overlay" id="modal-overlay" style="display:none" onclick="closeModal()">
|
||||||
|
<div class="modal-content" id="modal-content" onclick="event.stopPropagation()"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="assets/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Dispensa Manager",
|
||||||
|
"short_name": "Dispensa",
|
||||||
|
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||||
|
"start_url": "/dispensa/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f0f4e8",
|
||||||
|
"theme_color": "#2d5016",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%232d5016'/><text y='.9em' font-size='80' x='10'>🏠</text></svg>",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user