Mariem-Daha commited on
Commit
9aaec2c
·
verified ·
1 Parent(s): 1b6b032

Upload 31 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 slim image
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ gcc \
10
+ g++ \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy requirements first for better caching
14
+ COPY Backend/requirements.txt .
15
+
16
+ # Install Python dependencies
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Copy backend code
20
+ COPY Backend/ ./backend/
21
+
22
+ # Copy frontend assets
23
+ COPY frontend/mobile/assets/ ./frontend/
24
+
25
+ # Create a simple HTTP server for frontend
26
+ RUN pip install aiofiles
27
+
28
+ # Create a combined FastAPI app that serves both API and frontend
29
+ COPY app.py .
30
+
31
+ # Expose port (Hugging Face Spaces will set PORT environment variable)
32
+ EXPOSE 7860
33
+
34
+ # Set environment variables
35
+ ENV PYTHONPATH=/app
36
+ ENV DATABASE_URL=sqlite:///./chefcode.db
37
+
38
+ # Run the application
39
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,68 @@
1
- ---
2
- title: Chefcode
3
- emoji:
4
- colorFrom: pink
5
- colorTo: purple
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🍳 ChefCode - AI Restaurant Management
2
+
3
+ An AI-powered restaurant inventory and recipe management system with voice recognition and web recipe search.
4
+
5
+ ## 🚀 Features
6
+
7
+ - **AI Assistant**: Voice and text commands for inventory and recipe management
8
+ - **Web Recipe Search**: Find and import recipes from TheMealDB
9
+ - **Inventory Management**: Track ingredients with quantities and prices
10
+ - **Recipe Management**: Create, edit, and manage recipes
11
+ - **Voice Recognition**: Hands-free operation with continuous conversation
12
+
13
+ ## 🎯 Quick Start
14
+
15
+ 1. **Open the app** - The interface will load automatically
16
+ 2. **Enable microphone** when prompted for voice commands
17
+ 3. **Try these commands**:
18
+ - 🎤 "Add 5 kg of flour at 2 euros per kg"
19
+ - 🎤 "Search for pasta recipes"
20
+ - 🎤 "Add recipe Pizza with flour 500 grams and tomato 200 ml"
21
+
22
+ ## 🤖 AI Assistant Commands
23
+
24
+ ### Inventory Management
25
+ - "Add 5 kg of rice at 2.50 euros per kg"
26
+ - "Update flour to 10 kg"
27
+ - "Remove tomatoes from inventory"
28
+ - "How much rice do we have?"
29
+
30
+ ### Recipe Management
31
+ - "Add recipe Pizza with flour 100 kg and tomato sauce 200 ml"
32
+ - "Search for Italian pasta recipes"
33
+ - "Edit recipe Pizza by adding 2 grams of salt"
34
+ - "Show me all dessert recipes"
35
+
36
+ ## 🌐 Web Recipe Search
37
+
38
+ 1. Go to **Recipes** section
39
+ 2. Click **"Search Recipe from Web"**
40
+ 3. Search for any recipe (e.g., "Italian pasta", "chicken soup")
41
+ 4. View results and import with AI ingredient mapping
42
+
43
+ ## 🎨 Interface
44
+
45
+ - **Modern Design**: Clean, responsive interface
46
+ - **Voice-First**: Optimized for hands-free operation
47
+ - **Color-Coded Results**: Visual feedback for ingredient matching
48
+ - **Real-Time Updates**: Live data synchronization
49
+
50
+ ## 🔧 Technical Details
51
+
52
+ - **Backend**: FastAPI with SQLite database
53
+ - **AI Models**: OpenAI GPT-4o-mini and GPT-o3
54
+ - **Frontend**: Modern web interface with voice recognition
55
+ - **External APIs**: TheMealDB for recipe discovery
56
+
57
+ ## 📊 Database
58
+
59
+ The app uses SQLite for data storage with the following tables:
60
+ - `inventory_items` - Inventory data
61
+ - `recipes` - Recipe data with JSON ingredients
62
+ - `tasks` - Production tasks
63
+
64
+ ## 🎉 Enjoy ChefCode!
65
+
66
+ Perfect for restaurant managers, chefs, and kitchen staff who want to streamline their operations with AI-powered tools.
67
+
68
+ **Start by clicking the microphone button and saying your first command!** 🎤✨
app.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ChefCode - Combined FastAPI app for Hugging Face Spaces
3
+ Serves both backend API and frontend from a single application
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.staticfiles import StaticFiles
11
+ from fastapi.responses import FileResponse, HTMLResponse
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ import uvicorn
14
+
15
+ # Add backend to Python path
16
+ sys.path.append(str(Path(__file__).parent / "backend"))
17
+
18
+ # Import backend components
19
+ from backend.database import SessionLocal, engine
20
+ import backend.models
21
+ from backend.routes import inventory, recipes, tasks, data, actions, web_recipes, ai_assistant
22
+
23
+ # Create database tables
24
+ backend.models.Base.metadata.create_all(bind=engine)
25
+
26
+ # Create FastAPI app
27
+ app = FastAPI(
28
+ title="ChefCode - AI Restaurant Management",
29
+ description="AI-powered restaurant inventory and recipe management system",
30
+ version="1.0.0"
31
+ )
32
+
33
+ # CORS configuration for Hugging Face Spaces
34
+ app.add_middleware(
35
+ CORSMiddleware,
36
+ allow_origins=["*"],
37
+ allow_credentials=True,
38
+ allow_methods=["*"],
39
+ allow_headers=["*"],
40
+ )
41
+
42
+ # Include backend API routes
43
+ app.include_router(inventory.router, prefix="/api", tags=["inventory"])
44
+ app.include_router(recipes.router, prefix="/api", tags=["recipes"])
45
+ app.include_router(tasks.router, prefix="/api", tags=["tasks"])
46
+ app.include_router(data.router, prefix="/api", tags=["data"])
47
+ app.include_router(actions.router, prefix="/api", tags=["actions"])
48
+ app.include_router(web_recipes.router, prefix="/api/web-recipes", tags=["web-recipes"])
49
+ app.include_router(ai_assistant.router, prefix="/api/ai-assistant", tags=["ai-assistant"])
50
+
51
+ # Mount static files (frontend)
52
+ app.mount("/static", StaticFiles(directory="frontend"), name="static")
53
+
54
+ # Health check endpoint
55
+ @app.get("/health")
56
+ async def health_check():
57
+ return {"status": "healthy", "message": "ChefCode API is running"}
58
+
59
+ # Root endpoint - serve the main HTML file
60
+ @app.get("/", response_class=HTMLResponse)
61
+ async def serve_frontend():
62
+ return FileResponse("frontend/index.html")
63
+
64
+ # Catch-all route for frontend routing (SPA support)
65
+ @app.get("/{full_path:path}")
66
+ async def serve_frontend_routes(full_path: str):
67
+ # If it's an API call, let FastAPI handle it
68
+ if full_path.startswith("api/"):
69
+ return {"error": "API endpoint not found"}
70
+
71
+ # For all other routes, serve the frontend
72
+ return FileResponse("frontend/index.html")
73
+
74
+ # Update frontend config for Hugging Face Spaces
75
+ def update_frontend_config():
76
+ """Update frontend configuration for Hugging Face Spaces deployment"""
77
+ config_file = Path("frontend/config.js")
78
+ if config_file.exists():
79
+ config_content = config_file.read_text()
80
+
81
+ # Update API URL to use the current domain
82
+ updated_content = config_content.replace(
83
+ "API_URL: 'http://localhost:8000'",
84
+ "API_URL: window.location.origin"
85
+ )
86
+
87
+ config_file.write_text(updated_content)
88
+
89
+ # Update config on startup
90
+ update_frontend_config()
91
+
92
+ if __name__ == "__main__":
93
+ # Get port from environment (Hugging Face Spaces sets this)
94
+ port = int(os.getenv("PORT", 7860))
95
+
96
+ # Run the application
97
+ uvicorn.run(
98
+ "app:app",
99
+ host="0.0.0.0",
100
+ port=port,
101
+ log_level="info"
102
+ )
backend/database.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine, Column, Integer, String, Float, Text, DateTime, Boolean
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+ from datetime import datetime
5
+
6
+ SQLITE_DATABASE_URL = "sqlite:///./chefcode.db"
7
+
8
+ engine = create_engine(
9
+ SQLITE_DATABASE_URL,
10
+ connect_args={"check_same_thread": False}
11
+ )
12
+
13
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
14
+
15
+ Base = declarative_base()
backend/main.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Depends
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from sqlalchemy.orm import Session
4
+ import uvicorn
5
+ from dotenv import load_dotenv
6
+ import os
7
+
8
+ # Load environment variables from .env file
9
+ load_dotenv()
10
+
11
+ from database import SessionLocal, engine
12
+ import models
13
+ from routes import inventory, recipes, tasks, chat, data, actions, ocr, web_recipes, ai_assistant
14
+
15
+ # Create database tables
16
+ models.Base.metadata.create_all(bind=engine)
17
+
18
+ app = FastAPI(
19
+ title="ChefCode Backend",
20
+ description="FastAPI backend for ChefCode inventory management system",
21
+ version="1.0.0"
22
+ )
23
+
24
+ # CORS configuration to allow frontend connections
25
+ # In production, replace with specific frontend URLs
26
+ # Allow all origins for development (includes file:// protocol and localhost)
27
+ ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "*").split(",") if os.getenv("ALLOWED_ORIGINS") != "*" else ["*"]
28
+
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=ALLOWED_ORIGINS,
32
+ allow_credentials=True,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
+ )
36
+
37
+ # Dependency to get database session
38
+ def get_db():
39
+ db = SessionLocal()
40
+ try:
41
+ yield db
42
+ finally:
43
+ db.close()
44
+
45
+ # Include routers
46
+ app.include_router(inventory.router, prefix="/api", tags=["inventory"])
47
+ app.include_router(recipes.router, prefix="/api", tags=["recipes"])
48
+ app.include_router(tasks.router, prefix="/api", tags=["tasks"])
49
+ app.include_router(chat.router, prefix="/api", tags=["chat"])
50
+ app.include_router(data.router, prefix="/api", tags=["data"])
51
+ app.include_router(actions.router, prefix="/api", tags=["actions"])
52
+ app.include_router(ocr.router, prefix="/api", tags=["ocr"])
53
+ app.include_router(web_recipes.router, prefix="/api/web-recipes", tags=["web-recipes"])
54
+ app.include_router(ai_assistant.router, prefix="/api/ai-assistant", tags=["ai-assistant"])
55
+
56
+ @app.get("/")
57
+ async def root():
58
+ return {"message": "ChefCode FastAPI Backend", "version": "1.0.0"}
59
+
60
+ @app.get("/health")
61
+ async def health_check():
62
+ return {"status": "healthy", "service": "ChefCode Backend"}
63
+
64
+ if __name__ == "__main__":
65
+ # Use reload=True only in development
66
+ # For production, use: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
67
+ is_dev = os.getenv("ENVIRONMENT", "development") == "development"
68
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=is_dev)
backend/models.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, Float, Text, DateTime, Boolean, Date
2
+ from sqlalchemy.sql import func
3
+ from database import Base
4
+ from datetime import datetime, date
5
+
6
+ class InventoryItem(Base):
7
+ __tablename__ = "inventory_items"
8
+
9
+ id = Column(Integer, primary_key=True, index=True)
10
+ name = Column(String, index=True, nullable=False)
11
+ unit = Column(String, default="pz")
12
+ quantity = Column(Float, default=0.0)
13
+ category = Column(String, default="Other")
14
+ price = Column(Float, default=0.0)
15
+ # HACCP Traceability fields
16
+ lot_number = Column(String, nullable=True) # Batch/Lot number for traceability
17
+ expiry_date = Column(Date, nullable=True) # Expiry date for HACCP compliance
18
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
19
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
20
+
21
+ class Recipe(Base):
22
+ __tablename__ = "recipes"
23
+
24
+ id = Column(Integer, primary_key=True, index=True)
25
+ name = Column(String, index=True, nullable=False, unique=True)
26
+ items = Column(Text) # JSON string of recipe items
27
+ instructions = Column(Text, default="")
28
+ yield_data = Column(Text, nullable=True) # JSON string of yield info: {"qty": 10, "unit": "pz"}
29
+ # Web recipe metadata
30
+ source_url = Column(String, nullable=True) # Original recipe URL (e.g., TheMealDB)
31
+ image_url = Column(String, nullable=True) # Recipe thumbnail/image URL
32
+ cuisine = Column(String, nullable=True) # Cuisine type (Italian, Chinese, etc.)
33
+ ingredients_raw = Column(Text, nullable=True) # JSON: Original ingredients from web
34
+ ingredients_mapped = Column(Text, nullable=True) # JSON: AI-mapped ingredients to inventory
35
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
36
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
37
+
38
+ class Task(Base):
39
+ __tablename__ = "tasks"
40
+
41
+ id = Column(Integer, primary_key=True, index=True)
42
+ recipe = Column(String, nullable=False)
43
+ quantity = Column(Integer, default=1)
44
+ assigned_to = Column(String, default="")
45
+ status = Column(String, default="todo") # todo, inprogress, completed
46
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
47
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
48
+
49
+ class SyncData(Base):
50
+ __tablename__ = "sync_data"
51
+
52
+ id = Column(Integer, primary_key=True, index=True)
53
+ data_type = Column(String, nullable=False) # 'full_sync', 'inventory', 'recipes', 'tasks'
54
+ data_content = Column(Text) # JSON string of synced data
55
+ synced_at = Column(DateTime(timezone=True), server_default=func.now())
backend/requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ sqlalchemy==2.0.23
4
+ python-multipart==0.0.6
5
+ python-dotenv==1.0.0
6
+ openai>=1.0.0
7
+ pydantic==2.5.0
8
+ databases[sqlite]==0.9.0
9
+ aiosqlite==0.19.0
10
+ google-cloud-documentai>=2.20.0
11
+ google-api-core>=2.11.0
12
+ google-generativeai>=0.8.0
13
+ pillow>=10.0.0
14
+ httpx>=0.25.0
15
+
backend/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Routes package
backend/routes/actions.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from database import SessionLocal
4
+ from models import InventoryItem, Recipe, Task, SyncData
5
+ from pydantic import BaseModel
6
+ from typing import Dict, Any, List
7
+ import json
8
+ from datetime import datetime, date
9
+ from auth import verify_api_key
10
+
11
+ router = APIRouter()
12
+
13
+ def get_db():
14
+ db = SessionLocal()
15
+ try:
16
+ yield db
17
+ finally:
18
+ db.close()
19
+
20
+ def parse_date_string(date_str):
21
+ """Convert date string to date object"""
22
+ if not date_str:
23
+ return None
24
+ if isinstance(date_str, date):
25
+ return date_str
26
+ try:
27
+ return datetime.strptime(date_str, '%Y-%m-%d').date()
28
+ except (ValueError, TypeError):
29
+ return None
30
+
31
+ class ActionRequest(BaseModel):
32
+ action: str
33
+ data: Dict[Any, Any]
34
+
35
+ class SyncDataRequest(BaseModel):
36
+ inventory: List[Dict[str, Any]]
37
+ recipes: Dict[str, Dict[str, Any]]
38
+ tasks: List[Dict[str, Any]]
39
+
40
+ @router.post("/action")
41
+ async def handle_action(
42
+ request: ActionRequest,
43
+ db: Session = Depends(get_db),
44
+ api_key: str = Depends(verify_api_key)
45
+ ):
46
+ """Handle various actions from frontend - matches original backend format"""
47
+
48
+ action = request.action
49
+ data = request.data
50
+
51
+ if action == "add-inventory":
52
+ # Add inventory item - validate required fields
53
+ item_data = data
54
+
55
+ if "name" not in item_data:
56
+ raise HTTPException(status_code=400, detail="Missing required field: name")
57
+
58
+ # Check if item exists and merge if same price
59
+ existing_item = db.query(InventoryItem).filter(InventoryItem.name == item_data["name"]).first()
60
+
61
+ if existing_item and abs(existing_item.price - item_data.get("price", 0)) < 0.01:
62
+ # Merge quantities for same item at same price (only if HACCP fields match)
63
+ existing_lot = existing_item.lot_number or ""
64
+ existing_expiry = existing_item.expiry_date
65
+ new_lot = item_data.get("lot_number") or ""
66
+ new_expiry = parse_date_string(item_data.get("expiry_date"))
67
+
68
+ if existing_lot == new_lot and existing_expiry == new_expiry:
69
+ existing_item.quantity += item_data.get("quantity", 0)
70
+ db.commit()
71
+ return {"success": True, "message": "Item quantity updated"}
72
+ else:
73
+ # HACCP fields differ - create separate item for traceability
74
+ new_item = InventoryItem(
75
+ name=item_data["name"],
76
+ unit=item_data.get("unit", "pz"),
77
+ quantity=item_data.get("quantity", 0),
78
+ category=item_data.get("category", "Other"),
79
+ price=item_data.get("price", 0),
80
+ lot_number=item_data.get("lot_number"),
81
+ expiry_date=parse_date_string(item_data.get("expiry_date"))
82
+ )
83
+ db.add(new_item)
84
+ db.commit()
85
+ return {"success": True, "message": "Item added (separate entry for HACCP traceability)"}
86
+ else:
87
+ # Create new item with HACCP fields
88
+ new_item = InventoryItem(
89
+ name=item_data["name"],
90
+ unit=item_data.get("unit", "pz"),
91
+ quantity=item_data.get("quantity", 0),
92
+ category=item_data.get("category", "Other"),
93
+ price=item_data.get("price", 0),
94
+ lot_number=item_data.get("lot_number"),
95
+ expiry_date=parse_date_string(item_data.get("expiry_date"))
96
+ )
97
+ db.add(new_item)
98
+ db.commit()
99
+ return {"success": True, "message": "Item added successfully"}
100
+
101
+ elif action == "save-recipe":
102
+ # Save recipe - validate required fields
103
+ recipe_data = data
104
+
105
+ if "name" not in recipe_data:
106
+ raise HTTPException(status_code=400, detail="Missing required field: name")
107
+ if "recipe" not in recipe_data:
108
+ raise HTTPException(status_code=400, detail="Missing required field: recipe")
109
+
110
+ name = recipe_data["name"]
111
+ recipe_info = recipe_data["recipe"]
112
+
113
+ # Check if recipe exists
114
+ existing_recipe = db.query(Recipe).filter(Recipe.name == name).first()
115
+
116
+ items_json = json.dumps(recipe_info.get("items", []))
117
+
118
+ if existing_recipe:
119
+ # Update existing recipe
120
+ existing_recipe.items = items_json
121
+ existing_recipe.instructions = recipe_info.get("instructions", "")
122
+ db.commit()
123
+ return {"success": True, "message": "Recipe updated successfully"}
124
+ else:
125
+ # Create new recipe
126
+ new_recipe = Recipe(
127
+ name=name,
128
+ items=items_json,
129
+ instructions=recipe_info.get("instructions", "")
130
+ )
131
+ db.add(new_recipe)
132
+ db.commit()
133
+ return {"success": True, "message": "Recipe saved successfully"}
134
+
135
+ elif action == "add-task":
136
+ # Add task - validate required fields
137
+ task_data = data
138
+
139
+ if "recipe" not in task_data:
140
+ raise HTTPException(status_code=400, detail="Missing required field: recipe")
141
+
142
+ new_task = Task(
143
+ recipe=task_data["recipe"],
144
+ quantity=task_data.get("quantity", 1),
145
+ assigned_to=task_data.get("assignedTo", ""),
146
+ status=task_data.get("status", "todo")
147
+ )
148
+ db.add(new_task)
149
+ db.commit()
150
+ return {"success": True, "message": "Task added successfully"}
151
+
152
+ else:
153
+ raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
154
+
155
+ @router.post("/sync-data")
156
+ async def sync_data(
157
+ request: SyncDataRequest,
158
+ db: Session = Depends(get_db),
159
+ api_key: str = Depends(verify_api_key)
160
+ ):
161
+ """Sync all data from frontend - matches original backend format"""
162
+
163
+ try:
164
+ # Store sync data for backup
165
+ sync_record = SyncData(
166
+ data_type="full_sync",
167
+ data_content=json.dumps(request.dict())
168
+ )
169
+ db.add(sync_record)
170
+
171
+ # Sync inventory - Fix N+1 query problem
172
+ if request.inventory:
173
+ # Fetch all existing items in one query
174
+ inventory_names = [item["name"] for item in request.inventory if "name" in item]
175
+ existing_items = db.query(InventoryItem).filter(InventoryItem.name.in_(inventory_names)).all()
176
+ existing_items_dict = {item.name: item for item in existing_items}
177
+
178
+ for item_data in request.inventory:
179
+ if "name" not in item_data:
180
+ continue
181
+
182
+ existing_item = existing_items_dict.get(item_data["name"])
183
+ if existing_item:
184
+ # Update existing
185
+ existing_item.unit = item_data.get("unit", "pz")
186
+ existing_item.quantity = item_data.get("quantity", 0)
187
+ existing_item.category = item_data.get("category", "Other")
188
+ existing_item.price = item_data.get("price", 0)
189
+ existing_item.lot_number = item_data.get("lot_number")
190
+ existing_item.expiry_date = parse_date_string(item_data.get("expiry_date"))
191
+ else:
192
+ # Create new - convert date string to date object
193
+ item_data_copy = item_data.copy()
194
+ item_data_copy['expiry_date'] = parse_date_string(item_data.get("expiry_date"))
195
+ new_item = InventoryItem(**item_data_copy)
196
+ db.add(new_item)
197
+
198
+ # Sync recipes - Fix N+1 query problem and handle deletions
199
+ # Get all existing recipes from database
200
+ all_existing_recipes = db.query(Recipe).all()
201
+ existing_recipes_dict = {recipe.name: recipe for recipe in all_existing_recipes}
202
+
203
+ # Get recipe names from frontend
204
+ frontend_recipe_names = set(request.recipes.keys()) if request.recipes else set()
205
+
206
+ # Delete recipes that exist in DB but not in frontend
207
+ for db_recipe in all_existing_recipes:
208
+ if db_recipe.name not in frontend_recipe_names:
209
+ db.delete(db_recipe)
210
+
211
+ # Add or update recipes from frontend
212
+ if request.recipes:
213
+ for recipe_name, recipe_data in request.recipes.items():
214
+ existing_recipe = existing_recipes_dict.get(recipe_name)
215
+ items_json = json.dumps(recipe_data.get("items", []))
216
+ yield_json = json.dumps(recipe_data.get("yield")) if recipe_data.get("yield") else None
217
+
218
+ if existing_recipe:
219
+ existing_recipe.items = items_json
220
+ existing_recipe.yield_data = yield_json
221
+ else:
222
+ new_recipe = Recipe(
223
+ name=recipe_name,
224
+ items=items_json,
225
+ yield_data=yield_json
226
+ )
227
+ db.add(new_recipe)
228
+
229
+ # Sync tasks - Fix N+1 query problem
230
+ if request.tasks:
231
+ task_ids = [task_data["id"] for task_data in request.tasks if "id" in task_data]
232
+ existing_tasks = db.query(Task).filter(Task.id.in_(task_ids)).all() if task_ids else []
233
+ existing_tasks_dict = {task.id: task for task in existing_tasks}
234
+
235
+ for task_data in request.tasks:
236
+ if "recipe" not in task_data:
237
+ continue
238
+
239
+ if "id" in task_data:
240
+ existing_task = existing_tasks_dict.get(task_data["id"])
241
+ if existing_task:
242
+ existing_task.recipe = task_data["recipe"]
243
+ existing_task.quantity = task_data.get("quantity", 1)
244
+ existing_task.assigned_to = task_data.get("assignedTo", "")
245
+ existing_task.status = task_data.get("status", "todo")
246
+ continue
247
+
248
+ # Create new task
249
+ new_task = Task(
250
+ recipe=task_data["recipe"],
251
+ quantity=task_data.get("quantity", 1),
252
+ assigned_to=task_data.get("assignedTo", ""),
253
+ status=task_data.get("status", "todo")
254
+ )
255
+ db.add(new_task)
256
+
257
+ db.commit()
258
+ return {"success": True, "message": "Data synchronized successfully"}
259
+
260
+ except Exception as e:
261
+ db.rollback()
262
+ raise HTTPException(status_code=500, detail="Sync failed. Please try again.")
backend/routes/ai_assistant.py ADDED
@@ -0,0 +1,830 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Assistant API Routes
3
+ Handles natural language commands for inventory and recipe management
4
+ """
5
+
6
+ from fastapi import APIRouter, HTTPException, Depends
7
+ from pydantic import BaseModel, Field
8
+ from typing import Optional, Dict, Any, List
9
+ from sqlalchemy.orm import Session
10
+ from database import SessionLocal
11
+ from models import Recipe, InventoryItem
12
+ from services.ai_assistant_service import AIAssistantService
13
+ from services.ai_service import AIService
14
+ from services.mealdb_service import MealDBService
15
+ import json
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter()
21
+
22
+ # Database dependency
23
+ def get_db():
24
+ db = SessionLocal()
25
+ try:
26
+ yield db
27
+ finally:
28
+ db.close()
29
+
30
+ # Initialize services
31
+ ai_assistant = AIAssistantService()
32
+ ai_service = AIService()
33
+ mealdb_service = MealDBService()
34
+
35
+
36
+ # ===== REQUEST/RESPONSE MODELS =====
37
+
38
+ class CommandRequest(BaseModel):
39
+ """Request model for AI command"""
40
+ command: str = Field(..., description="Natural language command from user")
41
+ context: Optional[Dict[str, Any]] = Field(default=None, description="Conversation context")
42
+
43
+
44
+ class CommandResponse(BaseModel):
45
+ """Response model for AI command"""
46
+ intent: str
47
+ confidence: float
48
+ message: str
49
+ requires_confirmation: bool = False
50
+ confirmation_data: Optional[Dict[str, Any]] = None
51
+ action_result: Optional[Dict[str, Any]] = None
52
+ search_results: Optional[List[Dict]] = None
53
+
54
+
55
+ class ConfirmationRequest(BaseModel):
56
+ """Request model for confirming an action"""
57
+ confirmation_id: str
58
+ confirmed: bool
59
+ data: Dict[str, Any]
60
+
61
+
62
+ # ===== MAIN COMMAND ENDPOINT =====
63
+
64
+ @router.post("/command", response_model=CommandResponse)
65
+ async def process_command(
66
+ request: CommandRequest,
67
+ db: Session = Depends(get_db)
68
+ ):
69
+ """
70
+ Process a natural language command from the user
71
+ Detects intent and either executes or requests confirmation
72
+ """
73
+ try:
74
+ # Detect intent
75
+ intent_result = await ai_assistant.detect_intent(
76
+ request.command,
77
+ request.context
78
+ )
79
+
80
+ logger.info(f"Detected intent: {intent_result.intent} (confidence: {intent_result.confidence})")
81
+
82
+ # Route to appropriate handler
83
+ if intent_result.intent.startswith("add_inventory"):
84
+ return await handle_add_inventory(intent_result, db)
85
+
86
+ elif intent_result.intent.startswith("update_inventory"):
87
+ return await handle_update_inventory(intent_result, db)
88
+
89
+ elif intent_result.intent.startswith("delete_inventory"):
90
+ return await handle_delete_inventory(intent_result, db)
91
+
92
+ elif intent_result.intent == "query_inventory":
93
+ return await handle_query_inventory(intent_result, db)
94
+
95
+ elif intent_result.intent == "add_recipe":
96
+ return await handle_add_recipe(intent_result, request.command, db)
97
+
98
+ elif intent_result.intent == "edit_recipe":
99
+ return await handle_edit_recipe(intent_result, request.command, db)
100
+
101
+ elif intent_result.intent == "delete_recipe":
102
+ return await handle_delete_recipe(intent_result, db)
103
+
104
+ elif intent_result.intent == "search_recipe_web":
105
+ return await handle_search_recipe_web(intent_result)
106
+
107
+ elif intent_result.intent == "show_recipe":
108
+ return await handle_show_recipe(intent_result, db)
109
+
110
+ elif intent_result.intent == "show_catalogue":
111
+ return handle_show_catalogue(db)
112
+
113
+ elif intent_result.intent == "filter_catalogue":
114
+ return handle_filter_catalogue(intent_result, db)
115
+
116
+ else:
117
+ return CommandResponse(
118
+ intent=intent_result.intent,
119
+ confidence=intent_result.confidence,
120
+ message=intent_result.response_message or "I'm not sure how to help with that. Try rephrasing?",
121
+ requires_confirmation=False
122
+ )
123
+
124
+ except Exception as e:
125
+ logger.error(f"Command processing error: {str(e)}")
126
+ raise HTTPException(status_code=500, detail=str(e))
127
+
128
+
129
+ # ===== CONFIRMATION ENDPOINT =====
130
+
131
+ @router.post("/confirm")
132
+ async def confirm_action(
133
+ request: ConfirmationRequest,
134
+ db: Session = Depends(get_db)
135
+ ):
136
+ """Execute a confirmed action"""
137
+ try:
138
+ if not request.confirmed:
139
+ return {"message": "Action cancelled.", "success": False}
140
+
141
+ # Execute based on intent stored in confirmation_data
142
+ intent = request.data.get("intent")
143
+
144
+ if intent == "add_recipe":
145
+ return await execute_add_recipe(request.data, db)
146
+
147
+ elif intent == "delete_recipe":
148
+ return await execute_delete_recipe(request.data, db)
149
+
150
+ elif intent == "edit_recipe":
151
+ return await execute_edit_recipe(request.data, db)
152
+
153
+ elif intent == "add_inventory":
154
+ return await execute_add_inventory(request.data, db)
155
+
156
+ elif intent == "update_inventory":
157
+ return await execute_update_inventory(request.data, db)
158
+
159
+ elif intent == "delete_inventory":
160
+ return await execute_delete_inventory(request.data, db)
161
+
162
+ else:
163
+ return {"message": "Unknown action", "success": False}
164
+
165
+ except Exception as e:
166
+ logger.error(f"Confirmation execution error: {str(e)}")
167
+ raise HTTPException(status_code=500, detail=str(e))
168
+
169
+
170
+ # ===== INVENTORY HANDLERS =====
171
+
172
+ async def handle_add_inventory(intent_result, db: Session) -> CommandResponse:
173
+ """Handle add inventory intent with validation"""
174
+ entities = intent_result.entities
175
+
176
+ # Validate mandatory fields
177
+ missing_fields = []
178
+
179
+ if not entities.get('item_name'):
180
+ missing_fields.append('item name')
181
+ if not entities.get('quantity'):
182
+ missing_fields.append('quantity')
183
+ if not entities.get('unit'):
184
+ missing_fields.append('unit')
185
+ if not entities.get('price'):
186
+ missing_fields.append('unit price')
187
+
188
+ # If any mandatory fields are missing, ask for them
189
+ if missing_fields:
190
+ fields_text = ', '.join(missing_fields)
191
+ return CommandResponse(
192
+ intent=intent_result.intent,
193
+ confidence=intent_result.confidence,
194
+ message=f"📝 To add inventory, I need the {fields_text}. Please provide the missing information.\n\nExample: 'Add 5 kg of rice at 2.50 euros per kg'",
195
+ requires_confirmation=False,
196
+ action_result={
197
+ "awaiting_fields": missing_fields,
198
+ "partial_data": entities
199
+ }
200
+ )
201
+
202
+ # All fields present, proceed to confirmation
203
+ return CommandResponse(
204
+ intent=intent_result.intent,
205
+ confidence=intent_result.confidence,
206
+ message=f"Ready to add {entities.get('quantity')} {entities.get('unit')} of {entities.get('item_name')} at {entities.get('price')} per {entities.get('unit')}. Confirm?",
207
+ requires_confirmation=True,
208
+ confirmation_data={
209
+ "intent": "add_inventory",
210
+ "item_name": entities.get('item_name'),
211
+ "quantity": entities.get('quantity'),
212
+ "unit": entities.get('unit'),
213
+ "category": entities.get('category', 'Other'),
214
+ "price": entities.get('price')
215
+ }
216
+ )
217
+
218
+
219
+ async def handle_update_inventory(intent_result, db: Session) -> CommandResponse:
220
+ """Handle update inventory intent"""
221
+ entities = intent_result.entities
222
+
223
+ # Check if item exists
224
+ item = db.query(InventoryItem).filter(
225
+ InventoryItem.name.ilike(f"%{entities.get('item_name')}%")
226
+ ).first()
227
+
228
+ if not item:
229
+ return CommandResponse(
230
+ intent=intent_result.intent,
231
+ confidence=intent_result.confidence,
232
+ message=f"❌ Item '{entities.get('item_name')}' not found in inventory.",
233
+ requires_confirmation=False
234
+ )
235
+
236
+ return CommandResponse(
237
+ intent=intent_result.intent,
238
+ confidence=intent_result.confidence,
239
+ message=f"Update {item.name} from {item.quantity} {item.unit} to {entities.get('quantity')} {entities.get('unit')}?",
240
+ requires_confirmation=True,
241
+ confirmation_data={
242
+ "intent": "update_inventory",
243
+ "item_id": item.id,
244
+ "quantity": entities.get('quantity'),
245
+ "unit": entities.get('unit')
246
+ }
247
+ )
248
+
249
+
250
+ async def handle_delete_inventory(intent_result, db: Session) -> CommandResponse:
251
+ """Handle delete inventory intent"""
252
+ entities = intent_result.entities
253
+
254
+ item = db.query(InventoryItem).filter(
255
+ InventoryItem.name.ilike(f"%{entities.get('item_name')}%")
256
+ ).first()
257
+
258
+ if not item:
259
+ return CommandResponse(
260
+ intent=intent_result.intent,
261
+ confidence=intent_result.confidence,
262
+ message=f"❌ Item '{entities.get('item_name')}' not found.",
263
+ requires_confirmation=False
264
+ )
265
+
266
+ return CommandResponse(
267
+ intent=intent_result.intent,
268
+ confidence=intent_result.confidence,
269
+ message=f"⚠️ Delete {item.name} from inventory?",
270
+ requires_confirmation=True,
271
+ confirmation_data={
272
+ "intent": "delete_inventory",
273
+ "item_id": item.id
274
+ }
275
+ )
276
+
277
+
278
+ async def handle_query_inventory(intent_result, db: Session) -> CommandResponse:
279
+ """Handle inventory query intent"""
280
+ entities = intent_result.entities
281
+ item_name = entities.get('item_name')
282
+
283
+ if item_name:
284
+ item = db.query(InventoryItem).filter(
285
+ InventoryItem.name.ilike(f"%{item_name}%")
286
+ ).first()
287
+
288
+ if item:
289
+ message = f"📦 {item.name}: {item.quantity} {item.unit}"
290
+ if item.expiry_date:
291
+ message += f"\nExpires: {item.expiry_date}"
292
+ else:
293
+ message = f"❌ '{item_name}' not found in inventory."
294
+ else:
295
+ # Show all inventory count
296
+ count = db.query(InventoryItem).count()
297
+ message = f"📦 You have {count} items in inventory."
298
+
299
+ return CommandResponse(
300
+ intent=intent_result.intent,
301
+ confidence=intent_result.confidence,
302
+ message=message,
303
+ requires_confirmation=False
304
+ )
305
+
306
+
307
+ # ===== RECIPE HANDLERS =====
308
+
309
+ async def handle_add_recipe(intent_result, full_command: str, db: Session) -> CommandResponse:
310
+ """Handle add recipe intent - parse and confirm with validation"""
311
+ try:
312
+ # Parse recipe from natural language
313
+ recipe_data = await ai_assistant.parse_recipe_from_text(full_command)
314
+
315
+ # Validate mandatory fields
316
+ missing_fields = []
317
+
318
+ if not recipe_data.get('recipe_name'):
319
+ missing_fields.append('recipe name')
320
+
321
+ if not recipe_data.get('ingredients') or len(recipe_data.get('ingredients', [])) == 0:
322
+ missing_fields.append('ingredients')
323
+
324
+ # If mandatory fields are missing, ask for them
325
+ if missing_fields:
326
+ fields_text = ' and '.join(missing_fields)
327
+
328
+ if 'ingredients' in missing_fields:
329
+ return CommandResponse(
330
+ intent=intent_result.intent,
331
+ confidence=intent_result.confidence,
332
+ message=f"📝 To add a recipe, I need the {fields_text}.\n\nPlease tell me the ingredients for this recipe.\n\nExample: 'flour 500 grams, tomato sauce 200 ml, and cheese 50 grams'",
333
+ requires_confirmation=False,
334
+ action_result={
335
+ "awaiting_ingredients": True,
336
+ "recipe_name": recipe_data.get('recipe_name')
337
+ }
338
+ )
339
+ else:
340
+ return CommandResponse(
341
+ intent=intent_result.intent,
342
+ confidence=intent_result.confidence,
343
+ message=f"📝 I need the {fields_text} to add the recipe.\n\nExample: 'Add recipe Pizza with flour 500 grams'",
344
+ requires_confirmation=False
345
+ )
346
+
347
+ # Check if recipe already exists
348
+ existing = db.query(Recipe).filter(Recipe.name == recipe_data['recipe_name']).first()
349
+ if existing:
350
+ return CommandResponse(
351
+ intent=intent_result.intent,
352
+ confidence=intent_result.confidence,
353
+ message=f"❌ Recipe '{recipe_data['recipe_name']}' already exists.",
354
+ requires_confirmation=False
355
+ )
356
+
357
+ # Validate ingredient quantities and units
358
+ missing_quantities = []
359
+ for ing in recipe_data['ingredients']:
360
+ if ing.get('quantity') is None or ing.get('unit') is None:
361
+ missing_quantities.append(ing['name'])
362
+
363
+ if missing_quantities:
364
+ ingredients_text = ', '.join(missing_quantities)
365
+ return CommandResponse(
366
+ intent=intent_result.intent,
367
+ confidence=intent_result.confidence,
368
+ message=f"📝 Please provide quantities and units for: {ingredients_text}\n\nExample: 'flour 500 grams, salt 10 grams'",
369
+ requires_confirmation=False,
370
+ action_result={
371
+ "awaiting_quantities": True,
372
+ "recipe_name": recipe_data['recipe_name'],
373
+ "ingredients": recipe_data['ingredients']
374
+ }
375
+ )
376
+
377
+ # Check for yield
378
+ if recipe_data.get('yield_qty') is None or recipe_data.get('yield_unit') is None:
379
+ # Build ingredients list
380
+ ingredients_list = "\n".join([
381
+ f" • {ing['name']}: {ing['quantity']} {ing['unit']}"
382
+ for ing in recipe_data['ingredients']
383
+ ])
384
+
385
+ return CommandResponse(
386
+ intent=intent_result.intent,
387
+ confidence=intent_result.confidence,
388
+ message=f"📝 Recipe '{recipe_data['recipe_name']}' with:\n\n{ingredients_list}\n\n⚖️ What is the yield?\n\nExample: '10 servings' or '2 pizzas' or '5 liters'",
389
+ requires_confirmation=False,
390
+ action_result={
391
+ "awaiting_yield": True,
392
+ "recipe_data": recipe_data
393
+ }
394
+ )
395
+
396
+ # Build confirmation message
397
+ ingredients_list = "\n".join([
398
+ f" • {ing['name']}: {ing['quantity']} {ing['unit']}"
399
+ for ing in recipe_data['ingredients']
400
+ ])
401
+
402
+ message = f"📝 Add recipe '{recipe_data['recipe_name']}'?\n\nIngredients:\n{ingredients_list}\n\nYield: {recipe_data['yield_qty']} {recipe_data['yield_unit']}"
403
+
404
+ return CommandResponse(
405
+ intent=intent_result.intent,
406
+ confidence=intent_result.confidence,
407
+ message=message,
408
+ requires_confirmation=True,
409
+ confirmation_data={
410
+ "intent": "add_recipe",
411
+ "recipe_data": recipe_data
412
+ }
413
+ )
414
+
415
+ except Exception as e:
416
+ logger.error(f"Recipe parsing error: {str(e)}")
417
+ return CommandResponse(
418
+ intent=intent_result.intent,
419
+ confidence=0.0,
420
+ message=f"❌ Could not parse recipe. Please provide the recipe name and ingredients.\n\nExample: 'Add recipe Pizza with flour 500 grams and tomato sauce 200 ml'",
421
+ requires_confirmation=False
422
+ )
423
+
424
+
425
+ async def handle_edit_recipe(intent_result, full_command: str, db: Session) -> CommandResponse:
426
+ """Handle edit recipe intent - parse and execute the edit"""
427
+ entities = intent_result.entities
428
+ recipe_name = entities.get('recipe_name')
429
+
430
+ recipe = db.query(Recipe).filter(Recipe.name.ilike(f"%{recipe_name}%")).first()
431
+
432
+ if not recipe:
433
+ return CommandResponse(
434
+ intent=intent_result.intent,
435
+ confidence=intent_result.confidence,
436
+ message=f"❌ Recipe '{recipe_name}' not found.",
437
+ requires_confirmation=False
438
+ )
439
+
440
+ # Parse the edit action from the full command
441
+ action = entities.get('action', '') # add, remove, change
442
+ ingredient_name = entities.get('ingredient_name', '')
443
+ quantity = entities.get('quantity', '')
444
+ unit = entities.get('unit', '')
445
+
446
+ # If we have enough info to make the edit
447
+ if action and ingredient_name:
448
+ message = f"✏️ Edit recipe '{recipe.name}':\n"
449
+
450
+ if action.lower() in ['add', 'adding']:
451
+ message += f" • Add {quantity} {unit} of {ingredient_name}"
452
+ elif action.lower() in ['remove', 'removing', 'delete']:
453
+ message += f" • Remove {ingredient_name}"
454
+ elif action.lower() in ['change', 'update', 'modify']:
455
+ message += f" • Change {ingredient_name} to {quantity} {unit}"
456
+ else:
457
+ message += f" • Modify {ingredient_name}"
458
+
459
+ message += "\n\nConfirm?"
460
+
461
+ return CommandResponse(
462
+ intent=intent_result.intent,
463
+ confidence=intent_result.confidence,
464
+ message=message,
465
+ requires_confirmation=True,
466
+ confirmation_data={
467
+ "intent": "edit_recipe",
468
+ "recipe_id": recipe.id,
469
+ "recipe_name": recipe.name,
470
+ "action": action.lower(),
471
+ "ingredient_name": ingredient_name,
472
+ "quantity": quantity,
473
+ "unit": unit
474
+ }
475
+ )
476
+ else:
477
+ # Not enough information
478
+ return CommandResponse(
479
+ intent=intent_result.intent,
480
+ confidence=intent_result.confidence,
481
+ message=f"✏️ To edit '{recipe.name}', please specify:\n\nExamples:\n • 'Add 2 grams of salt'\n • 'Remove flour'\n • 'Change tomatoes to 500 grams'",
482
+ requires_confirmation=False
483
+ )
484
+
485
+
486
+ async def handle_delete_recipe(intent_result, db: Session) -> CommandResponse:
487
+ """Handle delete recipe intent"""
488
+ entities = intent_result.entities
489
+ recipe_name = entities.get('recipe_name')
490
+
491
+ recipe = db.query(Recipe).filter(Recipe.name.ilike(f"%{recipe_name}%")).first()
492
+
493
+ if not recipe:
494
+ return CommandResponse(
495
+ intent=intent_result.intent,
496
+ confidence=intent_result.confidence,
497
+ message=f"❌ Recipe '{recipe_name}' not found.",
498
+ requires_confirmation=False
499
+ )
500
+
501
+ return CommandResponse(
502
+ intent=intent_result.intent,
503
+ confidence=intent_result.confidence,
504
+ message=f"⚠️ Delete recipe '{recipe.name}'? This cannot be undone.",
505
+ requires_confirmation=True,
506
+ confirmation_data={
507
+ "intent": "delete_recipe",
508
+ "recipe_id": recipe.id,
509
+ "recipe_name": recipe.name
510
+ }
511
+ )
512
+
513
+
514
+ async def handle_search_recipe_web(intent_result) -> CommandResponse:
515
+ """Handle search recipe web intent - triggers existing web recipe modal"""
516
+ entities = intent_result.entities
517
+ query = entities.get('query', '')
518
+
519
+ try:
520
+ # Just return a success message with the query
521
+ # The frontend will handle opening the existing web recipe search modal
522
+ return CommandResponse(
523
+ intent=intent_result.intent,
524
+ confidence=intent_result.confidence,
525
+ message=f"🔍 Opening recipe search for '{query}'...",
526
+ requires_confirmation=False,
527
+ search_results=[{"trigger": "web_recipe_search"}], # Signal to frontend
528
+ action_result={"search_query": query}
529
+ )
530
+
531
+ except Exception as e:
532
+ logger.error(f"Recipe search error: {str(e)}")
533
+ return CommandResponse(
534
+ intent=intent_result.intent,
535
+ confidence=intent_result.confidence,
536
+ message=f"❌ Search failed: {str(e)}",
537
+ requires_confirmation=False
538
+ )
539
+
540
+
541
+ async def handle_show_recipe(intent_result, db: Session) -> CommandResponse:
542
+ """Handle show recipe intent"""
543
+ entities = intent_result.entities
544
+ recipe_name = entities.get('recipe_name')
545
+
546
+ recipe = db.query(Recipe).filter(Recipe.name.ilike(f"%{recipe_name}%")).first()
547
+
548
+ if not recipe:
549
+ return CommandResponse(
550
+ intent=intent_result.intent,
551
+ confidence=intent_result.confidence,
552
+ message=f"❌ Recipe '{recipe_name}' not found.",
553
+ requires_confirmation=False
554
+ )
555
+
556
+ # Parse items
557
+ items = json.loads(recipe.items) if recipe.items else []
558
+ ingredients_list = "\n".join([
559
+ f" • {item['name']}: {item.get('quantity', '?')} {item.get('unit', '')}"
560
+ for item in items
561
+ ])
562
+
563
+ message = f"📖 **{recipe.name}**\n\nIngredients:\n{ingredients_list}"
564
+ if recipe.instructions:
565
+ message += f"\n\n{recipe.instructions[:200]}..."
566
+
567
+ return CommandResponse(
568
+ intent=intent_result.intent,
569
+ confidence=intent_result.confidence,
570
+ message=message,
571
+ requires_confirmation=False,
572
+ action_result={"recipe_id": recipe.id}
573
+ )
574
+
575
+
576
+ def handle_show_catalogue(db: Session) -> CommandResponse:
577
+ """Handle show catalogue intent"""
578
+ recipes = db.query(Recipe).all()
579
+
580
+ recipe_list = "\n".join([f" • {r.name}" for r in recipes[:10]])
581
+ message = f"📚 Recipe Catalogue ({len(recipes)} total):\n\n{recipe_list}"
582
+
583
+ if len(recipes) > 10:
584
+ message += f"\n ... and {len(recipes) - 10} more"
585
+
586
+ return CommandResponse(
587
+ intent="show_catalogue",
588
+ confidence=1.0,
589
+ message=message,
590
+ requires_confirmation=False,
591
+ action_result={"total_recipes": len(recipes)}
592
+ )
593
+
594
+
595
+ def handle_filter_catalogue(intent_result, db: Session) -> CommandResponse:
596
+ """Handle filter catalogue intent"""
597
+ entities = intent_result.entities
598
+ category = entities.get('category', '').lower()
599
+
600
+ # Filter recipes (basic text search in name/cuisine)
601
+ recipes = db.query(Recipe).filter(
602
+ (Recipe.name.ilike(f"%{category}%")) |
603
+ (Recipe.cuisine.ilike(f"%{category}%"))
604
+ ).all()
605
+
606
+ if not recipes:
607
+ return CommandResponse(
608
+ intent=intent_result.intent,
609
+ confidence=intent_result.confidence,
610
+ message=f"No recipes found in category '{category}'.",
611
+ requires_confirmation=False
612
+ )
613
+
614
+ recipe_list = "\n".join([f" • {r.name}" for r in recipes])
615
+
616
+ return CommandResponse(
617
+ intent=intent_result.intent,
618
+ confidence=intent_result.confidence,
619
+ message=f"📚 {category.title()} Recipes ({len(recipes)}):\n\n{recipe_list}",
620
+ requires_confirmation=False
621
+ )
622
+
623
+
624
+ # ===== EXECUTION FUNCTIONS =====
625
+
626
+ async def execute_add_recipe(data: Dict, db: Session) -> Dict:
627
+ """Execute confirmed recipe addition"""
628
+ try:
629
+ recipe_data = data['recipe_data']
630
+
631
+ # Convert ingredients to items format with defaults (use 'qty' to match frontend)
632
+ items = [
633
+ {
634
+ "name": ing['name'],
635
+ "qty": ing.get('quantity') if ing.get('quantity') is not None else 1,
636
+ "unit": ing.get('unit') if ing.get('unit') is not None else 'piece'
637
+ }
638
+ for ing in recipe_data['ingredients']
639
+ ]
640
+
641
+ # Create recipe with proper defaults
642
+ yield_qty = recipe_data.get('yield_qty')
643
+ yield_unit = recipe_data.get('yield_unit')
644
+
645
+ # Only set yield_data if values are provided
646
+ yield_data_dict = {}
647
+ if yield_qty is not None and yield_unit is not None:
648
+ yield_data_dict = {"qty": yield_qty, "unit": yield_unit}
649
+ else:
650
+ yield_data_dict = {"qty": 1, "unit": "serving"}
651
+
652
+ new_recipe = Recipe(
653
+ name=recipe_data['recipe_name'],
654
+ items=json.dumps(items),
655
+ instructions=recipe_data.get('instructions', ''),
656
+ yield_data=json.dumps(yield_data_dict)
657
+ )
658
+
659
+ db.add(new_recipe)
660
+ db.commit()
661
+
662
+ return {
663
+ "success": True,
664
+ "message": f"✅ Recipe '{recipe_data['recipe_name']}' added successfully!"
665
+ }
666
+
667
+ except Exception as e:
668
+ db.rollback()
669
+ logger.error(f"Recipe add execution error: {str(e)}")
670
+ return {"success": False, "message": f"❌ Failed to add recipe: {str(e)}"}
671
+
672
+
673
+ async def execute_delete_recipe(data: Dict, db: Session) -> Dict:
674
+ """Execute confirmed recipe deletion"""
675
+ try:
676
+ recipe = db.query(Recipe).filter(Recipe.id == data['recipe_id']).first()
677
+ if not recipe:
678
+ return {"success": False, "message": "Recipe not found"}
679
+
680
+ recipe_name = recipe.name
681
+ db.delete(recipe)
682
+ db.commit()
683
+
684
+ return {
685
+ "success": True,
686
+ "message": f"✅ Recipe '{recipe_name}' deleted."
687
+ }
688
+
689
+ except Exception as e:
690
+ db.rollback()
691
+ logger.error(f"Recipe delete execution error: {str(e)}")
692
+ return {"success": False, "message": f"❌ Failed to delete recipe: {str(e)}"}
693
+
694
+
695
+ async def execute_edit_recipe(data: Dict, db: Session) -> Dict:
696
+ """Execute confirmed recipe edit"""
697
+ try:
698
+ recipe = db.query(Recipe).filter(Recipe.id == data['recipe_id']).first()
699
+ if not recipe:
700
+ return {"success": False, "message": "Recipe not found"}
701
+
702
+ # Parse current items
703
+ items = json.loads(recipe.items) if recipe.items else []
704
+ action = data.get('action', '')
705
+ ingredient_name = data.get('ingredient_name', '').lower()
706
+ quantity = data.get('quantity')
707
+ unit = data.get('unit', '')
708
+
709
+ # Find existing ingredient (case-insensitive)
710
+ existing_idx = None
711
+ for idx, item in enumerate(items):
712
+ if item.get('name', '').lower() == ingredient_name:
713
+ existing_idx = idx
714
+ break
715
+
716
+ # Perform action
717
+ if action in ['add', 'adding']:
718
+ if existing_idx is not None:
719
+ # Update existing ingredient
720
+ items[existing_idx]['qty'] = float(quantity) if quantity else items[existing_idx].get('qty', 1)
721
+ items[existing_idx]['unit'] = unit if unit else items[existing_idx].get('unit', '')
722
+ message = f"✅ Updated {ingredient_name} in '{recipe.name}'"
723
+ else:
724
+ # Add new ingredient
725
+ items.append({
726
+ "name": ingredient_name,
727
+ "qty": float(quantity) if quantity else 1,
728
+ "unit": unit
729
+ })
730
+ message = f"✅ Added {quantity} {unit} of {ingredient_name} to '{recipe.name}'"
731
+
732
+ elif action in ['remove', 'removing', 'delete']:
733
+ if existing_idx is not None:
734
+ removed = items.pop(existing_idx)
735
+ message = f"✅ Removed {removed['name']} from '{recipe.name}'"
736
+ else:
737
+ return {"success": False, "message": f"❌ {ingredient_name} not found in recipe"}
738
+
739
+ elif action in ['change', 'update', 'modify']:
740
+ if existing_idx is not None:
741
+ items[existing_idx]['qty'] = float(quantity) if quantity else items[existing_idx].get('qty', 1)
742
+ items[existing_idx]['unit'] = unit if unit else items[existing_idx].get('unit', '')
743
+ message = f"✅ Updated {ingredient_name} to {quantity} {unit} in '{recipe.name}'"
744
+ else:
745
+ return {"success": False, "message": f"❌ {ingredient_name} not found in recipe"}
746
+
747
+ else:
748
+ return {"success": False, "message": "Unknown action"}
749
+
750
+ # Save updated recipe
751
+ recipe.items = json.dumps(items)
752
+ db.commit()
753
+
754
+ return {"success": True, "message": message}
755
+
756
+ except Exception as e:
757
+ db.rollback()
758
+ logger.error(f"Recipe edit execution error: {str(e)}")
759
+ return {"success": False, "message": f"❌ Failed to edit recipe: {str(e)}"}
760
+
761
+
762
+ async def execute_add_inventory(data: Dict, db: Session) -> Dict:
763
+ """Execute confirmed inventory addition"""
764
+ try:
765
+ # Check if item exists, update or create
766
+ existing = db.query(InventoryItem).filter(
767
+ InventoryItem.name.ilike(f"%{data['item_name']}%")
768
+ ).first()
769
+
770
+ if existing:
771
+ existing.quantity += float(data['quantity'])
772
+ db.commit()
773
+ message = f"✅ Updated {existing.name}: {existing.quantity} {existing.unit}"
774
+ else:
775
+ new_item = InventoryItem(
776
+ name=data['item_name'],
777
+ unit=data['unit'],
778
+ quantity=float(data['quantity']),
779
+ category=data.get('category', 'Other')
780
+ )
781
+ db.add(new_item)
782
+ db.commit()
783
+ message = f"✅ Added {data['quantity']} {data['unit']} of {data['item_name']}"
784
+
785
+ return {"success": True, "message": message}
786
+
787
+ except Exception as e:
788
+ db.rollback()
789
+ logger.error(f"Inventory add error: {str(e)}")
790
+ return {"success": False, "message": f"❌ Failed: {str(e)}"}
791
+
792
+
793
+ async def execute_update_inventory(data: Dict, db: Session) -> Dict:
794
+ """Execute confirmed inventory update"""
795
+ try:
796
+ item = db.query(InventoryItem).filter(InventoryItem.id == data['item_id']).first()
797
+ if not item:
798
+ return {"success": False, "message": "Item not found"}
799
+
800
+ item.quantity = float(data['quantity'])
801
+ item.unit = data['unit']
802
+ db.commit()
803
+
804
+ return {
805
+ "success": True,
806
+ "message": f"✅ Updated {item.name} to {item.quantity} {item.unit}"
807
+ }
808
+
809
+ except Exception as e:
810
+ db.rollback()
811
+ return {"success": False, "message": f"❌ Failed: {str(e)}"}
812
+
813
+
814
+ async def execute_delete_inventory(data: Dict, db: Session) -> Dict:
815
+ """Execute confirmed inventory deletion"""
816
+ try:
817
+ item = db.query(InventoryItem).filter(InventoryItem.id == data['item_id']).first()
818
+ if not item:
819
+ return {"success": False, "message": "Item not found"}
820
+
821
+ item_name = item.name
822
+ db.delete(item)
823
+ db.commit()
824
+
825
+ return {"success": True, "message": f"✅ Removed {item_name} from inventory"}
826
+
827
+ except Exception as e:
828
+ db.rollback()
829
+ return {"success": False, "message": f"❌ Failed: {str(e)}"}
830
+
backend/routes/chat.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from fastapi.concurrency import run_in_threadpool
3
+ from pydantic import BaseModel
4
+ from typing import Optional
5
+ import json, os, openai
6
+ import logging
7
+
8
+ router = APIRouter()
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class ChatRequest(BaseModel):
12
+ prompt: str
13
+ language: Optional[str] = "en" # Added: language support (en/it)
14
+
15
+ class ChatResponse(BaseModel):
16
+ status: str
17
+ message: Optional[str] = None
18
+ parsed_data: Optional[dict] = None
19
+
20
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
21
+ client = openai.OpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
22
+
23
+ # Multi-language system prompts
24
+ SYSTEM_PROMPTS = {
25
+ "en": """You are ChefCode's AI Inventory Parser. Parse commands silently and return only JSON.
26
+
27
+ Extract: item_name, unit, quantity, unit_price, type, lot_number (optional), expiry_date (optional)
28
+
29
+ Type detection:
30
+ beef/chicken/pork/meat → meat
31
+ lettuce/tomato/onion/vegetable → vegetable
32
+ apple/banana/orange/fruit → fruit
33
+ milk/cheese/yogurt/dairy → dairy
34
+ water/juice/wine/soda/beverage → beverage
35
+ sugar/flour/pasta/rice/bread → grocery
36
+ soap/detergent/cleaner → cleaning
37
+
38
+ Lot number keywords: "lot", "batch", "lot number", "batch number", "LOT"
39
+ Expiry date keywords: "expires", "expiry", "best before", "use by", "exp date", "expiration"
40
+ Date formats: Parse dates flexibly (e.g., "Dec 25", "12/25/2024", "2024-12-25", "December 25 2024")
41
+
42
+ If price missing: {"status": "ask_price", "message": "Price?"}
43
+ If complete: {"status": "complete", "parsed_data": {"item_name": "...", "unit": "...", "quantity": ..., "unit_price": ..., "type": "...", "lot_number": "..." or null, "expiry_date": "YYYY-MM-DD" or null}}
44
+
45
+ Output ONLY valid JSON. No explanations.""",
46
+
47
+ "it": """Sei l'AI parser di ChefCode. Analizza comandi in silenzio e restituisci solo JSON.
48
+
49
+ Estrai: item_name, unit, quantity, unit_price, type, lot_number (opzionale), expiry_date (opzionale)
50
+
51
+ Rilevamento tipo:
52
+ manzo/pollo/maiale/carne → meat
53
+ lattuga/pomodoro/cipolla/verdura → vegetable
54
+ mela/banana/arancia/frutta → fruit
55
+ latte/formaggio/yogurt/latticini → dairy
56
+ acqua/succo/vino/bevanda → beverage
57
+ zucchero/farina/pasta/riso/pane → grocery
58
+ sapone/detergente → cleaning
59
+
60
+ Parole chiave lotto: "lotto", "batch", "numero lotto", "lotto numero"
61
+ Parole chiave scadenza: "scadenza", "scade", "da consumarsi entro", "exp", "data scadenza"
62
+ Formati data: Analizza date flessibilmente (es. "25 dic", "25/12/2024", "2024-12-25", "25 dicembre 2024")
63
+
64
+ Se manca prezzo: {"status": "ask_price", "message": "Prezzo?"}
65
+ Se completo: {"status": "complete", "parsed_data": {"item_name": "...", "unit": "...", "quantity": ..., "unit_price": ..., "type": "...", "lot_number": "..." o null, "expiry_date": "YYYY-MM-DD" o null}}
66
+
67
+ Output SOLO JSON valido. Niente spiegazioni."""
68
+ }
69
+
70
+ # Added: Health check endpoint
71
+ @router.get("/chat/health")
72
+ async def chat_health():
73
+ """Health check for ChatGPT integration"""
74
+ return {
75
+ "status": "available" if client else "unavailable",
76
+ "message": "ChatGPT integration ready" if client else "OPENAI_API_KEY not set",
77
+ "supported_languages": ["en", "it"],
78
+ "default_language": "en"
79
+ }
80
+
81
+ @router.post("/chatgpt-smart", response_model=ChatResponse)
82
+ async def parse_inventory_command(request: ChatRequest):
83
+ if not client:
84
+ # Mock response when API key is not set
85
+ lang = request.language or "en"
86
+ mock_message = {
87
+ "en": "ChatGPT integration ready. Please set OPENAI_API_KEY environment variable to enable AI functionality.",
88
+ "it": "Integrazione ChatGPT pronta. Imposta la variabile d'ambiente OPENAI_API_KEY per abilitare la funzionalità AI."
89
+ }
90
+ return ChatResponse(
91
+ status="mock",
92
+ message=mock_message.get(lang, mock_message["en"])
93
+ )
94
+
95
+ lang = request.language or "en"
96
+
97
+ try:
98
+ # Get language-specific system prompt
99
+ system_prompt = SYSTEM_PROMPTS.get(lang, SYSTEM_PROMPTS["en"])
100
+
101
+ # Run blocking OpenAI call in thread pool to avoid blocking event loop
102
+ response = await run_in_threadpool(
103
+ lambda: client.chat.completions.create(
104
+ model="gpt-4o-mini",
105
+ messages=[
106
+ {"role": "system", "content": system_prompt},
107
+ {"role": "user", "content": request.prompt}
108
+ ],
109
+ temperature=0
110
+ )
111
+ )
112
+
113
+ ai_response = response.choices[0].message.content.strip()
114
+
115
+ try:
116
+ parsed = json.loads(ai_response)
117
+ except json.JSONDecodeError:
118
+ logger.error(f"Invalid JSON response from OpenAI: {ai_response}")
119
+ error_msg = {
120
+ "en": "Unable to parse AI response. Please try again.",
121
+ "it": "Impossibile analizzare la risposta AI. Riprova."
122
+ }
123
+ raise HTTPException(status_code=500, detail=error_msg.get(lang, error_msg["en"]))
124
+
125
+ # If missing price → ask user
126
+ if parsed.get("status") == "ask_price":
127
+ return ChatResponse(
128
+ status="ask_price",
129
+ message=parsed.get("message")
130
+ )
131
+
132
+ # If complete → save to inventory
133
+ elif parsed.get("status") == "complete":
134
+ data = parsed.get("parsed_data")
135
+ success_msg = {
136
+ "en": f"Item '{data.get('item_name')}' added to inventory.",
137
+ "it": f"Articolo '{data.get('item_name')}' aggiunto all'inventario."
138
+ }
139
+ return ChatResponse(
140
+ status="success",
141
+ message=success_msg.get(lang, success_msg["en"]),
142
+ parsed_data=data
143
+ )
144
+
145
+ else:
146
+ error_msg = {
147
+ "en": "Unexpected AI response format",
148
+ "it": "Formato risposta AI inaspettato"
149
+ }
150
+ raise HTTPException(status_code=400, detail=error_msg.get(lang, error_msg["en"]))
151
+
152
+ except HTTPException:
153
+ # Re-raise HTTP exceptions as-is
154
+ raise
155
+ except Exception as e:
156
+ # Log the detailed error internally but return generic message to user
157
+ logger.error(f"ChatGPT error: {type(e).__name__} - {str(e)}")
158
+ error_msg = {
159
+ "en": "An error occurred while processing your request. Please try again.",
160
+ "it": "Si è verificato un errore durante l'elaborazione della richiesta. Riprova."
161
+ }
162
+ raise HTTPException(status_code=500, detail=error_msg.get(lang, error_msg["en"]))
backend/routes/data.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from database import SessionLocal
4
+ from models import InventoryItem, Recipe, Task
5
+ import json
6
+ from typing import Dict, Any
7
+
8
+ router = APIRouter()
9
+
10
+ def get_db():
11
+ db = SessionLocal()
12
+ try:
13
+ yield db
14
+ finally:
15
+ db.close()
16
+
17
+ @router.get("/data")
18
+ async def get_all_data(db: Session = Depends(get_db)):
19
+ """Get all data for frontend synchronization - matches original backend format"""
20
+
21
+ # Get inventory
22
+ inventory_items = db.query(InventoryItem).all()
23
+ inventory = [
24
+ {
25
+ "id": item.id,
26
+ "name": item.name,
27
+ "unit": item.unit,
28
+ "quantity": item.quantity,
29
+ "category": item.category,
30
+ "price": item.price,
31
+ "lot_number": item.lot_number,
32
+ "expiry_date": item.expiry_date.isoformat() if item.expiry_date else None
33
+ }
34
+ for item in inventory_items
35
+ ]
36
+
37
+ # Get recipes
38
+ recipe_items = db.query(Recipe).all()
39
+ recipes = {}
40
+ for recipe in recipe_items:
41
+ items_data = json.loads(recipe.items) if recipe.items else []
42
+ yield_data = json.loads(recipe.yield_data) if recipe.yield_data else None
43
+ recipes[recipe.name] = {
44
+ "items": items_data,
45
+ "yield": yield_data
46
+ }
47
+
48
+ # Get tasks
49
+ task_items = db.query(Task).all()
50
+ tasks = [
51
+ {
52
+ "id": task.id,
53
+ "recipe": task.recipe,
54
+ "quantity": task.quantity,
55
+ "assignedTo": task.assigned_to,
56
+ "status": task.status
57
+ }
58
+ for task in task_items
59
+ ]
60
+
61
+ return {
62
+ "inventory": inventory,
63
+ "recipes": recipes,
64
+ "tasks": tasks
65
+ }
backend/routes/inventory.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from database import SessionLocal
4
+ from models import InventoryItem
5
+ from schemas import InventoryItemCreate, InventoryItemUpdate, InventoryItemResponse
6
+ from typing import List
7
+ from auth import verify_api_key
8
+
9
+ router = APIRouter()
10
+
11
+ # Dependency to get database session
12
+ def get_db():
13
+ db = SessionLocal()
14
+ try:
15
+ yield db
16
+ finally:
17
+ db.close()
18
+
19
+ @router.get("/inventory", response_model=List[InventoryItemResponse])
20
+ async def get_inventory(db: Session = Depends(get_db)):
21
+ """Get all inventory items"""
22
+ items = db.query(InventoryItem).all()
23
+ return items
24
+
25
+ @router.post("/inventory", response_model=InventoryItemResponse)
26
+ async def add_inventory_item(
27
+ item: InventoryItemCreate,
28
+ db: Session = Depends(get_db),
29
+ api_key: str = Depends(verify_api_key)
30
+ ):
31
+ """Add a new inventory item"""
32
+ # Check if item with same name already exists
33
+ existing_item = db.query(InventoryItem).filter(InventoryItem.name == item.name).first()
34
+
35
+ if existing_item:
36
+ # Update quantity if same price, otherwise create new entry
37
+ if abs(existing_item.price - item.price) < 0.01: # Same price
38
+ existing_item.quantity += item.quantity
39
+ db.commit()
40
+ db.refresh(existing_item)
41
+ return existing_item
42
+
43
+ # Create new item
44
+ db_item = InventoryItem(**item.dict())
45
+ db.add(db_item)
46
+ db.commit()
47
+ db.refresh(db_item)
48
+ return db_item
49
+
50
+ @router.put("/inventory/{item_id}")
51
+ async def update_inventory_item(
52
+ item_id: int,
53
+ item: InventoryItemUpdate,
54
+ db: Session = Depends(get_db),
55
+ api_key: str = Depends(verify_api_key)
56
+ ):
57
+ """Update an inventory item (partial update supported)"""
58
+ db_item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
59
+ if not db_item:
60
+ raise HTTPException(status_code=404, detail="Item not found")
61
+
62
+ # Only update fields that were explicitly set (exclude_unset=True)
63
+ update_data = item.dict(exclude_unset=True)
64
+ for key, value in update_data.items():
65
+ setattr(db_item, key, value)
66
+
67
+ db.commit()
68
+ db.refresh(db_item)
69
+ return db_item
70
+
71
+ @router.delete("/inventory/delete")
72
+ async def delete_inventory_item_by_id(
73
+ request: dict,
74
+ db: Session = Depends(get_db),
75
+ api_key: str = Depends(verify_api_key)
76
+ ):
77
+ """Delete an inventory item by ID from request body"""
78
+ item_id = request.get("id")
79
+ if not item_id:
80
+ raise HTTPException(status_code=400, detail="Item ID is required")
81
+
82
+ db_item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
83
+ if not db_item:
84
+ raise HTTPException(status_code=404, detail="Item not found")
85
+
86
+ db.delete(db_item)
87
+ db.commit()
88
+ return {"message": "Item deleted successfully"}
89
+
90
+ @router.delete("/inventory/{item_id}")
91
+ async def delete_inventory_item(
92
+ item_id: int,
93
+ db: Session = Depends(get_db),
94
+ api_key: str = Depends(verify_api_key)
95
+ ):
96
+ """Delete an inventory item"""
97
+ db_item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
98
+ if not db_item:
99
+ raise HTTPException(status_code=404, detail="Item not found")
100
+
101
+ db.delete(db_item)
102
+ db.commit()
103
+ return {"message": "Item deleted successfully"}
backend/routes/ocr.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, File, UploadFile, HTTPException, Depends
2
+ from typing import Dict, Any
3
+ import os
4
+ import tempfile
5
+ import asyncio
6
+ from pathlib import Path
7
+ from auth import verify_api_key
8
+
9
+ router = APIRouter()
10
+
11
+ # Cached OCR processor instance
12
+ _ocr_processor_cache = {"instance": None, "error": None}
13
+
14
+ # Lazy import of OCR to avoid import errors if dependencies are missing
15
+ def get_ocr_processor():
16
+ """Lazy load OCR processor with caching to avoid repeated initialization"""
17
+ # Return cached instance if available
18
+ if _ocr_processor_cache["instance"] is not None:
19
+ return _ocr_processor_cache["instance"], None
20
+ if _ocr_processor_cache["error"] is not None:
21
+ return None, _ocr_processor_cache["error"]
22
+
23
+ try:
24
+ import sys
25
+ import io
26
+
27
+ # Set UTF-8 encoding for stdout/stderr to handle emojis
28
+ if sys.stdout.encoding != 'utf-8':
29
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
30
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
31
+
32
+ from tools.ocr_invoice import InvoiceOCR
33
+
34
+ # Get configuration from environment
35
+ PROJECT_ID = os.getenv("PROJECT_ID")
36
+ LOCATION = os.getenv("LOCATION")
37
+ PROCESSOR_ID = os.getenv("PROCESSOR_ID")
38
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
39
+
40
+ if not all([PROJECT_ID, LOCATION, PROCESSOR_ID, GEMINI_API_KEY]):
41
+ error = "Missing OCR configuration. Set PROJECT_ID, LOCATION, PROCESSOR_ID, and GEMINI_API_KEY in .env"
42
+ _ocr_processor_cache["error"] = error
43
+ return None, error
44
+
45
+ ocr = InvoiceOCR(
46
+ project_id=PROJECT_ID,
47
+ location=LOCATION,
48
+ processor_id=PROCESSOR_ID,
49
+ gemini_api_key=GEMINI_API_KEY
50
+ )
51
+ _ocr_processor_cache["instance"] = ocr
52
+ return ocr, None
53
+
54
+ except ImportError as e:
55
+ error = f"OCR dependencies not installed: {str(e)}"
56
+ _ocr_processor_cache["error"] = error
57
+ return None, error
58
+ except Exception as e:
59
+ import logging
60
+ logging.error(f"OCR initialization error: {str(e)}", exc_info=True)
61
+ error = f"Failed to initialize OCR: {str(e)}"
62
+ _ocr_processor_cache["error"] = error
63
+ return None, error
64
+
65
+
66
+ @router.post("/ocr-invoice")
67
+ async def process_invoice(
68
+ file: UploadFile = File(...),
69
+ api_key: str = Depends(verify_api_key)
70
+ ) -> Dict[str, Any]:
71
+ """
72
+ Process an invoice image/PDF and extract structured data using OCR
73
+
74
+ Supports: PDF, JPG, JPEG, PNG, TIFF
75
+ """
76
+ # Get OCR processor
77
+ ocr, error = get_ocr_processor()
78
+ if error:
79
+ raise HTTPException(
80
+ status_code=503,
81
+ detail=f"OCR service not available: {error}"
82
+ )
83
+
84
+ # Validate file type
85
+ allowed_types = {'application/pdf', 'image/jpeg', 'image/png', 'image/tiff'}
86
+ if file.content_type not in allowed_types:
87
+ raise HTTPException(
88
+ status_code=400,
89
+ detail=f"Unsupported file type: {file.content_type}. Supported: PDF, JPEG, PNG, TIFF"
90
+ )
91
+
92
+ # Create temporary file to store upload
93
+ temp_file = None
94
+ try:
95
+ # Determine file extension
96
+ extension = Path(file.filename).suffix or '.tmp'
97
+
98
+ # Create temporary file
99
+ with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as temp:
100
+ # Read and write file content
101
+ content = await file.read()
102
+ temp.write(content)
103
+ temp_file = temp.name
104
+
105
+ # Process the invoice asynchronously (run synchronous OCR in thread pool)
106
+ invoice_data = await asyncio.to_thread(ocr.process_invoice, temp_file, save_json=False)
107
+
108
+ # Extract line items and convert to frontend format
109
+ line_items = invoice_data.get("line_items", [])
110
+ items = []
111
+ for item in line_items:
112
+ items.append({
113
+ "name": item.get("description", "Unknown"),
114
+ "quantity": item.get("quantity", 0),
115
+ "unit": item.get("unit", "pz"),
116
+ "price": item.get("unit_price", 0),
117
+ "category": item.get("type", "Food"), # Use OCR extracted type as category
118
+ "lot_number": item.get("item_code", ""), # Use item_code as lot_number
119
+ "expiry_date": item.get("expiry_date", "") # Extract expiry date from OCR
120
+ })
121
+
122
+ # Return in format frontend expects
123
+ return {
124
+ "status": "success",
125
+ "items": items,
126
+ "filename": file.filename,
127
+ "metadata": invoice_data.get("_processing_metadata", {})
128
+ }
129
+
130
+ except Exception as e:
131
+ # Log detailed error internally
132
+ import logging
133
+ logging.error(f"OCR processing error: {str(e)}")
134
+
135
+ raise HTTPException(
136
+ status_code=500,
137
+ detail="Error processing invoice. Please check the file format and try again."
138
+ )
139
+
140
+ finally:
141
+ # Clean up temporary file
142
+ if temp_file and os.path.exists(temp_file):
143
+ try:
144
+ os.unlink(temp_file)
145
+ except:
146
+ pass
147
+
148
+
149
+ @router.get("/ocr-status")
150
+ async def ocr_status():
151
+ """Check if OCR service is available"""
152
+ ocr, error = get_ocr_processor()
153
+
154
+ if error:
155
+ return {
156
+ "available": False,
157
+ "error": error,
158
+ "message": "OCR service is not configured or dependencies are missing"
159
+ }
160
+
161
+ return {
162
+ "available": True,
163
+ "message": "OCR service is ready",
164
+ "supported_formats": ["PDF", "JPEG", "PNG", "TIFF"]
165
+ }
166
+
backend/routes/recipes.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.orm import Session
3
+ from database import SessionLocal
4
+ from models import Recipe
5
+ from pydantic import BaseModel
6
+ from typing import List, Optional, Dict, Any
7
+ import json
8
+ from auth import verify_api_key
9
+
10
+ router = APIRouter()
11
+
12
+ def get_db():
13
+ db = SessionLocal()
14
+ try:
15
+ yield db
16
+ finally:
17
+ db.close()
18
+
19
+ class RecipeItem(BaseModel):
20
+ name: str
21
+ qty: float
22
+ unit: str
23
+
24
+ class RecipeCreate(BaseModel):
25
+ name: str
26
+ items: List[RecipeItem]
27
+ instructions: Optional[str] = ""
28
+
29
+ class RecipeResponse(BaseModel):
30
+ id: int
31
+ name: str
32
+ items: List[RecipeItem]
33
+ instructions: str
34
+
35
+ class Config:
36
+ from_attributes = True
37
+
38
+ @router.get("/recipes", response_model=List[RecipeResponse])
39
+ async def get_recipes(
40
+ skip: int = Query(0, ge=0, description="Number of recipes to skip"),
41
+ limit: int = Query(100, ge=1, le=1000, description="Number of recipes to return"),
42
+ db: Session = Depends(get_db)
43
+ ):
44
+ """Get all recipes with pagination"""
45
+ recipes = db.query(Recipe).offset(skip).limit(limit).all()
46
+ result = []
47
+ for recipe in recipes:
48
+ items_data = json.loads(recipe.items) if recipe.items else []
49
+ result.append({
50
+ "id": recipe.id,
51
+ "name": recipe.name,
52
+ "items": items_data,
53
+ "instructions": recipe.instructions or ""
54
+ })
55
+ return result
56
+
57
+ @router.get("/recipes/{recipe_id}", response_model=RecipeResponse)
58
+ async def get_recipe(recipe_id: int, db: Session = Depends(get_db)):
59
+ """Get a specific recipe"""
60
+ recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first()
61
+ if not recipe:
62
+ raise HTTPException(status_code=404, detail="Recipe not found")
63
+
64
+ items_data = json.loads(recipe.items) if recipe.items else []
65
+ return {
66
+ "id": recipe.id,
67
+ "name": recipe.name,
68
+ "items": items_data,
69
+ "instructions": recipe.instructions or ""
70
+ }
71
+
72
+ @router.post("/recipes", response_model=RecipeResponse)
73
+ async def create_recipe(
74
+ recipe: RecipeCreate,
75
+ db: Session = Depends(get_db),
76
+ api_key: str = Depends(verify_api_key)
77
+ ):
78
+ """Create a new recipe"""
79
+ # Check if recipe name already exists
80
+ existing_recipe = db.query(Recipe).filter(Recipe.name == recipe.name).first()
81
+ if existing_recipe:
82
+ raise HTTPException(status_code=400, detail="Recipe with this name already exists")
83
+
84
+ items_json = json.dumps([item.dict() for item in recipe.items])
85
+ db_recipe = Recipe(
86
+ name=recipe.name,
87
+ items=items_json,
88
+ instructions=recipe.instructions
89
+ )
90
+
91
+ db.add(db_recipe)
92
+ db.commit()
93
+ db.refresh(db_recipe)
94
+
95
+ # Return consistent structure
96
+ return {
97
+ "id": db_recipe.id,
98
+ "name": db_recipe.name,
99
+ "items": [item.dict() for item in recipe.items],
100
+ "instructions": db_recipe.instructions or ""
101
+ }
102
+
103
+ @router.put("/recipes/{recipe_id}")
104
+ async def update_recipe(
105
+ recipe_id: int,
106
+ recipe: RecipeCreate,
107
+ db: Session = Depends(get_db),
108
+ api_key: str = Depends(verify_api_key)
109
+ ):
110
+ """Update a recipe"""
111
+ db_recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first()
112
+ if not db_recipe:
113
+ raise HTTPException(status_code=404, detail="Recipe not found")
114
+
115
+ db_recipe.name = recipe.name
116
+ db_recipe.items = json.dumps([item.dict() for item in recipe.items])
117
+ db_recipe.instructions = recipe.instructions
118
+
119
+ db.commit()
120
+ db.refresh(db_recipe)
121
+
122
+ # Return consistent structure
123
+ return {
124
+ "id": db_recipe.id,
125
+ "name": db_recipe.name,
126
+ "items": [item.dict() for item in recipe.items],
127
+ "instructions": db_recipe.instructions or ""
128
+ }
129
+
130
+ @router.delete("/recipes/{recipe_id}")
131
+ async def delete_recipe(
132
+ recipe_id: int,
133
+ db: Session = Depends(get_db),
134
+ api_key: str = Depends(verify_api_key)
135
+ ):
136
+ """Delete a recipe"""
137
+ db_recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first()
138
+ if not db_recipe:
139
+ raise HTTPException(status_code=404, detail="Recipe not found")
140
+
141
+ db.delete(db_recipe)
142
+ db.commit()
143
+ return {"message": "Recipe deleted successfully"}
backend/routes/tasks.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from database import SessionLocal
4
+ from models import Task
5
+ from schemas import TaskCreate, TaskResponse
6
+ from typing import List
7
+ from auth import verify_api_key
8
+
9
+ router = APIRouter()
10
+
11
+ def get_db():
12
+ db = SessionLocal()
13
+ try:
14
+ yield db
15
+ finally:
16
+ db.close()
17
+
18
+ @router.get("/tasks", response_model=List[TaskResponse])
19
+ async def get_tasks(db: Session = Depends(get_db)):
20
+ """Get all tasks"""
21
+ tasks = db.query(Task).all()
22
+ return tasks
23
+
24
+ @router.get("/tasks/{task_id}", response_model=TaskResponse)
25
+ async def get_task(task_id: int, db: Session = Depends(get_db)):
26
+ """Get a specific task"""
27
+ task = db.query(Task).filter(Task.id == task_id).first()
28
+ if not task:
29
+ raise HTTPException(status_code=404, detail="Task not found")
30
+ return task
31
+
32
+ @router.post("/tasks", response_model=TaskResponse)
33
+ async def create_task(
34
+ task: TaskCreate,
35
+ db: Session = Depends(get_db),
36
+ api_key: str = Depends(verify_api_key)
37
+ ):
38
+ """Create a new task"""
39
+ db_task = Task(**task.dict())
40
+ db.add(db_task)
41
+ db.commit()
42
+ db.refresh(db_task)
43
+ return db_task
44
+
45
+ @router.put("/tasks/{task_id}")
46
+ async def update_task(
47
+ task_id: int,
48
+ task: TaskCreate,
49
+ db: Session = Depends(get_db),
50
+ api_key: str = Depends(verify_api_key)
51
+ ):
52
+ """Update a task"""
53
+ db_task = db.query(Task).filter(Task.id == task_id).first()
54
+ if not db_task:
55
+ raise HTTPException(status_code=404, detail="Task not found")
56
+
57
+ for key, value in task.dict().items():
58
+ setattr(db_task, key, value)
59
+
60
+ db.commit()
61
+ db.refresh(db_task)
62
+ return db_task
63
+
64
+ @router.put("/tasks/{task_id}/status")
65
+ async def update_task_status(
66
+ task_id: int,
67
+ status: str,
68
+ db: Session = Depends(get_db),
69
+ api_key: str = Depends(verify_api_key)
70
+ ):
71
+ """Update task status"""
72
+ if status not in ["todo", "inprogress", "completed"]:
73
+ raise HTTPException(status_code=400, detail="Invalid status")
74
+
75
+ db_task = db.query(Task).filter(Task.id == task_id).first()
76
+ if not db_task:
77
+ raise HTTPException(status_code=404, detail="Task not found")
78
+
79
+ db_task.status = status
80
+ db.commit()
81
+ db.refresh(db_task)
82
+ return db_task
83
+
84
+ @router.delete("/tasks/{task_id}")
85
+ async def delete_task(
86
+ task_id: int,
87
+ db: Session = Depends(get_db),
88
+ api_key: str = Depends(verify_api_key)
89
+ ):
90
+ """Delete a task"""
91
+ db_task = db.query(Task).filter(Task.id == task_id).first()
92
+ if not db_task:
93
+ raise HTTPException(status_code=404, detail="Task not found")
94
+
95
+ db.delete(db_task)
96
+ db.commit()
97
+ return {"message": "Task deleted successfully"}
backend/routes/web_recipes.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Web Recipe Search Routes
3
+ Handles recipe search from web sources (TheMealDB) and AI-powered ingredient mapping
4
+ """
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Query
7
+ from sqlalchemy.orm import Session
8
+ from pydantic import BaseModel, Field
9
+ from typing import List, Optional, Dict, Any
10
+ import json
11
+ import logging
12
+
13
+ from database import SessionLocal
14
+ from models import Recipe, InventoryItem
15
+ from auth import verify_api_key
16
+ from services.ai_service import get_ai_service
17
+ from services.mealdb_service import get_mealdb_service
18
+
19
+ router = APIRouter()
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def get_db():
24
+ """Database session dependency"""
25
+ db = SessionLocal()
26
+ try:
27
+ yield db
28
+ finally:
29
+ db.close()
30
+
31
+
32
+ # ============================================================================
33
+ # REQUEST/RESPONSE MODELS
34
+ # ============================================================================
35
+
36
+ class InterpretQueryRequest(BaseModel):
37
+ """Request model for query interpretation"""
38
+ query: str = Field(..., description="Natural language recipe search query")
39
+
40
+ class Config:
41
+ json_schema_extra = {
42
+ "example": {
43
+ "query": "Find a quick Italian pasta recipe without cheese"
44
+ }
45
+ }
46
+
47
+
48
+ class InterpretQueryResponse(BaseModel):
49
+ """Response model for interpreted query"""
50
+ keywords: List[str] = Field(default_factory=list)
51
+ cuisine: Optional[str] = None
52
+ restrictions: List[str] = Field(default_factory=list)
53
+ max_time: Optional[int] = None
54
+
55
+
56
+ class SearchRecipesRequest(BaseModel):
57
+ """Request model for recipe search"""
58
+ query: str = Field(..., description="Search query or keywords")
59
+ cuisine: Optional[str] = None
60
+ restrictions: List[str] = Field(default_factory=list)
61
+
62
+ class Config:
63
+ json_schema_extra = {
64
+ "example": {
65
+ "query": "pasta",
66
+ "cuisine": "Italian",
67
+ "restrictions": ["no cheese"]
68
+ }
69
+ }
70
+
71
+
72
+ class RecipeIngredient(BaseModel):
73
+ """Recipe ingredient model"""
74
+ name: str
75
+ measure: str
76
+
77
+
78
+ class WebRecipeResponse(BaseModel):
79
+ """Response model for web recipe"""
80
+ id: str
81
+ name: str
82
+ image: Optional[str]
83
+ category: Optional[str]
84
+ area: Optional[str] # Cuisine
85
+ instructions: str
86
+ ingredients: List[RecipeIngredient]
87
+ source_url: str
88
+
89
+
90
+ class MapIngredientsRequest(BaseModel):
91
+ """Request model for ingredient mapping"""
92
+ recipe_id: str = Field(..., description="TheMealDB recipe ID")
93
+ recipe_ingredients: List[Dict[str, str]] = Field(
94
+ ...,
95
+ description="List of recipe ingredients with name, quantity, and unit"
96
+ )
97
+
98
+ class Config:
99
+ json_schema_extra = {
100
+ "example": {
101
+ "recipe_id": "52772",
102
+ "recipe_ingredients": [
103
+ {"name": "Spaghetti", "quantity": "400", "unit": "g"},
104
+ {"name": "Tomatoes", "quantity": "500", "unit": "g"}
105
+ ]
106
+ }
107
+ }
108
+
109
+
110
+ class IngredientMapping(BaseModel):
111
+ """Single ingredient mapping result"""
112
+ recipe_ingredient: str
113
+ recipe_quantity: str
114
+ recipe_unit: str
115
+ mapped_to: Optional[str]
116
+ match_confidence: float = Field(ge=0.0, le=1.0)
117
+ match_type: str = Field(..., description="exact, substitute, or missing")
118
+ note: str
119
+
120
+ # Allow numbers to be automatically converted to strings
121
+ @classmethod
122
+ def model_validate(cls, obj):
123
+ if isinstance(obj, dict):
124
+ # Convert recipe_quantity to string if it's a number
125
+ if 'recipe_quantity' in obj and not isinstance(obj['recipe_quantity'], str):
126
+ obj['recipe_quantity'] = str(obj['recipe_quantity'])
127
+ return super().model_validate(obj)
128
+
129
+
130
+ class MapIngredientsResponse(BaseModel):
131
+ """Response model for ingredient mapping"""
132
+ recipe_id: str
133
+ mappings: List[IngredientMapping]
134
+
135
+
136
+ class SaveWebRecipeRequest(BaseModel):
137
+ """Request model for saving web recipe"""
138
+ recipe_id: str
139
+ name: str
140
+ instructions: str
141
+ cuisine: Optional[str]
142
+ image_url: Optional[str]
143
+ source_url: str
144
+ ingredients_raw: List[Dict[str, str]] # Original web ingredients
145
+ ingredients_mapped: List[Dict[str, Any]] # AI-mapped ingredients
146
+
147
+ class Config:
148
+ json_schema_extra = {
149
+ "example": {
150
+ "recipe_id": "52772",
151
+ "name": "Teriyaki Chicken Casserole",
152
+ "instructions": "Preheat oven to 350°...",
153
+ "cuisine": "Japanese",
154
+ "image_url": "https://example.com/image.jpg",
155
+ "source_url": "https://www.themealdb.com/meal/52772",
156
+ "ingredients_raw": [
157
+ {"name": "soy sauce", "measure": "3/4 cup"}
158
+ ],
159
+ "ingredients_mapped": [
160
+ {
161
+ "recipe_ingredient": "soy sauce",
162
+ "mapped_to": "Soy Sauce",
163
+ "match_confidence": 1.0,
164
+ "match_type": "exact"
165
+ }
166
+ ]
167
+ }
168
+ }
169
+
170
+
171
+ # ============================================================================
172
+ # ENDPOINTS
173
+ # ============================================================================
174
+
175
+ @router.post("/interpret_query", response_model=InterpretQueryResponse)
176
+ async def interpret_query(request: InterpretQueryRequest):
177
+ """
178
+ Endpoint 1: Interpret natural language query using GPT-4o-mini
179
+
180
+ Converts user's natural text into structured search filters.
181
+ Example: "quick Italian pasta without cheese" →
182
+ {"keywords": ["pasta"], "cuisine": "Italian", "restrictions": ["no cheese"]}
183
+ """
184
+ try:
185
+ ai_service = get_ai_service()
186
+ result = await ai_service.interpret_query(request.query)
187
+ return InterpretQueryResponse(**result)
188
+
189
+ except Exception as e:
190
+ logger.error(f"Error interpreting query: {str(e)}")
191
+ raise HTTPException(
192
+ status_code=500,
193
+ detail=f"Failed to interpret query: {str(e)}"
194
+ )
195
+
196
+
197
+ @router.post("/search_recipes", response_model=List[WebRecipeResponse])
198
+ async def search_recipes(request: SearchRecipesRequest):
199
+ """
200
+ Endpoint 2: Search recipes using TheMealDB API
201
+
202
+ Takes structured filters and searches TheMealDB for matching recipes.
203
+ Returns array of recipes with full details including ingredients.
204
+ """
205
+ try:
206
+ mealdb_service = get_mealdb_service()
207
+
208
+ # Primary search by query/keywords
209
+ recipes = await mealdb_service.search_by_name(request.query)
210
+
211
+ # Apply filters
212
+ if request.cuisine:
213
+ # Filter by cuisine (case-insensitive)
214
+ cuisine_lower = request.cuisine.lower()
215
+ recipes = [
216
+ r for r in recipes
217
+ if r.get("area") and cuisine_lower in r["area"].lower()
218
+ ]
219
+
220
+ # Note: TheMealDB doesn't have cooking time or dietary restrictions
221
+ # in the free API, so we can't filter by those directly
222
+ # Could add AI-based filtering here if needed
223
+
224
+ if not recipes:
225
+ return []
226
+
227
+ # Convert to response model
228
+ response_recipes = []
229
+ for recipe in recipes:
230
+ response_recipes.append(WebRecipeResponse(
231
+ id=recipe["id"],
232
+ name=recipe["name"],
233
+ image=recipe.get("image"),
234
+ category=recipe.get("category"),
235
+ area=recipe.get("area"),
236
+ instructions=recipe["instructions"],
237
+ ingredients=[
238
+ RecipeIngredient(name=ing["name"], measure=ing["measure"])
239
+ for ing in recipe["ingredients"]
240
+ ],
241
+ source_url=recipe["source_url"]
242
+ ))
243
+
244
+ return response_recipes
245
+
246
+ except Exception as e:
247
+ logger.error(f"Error searching recipes: {str(e)}")
248
+ raise HTTPException(
249
+ status_code=500,
250
+ detail=f"Failed to search recipes: {str(e)}"
251
+ )
252
+
253
+
254
+ @router.post("/map_ingredients", response_model=MapIngredientsResponse)
255
+ async def map_ingredients(
256
+ request: MapIngredientsRequest,
257
+ db: Session = Depends(get_db)
258
+ ):
259
+ """
260
+ Endpoint 3: Map recipe ingredients to inventory using GPT-o3 reasoning
261
+
262
+ Uses AI to semantically match recipe ingredients with restaurant's inventory.
263
+ Returns confidence scores and suggests substitutes for missing items.
264
+ """
265
+ try:
266
+ # Get all inventory items
267
+ inventory_items = db.query(InventoryItem).all()
268
+ inventory_list = [
269
+ {"name": item.name, "unit": item.unit, "quantity": item.quantity}
270
+ for item in inventory_items
271
+ ]
272
+
273
+ if not inventory_list:
274
+ # No inventory to map against
275
+ logger.warning("No inventory items found for mapping")
276
+ return MapIngredientsResponse(
277
+ recipe_id=request.recipe_id,
278
+ mappings=[
279
+ IngredientMapping(
280
+ recipe_ingredient=ing["name"],
281
+ recipe_quantity=ing.get("quantity", ""),
282
+ recipe_unit=ing.get("unit", ""),
283
+ mapped_to=None,
284
+ match_confidence=0.0,
285
+ match_type="missing",
286
+ note="No inventory items available"
287
+ )
288
+ for ing in request.recipe_ingredients
289
+ ]
290
+ )
291
+
292
+ # Use AI service to perform semantic matching
293
+ ai_service = get_ai_service()
294
+ mappings = await ai_service.map_ingredients(
295
+ request.recipe_ingredients,
296
+ inventory_list
297
+ )
298
+
299
+ # Convert to response model
300
+ mapping_objects = [
301
+ IngredientMapping(**mapping) for mapping in mappings
302
+ ]
303
+
304
+ return MapIngredientsResponse(
305
+ recipe_id=request.recipe_id,
306
+ mappings=mapping_objects
307
+ )
308
+
309
+ except Exception as e:
310
+ logger.error(f"Error mapping ingredients: {str(e)}")
311
+ raise HTTPException(
312
+ status_code=500,
313
+ detail=f"Failed to map ingredients: {str(e)}"
314
+ )
315
+
316
+
317
+ @router.post("/save_recipe")
318
+ async def save_web_recipe(
319
+ request: SaveWebRecipeRequest,
320
+ db: Session = Depends(get_db),
321
+ api_key: str = Depends(verify_api_key)
322
+ ):
323
+ """
324
+ Endpoint 4: Save imported web recipe to database
325
+
326
+ Saves the recipe with both original and mapped ingredients.
327
+ Links to user/restaurant and stores web metadata (source URL, image, etc.)
328
+ """
329
+ try:
330
+ # Check if recipe name already exists
331
+ existing_recipe = db.query(Recipe).filter(
332
+ Recipe.name == request.name
333
+ ).first()
334
+
335
+ if existing_recipe:
336
+ raise HTTPException(
337
+ status_code=400,
338
+ detail=f"Recipe '{request.name}' already exists in your catalogue"
339
+ )
340
+
341
+ # Convert mapped ingredients to the format used by existing recipes
342
+ # Format: [{"name": "...", "qty": ..., "unit": "..."}]
343
+ items_for_recipe = []
344
+ for mapping in request.ingredients_mapped:
345
+ if mapping.get("mapped_to"): # Only include successfully mapped items
346
+ items_for_recipe.append({
347
+ "name": mapping.get("mapped_to"),
348
+ "qty": float(mapping.get("recipe_quantity", 0)) if mapping.get("recipe_quantity") else 0,
349
+ "unit": mapping.get("recipe_unit", "pz")
350
+ })
351
+
352
+ # Create new recipe
353
+ new_recipe = Recipe(
354
+ name=request.name,
355
+ items=json.dumps(items_for_recipe),
356
+ instructions=request.instructions,
357
+ source_url=request.source_url,
358
+ image_url=request.image_url,
359
+ cuisine=request.cuisine,
360
+ ingredients_raw=json.dumps(request.ingredients_raw),
361
+ ingredients_mapped=json.dumps(request.ingredients_mapped)
362
+ )
363
+
364
+ db.add(new_recipe)
365
+ db.commit()
366
+ db.refresh(new_recipe)
367
+
368
+ return {
369
+ "success": True,
370
+ "message": f"Recipe '{request.name}' saved successfully",
371
+ "recipe_id": new_recipe.id,
372
+ "name": new_recipe.name,
373
+ "items_count": len(items_for_recipe)
374
+ }
375
+
376
+ except HTTPException:
377
+ raise
378
+ except Exception as e:
379
+ db.rollback()
380
+ logger.error(f"Error saving recipe: {str(e)}")
381
+ raise HTTPException(
382
+ status_code=500,
383
+ detail=f"Failed to save recipe: {str(e)}"
384
+ )
385
+
386
+
387
+ @router.get("/test")
388
+ async def test_web_recipes():
389
+ """Test endpoint to verify the router is working"""
390
+ return {
391
+ "status": "ok",
392
+ "message": "Web recipes router is operational",
393
+ "endpoints": [
394
+ "/interpret_query",
395
+ "/search_recipes",
396
+ "/map_ingredients",
397
+ "/save_recipe"
398
+ ]
399
+ }
400
+
backend/schemas.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, field_validator
2
+ from typing import List, Optional, Dict, Any
3
+ from datetime import date
4
+
5
+ # Request/Response models for API
6
+
7
+ class InventoryItemCreate(BaseModel):
8
+ name: str
9
+ unit: str = "pz"
10
+ quantity: float = 0.0
11
+ category: str = "Other"
12
+ price: float = 0.0
13
+ lot_number: Optional[str] = None
14
+ expiry_date: Optional[date] = None
15
+
16
+ @field_validator('expiry_date')
17
+ @classmethod
18
+ def expiry_date_cannot_be_past(cls, v: Optional[date]) -> Optional[date]:
19
+ if v is not None and v < date.today():
20
+ raise ValueError('Expiry date cannot be in the past')
21
+ return v
22
+
23
+ class InventoryItemResponse(BaseModel):
24
+ id: int
25
+ name: str
26
+ unit: str
27
+ quantity: float
28
+ category: str
29
+ price: float
30
+ lot_number: Optional[str] = None
31
+ expiry_date: Optional[date] = None
32
+
33
+ class Config:
34
+ from_attributes = True
35
+
36
+ class RecipeItem(BaseModel):
37
+ name: str
38
+ qty: float
39
+ unit: str
40
+
41
+ class RecipeCreate(BaseModel):
42
+ name: str
43
+ items: List[RecipeItem]
44
+ instructions: Optional[str] = ""
45
+
46
+ class RecipeResponse(BaseModel):
47
+ id: int
48
+ name: str
49
+ items: List[RecipeItem]
50
+ instructions: str
51
+
52
+ class Config:
53
+ from_attributes = True
54
+
55
+ class TaskCreate(BaseModel):
56
+ recipe: str
57
+ quantity: int = 1
58
+ assigned_to: Optional[str] = ""
59
+ status: str = "todo"
60
+
61
+ class TaskResponse(BaseModel):
62
+ id: int
63
+ recipe: str
64
+ quantity: int
65
+ assigned_to: str
66
+ status: str
67
+
68
+ class Config:
69
+ from_attributes = True
70
+
71
+ class ChatRequest(BaseModel):
72
+ prompt: str
73
+
74
+ class ChatResponse(BaseModel):
75
+ choices: List[Dict[str, Any]]
76
+
77
+ class ActionRequest(BaseModel):
78
+ action: str
79
+ data: Dict[Any, Any]
80
+
81
+ class SyncDataRequest(BaseModel):
82
+ inventory: Optional[List[Dict[str, Any]]] = []
83
+ recipes: Optional[Dict[str, Dict[str, Any]]] = {}
84
+ tasks: Optional[List[Dict[str, Any]]] = []
85
+
86
+ class InventoryItemUpdate(BaseModel):
87
+ name: Optional[str] = None
88
+ unit: Optional[str] = None
89
+ quantity: Optional[float] = None
90
+ category: Optional[str] = None
91
+ price: Optional[float] = None
92
+ lot_number: Optional[str] = None
93
+ expiry_date: Optional[date] = None
94
+
95
+ @field_validator('expiry_date')
96
+ @classmethod
97
+ def expiry_date_cannot_be_past(cls, v: Optional[date]) -> Optional[date]:
98
+ if v is not None and v < date.today():
99
+ raise ValueError('Expiry date cannot be in the past')
100
+ return v
backend/services/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Services module for external integrations
2
+
3
+
backend/services/ai_assistant_service.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Assistant Service for ChefCode
3
+ Handles intent detection, conversational AI, and action orchestration
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import logging
9
+ from typing import Dict, Any, List, Optional
10
+ from openai import OpenAI
11
+ from pydantic import BaseModel
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class IntentResult(BaseModel):
17
+ """Structure for intent detection result"""
18
+ intent: str
19
+ confidence: float
20
+ entities: Dict[str, Any]
21
+ requires_confirmation: bool = False
22
+ response_message: str = ""
23
+
24
+
25
+ class AIAssistantService:
26
+ """
27
+ AI Assistant for natural language command processing
28
+ Uses GPT-4o-mini for intent detection and conversational responses
29
+ """
30
+
31
+ # Supported intents
32
+ INTENTS = {
33
+ "add_inventory": "Add items to inventory",
34
+ "update_inventory": "Update inventory quantities",
35
+ "delete_inventory": "Remove items from inventory",
36
+ "query_inventory": "Query inventory status",
37
+
38
+ "add_recipe": "Add a new recipe manually",
39
+ "edit_recipe": "Edit existing recipe",
40
+ "delete_recipe": "Delete a recipe",
41
+ "search_recipe_web": "Search recipes online",
42
+ "show_recipe": "Display specific recipe",
43
+ "import_recipe": "Import recipe from search results",
44
+
45
+ "show_catalogue": "Show recipe catalogue",
46
+ "filter_catalogue": "Filter recipes by category",
47
+
48
+ "general_query": "General questions",
49
+ "unknown": "Cannot determine intent"
50
+ }
51
+
52
+ def __init__(self):
53
+ """Initialize OpenAI client"""
54
+ api_key = os.getenv("OPENAI_API_KEY")
55
+ if not api_key:
56
+ raise ValueError("OPENAI_API_KEY environment variable not set")
57
+
58
+ self.client = OpenAI(api_key=api_key)
59
+ self.model = "gpt-4o-mini"
60
+ self.conversation_context = [] # Store recent conversation for context
61
+
62
+ async def detect_intent(self, user_input: str, context: Optional[Dict] = None) -> IntentResult:
63
+ """
64
+ Detect user intent from natural language input
65
+
66
+ Args:
67
+ user_input: User's natural language command
68
+ context: Optional conversation context
69
+
70
+ Returns:
71
+ IntentResult with detected intent and extracted entities
72
+ """
73
+ try:
74
+ system_prompt = self._build_intent_detection_prompt()
75
+
76
+ # Add context if available
77
+ context_info = ""
78
+ if context:
79
+ context_info = f"\n\nConversation context: {json.dumps(context)}"
80
+
81
+ user_prompt = f"""Analyze this user command and return a structured JSON response:
82
+
83
+ User Input: "{user_input}"{context_info}
84
+
85
+ Return JSON with this structure:
86
+ {{
87
+ "intent": "intent_name",
88
+ "confidence": 0.95,
89
+ "entities": {{
90
+ // Extracted entities based on intent
91
+ }},
92
+ "requires_confirmation": true/false,
93
+ "response_message": "Conversational response to user"
94
+ }}
95
+
96
+ IMPORTANT: Return ONLY the JSON, no markdown formatting."""
97
+
98
+ response = self.client.chat.completions.create(
99
+ model=self.model,
100
+ messages=[
101
+ {"role": "system", "content": system_prompt},
102
+ {"role": "user", "content": user_prompt}
103
+ ],
104
+ temperature=0.3,
105
+ max_tokens=1000
106
+ )
107
+
108
+ result_text = response.choices[0].message.content.strip()
109
+
110
+ # Clean markdown if present
111
+ if result_text.startswith("```"):
112
+ lines = result_text.split('\n')
113
+ result_text = '\n'.join([l for l in lines if not l.startswith("```")])
114
+
115
+ result_dict = json.loads(result_text)
116
+
117
+ return IntentResult(**result_dict)
118
+
119
+ except Exception as e:
120
+ logger.error(f"Intent detection error: {str(e)}")
121
+ return IntentResult(
122
+ intent="unknown",
123
+ confidence=0.0,
124
+ entities={},
125
+ response_message=f"I'm not sure what you mean. Could you rephrase that?"
126
+ )
127
+
128
+ async def parse_recipe_from_text(self, user_input: str) -> Dict[str, Any]:
129
+ """
130
+ Parse recipe details from natural language
131
+ Example: "Add a recipe called Pizza with flour 100 kg and tomato sauce 200 ml"
132
+
133
+ Returns:
134
+ {
135
+ "recipe_name": "Pizza",
136
+ "ingredients": [
137
+ {"name": "flour", "quantity": 100, "unit": "kg"},
138
+ {"name": "tomato sauce", "quantity": 200, "unit": "ml"}
139
+ ],
140
+ "yield_qty": 1,
141
+ "yield_unit": "piece"
142
+ }
143
+ """
144
+ try:
145
+ prompt = f"""You are a recipe parsing expert. Extract ALL ingredient information from this command.
146
+
147
+ User Input: "{user_input}"
148
+
149
+ CRITICAL RULES:
150
+ 1. Extract the recipe name
151
+ 2. For EACH ingredient, extract:
152
+ - name (ingredient name)
153
+ - quantity (numeric value, if missing use null)
154
+ - unit (measurement unit like kg, g, ml, liters, pieces, etc. If missing use null)
155
+ 3. Extract yield if mentioned (default: null)
156
+
157
+ EXAMPLES:
158
+ "Add recipe Pizza with flour 500 grams and tomato 200 ml"
159
+ → {{"recipe_name": "Pizza", "ingredients": [{{"name": "flour", "quantity": 500, "unit": "grams"}}, {{"name": "tomato", "quantity": 200, "unit": "ml"}}], "yield_qty": null, "yield_unit": null}}
160
+
161
+ "Add recipe spaghetti with flour and salt"
162
+ → {{"recipe_name": "spaghetti", "ingredients": [{{"name": "flour", "quantity": null, "unit": null}}, {{"name": "salt", "quantity": null, "unit": null}}], "yield_qty": null, "yield_unit": null}}
163
+
164
+ Return ONLY valid JSON, no markdown, no explanation:
165
+ {{
166
+ "recipe_name": "string",
167
+ "ingredients": [
168
+ {{"name": "string", "quantity": number or null, "unit": "string or null"}},
169
+ ...
170
+ ],
171
+ "yield_qty": number or null,
172
+ "yield_unit": "string or null",
173
+ "instructions": "string or empty"
174
+ }}"""
175
+
176
+ response = self.client.chat.completions.create(
177
+ model=self.model,
178
+ messages=[
179
+ {"role": "system", "content": "You are a precise recipe data extractor. Always return valid JSON."},
180
+ {"role": "user", "content": prompt}
181
+ ],
182
+ temperature=0.1,
183
+ max_tokens=1000
184
+ )
185
+
186
+ result = response.choices[0].message.content.strip()
187
+
188
+ # Clean markdown
189
+ if result.startswith("```"):
190
+ lines = result.split('\n')
191
+ result = '\n'.join([l for l in lines if not l.startswith("```")])
192
+
193
+ parsed = json.loads(result)
194
+
195
+ # Convert null to proper values
196
+ for ing in parsed.get('ingredients', []):
197
+ if ing.get('quantity') is None:
198
+ ing['quantity'] = None
199
+ if ing.get('unit') is None:
200
+ ing['unit'] = None
201
+
202
+ return parsed
203
+
204
+ except Exception as e:
205
+ logger.error(f"Recipe parsing error: {str(e)}")
206
+ raise ValueError(f"Could not parse recipe: {str(e)}")
207
+
208
+ async def generate_response(self, intent: str, action_result: Dict[str, Any]) -> str:
209
+ """
210
+ Generate a conversational response based on the action result
211
+
212
+ Args:
213
+ intent: The detected intent
214
+ action_result: Result from the action handler
215
+
216
+ Returns:
217
+ Conversational response string
218
+ """
219
+ try:
220
+ prompt = f"""Generate a short, friendly response for this action:
221
+
222
+ Intent: {intent}
223
+ Action Result: {json.dumps(action_result)}
224
+
225
+ Rules:
226
+ - Be conversational and concise (max 2 sentences)
227
+ - Use emojis sparingly for emphasis
228
+ - Confirm what was done
229
+ - If error, be helpful and suggest alternatives
230
+
231
+ Return only the response text, nothing else."""
232
+
233
+ response = self.client.chat.completions.create(
234
+ model=self.model,
235
+ messages=[
236
+ {"role": "system", "content": "You are ChefCode's friendly AI assistant. Be concise and helpful."},
237
+ {"role": "user", "content": prompt}
238
+ ],
239
+ temperature=0.7,
240
+ max_tokens=150
241
+ )
242
+
243
+ return response.choices[0].message.content.strip()
244
+
245
+ except Exception as e:
246
+ logger.error(f"Response generation error: {str(e)}")
247
+ return "Action completed." if action_result.get("success") else "Something went wrong."
248
+
249
+ def _build_intent_detection_prompt(self) -> str:
250
+ """Build the system prompt for intent detection with examples"""
251
+ return """You are ChefCode's intelligent assistant. Analyze user commands and detect their intent.
252
+
253
+ SUPPORTED INTENTS:
254
+
255
+ 📦 INVENTORY:
256
+ - add_inventory: Add items to inventory
257
+ Example: "Add 5 kg of rice at 2.50 euros"
258
+ Entities: {"item_name": "rice", "quantity": 5, "unit": "kg", "price": 2.50}
259
+ IMPORTANT: Always extract price if mentioned (at, for, cost, price, euro, dollar, etc.)
260
+
261
+ - update_inventory: Update quantities
262
+ Example: "Update flour to 10 kg"
263
+ Entities: {"item_name": "flour", "quantity": 10, "unit": "kg"}
264
+
265
+ - delete_inventory: Remove items
266
+ Example: "Remove tomatoes from inventory"
267
+ Entities: {"item_name": "tomatoes"}
268
+
269
+ - query_inventory: Check stock
270
+ Example: "How much rice do we have?"
271
+ Entities: {"item_name": "rice"}
272
+
273
+ 🍳 RECIPE MANAGEMENT:
274
+ - add_recipe: Create new recipe manually
275
+ Example: "Add a recipe called Pizza with flour 100 kg and cheese 50 kg"
276
+ Entities: {"recipe_name": "Pizza", "raw_text": "...full input..."}
277
+
278
+ - edit_recipe: Modify existing recipe (add/remove/change ingredient)
279
+ Example: "Edit recipe Pizza by adding 2 grams of salt"
280
+ Entities: {"recipe_name": "Pizza", "action": "adding", "ingredient_name": "salt", "quantity": "2", "unit": "grams"}
281
+
282
+ Example: "Remove flour from Pizza recipe"
283
+ Entities: {"recipe_name": "Pizza", "action": "remove", "ingredient_name": "flour"}
284
+
285
+ Example: "Change tomatoes in Pizza to 500 grams"
286
+ Entities: {"recipe_name": "Pizza", "action": "change", "ingredient_name": "tomatoes", "quantity": "500", "unit": "grams"}
287
+
288
+ - delete_recipe: Remove recipe
289
+ Example: "Delete the recipe Pasta"
290
+ Entities: {"recipe_name": "Pasta"}
291
+
292
+ - search_recipe_web: Search recipes online
293
+ Example: "Search pasta recipes" or "Find Italian recipes"
294
+ Entities: {"query": "pasta", "filters": {"cuisine": "Italian"}}
295
+
296
+ - show_recipe: Display specific recipe
297
+ Example: "Show me the Pizza recipe"
298
+ Entities: {"recipe_name": "Pizza"}
299
+
300
+ - import_recipe: Import from search results
301
+ Example: "Import the second one" or "Import that recipe"
302
+ Entities: {"index": 2, "recipe_id": "..."}
303
+
304
+ 📚 CATALOGUE:
305
+ - show_catalogue: Show all recipes
306
+ Example: "Show all recipes" or "Open recipe catalogue"
307
+ Entities: {}
308
+
309
+ - filter_catalogue: Filter by category
310
+ Example: "Show dessert recipes"
311
+ Entities: {"category": "dessert"}
312
+
313
+ ❓ OTHER:
314
+ - general_query: General questions
315
+ - unknown: Cannot determine
316
+
317
+ RULES:
318
+ 1. Set requires_confirmation=true for destructive actions (add, update, delete)
319
+ 2. Extract ALL relevant entities from the input
320
+ 3. Be conversational in response_message
321
+ 4. If ambiguous, ask clarifying questions
322
+ 5. For numbers, always extract both quantity and unit
323
+ 6. For recipe commands, capture the full raw text for later parsing
324
+
325
+ Return JSON only, no markdown."""
326
+
327
+ def add_to_context(self, role: str, content: str):
328
+ """Add message to conversation context"""
329
+ self.conversation_context.append({"role": role, "content": content})
330
+ # Keep only last 10 messages
331
+ if len(self.conversation_context) > 10:
332
+ self.conversation_context = self.conversation_context[-10:]
333
+
334
+ def clear_context(self):
335
+ """Clear conversation context"""
336
+ self.conversation_context = []
337
+
backend/services/ai_service.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Service Module
3
+ Handles interactions with OpenAI models (GPT-4o-mini and GPT-o3 reasoning)
4
+ """
5
+
6
+ import os
7
+ import json
8
+ from typing import Dict, List, Any, Optional
9
+ from openai import OpenAI
10
+ import logging
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class AIService:
15
+ """Service for AI-powered recipe interpretation and ingredient mapping"""
16
+
17
+ def __init__(self):
18
+ """Initialize OpenAI client with API key from environment"""
19
+ api_key = os.getenv("OPENAI_API_KEY")
20
+ if not api_key:
21
+ raise ValueError("OPENAI_API_KEY environment variable not set")
22
+
23
+ self.client = OpenAI(api_key=api_key)
24
+ self.gpt_4o_mini_model = "gpt-4o-mini"
25
+ # Use o3 reasoning model for ingredient mapping
26
+ self.gpt_o3_model = "o3"
27
+
28
+ async def interpret_query(self, user_query: str) -> Dict[str, Any]:
29
+ """
30
+ Use GPT-4o-mini to interpret natural language recipe search query
31
+
32
+ Args:
33
+ user_query: Natural language search query (e.g., "quick Italian pasta without cheese")
34
+
35
+ Returns:
36
+ Structured filters dictionary with:
37
+ - keywords: List of search keywords
38
+ - cuisine: Cuisine type (optional)
39
+ - restrictions: List of dietary restrictions (optional)
40
+ - max_time: Maximum cooking time in minutes (optional)
41
+
42
+ Example response:
43
+ {
44
+ "keywords": ["pasta"],
45
+ "cuisine": "Italian",
46
+ "restrictions": ["no cheese"],
47
+ "max_time": 30
48
+ }
49
+ """
50
+ try:
51
+ system_prompt = """You are a culinary assistant that interprets recipe search queries.
52
+ Extract structured information from natural language queries.
53
+
54
+ Return ONLY valid JSON with these fields:
55
+ - keywords: array of strings (main ingredients or dish types to search)
56
+ - cuisine: string or null (e.g., "Italian", "Chinese", "Mexican")
57
+ - restrictions: array of strings (dietary restrictions like "no cheese", "vegetarian", "vegan")
58
+ - max_time: number or null (maximum cooking time in minutes)
59
+
60
+ Examples:
61
+ Query: "quick Italian pasta without cheese"
62
+ Response: {"keywords": ["pasta"], "cuisine": "Italian", "restrictions": ["no cheese"], "max_time": 30}
63
+
64
+ Query: "spicy chicken curry"
65
+ Response: {"keywords": ["chicken", "curry"], "cuisine": null, "restrictions": ["spicy"], "max_time": null}
66
+
67
+ Query: "easy vegetarian soup under 20 minutes"
68
+ Response: {"keywords": ["soup"], "cuisine": null, "restrictions": ["vegetarian"], "max_time": 20}
69
+ """
70
+
71
+ response = self.client.chat.completions.create(
72
+ model=self.gpt_4o_mini_model,
73
+ messages=[
74
+ {"role": "system", "content": system_prompt},
75
+ {"role": "user", "content": user_query}
76
+ ],
77
+ temperature=0.3,
78
+ max_tokens=500,
79
+ response_format={"type": "json_object"}
80
+ )
81
+
82
+ result = json.loads(response.choices[0].message.content)
83
+
84
+ # Ensure all required fields exist with defaults
85
+ return {
86
+ "keywords": result.get("keywords", []),
87
+ "cuisine": result.get("cuisine"),
88
+ "restrictions": result.get("restrictions", []),
89
+ "max_time": result.get("max_time")
90
+ }
91
+
92
+ except Exception as e:
93
+ logger.error(f"Error interpreting query: {str(e)}")
94
+ # Fallback: return simple keyword extraction
95
+ return {
96
+ "keywords": [user_query],
97
+ "cuisine": None,
98
+ "restrictions": [],
99
+ "max_time": None
100
+ }
101
+
102
+ async def map_ingredients(
103
+ self,
104
+ recipe_ingredients: List[Dict[str, str]],
105
+ inventory_items: List[Dict[str, str]]
106
+ ) -> List[Dict[str, Any]]:
107
+ """
108
+ Use AI reasoning to semantically map recipe ingredients to inventory
109
+ Tries o3 first, falls back to gpt-4 if needed
110
+
111
+ Args:
112
+ recipe_ingredients: List of recipe ingredients [{"name": "...", "quantity": "...", "unit": "..."}]
113
+ inventory_items: List of inventory items [{"name": "...", "unit": "..."}]
114
+
115
+ Returns:
116
+ List of mapping results:
117
+ [{
118
+ "recipe_ingredient": str,
119
+ "recipe_quantity": str,
120
+ "recipe_unit": str,
121
+ "mapped_to": str or null,
122
+ "match_confidence": float (0.0-1.0),
123
+ "match_type": "exact" | "substitute" | "missing",
124
+ "note": str (explanation or suggestion)
125
+ }]
126
+ """
127
+ # Try o3 first, fallback to gpt-4 if it fails
128
+ models_to_try = ["o3", "gpt-4"]
129
+
130
+ for model_name in models_to_try:
131
+ try:
132
+ logger.info(f"Attempting ingredient mapping with model: {model_name}")
133
+ return await self._map_with_model(model_name, recipe_ingredients, inventory_items)
134
+ except Exception as e:
135
+ logger.warning(f"Model {model_name} failed: {str(e)}")
136
+ if model_name == models_to_try[-1]: # Last model
137
+ raise
138
+ else:
139
+ logger.info(f"Falling back to next model...")
140
+ continue
141
+
142
+ # This should never be reached, but just in case
143
+ raise Exception("All AI models failed")
144
+
145
+ async def _map_with_model(
146
+ self,
147
+ model_name: str,
148
+ recipe_ingredients: List[Dict[str, str]],
149
+ inventory_items: List[Dict[str, str]]
150
+ ) -> List[Dict[str, Any]]:
151
+ """Internal method to map ingredients using a specific model"""
152
+ try:
153
+ # Prepare data for the AI
154
+ recipe_list = "\n".join([
155
+ f"- {ing.get('name', 'Unknown')} ({ing.get('quantity', '')} {ing.get('unit', '')})"
156
+ for ing in recipe_ingredients
157
+ ])
158
+
159
+ inventory_list = "\n".join([
160
+ f"- {item.get('name', 'Unknown')} ({item.get('unit', 'pz')})"
161
+ for item in inventory_items
162
+ ])
163
+
164
+ system_prompt = """You are an expert AI sous-chef specializing in ingredient matching and substitutions.
165
+
166
+ Your task: Match recipe ingredients to the restaurant's inventory items semantically.
167
+
168
+ Rules:
169
+ 1. EXACT MATCH: Same ingredient or very similar (e.g., "tomatoes" → "San Marzano tomatoes")
170
+ 2. SUBSTITUTE: Compatible replacement (e.g., "butter" → "margarine", "basil" → "dried basil")
171
+ 3. MISSING: No suitable match exists in inventory
172
+
173
+ For each recipe ingredient, analyze and return:
174
+ - recipe_ingredient: exact name from recipe
175
+ - recipe_quantity: amount needed
176
+ - recipe_unit: unit of measurement
177
+ - mapped_to: inventory item name (or null if missing)
178
+ - match_confidence: 0.0 to 1.0 (1.0 = perfect match)
179
+ - match_type: "exact" | "substitute" | "missing"
180
+ - note: Brief explanation or substitution advice
181
+
182
+ Return ONLY valid JSON array. No markdown, no extra text."""
183
+
184
+ user_prompt = f"""Recipe Ingredients:
185
+ {recipe_list}
186
+
187
+ Inventory Available:
188
+ {inventory_list}
189
+
190
+ Match each recipe ingredient to inventory. Return JSON array of mappings."""
191
+
192
+ # Use different parameters based on model
193
+ if model_name == "o3":
194
+ response = self.client.chat.completions.create(
195
+ model=model_name,
196
+ messages=[
197
+ {"role": "user", "content": f"{system_prompt}\n\n{user_prompt}"}
198
+ ],
199
+ max_completion_tokens=4000
200
+ )
201
+ else: # gpt-4 or other models
202
+ response = self.client.chat.completions.create(
203
+ model=model_name,
204
+ messages=[
205
+ {"role": "system", "content": system_prompt},
206
+ {"role": "user", "content": user_prompt}
207
+ ],
208
+ max_tokens=2000,
209
+ temperature=0.2,
210
+ response_format={"type": "json_object"}
211
+ )
212
+
213
+ result = response.choices[0].message.content
214
+
215
+ # Log raw response for debugging
216
+ logger.debug(f"Raw AI response: {result[:500] if result else 'EMPTY'}")
217
+
218
+ if not result or not result.strip():
219
+ logger.error("AI returned empty response")
220
+ raise ValueError("AI returned empty response")
221
+
222
+ # Try to extract JSON if wrapped in markdown code blocks
223
+ if result.startswith("```"):
224
+ # Extract JSON from markdown code block
225
+ lines = result.split('\n')
226
+ json_lines = []
227
+ in_code_block = False
228
+ for line in lines:
229
+ if line.startswith("```"):
230
+ in_code_block = not in_code_block
231
+ continue
232
+ if in_code_block or (not line.startswith("```") and json_lines):
233
+ json_lines.append(line)
234
+ result = '\n'.join(json_lines).strip()
235
+
236
+ # Parse response - handle if it's wrapped in a root key
237
+ parsed = json.loads(result)
238
+ if isinstance(parsed, dict):
239
+ # If response has a wrapper key like "mappings", extract it
240
+ if "mappings" in parsed:
241
+ mappings = parsed["mappings"]
242
+ elif "ingredients" in parsed:
243
+ mappings = parsed["ingredients"]
244
+ else:
245
+ # Try to find the first list value
246
+ mappings = None
247
+ for value in parsed.values():
248
+ if isinstance(value, list):
249
+ mappings = value
250
+ break
251
+ if not mappings:
252
+ mappings = []
253
+ else:
254
+ mappings = parsed if isinstance(parsed, list) else []
255
+
256
+ # Ensure all recipe_quantity values are strings
257
+ for mapping in mappings:
258
+ if 'recipe_quantity' in mapping and not isinstance(mapping['recipe_quantity'], str):
259
+ mapping['recipe_quantity'] = str(mapping['recipe_quantity'])
260
+
261
+ return mappings
262
+
263
+ except Exception as e:
264
+ logger.error(f"Error mapping ingredients: {str(e)}", exc_info=True)
265
+ # Fallback: return basic structure marking all as missing
266
+ return [
267
+ {
268
+ "recipe_ingredient": ing.get("name", "Unknown"),
269
+ "recipe_quantity": ing.get("quantity", ""),
270
+ "recipe_unit": ing.get("unit", ""),
271
+ "mapped_to": None,
272
+ "match_confidence": 0.0,
273
+ "match_type": "missing",
274
+ "note": f"Unable to perform AI matching: {str(e)}"
275
+ }
276
+ for ing in recipe_ingredients
277
+ ]
278
+
279
+
280
+ # Singleton instance
281
+ _ai_service: Optional[AIService] = None
282
+
283
+ def get_ai_service() -> AIService:
284
+ """Get or create the AI service singleton"""
285
+ global _ai_service
286
+ if _ai_service is None:
287
+ _ai_service = AIService()
288
+ return _ai_service
289
+
backend/services/mealdb_service.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TheMealDB API Service
3
+ Provides recipe search functionality using TheMealDB public API
4
+ API Documentation: https://www.themealdb.com/api.php
5
+ """
6
+
7
+ import httpx
8
+ from typing import List, Dict, Any, Optional
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class MealDBService:
15
+ """Service for interacting with TheMealDB API"""
16
+
17
+ BASE_URL = "https://www.themealdb.com/api/json/v1/1"
18
+
19
+ def __init__(self):
20
+ """Initialize MealDB service"""
21
+ self.base_url = self.BASE_URL
22
+
23
+ async def search_by_name(self, query: str) -> List[Dict[str, Any]]:
24
+ """
25
+ Search recipes by name using TheMealDB API
26
+
27
+ Args:
28
+ query: Search query string (e.g., "pasta", "chicken")
29
+
30
+ Returns:
31
+ List of recipe dictionaries with structure:
32
+ [{
33
+ "id": str,
34
+ "name": str,
35
+ "image": str (URL),
36
+ "category": str,
37
+ "area": str (cuisine),
38
+ "instructions": str,
39
+ "ingredients": [{"name": str, "measure": str}],
40
+ "source_url": str
41
+ }]
42
+ """
43
+ try:
44
+ async with httpx.AsyncClient(timeout=10.0) as client:
45
+ response = await client.get(
46
+ f"{self.base_url}/search.php",
47
+ params={"s": query}
48
+ )
49
+ response.raise_for_status()
50
+ data = response.json()
51
+
52
+ if not data.get("meals"):
53
+ return []
54
+
55
+ # Parse and structure the recipes
56
+ recipes = []
57
+ for meal in data["meals"]:
58
+ # Extract ingredients and measures (TheMealDB has 20 ingredient slots)
59
+ ingredients = []
60
+ for i in range(1, 21):
61
+ ingredient_name = meal.get(f"strIngredient{i}", "")
62
+ ingredient_measure = meal.get(f"strMeasure{i}", "")
63
+
64
+ if ingredient_name and ingredient_name.strip():
65
+ ingredients.append({
66
+ "name": ingredient_name.strip(),
67
+ "measure": ingredient_measure.strip() if ingredient_measure else ""
68
+ })
69
+
70
+ recipe = {
71
+ "id": meal.get("idMeal"),
72
+ "name": meal.get("strMeal"),
73
+ "image": meal.get("strMealThumb"),
74
+ "category": meal.get("strCategory"),
75
+ "area": meal.get("strArea"), # Cuisine type
76
+ "instructions": meal.get("strInstructions", ""),
77
+ "ingredients": ingredients,
78
+ "source_url": meal.get("strSource") or meal.get("strYoutube") or "",
79
+ "tags": meal.get("strTags", "").split(",") if meal.get("strTags") else []
80
+ }
81
+ recipes.append(recipe)
82
+
83
+ return recipes
84
+
85
+ except httpx.HTTPError as e:
86
+ logger.error(f"HTTP error searching TheMealDB: {str(e)}")
87
+ return []
88
+ except Exception as e:
89
+ logger.error(f"Error searching TheMealDB: {str(e)}")
90
+ return []
91
+
92
+ async def search_by_ingredient(self, ingredient: str) -> List[Dict[str, Any]]:
93
+ """
94
+ Search recipes by main ingredient
95
+
96
+ Args:
97
+ ingredient: Main ingredient name (e.g., "chicken", "beef")
98
+
99
+ Returns:
100
+ List of simplified recipe info (less detail than search_by_name)
101
+ """
102
+ try:
103
+ async with httpx.AsyncClient(timeout=10.0) as client:
104
+ response = await client.get(
105
+ f"{self.base_url}/filter.php",
106
+ params={"i": ingredient}
107
+ )
108
+ response.raise_for_status()
109
+ data = response.json()
110
+
111
+ if not data.get("meals"):
112
+ return []
113
+
114
+ # This endpoint returns limited info, so we'll return basic structure
115
+ recipes = []
116
+ for meal in data["meals"]:
117
+ recipe = {
118
+ "id": meal.get("idMeal"),
119
+ "name": meal.get("strMeal"),
120
+ "image": meal.get("strMealThumb"),
121
+ "category": None,
122
+ "area": None,
123
+ "instructions": None,
124
+ "ingredients": [],
125
+ "source_url": ""
126
+ }
127
+ recipes.append(recipe)
128
+
129
+ return recipes
130
+
131
+ except Exception as e:
132
+ logger.error(f"Error searching by ingredient: {str(e)}")
133
+ return []
134
+
135
+ async def get_recipe_by_id(self, meal_id: str) -> Optional[Dict[str, Any]]:
136
+ """
137
+ Get full recipe details by ID
138
+
139
+ Args:
140
+ meal_id: TheMealDB recipe ID
141
+
142
+ Returns:
143
+ Full recipe dictionary or None if not found
144
+ """
145
+ try:
146
+ async with httpx.AsyncClient(timeout=10.0) as client:
147
+ response = await client.get(
148
+ f"{self.base_url}/lookup.php",
149
+ params={"i": meal_id}
150
+ )
151
+ response.raise_for_status()
152
+ data = response.json()
153
+
154
+ if not data.get("meals") or len(data["meals"]) == 0:
155
+ return None
156
+
157
+ meal = data["meals"][0]
158
+
159
+ # Extract ingredients
160
+ ingredients = []
161
+ for i in range(1, 21):
162
+ ingredient_name = meal.get(f"strIngredient{i}", "")
163
+ ingredient_measure = meal.get(f"strMeasure{i}", "")
164
+
165
+ if ingredient_name and ingredient_name.strip():
166
+ ingredients.append({
167
+ "name": ingredient_name.strip(),
168
+ "measure": ingredient_measure.strip() if ingredient_measure else ""
169
+ })
170
+
171
+ recipe = {
172
+ "id": meal.get("idMeal"),
173
+ "name": meal.get("strMeal"),
174
+ "image": meal.get("strMealThumb"),
175
+ "category": meal.get("strCategory"),
176
+ "area": meal.get("strArea"),
177
+ "instructions": meal.get("strInstructions", ""),
178
+ "ingredients": ingredients,
179
+ "source_url": meal.get("strSource") or meal.get("strYoutube") or "",
180
+ "tags": meal.get("strTags", "").split(",") if meal.get("strTags") else []
181
+ }
182
+
183
+ return recipe
184
+
185
+ except Exception as e:
186
+ logger.error(f"Error fetching recipe by ID: {str(e)}")
187
+ return None
188
+
189
+
190
+ # Singleton instance
191
+ _mealdb_service: Optional[MealDBService] = None
192
+
193
+ def get_mealdb_service() -> MealDBService:
194
+ """Get or create the MealDB service singleton"""
195
+ global _mealdb_service
196
+ if _mealdb_service is None:
197
+ _mealdb_service = MealDBService()
198
+ return _mealdb_service
199
+
200
+
frontend/ai-assistant.js ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ChefCode AI Assistant
3
+ * Intelligent voice and text assistant for inventory and recipe management
4
+ */
5
+
6
+ const AI_ASSISTANT = (() => {
7
+ // Configuration
8
+ const API_BASE = window.CHEFCODE_CONFIG?.API_BASE_URL || 'http://localhost:8000';
9
+ const API_KEY = window.CHEFCODE_CONFIG?.API_KEY || '';
10
+
11
+ // State
12
+ let conversationContext = {};
13
+ let pendingConfirmation = null;
14
+ let recognition = null;
15
+ let isListening = false;
16
+
17
+ // DOM Elements
18
+ let chatOverlay, chatMessages, commandInput, sendBtn, voiceBtn;
19
+ let confirmationDialog;
20
+
21
+ // Initialize
22
+ function init() {
23
+ console.log('🤖 Initializing AI Assistant...');
24
+
25
+ // Get DOM elements
26
+ commandInput = document.getElementById('ai-command-input');
27
+ sendBtn = document.getElementById('ai-send-btn');
28
+ voiceBtn = document.getElementById('ai-voice-btn');
29
+
30
+ // Create chat overlay
31
+ createChatOverlay();
32
+ createConfirmationDialog();
33
+ // Note: We use the existing web recipe search modal, no need to create our own
34
+
35
+ // Setup voice recognition
36
+ setupVoiceRecognition();
37
+
38
+ // Event listeners
39
+ if (sendBtn && commandInput) {
40
+ sendBtn.addEventListener('click', handleSendCommand);
41
+ commandInput.addEventListener('keypress', (e) => {
42
+ if (e.key === 'Enter') {
43
+ e.preventDefault();
44
+ handleSendCommand();
45
+ }
46
+ });
47
+ }
48
+
49
+ if (voiceBtn) {
50
+ voiceBtn.addEventListener('click', toggleVoiceInput);
51
+ }
52
+
53
+ console.log('✅ AI Assistant initialized');
54
+ }
55
+
56
+ // Create chat overlay UI
57
+ function createChatOverlay() {
58
+ chatOverlay = document.createElement('div');
59
+ chatOverlay.id = 'ai-chat-overlay';
60
+ chatOverlay.className = 'ai-chat-overlay';
61
+ chatOverlay.style.display = 'none';
62
+
63
+ chatOverlay.innerHTML = `
64
+ <div class="ai-chat-container">
65
+ <div class="ai-chat-header">
66
+ <div>
67
+ <h3>🤖 ChefCode AI Assistant</h3>
68
+ <p>Ask me anything about recipes and inventory</p>
69
+ </div>
70
+ <button class="ai-chat-close" id="ai-chat-close">
71
+ <i class="fas fa-times"></i>
72
+ </button>
73
+ </div>
74
+ <div class="ai-chat-messages" id="ai-chat-messages"></div>
75
+ <div class="ai-chat-footer">
76
+ <button class="ai-chat-voice-btn" id="ai-chat-voice-btn" title="Voice Input">
77
+ <i class="fas fa-microphone"></i>
78
+ </button>
79
+ <p class="ai-chat-hint">💡 Try: "Add recipe Pizza" or "Search pasta recipes"</p>
80
+ </div>
81
+ </div>
82
+ `;
83
+
84
+ document.body.appendChild(chatOverlay);
85
+ chatMessages = document.getElementById('ai-chat-messages');
86
+
87
+ document.getElementById('ai-chat-close').addEventListener('click', closeChatOverlay);
88
+
89
+ // Add voice button handler in chat panel
90
+ const chatVoiceBtn = document.getElementById('ai-chat-voice-btn');
91
+ if (chatVoiceBtn) {
92
+ chatVoiceBtn.addEventListener('click', toggleVoiceInput);
93
+ }
94
+ }
95
+
96
+ // Create confirmation dialog
97
+ function createConfirmationDialog() {
98
+ confirmationDialog = document.createElement('div');
99
+ confirmationDialog.id = 'ai-confirmation-dialog';
100
+ confirmationDialog.className = 'ai-confirmation-dialog';
101
+ confirmationDialog.style.display = 'none';
102
+
103
+ confirmationDialog.innerHTML = `
104
+ <div class="ai-confirmation-content">
105
+ <div class="ai-confirmation-icon">⚠️</div>
106
+ <div class="ai-confirmation-message" id="ai-confirmation-message"></div>
107
+ <div class="ai-confirmation-actions">
108
+ <button class="ai-btn ai-btn-secondary" id="ai-confirm-no">Cancel</button>
109
+ <button class="ai-btn ai-btn-primary" id="ai-confirm-yes">Confirm</button>
110
+ </div>
111
+ </div>
112
+ `;
113
+
114
+ document.body.appendChild(confirmationDialog);
115
+
116
+ document.getElementById('ai-confirm-yes').addEventListener('click', () => confirmAction(true));
117
+ document.getElementById('ai-confirm-no').addEventListener('click', () => confirmAction(false));
118
+ }
119
+
120
+ // Note: Search results use the existing web recipe search modal
121
+ // No separate overlay needed
122
+
123
+ // Setup voice recognition
124
+ function setupVoiceRecognition() {
125
+ if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
126
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
127
+ recognition = new SpeechRecognition();
128
+ recognition.lang = 'en-US';
129
+ recognition.continuous = false;
130
+ recognition.interimResults = false;
131
+
132
+ recognition.onresult = (event) => {
133
+ const transcript = event.results[0][0].transcript;
134
+ commandInput.value = transcript;
135
+ addMessage('user', transcript);
136
+ processCommand(transcript);
137
+ };
138
+
139
+ recognition.onerror = (event) => {
140
+ console.error('Speech recognition error:', event.error);
141
+ isListening = false;
142
+ updateVoiceButton();
143
+ };
144
+
145
+ recognition.onend = () => {
146
+ isListening = false;
147
+ updateVoiceButton();
148
+ };
149
+ } else {
150
+ console.warn('Speech recognition not supported');
151
+ if (voiceBtn) voiceBtn.style.display = 'none';
152
+ }
153
+ }
154
+
155
+ // Handle send command
156
+ async function handleSendCommand() {
157
+ const command = commandInput.value.trim();
158
+ if (!command) return;
159
+
160
+ // Show chat overlay
161
+ showChatOverlay();
162
+
163
+ // Add user message
164
+ addMessage('user', command);
165
+
166
+ // Clear input
167
+ commandInput.value = '';
168
+
169
+ // Process command
170
+ await processCommand(command);
171
+ }
172
+
173
+ // Process command via AI backend
174
+ async function processCommand(command) {
175
+ try {
176
+ // Show typing indicator
177
+ const typingId = addTypingIndicator();
178
+
179
+ const response = await fetch(`${API_BASE}/api/ai-assistant/command`, {
180
+ method: 'POST',
181
+ headers: {
182
+ 'Content-Type': 'application/json',
183
+ 'X-API-Key': API_KEY
184
+ },
185
+ body: JSON.stringify({
186
+ command: command,
187
+ context: conversationContext
188
+ })
189
+ });
190
+
191
+ // Remove typing indicator
192
+ removeTypingIndicator(typingId);
193
+
194
+ if (!response.ok) {
195
+ throw new Error(`API error: ${response.status}`);
196
+ }
197
+
198
+ const result = await response.json();
199
+
200
+ console.log('AI Response:', result);
201
+
202
+ // Handle response based on intent
203
+ if (result.requires_confirmation) {
204
+ // Store confirmation data
205
+ pendingConfirmation = result.confirmation_data;
206
+
207
+ // Show confirmation dialog
208
+ showConfirmation(result.message);
209
+
210
+ } else if (result.search_results) {
211
+ // Use existing web recipe search modal
212
+ addMessage('assistant', result.message);
213
+
214
+ // Close AI chat and open web recipe search
215
+ const query = result.action_result?.search_query || '';
216
+ if (window.WEB_RECIPE_SEARCH && query) {
217
+ closeChatOverlay();
218
+ // Small delay to ensure smooth transition
219
+ setTimeout(() => {
220
+ window.WEB_RECIPE_SEARCH.searchWithQuery(query);
221
+ }, 300);
222
+ }
223
+
224
+ } else {
225
+ // Just show message
226
+ addMessage('assistant', result.message);
227
+
228
+ // Update context
229
+ conversationContext.last_intent = result.intent;
230
+ conversationContext.last_result = result.action_result;
231
+ }
232
+
233
+ } catch (error) {
234
+ console.error('Command processing error:', error);
235
+ removeTypingIndicator();
236
+ addMessage('assistant', '❌ Sorry, something went wrong. Make sure the backend is running.');
237
+ }
238
+ }
239
+
240
+ // Show confirmation dialog
241
+ function showConfirmation(message) {
242
+ document.getElementById('ai-confirmation-message').innerHTML = message.replace(/\n/g, '<br>');
243
+ confirmationDialog.style.display = 'flex';
244
+ }
245
+
246
+ // Handle confirmation response
247
+ async function confirmAction(confirmed) {
248
+ confirmationDialog.style.display = 'none';
249
+
250
+ if (!pendingConfirmation) return;
251
+
252
+ try {
253
+ const typingId = addTypingIndicator();
254
+
255
+ const response = await fetch(`${API_BASE}/api/ai-assistant/confirm`, {
256
+ method: 'POST',
257
+ headers: {
258
+ 'Content-Type': 'application/json',
259
+ 'X-API-Key': API_KEY
260
+ },
261
+ body: JSON.stringify({
262
+ confirmation_id: Date.now().toString(),
263
+ confirmed: confirmed,
264
+ data: pendingConfirmation
265
+ })
266
+ });
267
+
268
+ removeTypingIndicator(typingId);
269
+
270
+ if (!response.ok) {
271
+ throw new Error(`API error: ${response.status}`);
272
+ }
273
+
274
+ const result = await response.json();
275
+
276
+ addMessage('assistant', result.message);
277
+
278
+ // Show success toast
279
+ if (result.success) {
280
+ showToast('✅ Action completed successfully', 'success');
281
+ }
282
+
283
+ } catch (error) {
284
+ console.error('Confirmation error:', error);
285
+ removeTypingIndicator();
286
+ addMessage('assistant', '❌ Failed to execute action.');
287
+ } finally {
288
+ pendingConfirmation = null;
289
+ }
290
+ }
291
+
292
+ // Note: Recipe search now uses the existing web recipe search modal
293
+ // No need for separate display logic - we just trigger the existing modal
294
+
295
+ // Add message to chat
296
+ function addMessage(role, content) {
297
+ if (!chatMessages) return;
298
+
299
+ const messageDiv = document.createElement('div');
300
+ messageDiv.className = `ai-message ai-message-${role}`;
301
+
302
+ const bubble = document.createElement('div');
303
+ bubble.className = 'ai-message-bubble';
304
+
305
+ // Convert markdown-style formatting
306
+ let formattedContent = content
307
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
308
+ .replace(/\n/g, '<br>');
309
+
310
+ bubble.innerHTML = formattedContent;
311
+ messageDiv.appendChild(bubble);
312
+
313
+ chatMessages.appendChild(messageDiv);
314
+ chatMessages.scrollTop = chatMessages.scrollHeight;
315
+ }
316
+
317
+ // Add typing indicator
318
+ function addTypingIndicator() {
319
+ const id = 'typing-' + Date.now();
320
+ const messageDiv = document.createElement('div');
321
+ messageDiv.id = id;
322
+ messageDiv.className = 'ai-message ai-message-assistant';
323
+
324
+ messageDiv.innerHTML = `
325
+ <div class="ai-message-bubble">
326
+ <div class="ai-typing-indicator">
327
+ <span></span><span></span><span></span>
328
+ </div>
329
+ </div>
330
+ `;
331
+
332
+ chatMessages.appendChild(messageDiv);
333
+ chatMessages.scrollTop = chatMessages.scrollHeight;
334
+
335
+ return id;
336
+ }
337
+
338
+ // Remove typing indicator
339
+ function removeTypingIndicator(id = null) {
340
+ if (id) {
341
+ const element = document.getElementById(id);
342
+ if (element) element.remove();
343
+ } else {
344
+ // Remove all typing indicators
345
+ document.querySelectorAll('.ai-typing-indicator').forEach(el => {
346
+ el.closest('.ai-message').remove();
347
+ });
348
+ }
349
+ }
350
+
351
+ // Toggle voice input
352
+ function toggleVoiceInput() {
353
+ if (!recognition) {
354
+ alert('Voice recognition not supported in this browser.');
355
+ return;
356
+ }
357
+
358
+ if (isListening) {
359
+ recognition.stop();
360
+ isListening = false;
361
+ } else {
362
+ showChatOverlay();
363
+ addMessage('assistant', '🎤 Listening... Speak your command.');
364
+ recognition.start();
365
+ isListening = true;
366
+ }
367
+
368
+ updateVoiceButton();
369
+ }
370
+
371
+ // Update voice button appearance (both toolbar and chat panel)
372
+ function updateVoiceButton() {
373
+ const buttons = [voiceBtn, document.getElementById('ai-chat-voice-btn')];
374
+
375
+ buttons.forEach(btn => {
376
+ if (!btn) return;
377
+
378
+ if (isListening) {
379
+ btn.classList.add('listening');
380
+ const icon = btn.querySelector('i');
381
+ if (icon) icon.className = 'fas fa-microphone-slash';
382
+ } else {
383
+ btn.classList.remove('listening');
384
+ const icon = btn.querySelector('i');
385
+ if (icon) icon.className = 'fas fa-microphone';
386
+ }
387
+ });
388
+ }
389
+
390
+ // Show chat overlay
391
+ function showChatOverlay() {
392
+ if (chatOverlay) {
393
+ chatOverlay.style.display = 'flex';
394
+ // Add welcome message if empty
395
+ if (chatMessages && chatMessages.children.length === 0) {
396
+ addMessage('assistant', '👋 Hi! I\'m your ChefCode AI assistant. I can help you manage recipes and inventory. What would you like to do?');
397
+ }
398
+ }
399
+ }
400
+
401
+ // Close chat overlay
402
+ function closeChatOverlay() {
403
+ if (chatOverlay) {
404
+ chatOverlay.style.display = 'none';
405
+ }
406
+ }
407
+
408
+ // Note: Search results handled by existing web recipe search modal
409
+
410
+ // Show toast notification
411
+ function showToast(message, type = 'info') {
412
+ const toast = document.createElement('div');
413
+ toast.className = `ai-toast ai-toast-${type}`;
414
+ toast.textContent = message;
415
+
416
+ document.body.appendChild(toast);
417
+
418
+ setTimeout(() => {
419
+ toast.classList.add('show');
420
+ }, 100);
421
+
422
+ setTimeout(() => {
423
+ toast.classList.remove('show');
424
+ setTimeout(() => toast.remove(), 300);
425
+ }, 3000);
426
+ }
427
+
428
+ // Public API
429
+ return {
430
+ init,
431
+ processCommand,
432
+ showChatOverlay,
433
+ closeChatOverlay
434
+ };
435
+ })();
436
+
437
+ // Initialize when DOM is ready
438
+ if (document.readyState === 'loading') {
439
+ document.addEventListener('DOMContentLoaded', () => AI_ASSISTANT.init());
440
+ } else {
441
+ AI_ASSISTANT.init();
442
+ }
443
+
frontend/api.js ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ChefCode API Layer - Connected to FastAPI Backend
3
+ * Gestisce tutte le chiamate al backend (port 8000)
4
+ */
5
+
6
+ class ChefCodeAPI {
7
+ constructor(baseURL = 'http://localhost:8000') {
8
+ this.baseURL = baseURL;
9
+ this.apiKey = null; // MUST be set via setMobileConfig() or setApiKey()
10
+ console.log('🌐 ChefCode API connected to:', this.baseURL);
11
+ console.log('⚠️ API Key authentication required - set via setApiKey() or setMobileConfig()');
12
+ }
13
+
14
+ // Set API Key (must be called before making authenticated requests)
15
+ setApiKey(apiKey) {
16
+ if (!apiKey) {
17
+ console.error('❌ API Key cannot be empty');
18
+ throw new Error('API Key is required');
19
+ }
20
+ this.apiKey = apiKey;
21
+ console.log('✅ API Key configured');
22
+ }
23
+
24
+ // Configurazione per mobile (React Native / Flutter)
25
+ setMobileConfig(config) {
26
+ this.baseURL = config.baseURL || this.baseURL;
27
+ if (config.apiKey) {
28
+ this.setApiKey(config.apiKey);
29
+ }
30
+ this.token = config.token; // Per autenticazione future (deprecated)
31
+ console.log('📱 API URL updated to:', this.baseURL);
32
+ }
33
+
34
+ // Get headers with authentication
35
+ getHeaders() {
36
+ if (!this.apiKey) {
37
+ console.error('❌ API Key not set. Call setApiKey() first.');
38
+ throw new Error('API Key not configured. Call setApiKey() before making requests.');
39
+ }
40
+ return {
41
+ 'Content-Type': 'application/json',
42
+ 'X-API-Key': this.apiKey
43
+ };
44
+ }
45
+
46
+ // ===== SYNC DATA =====
47
+ async syncData(data) {
48
+ try {
49
+ const response = await fetch(`${this.baseURL}/api/sync-data`, {
50
+ method: 'POST',
51
+ headers: this.getHeaders(),
52
+ body: JSON.stringify(data)
53
+ });
54
+
55
+ if (response.status === 401) {
56
+ throw new Error('Authentication failed. Check API Key configuration.');
57
+ }
58
+
59
+ if (!response.ok) {
60
+ const error = await response.json();
61
+ throw new Error(error.detail || 'Sync failed');
62
+ }
63
+
64
+ return await response.json();
65
+ } catch (error) {
66
+ console.error('❌ Sync error:', error);
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ // ===== CHATGPT AI =====
72
+ async sendChatMessage(prompt, language = 'en') {
73
+ try {
74
+ const response = await fetch(`${this.baseURL}/api/chatgpt-smart`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' }, // Chat endpoint doesn't require auth for now
77
+ body: JSON.stringify({ prompt, language })
78
+ });
79
+
80
+ if (!response.ok) {
81
+ const error = await response.json();
82
+ throw new Error(error.detail || 'ChatGPT request failed');
83
+ }
84
+
85
+ return await response.json();
86
+ } catch (error) {
87
+ console.error('❌ ChatGPT error:', error);
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ // ===== INVENTORY =====
93
+ async getInventory() {
94
+ try {
95
+ const response = await fetch(`${this.baseURL}/api/data`);
96
+ const data = await response.json();
97
+ return data.inventory || [];
98
+ } catch (error) {
99
+ console.error('❌ Get inventory error:', error);
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ async addInventoryItem(item) {
105
+ try {
106
+ const response = await fetch(`${this.baseURL}/api/action`, {
107
+ method: 'POST',
108
+ headers: this.getHeaders(),
109
+ body: JSON.stringify({
110
+ action: 'add-inventory',
111
+ data: item
112
+ })
113
+ });
114
+
115
+ if (response.status === 401) {
116
+ throw new Error('Authentication failed. Check API Key configuration.');
117
+ }
118
+
119
+ if (!response.ok) {
120
+ const error = await response.json();
121
+ throw new Error(error.detail || 'Failed to add inventory item');
122
+ }
123
+
124
+ return await response.json();
125
+ } catch (error) {
126
+ console.error('❌ Add inventory error:', error);
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ // ===== RECIPES =====
132
+ async getRecipes() {
133
+ try {
134
+ const response = await fetch(`${this.baseURL}/api/data`);
135
+ const data = await response.json();
136
+ return data.recipes || {};
137
+ } catch (error) {
138
+ console.error('❌ Get recipes error:', error);
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ async saveRecipe(name, recipe) {
144
+ try {
145
+ const response = await fetch(`${this.baseURL}/api/action`, {
146
+ method: 'POST',
147
+ headers: this.getHeaders(),
148
+ body: JSON.stringify({
149
+ action: 'save-recipe',
150
+ data: { name, recipe }
151
+ })
152
+ });
153
+
154
+ if (response.status === 401) {
155
+ throw new Error('Authentication failed. Check API Key configuration.');
156
+ }
157
+
158
+ if (!response.ok) {
159
+ const error = await response.json();
160
+ throw new Error(error.detail || 'Failed to save recipe');
161
+ }
162
+
163
+ return await response.json();
164
+ } catch (error) {
165
+ console.error('❌ Save recipe error:', error);
166
+ throw error;
167
+ }
168
+ }
169
+
170
+ // ===== TASKS =====
171
+ async getTasks() {
172
+ try {
173
+ const response = await fetch(`${this.baseURL}/api/data`);
174
+ const data = await response.json();
175
+ return data.tasks || [];
176
+ } catch (error) {
177
+ console.error('❌ Get tasks error:', error);
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ async addTask(task) {
183
+ try {
184
+ const response = await fetch(`${this.baseURL}/api/action`, {
185
+ method: 'POST',
186
+ headers: this.getHeaders(),
187
+ body: JSON.stringify({
188
+ action: 'add-task',
189
+ data: task
190
+ })
191
+ });
192
+
193
+ if (response.status === 401) {
194
+ throw new Error('Authentication failed. Check API Key configuration.');
195
+ }
196
+
197
+ if (!response.ok) {
198
+ const error = await response.json();
199
+ throw new Error(error.detail || 'Failed to add task');
200
+ }
201
+
202
+ return await response.json();
203
+ } catch (error) {
204
+ console.error('❌ Add task error:', error);
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ // ===== HEALTH CHECK =====
210
+ async ping() {
211
+ try {
212
+ const response = await fetch(`${this.baseURL}/health`);
213
+ return response.ok;
214
+ } catch (error) {
215
+ return false;
216
+ }
217
+ }
218
+ }
219
+
220
+ // Export per Web (browser)
221
+ if (typeof window !== 'undefined') {
222
+ window.ChefCodeAPI = ChefCodeAPI;
223
+ }
224
+
225
+ // Export per Mobile (React Native / Node.js)
226
+ if (typeof module !== 'undefined' && module.exports) {
227
+ module.exports = ChefCodeAPI;
228
+ }
229
+
230
+ // Export per ES6 modules
231
+ if (typeof exports !== 'undefined') {
232
+ exports.ChefCodeAPI = ChefCodeAPI;
233
+ }
frontend/config.js ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ChefCode Frontend Configuration
3
+ * ⚠️ SECURITY WARNING: This file contains sensitive API credentials
4
+ *
5
+ * For Production:
6
+ * - DO NOT commit this file to git (add to .gitignore)
7
+ * - Use environment variables or secure key management
8
+ * - Implement proper authentication (login/session-based)
9
+ * - Consider using a backend proxy to hide API keys
10
+ */
11
+
12
+ const CHEFCODE_CONFIG = {
13
+ // Backend API URL
14
+ API_URL: 'http://localhost:8000',
15
+
16
+ // ⚠️ SECURITY: API Key should NOT be in frontend code in production
17
+ // This is only for local development/demo
18
+ // In production, use session-based auth or backend proxy
19
+ API_KEY: 'chefcode-secret-key-2024'
20
+ };
21
+
22
+ // Make available globally for non-module scripts
23
+ if (typeof window !== 'undefined') {
24
+ window.CHEFCODE_CONFIG = CHEFCODE_CONFIG;
25
+ }
26
+
27
+
frontend/index.html ADDED
@@ -0,0 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ChefCode - Workflow Simulation</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <!-- ChefCode API Layer -->
9
+ <script src="config.js"></script>
10
+ <script src="utils.js"></script>
11
+ <script src="api.js"></script>
12
+ <script>
13
+ // Configure API authentication for web version
14
+ document.addEventListener('DOMContentLoaded', function() {
15
+ if (typeof ChefCodeAPI !== 'undefined' && typeof CHEFCODE_CONFIG !== 'undefined') {
16
+ const api = new ChefCodeAPI(CHEFCODE_CONFIG.API_URL);
17
+ api.setApiKey(CHEFCODE_CONFIG.API_KEY);
18
+ window.chefCodeAPI = api; // Make available globally
19
+ console.log('✅ ChefCode API configured with authentication');
20
+ }
21
+ });
22
+ </script>
23
+ <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
24
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
25
+ </head>
26
+ <body>
27
+ <header class="header">
28
+ <div class="header-left">
29
+ <button class="home-button" id="chefcode-logo-btn">
30
+ <i class="fas fa-home"></i>
31
+ </button>
32
+ </div>
33
+ <div class="header-center">
34
+ <h1 class="logo">ChefCode</h1>
35
+ </div>
36
+ <div class="header-right">
37
+ <div class="account-menu">
38
+ <button class="account-button">Account <span class="arrow-down">&#9660;</span></button>
39
+ <div class="dropdown-content">
40
+ <a href="#">Profile</a>
41
+ <a href="#">Settings</a>
42
+ <a href="#">Logout</a>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </header>
47
+
48
+ <main class="main-container">
49
+ <!-- ...existing code... -->
50
+ <section id="step-selection-page" class="page-section active">
51
+ <div class="step-buttons-grid">
52
+ <button class="big-step-button" data-step="goods-in">
53
+ <i class="fas fa-box-open"></i>
54
+ <span>GOODS IN</span>
55
+ </button>
56
+ <button class="big-step-button" data-step="recipe-selection">
57
+ <i class="fas fa-utensils"></i>
58
+ <span>RECIPES</span>
59
+ </button>
60
+ <button class="big-step-button" data-step="production">
61
+ <i class="fas fa-industry"></i>
62
+ <span>PRODUCTION</span>
63
+ </button>
64
+ <button class="big-step-button" data-step="sales">
65
+ <i class="fas fa-cash-register"></i>
66
+ <span>SALES</span>
67
+ </button>
68
+ <button class="big-step-button" data-step="end-of-day">
69
+ <i class="fas fa-shopping-basket"></i>
70
+ <span>SHOPPING LIST</span>
71
+ </button>
72
+ <button class="big-step-button" data-step="dashboard-selection">
73
+ <i class="fas fa-chart-line"></i>
74
+ <span>DASHBOARD</span>
75
+ </button>
76
+ <button class="big-step-button" data-step="inventory-page">
77
+ <i class="fas fa-clipboard-check"></i>
78
+ <span>INVENTORY</span>
79
+ </button>
80
+ <button class="big-step-button" data-step="add-module">
81
+ <i class="fas fa-plus"></i>
82
+ <span>ADD MODULE</span>
83
+ </button>
84
+ </div>
85
+ </section>
86
+
87
+ <section id="input-detail-page" class="page-section">
88
+ <div id="input-pages-container">
89
+
90
+ <div id="goods-in-content" class="input-page">
91
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>GOODS IN</h3></div>
92
+ <div class="goods-in-buttons-grid">
93
+ <button class="big-step-button" data-action="invoice-photo"><i class="fas fa-camera"></i><span>INVOICE PHOTO (OCR)</span></button>
94
+ <button class="big-step-button" data-action="voice-input" style="display:none;"><i class="fas fa-microphone"></i><span>VOICE INPUT</span></button>
95
+ <button class="big-step-button" data-action="manual-input"><i class="fas fa-keyboard"></i><span>MANUAL INPUT</span></button>
96
+ </div>
97
+ </div>
98
+
99
+ <div id="manual-input-content" class="input-page">
100
+ <div class="page-header-with-back"><button class="back-button" data-back-target="goods-in-content"><i class="fas fa-arrow-left"></i> Back</button><h3>MANUAL INPUT</h3></div>
101
+ <form id="manual-entry-form" class="manual-form">
102
+ <div class="form-group full-width"><label for="item-name">Item Name</label><input type="text" id="item-name" name="item-name" placeholder="e.g., San Marzano Tomatoes" required></div>
103
+ <div class="form-group"><label for="item-quantity">Quantity</label><input type="number" id="item-quantity" name="item-quantity" placeholder="e.g., 25" step="0.01" required></div>
104
+ <div class="form-group"><label for="item-unit">Unit</label><input type="text" id="item-unit" name="item-unit" placeholder="e.g., kg" required></div>
105
+ <div class="form-group"><label for="item-category">Category</label><input type="text" id="item-category" name="item-category" placeholder="e.g., Vegetables" required></div>
106
+ <div class="form-group"><label for="item-price">Unit Price (€)</label><input type="number" id="item-price" name="item-price" placeholder="e.g., 2.50" step="0.01" required></div>
107
+ <!-- HACCP Traceability Fields -->
108
+ <div class="form-group"><label for="item-lot-number">Lot Number <span style="color:#888;">(optional)</span></label><input type="text" id="item-lot-number" name="item-lot-number" placeholder="e.g., LOT2024-001"></div>
109
+ <div class="form-group"><label for="item-expiry-date">Expiry Date <span style="color:#888;">(optional)</span></label><input type="date" id="item-expiry-date" name="item-expiry-date"></div>
110
+ <div style="text-align:center; margin-top:16px;">
111
+ <button type="submit" class="submit-btn"><i class="fas fa-save"></i> Add to Inventory</button>
112
+ </div>
113
+ </form>
114
+ </div>
115
+
116
+ <div id="dashboard-selection-content" class="input-page">
117
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>Select Dashboard</h3></div>
118
+ <div class="step-buttons-grid">
119
+ <button class="big-step-button" data-step="kitchen-dashboard"><i class="fas fa-utensils"></i><span>KITCHEN DASHBOARD</span></button>
120
+ <button class="big-step-button" data-step="management-dashboard"><i class="fas fa-chart-bar"></i><span>MANAGEMENT DASHBOARD</span></button>
121
+ </div>
122
+ </div>
123
+
124
+ <div id="kitchen-dashboard-content" class="input-page">
125
+ <div class="page-header-with-back"><button class="back-button" data-back-target="dashboard-selection-content"><i class="fas fa-arrow-left"></i> Back</button><h3>Chef's Operational Dashboard</h3></div>
126
+ <div class="new-dashboard-grid kitchen-dash"><div class="new-dash-card"><h5 class="new-card-title">Today's Work Plan (Mise en Place)</h5><table class="prep-list-table"><thead><tr><th>Priority</th><th>Task</th><th>Status</th></tr></thead><tbody><tr><td><span class="priority high">High</span></td><td>Prepare Amatriciana Sauce (5L)</td><td><span class="status done">Done</span></td></tr><tr><td><span class="priority high">High</span></td><td>Clean Mussels and Clams</td><td><span class="status done">Done</span></td></tr><tr><td><span class="priority medium">Medium</span></td><td>Cut Vegetables for Soffritto</td><td><span class="status pending">To Do</span></td></tr><tr><td><span class="priority medium">Medium</span></td><td>Fillet Sea Bass (10 pcs)</td><td><span class="status pending">To Do</span></td></tr></tbody></table></div><div class="new-dash-card"><h5 class="new-card-title">Today's "Hot" Items</h5><table class="hot-items-table"><thead><tr><th>Dish</th><th>Qty</th><th>Margin</th></tr></thead><tbody><tr><td>Spaghetti Carbonara</td><td>18</td><td><span class="margin good">€ 10.90</span></td></tr><tr><td>Frittura di Calamari</td><td>12</td><td><span class="margin ok">€ 9.90</span></td></tr><tr><td>Tagliata di Manzo</td><td>9</td><td><span class="margin good">€ 14.30</span></td></tr><tr><td>Tonnarelli Cacio e Pepe</td><td>7</td><td><span class="margin good">€ 9.80</span></td></tr></tbody></table></div><div class="new-dash-card"><h5 class="new-card-title">Critical Stock</h5><ul class="dash-list"><li>Guanciale (under 1 kg)</li><li>Pecorino Romano</li><li>White Wine for cooking</li></ul></div><div class="new-dash-card"><h5 class="new-card-title">Use Soon (Anti-Waste)</h5><ul class="dash-list"><li>Fresh Porcini Mushrooms</li><li>Buffalo Mozzarella</li><li>Fresh Basil</li></ul></div><div class="new-dash-card full-width"><h5 class="new-card-title">Menu Engineering Analysis</h5><div class="menu-engineering-grid"><div class="quadrant puzzle"><h6>PUZZLES ❓</h6><small>High profit, low popularity. Needs promotion.</small><p>Filetto al Pepe Verde</p></div><div class="quadrant star"><h6>STARS ⭐</h6><small>High profit, high popularity. Our champions!</small><p>Tagliata di Manzo</p><p>Spaghetti Carbonara</p></div><div class="quadrant dog"><h6>DOGS 🐶</h6><small>Low profit, low popularity. Consider removing.</small><p>Insalatona "Chef"</p></div><div class="quadrant plow-horse"><h6>PLOW-HORSES 🐴</h6><small>Low profit, high popularity. Optimize cost.</small><p>Frittura di Calamari</p></div></div></div></div>
127
+ </div>
128
+
129
+ <div id="management-dashboard-content" class="input-page">
130
+ <div class="page-header-with-back"><button class="back-button" data-back-target="dashboard-selection-content"><i class="fas fa-arrow-left"></i> Back</button><h3>Management Dashboard</h3></div>
131
+ <div class="new-dashboard-grid"><div class="new-dash-card kpi-card"><h5 class="kpi-title">REVENUE</h5><p class="kpi-amount">$895,430</p></div><div class="new-dash-card kpi-card"><h5 class="kpi-title">AVERAGE TRANSACTION</h5><p class="kpi-amount">$34.49</p><small class="kpi-change up"><i class="fas fa-caret-up"></i> 3,1%</small></div><div class="new-dash-card kpi-card"><h5 class="kpi-title">ORDERS</h5><p class="kpi-amount">25,959</p><small class="kpi-change down"><i class="fas fa-caret-down"></i> 2,5%</small></div><div class="new-dash-card tall"><h5 class="new-card-title">SALES BY MONTH</h5><div class="bar-chart-v"><div class="bar-wrapper"><div class="bar" style="height: 20%;"></div><span class="bar-label-month">Jan</span></div><div class="bar-wrapper"><div class="bar" style="height: 30%;"></div><span class="bar-label-month">Feb</span></div><div class="bar-wrapper"><div class="bar" style="height: 45%;"></div><span class="bar-label-month">Mar</span></div><div class="bar-wrapper"><div class="bar" style="height: 60%;"></div><span class="bar-label-month">Apr</span></div><div class="bar-wrapper"><div class="bar" style="height: 55%;"></div><span class="bar-label-month">May</span></div><div class="bar-wrapper"><div class="bar" style="height: 70%;"></div><span class="bar-label-month">Jun</span></div><div class="bar-wrapper"><div class="bar" style="height: 85%;"></div><span class="bar-label-month">Jul</span></div><div class="bar-wrapper"><div class="bar" style="height: 95%;"></div><span class="bar-label-month">Aug</span></div></div></div><div class="new-dash-card tall"><h5 class="new-card-title">SALES BY CHANNEL</h5><div class="chart-with-legend"><div class="pie-chart"></div><div class="channel-list"><div class="channel-item"><i class="fas fa-circle channel-icon dine-in"></i><span class="channel-label">Dine-in</span><span class="channel-perc">70%</span></div><div class="channel-item"><i class="fas fa-circle channel-icon takeaway"></i><span class="channel-label">Takeaway</span><span class="channel-perc">15%</span></div><div class="channel-item"><i class="fas fa-circle channel-icon delivery"></i><span class="channel-label">Delivery</span><span class="channel-perc">15%</span></div></div></div></div><div class="new-dash-card tall"><h5 class="new-card-title">SALES BY CATEGORY</h5><div class="bar-chart-h"><div class="bar-item"><span class="bar-label">Food</span><div class="bar-bg"><div class="bar-fg food" style="width: 62%;"></div></div><span class="bar-perc">62%</span></div><div class="bar-item"><span class="bar-label">Beverage</span><div class="bar-bg"><div class="bar-fg beverage" style="width: 21%;"></div></div><span class="bar-perc">21%</span></div><div class="bar-item"><span class="bar-label">Dessert</span><div class="bar-bg"><div class="bar-fg dessert" style="width: 11%;"></div></div><span class="bar-perc">11%</span></div><div class="bar-item"><span class="bar-label">Other</span><div class="bar-bg"><div class="bar-fg other" style="width: 6%;"></div></div><span class="bar-perc">6%</span></div></div></div></div>
132
+ </div>
133
+
134
+ <div id="inventory-page-content" class="input-page">
135
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>FOOD INVENTORY</h3></div>
136
+ <div class="inventory-controls">
137
+ <input type="search" id="inventory-search" placeholder="Search by name..."><select id="inventory-category-filter"><option value="all">All Categories</option><option value="Vegetables">Vegetables</option><option value="Dry Goods">Dry Goods</option><option value="Dairy">Dairy</option><option value="Meat">Meat</option><option value="Oils">Oils</option><option value="Herbs">Herbs</option><option value="Pasta">Pasta</option><option value="Fish">Fish</option><option value="Legumes">Legumes</option><option value="Bakery">Bakery</option><option value="Beverages">Beverages</option><option value="Sweets">Sweets</option><option value="Condiments">Condiments</option><option value="Nuts">Nuts</option></select>
138
+ <button id="expand-table-btn" class="icon-btn" title="Collapse to Summary"><i class="fas fa-chevron-up"></i></button>
139
+ </div>
140
+ <div class="inventory-table-container">
141
+ <table>
142
+ <thead><tr><th>Item</th><th>Unit Price</th><th>Unit</th><th>Quantity</th><th>Category</th><th>Lot #</th><th>Expiry</th><th>Total Value</th><th>Action</th></tr></thead>
143
+ <tbody id="inventory-table-body">
144
+ <!-- Inventory items will be loaded dynamically -->
145
+ </tbody>
146
+ </table>
147
+ </div>
148
+ <div class="inventory-summary"><h4>Total Inventory Value: <span id="inventory-total-value">€2,362.90</span></h4></div>
149
+ </div>
150
+
151
+ <div id="camera-simulation-page" class="input-page">
152
+ <div class="page-header-with-back"><button class="back-button" data-back-target="goods-in-content"><i class="fas fa-arrow-left"></i> Back</button><h3>TAKE INVOICE PHOTO</h3></div>
153
+ <div class="camera-preview-container"><div class="camera-viewfinder"><i class="fas fa-camera-retro"></i><p>Place invoice here</p></div></div>
154
+ <div class="camera-controls">
155
+ <button id="take-photo-btn" class="input-btn"><i class="fas fa-circle"></i><span>Take Photo</span></button>
156
+ <button id="retake-photo-btn" class="input-btn" style="display: none;"><i class="fas fa-redo"></i><span>Retake Photo</span></button>
157
+ <button id="confirm-photo-btn" class="input-btn" style="display: none;"><i class="fas fa-check"></i><span>Confirm Photo & OCR</span></button>
158
+ </div>
159
+ <div class="output-section camera-output" style="display: none;">
160
+ <h4>OCR Result Simulation</h4>
161
+ <ul><li>Invoice #: INV2025-07-001</li><li>Date: 03/07/2025</li><li>Supplier: Global Foods S.p.A.</li><li>Items detected: 5</li><li>Total Amount: €150.00</li></ul>
162
+ </div>
163
+ </div>
164
+
165
+ <div id="voice-input-page-content" class="input-page">
166
+ <div class="page-header-with-back"><button class="back-button" data-back-target="goods-in-content"><i class="fas fa-arrow-left"></i> Back</button><h3>VOICE INPUT</h3></div>
167
+ <div class="voice-input-container">
168
+ <p id="voice-status">Press the microphone to start speaking...</p>
169
+ <div class="voice-mic-container"><button id="microphone-btn"><i class="fas fa-microphone"></i></button><span id="mic-label">Start Recording</span></div>
170
+ <div id="voice-recognized-text" class="output-section" style="display: none;">
171
+ <h4>Recognized Text</h4><p id="recognized-text-content"></p>
172
+ <button id="process-voice-btn" class="input-btn"><i class="fas fa-check"></i><span>Process Input</span></button>
173
+ </div>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Recipe Selection Page -->
178
+ <div id="recipe-selection-content" class="input-page">
179
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>RECIPE MANAGEMENT</h3></div>
180
+ <div class="step-buttons-grid">
181
+ <button class="big-step-button" data-step="add-recipe">
182
+ <i class="fas fa-plus-circle"></i>
183
+ <span>ADD RECIPE</span>
184
+ </button>
185
+ <button class="big-step-button" id="search-web-recipe-btn">
186
+ <i class="fas fa-globe"></i>
187
+ <span>SEARCH RECIPE FROM WEB</span>
188
+ </button>
189
+ <button class="big-step-button" data-step="recipe-catalogue">
190
+ <i class="fas fa-book"></i>
191
+ <span>RECIPE CATALOGUE</span>
192
+ </button>
193
+ </div>
194
+ </div>
195
+
196
+ <!-- Add Recipe (existing setup) -->
197
+ <div id="add-recipe-content" class="input-page">
198
+ <div class="page-header-with-back"><button class="back-button" data-back-target="recipe-selection-content"><i class="fas fa-arrow-left"></i> Back</button><h3>ADD RECIPE</h3></div>
199
+ <form id="recipe-form">
200
+ <div class="form-group full-width"><label for="recipe-name">Recipe Name</label><input type="text" id="recipe-name" required placeholder="e.g., Spaghetti alla Carbonara"></div>
201
+ <div class="form-group full-width"><label for="recipe-instructions">Instructions</label><textarea id="recipe-instructions" rows="4" placeholder="Describe the preparation steps..."></textarea></div>
202
+ </form>
203
+ <!-- === RECIPE YIELD (quanto prodotto fa 1 batch) === -->
204
+ <div class="form-group full-width">
205
+ <label for="recipe-yield-qty">Recipe yield (product obtained per batch)</label>
206
+ <div class="yield-row">
207
+ <div style="display:flex; gap:10px; align-items:center;">
208
+ <input type="number" id="recipe-yield-qty" placeholder="es. 10" step="0.01" style="width:100px;">
209
+ <select id="recipe-yield-unit" style="width:80px;">
210
+ <option value="pz">pz</option>
211
+ <option value="kg">kg</option>
212
+ <option value="g">g</option>
213
+ <option value="lt">lt</option>
214
+ <option value="ml">ml</option>
215
+ </select>
216
+ </div>
217
+ </div>
218
+ </div>
219
+
220
+ <div class="add-ingredient-section">
221
+ <div class="form-group"><label for="ingredient-select">Select Ingredient</label><select id="ingredient-select"><option value="" disabled selected>-- Choose an ingredient --</option></select></div>
222
+ <div class="form-group"><label for="ingredient-qty">Quantity</label><input type="number" id="ingredient-qty" placeholder="e.g., 150"></div>
223
+ <div class="form-group"><label for="ingredient-unit">Unit</label><select id="ingredient-unit"><option>g</option><option>kg</option><option>ml</option><option>l</option><option>pz</option></select></div>
224
+ <button id="add-ingredient-btn" class="icon-btn"><i class="fas fa-plus"></i></button>
225
+ </div>
226
+ <div class="ingredients-list-container">
227
+ <ul id="recipe-ingredients-list"></ul>
228
+ </div>
229
+ <button id="save-recipe-btn" class="submit-btn"><i class="fas fa-save"></i> Save Recipe</button>
230
+ </div>
231
+
232
+ <!-- Recipe Catalogue Page -->
233
+ <div id="recipe-catalogue-content" class="input-page">
234
+ <div class="page-header-with-back">
235
+ <button class="back-button" data-back-target="recipe-selection-content"><i class="fas fa-arrow-left"></i> Back</button>
236
+ <h3>RECIPE CATALOGUE</h3>
237
+ </div>
238
+
239
+ <div class="recipe-catalogue-container">
240
+ <!-- Modern Search Bar -->
241
+ <div class="recipe-search-wrapper">
242
+ <i class="fas fa-search search-icon"></i>
243
+ <input type="search" id="recipe-search" class="recipe-search-input" placeholder="Search recipes by name...">
244
+ </div>
245
+
246
+ <!-- Recipe Cards Grid -->
247
+ <div id="recipe-catalogue-body" class="recipe-cards-grid">
248
+ <!-- Recipe cards will be populated here by JavaScript -->
249
+ </div>
250
+
251
+ <!-- Empty State -->
252
+ <div id="recipe-catalogue-empty" class="recipe-empty-state" style="display:none;">
253
+ <i class="fas fa-book-open"></i>
254
+ <h3>No Recipes Yet</h3>
255
+ <p>Start building your recipe collection!</p>
256
+ <button class="empty-state-btn" onclick="document.querySelector('[data-step=add-recipe]')?.click()">
257
+ <i class="fas fa-plus"></i> Add Your First Recipe
258
+ </button>
259
+ </div>
260
+ </div>
261
+ </div>
262
+
263
+ <div id="production-content" class="input-page">
264
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>PRODUCTION</h3></div>
265
+ <div class="add-production-task-section">
266
+ <!-- Recipe -->
267
+ <div class="form-group">
268
+ <label for="recipe-select-prod">Recipe to Prepare</label>
269
+ <select id="recipe-select-prod">
270
+ <option value="" disabled selected>-- Choose a recipe --</option>
271
+ </select>
272
+ </div>
273
+
274
+ <!-- Quantity -->
275
+ <div class="form-group">
276
+ <label for="production-qty">Quantity to Produce</label>
277
+ <input type="text" id="production-qty" placeholder="Insert Quantity">
278
+ </div>
279
+
280
+ <!-- Unità di misura (resta com’era) -->
281
+ <div class="form-group">
282
+ <label for="production-unit">Unit</label>
283
+ <select id="production-unit">
284
+ <option value="kg">kg</option>
285
+ <option value="g">g</option>
286
+ <option value="lt">lt</option>
287
+ <option value="ml">ml</option>
288
+ <option value="pz">pz</option>
289
+ </select>
290
+ </div>
291
+
292
+ <!-- Assign to -->
293
+ <div class="form-group">
294
+ <label for="assign-to">Assign to</label>
295
+ <select id="assign-to">
296
+ <option>Luca</option>
297
+ <option>Sofia</option>
298
+ <option>Marco</option>
299
+ <option>Tutti</option>
300
+ </select>
301
+ </div>
302
+
303
+ <!-- Initial Status (nuovo) -->
304
+ <div class="form-group">
305
+ <label for="initial-status">Initial Status</label>
306
+ <select id="initial-status">
307
+ <option value="todo" selected>To Do</option>
308
+ <option value="completed">Completed Task</option>
309
+ </select>
310
+ </div>
311
+
312
+ <button id="add-task-btn" class="submit-btn small-btn">
313
+ <i class="fas fa-plus"></i> Add to List
314
+ </button>
315
+
316
+ <!-- RIMOSSI I TAB BUTTON, SOLO DUE COLONNE VISIBILI -->
317
+ <div class="production-tasks-tabbed">
318
+ <div id="todo-tasks" class="task-list prod-tab-panel active"></div>
319
+ <div id="completed-tasks-list" class="task-list prod-tab-panel"></div>
320
+ </div>
321
+
322
+ </div>
323
+ </div>
324
+ </div>
325
+
326
+ <div id="sales-content" class="input-page">
327
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>SALES</h3></div><p>Record sales and update stock.</p>
328
+ </div>
329
+ <div id="end-of-day-content" class="input-page">
330
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>SHOPPING LIST</h3></div><p>Generate and manage your daily shopping list.</p>
331
+ </div>
332
+
333
+ <div id="add-module-content" class="input-page">
334
+ <div class="page-header-with-back"><button class="back-button" data-back-target="step-selection-page"><i class="fas fa-arrow-left"></i> Back</button><h3>Add New Module</h3></div>
335
+ <p>Module creation/selection interface will be here...</p>
336
+ </div>
337
+
338
+ </div>
339
+ </section>
340
+ </main>
341
+
342
+ <!-- AI Toolbar Footer -->
343
+ <footer class="ai-toolbar-footer">
344
+ <div class="ai-toolbar-container">
345
+ <input
346
+ type="text"
347
+ class="ai-command-input"
348
+ placeholder="Type or speak a command..."
349
+ id="ai-command-input"
350
+ />
351
+ <button class="ai-toolbar-btn ai-send-btn" id="ai-send-btn" title="Send Command">
352
+ <i class="fas fa-paper-plane"></i>
353
+ </button>
354
+ <button class="ai-toolbar-btn ai-voice-btn" id="ai-voice-btn" title="Voice Input">
355
+ <i class="fas fa-microphone"></i>
356
+ </button>
357
+ <button class="ai-toolbar-btn ai-upload-btn" id="ai-upload-btn" title="Upload">
358
+ <i class="fas fa-plus"></i>
359
+ </button>
360
+ </div>
361
+ </footer>
362
+
363
+ <!-- OCR Modal -->
364
+ <div id="ocr-modal" class="ocr-modal-overlay" style="display: none;">
365
+ <div class="ocr-modal-content">
366
+ <!-- Modal Header -->
367
+ <div class="ocr-modal-header">
368
+ <h2 class="ocr-modal-title">Scan or Upload Invoice</h2>
369
+ <p class="ocr-modal-subtitle">Choose how to import your supplier invoice. ChefCode will extract item details automatically.</p>
370
+ <button class="ocr-modal-close" id="ocr-modal-close-btn">
371
+ <i class="fas fa-times"></i>
372
+ </button>
373
+ </div>
374
+
375
+ <!-- Modal Body -->
376
+ <div class="ocr-modal-body">
377
+ <!-- Initial Selection Screen -->
378
+ <div id="ocr-selection-screen" class="ocr-screen">
379
+ <div class="ocr-option-grid">
380
+ <div class="ocr-option-card" id="camera-option">
381
+ <div class="ocr-option-icon">
382
+ <i class="fas fa-camera"></i>
383
+ </div>
384
+ <h3>📸 Take Photo</h3>
385
+ <p>Capture invoice with your camera</p>
386
+ </div>
387
+ <div class="ocr-option-card" id="upload-option">
388
+ <div class="ocr-option-icon">
389
+ <i class="fas fa-upload"></i>
390
+ </div>
391
+ <h3>🗂️ Upload File</h3>
392
+ <p>Select image or PDF file</p>
393
+ </div>
394
+ </div>
395
+ </div>
396
+
397
+ <!-- Camera Screen -->
398
+ <div id="ocr-camera-screen" class="ocr-screen" style="display: none;">
399
+ <div class="ocr-camera-container">
400
+ <video id="ocr-camera-preview" autoplay muted playsinline></video>
401
+ <div class="ocr-camera-overlay">
402
+ <div class="ocr-camera-guides">
403
+ <div class="ocr-guide-line"></div>
404
+ <div class="ocr-guide-line"></div>
405
+ <div class="ocr-guide-line"></div>
406
+ <div class="ocr-guide-line"></div>
407
+ </div>
408
+ </div>
409
+ <div class="ocr-camera-controls">
410
+ <button class="ocr-camera-btn secondary" id="ocr-camera-back">
411
+ <i class="fas fa-arrow-left"></i>
412
+ </button>
413
+ <button class="ocr-camera-btn primary" id="ocr-camera-capture">
414
+ <i class="fas fa-camera"></i>
415
+ </button>
416
+ <button class="ocr-camera-btn secondary" id="ocr-camera-switch">
417
+ <i class="fas fa-sync-alt"></i>
418
+ </button>
419
+ </div>
420
+ </div>
421
+ </div>
422
+
423
+ <!-- Preview Screen -->
424
+ <div id="ocr-preview-screen" class="ocr-screen" style="display: none;">
425
+ <div class="ocr-preview-container">
426
+ <img id="ocr-preview-image" alt="Invoice Preview" />
427
+ <div class="ocr-preview-controls">
428
+ <button class="ocr-preview-btn secondary" id="ocr-preview-back">
429
+ <i class="fas fa-arrow-left"></i> Back
430
+ </button>
431
+ <button class="ocr-preview-btn primary" id="ocr-preview-process">
432
+ <i class="fas fa-magic"></i> Process Invoice
433
+ </button>
434
+ </div>
435
+ </div>
436
+ </div>
437
+
438
+ <!-- Processing Screen -->
439
+ <div id="ocr-processing-screen" class="ocr-screen" style="display: none;">
440
+ <div class="ocr-processing-container">
441
+ <div class="ocr-spinner"></div>
442
+ <h3>Analyzing invoice...</h3>
443
+ <p>ChefCode is extracting item details from your invoice</p>
444
+ </div>
445
+ </div>
446
+
447
+ <!-- Results Screen -->
448
+ <div id="ocr-results-screen" class="ocr-screen" style="display: none;">
449
+ <div class="ocr-results-container">
450
+ <div class="ocr-results-header">
451
+ <h3>Invoice Analysis Complete</h3>
452
+ <div class="ocr-results-meta">
453
+ <span id="ocr-supplier-name">Supplier: Unknown</span>
454
+ <span id="ocr-invoice-date">Date: Unknown</span>
455
+ </div>
456
+ <p style="font-size: 0.9em; color: #7f8c8d; margin-top: 12px; text-align: center;">
457
+ ✏️ <strong>Edit any field</strong> to correct OCR results. Add expiry dates for HACCP compliance.
458
+ </p>
459
+ </div>
460
+ <div class="ocr-results-table-container">
461
+ <table class="ocr-results-table">
462
+ <thead>
463
+ <tr>
464
+ <th>Item</th>
465
+ <th>Qty</th>
466
+ <th>Unit</th>
467
+ <th>Price</th>
468
+ <th>Lot #</th>
469
+ <th>Expiry</th>
470
+ </tr>
471
+ </thead>
472
+ <tbody id="ocr-results-tbody">
473
+ <!-- Results will be populated here -->
474
+ </tbody>
475
+ </table>
476
+ </div>
477
+ <div class="ocr-results-actions">
478
+ <button class="ocr-results-btn secondary" id="ocr-results-back">
479
+ <i class="fas fa-arrow-left"></i> Back
480
+ </button>
481
+ <button class="ocr-results-btn primary" id="ocr-results-confirm">
482
+ <i class="fas fa-check"></i> Add to Inventory
483
+ </button>
484
+ </div>
485
+ </div>
486
+ </div>
487
+
488
+ <!-- Success Screen -->
489
+ <div id="ocr-success-screen" class="ocr-screen" style="display: none;">
490
+ <div class="ocr-success-container">
491
+ <div class="ocr-success-icon">
492
+ <i class="fas fa-check-circle"></i>
493
+ </div>
494
+ <h3>✅ Items Added Successfully</h3>
495
+ <p>Your invoice items have been added to the inventory</p>
496
+ <button class="ocr-success-btn primary" id="ocr-success-close">
497
+ <i class="fas fa-check"></i> Done
498
+ </button>
499
+ </div>
500
+ </div>
501
+ </div>
502
+ </div>
503
+ </div>
504
+
505
+ <!-- Quick Add Popup -->
506
+ <div id="quick-add-popup" class="quick-add-popup" style="display: none;">
507
+ <div class="quick-add-popup-option" id="quick-add-upload">
508
+ <i class="fas fa-upload"></i>
509
+ <span>📂 Upload File</span>
510
+ </div>
511
+ <div class="quick-add-popup-option" id="quick-add-camera">
512
+ <i class="fas fa-camera"></i>
513
+ <span>📸 Take a Picture</span>
514
+ </div>
515
+ </div>
516
+
517
+ <!-- Web Recipe Search Modal -->
518
+ <div id="web-recipe-modal" class="web-recipe-modal-overlay" style="display: none;">
519
+ <div class="web-recipe-modal-content">
520
+ <!-- Modal Header -->
521
+ <div class="web-recipe-modal-header">
522
+ <div>
523
+ <h2 class="web-recipe-modal-title">
524
+ <span style="display: flex; align-items: center; gap: 8px;">
525
+ 🔍 Search Recipe from Web
526
+ </span>
527
+ <span class="web-recipe-modal-subtitle">Discover recipes from the web to add to your collection</span>
528
+ </h2>
529
+ </div>
530
+ <button class="web-recipe-modal-close" id="web-recipe-close-btn">
531
+ <i class="fas fa-times"></i>
532
+ </button>
533
+ </div>
534
+ <!-- Modal Body -->
535
+ <div class="web-recipe-modal-body">
536
+ <!-- Search Screen -->
537
+ <div id="web-recipe-search-screen" class="web-recipe-screen">
538
+ <div class="web-recipe-search-container">
539
+ <div class="web-recipe-search-box">
540
+ <input
541
+ type="text"
542
+ id="web-recipe-search-input"
543
+ class="web-recipe-search-input"
544
+ placeholder="Find a quick Italian pasta recipe without cheese..."
545
+ />
546
+ <button id="web-recipe-search-btn" class="web-recipe-search-button">
547
+ <i class="fas fa-search"></i> Search
548
+ </button>
549
+ </div>
550
+ <p style="text-align: center; color: #94a3b8; font-size: 0.85em; margin: 8px 0 0 0; font-style: italic;">
551
+ Powered by TheMealDB
552
+ </p>
553
+ <div class="web-recipe-filters" style="display: none;">
554
+ <select id="web-recipe-cuisine-filter" class="web-recipe-filter-select">
555
+ <option value="">All Cuisines</option>
556
+ <option value="Italian">Italian</option>
557
+ <option value="Chinese">Chinese</option>
558
+ <option value="Mexican">Mexican</option>
559
+ <option value="Indian">Indian</option>
560
+ <option value="Japanese">Japanese</option>
561
+ <option value="French">French</option>
562
+ <option value="Thai">Thai</option>
563
+ <option value="American">American</option>
564
+ </select>
565
+ </div>
566
+ </div>
567
+
568
+ <!-- Loading State -->
569
+ <div id="web-recipe-loading" class="web-recipe-loading" style="display: none;">
570
+ <div class="web-recipe-spinner"></div>
571
+ <p>Searching for recipes...</p>
572
+ </div>
573
+
574
+ <!-- Results Grid -->
575
+ <div id="web-recipe-results-container" class="web-recipe-results-grid" style="display: none;">
576
+ <!-- Recipe cards will be populated here -->
577
+ </div>
578
+
579
+ <!-- Empty State -->
580
+ <div id="web-recipe-empty" class="web-recipe-empty-state" style="display: none;">
581
+ <i class="fas fa-search"></i>
582
+ <h3>No Recipes Found</h3>
583
+ <p>Try a different search term or cuisine</p>
584
+ </div>
585
+ </div>
586
+
587
+ <!-- Recipe Detail Screen -->
588
+ <div id="web-recipe-detail-screen" class="web-recipe-screen">
589
+ <div class="web-recipe-detail-container">
590
+ <button class="web-recipe-back-btn" id="web-recipe-detail-back">
591
+ <i class="fas fa-arrow-left"></i> Back to Results
592
+ </button>
593
+
594
+ <div id="web-recipe-detail-content">
595
+ <!-- Recipe details will be populated here -->
596
+ </div>
597
+
598
+ <div class="web-recipe-detail-actions">
599
+ <button class="web-recipe-btn primary" id="web-recipe-import-btn">
600
+ <i class="fas fa-download"></i> Import Recipe
601
+ </button>
602
+ </div>
603
+ </div>
604
+ </div>
605
+
606
+ <!-- Ingredient Mapping Screen -->
607
+ <div id="web-recipe-mapping-screen" class="web-recipe-screen">
608
+ <div class="web-recipe-mapping-container">
609
+ <button class="web-recipe-back-btn" id="web-recipe-mapping-back">
610
+ <i class="fas fa-arrow-left"></i> Back
611
+ </button>
612
+
613
+ <h3>Ingredient Mapping</h3>
614
+ <p class="web-recipe-mapping-subtitle">
615
+ AI is matching recipe ingredients to your inventory...
616
+ </p>
617
+
618
+ <!-- Loading State -->
619
+ <div id="web-recipe-mapping-loading" class="web-recipe-loading">
620
+ <div class="web-recipe-spinner"></div>
621
+ <p>Analyzing ingredients...</p>
622
+ </div>
623
+
624
+ <!-- Mapping Results -->
625
+ <div id="web-recipe-mapping-results" class="web-recipe-mapping-results" style="display: none;">
626
+ <div class="web-recipe-mapping-legend">
627
+ <span class="mapping-legend-item">
628
+ <span class="mapping-badge exact"></span> Exact Match
629
+ </span>
630
+ <span class="mapping-legend-item">
631
+ <span class="mapping-badge substitute"></span> Substitute
632
+ </span>
633
+ <span class="mapping-legend-item">
634
+ <span class="mapping-badge missing"></span> Missing
635
+ </span>
636
+ </div>
637
+
638
+ <div id="web-recipe-mapping-list" class="web-recipe-mapping-list">
639
+ <!-- Mapping items will be populated here -->
640
+ </div>
641
+
642
+ <div class="web-recipe-mapping-actions">
643
+ <button class="web-recipe-btn secondary" id="web-recipe-mapping-cancel">
644
+ Cancel
645
+ </button>
646
+ <button class="web-recipe-btn primary" id="web-recipe-mapping-confirm">
647
+ <i class="fas fa-check"></i> Save Recipe
648
+ </button>
649
+ </div>
650
+ </div>
651
+ </div>
652
+ </div>
653
+
654
+ <!-- Success Screen -->
655
+ <div id="web-recipe-success-screen" class="web-recipe-screen">
656
+ <div class="web-recipe-success-container">
657
+ <div class="web-recipe-success-icon">
658
+ <i class="fas fa-check-circle"></i>
659
+ </div>
660
+ <h3>✅ Recipe Saved Successfully!</h3>
661
+ <p>The recipe has been added to your catalogue</p>
662
+ <button class="web-recipe-btn primary" id="web-recipe-success-close">
663
+ <i class="fas fa-check"></i> Done
664
+ </button>
665
+ </div>
666
+ </div>
667
+ </div>
668
+ </div>
669
+ </div>
670
+
671
+ <script src="script.js"></script>
672
+ <script src="web-recipe-search.js"></script>
673
+ <script src="ai-assistant.js"></script>
674
+ </body>
675
+ </html>
frontend/script.js ADDED
@@ -0,0 +1,1830 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ChefCode – MVP Controller
2
+ PATCH 1.2.2 — Stable (Inventory deduction + Production fix)
3
+ */
4
+
5
+ // Global utility functions
6
+ function normName(s) {
7
+ return String(s || '')
8
+ .toLowerCase()
9
+ .normalize('NFD').replace(/[\u0300-\u036f]/g,'')
10
+ .replace(/[^a-z0-9\s]/g,' ')
11
+ .replace(/\s+/g,' ')
12
+ .trim();
13
+ }
14
+
15
+ function normUnit(u) {
16
+ u = String(u || '').trim().toLowerCase();
17
+ if (u === 'l') u = 'lt';
18
+ if (u === 'gr') u = 'g';
19
+ if (u === 'pz.' || u === 'pcs' || u === 'pc') u = 'pz';
20
+ return u;
21
+ }
22
+
23
+ function convertFactor(from, to) {
24
+ from = normUnit(from); to = normUnit(to);
25
+ if (from === to) return 1;
26
+ if (from === 'kg' && to === 'g') return 1000;
27
+ if (from === 'g' && to === 'kg') return 1/1000;
28
+ if (from === 'lt' && to === 'ml') return 1000;
29
+ if (from === 'ml' && to === 'lt') return 1/1000;
30
+ // MVP: pz <-> bt 1:1
31
+ if ((from === 'pz' && to === 'bt') || (from === 'bt' && to === 'pz')) return 1;
32
+ return null; // non convertibili
33
+ }
34
+
35
+ // Global function for adding/merging inventory items
36
+ window.addOrMergeInventoryItem = function({ name, unit, quantity, category, price, lot_number, expiry_date }) {
37
+ const nName = normName(name);
38
+ const pCents = Math.round((Number(price) || 0) * 100);
39
+
40
+ // HACCP: Items with different lot numbers or expiry dates MUST be kept separate for traceability
41
+ const idx = window.STATE.inventory.findIndex(it =>
42
+ normName(it.name) === nName &&
43
+ Math.round((Number(it.price) || 0) * 100) === pCents &&
44
+ (it.lot_number || '') === (lot_number || '') &&
45
+ (it.expiry_date || '') === (expiry_date || '')
46
+ );
47
+
48
+ if (idx >= 0) {
49
+ const row = window.STATE.inventory[idx];
50
+ const fromU = normUnit(unit || row.unit);
51
+ const toU = normUnit(row.unit || fromU);
52
+ const f = convertFactor(fromU, toU);
53
+
54
+ if (f === null) {
55
+ // unità non compatibili: NON fondere, crea una nuova riga
56
+ window.STATE.inventory.push({ name, unit, quantity, category: category || row.category || 'Other', price, lot_number, expiry_date });
57
+ } else {
58
+ row.quantity = (Number(row.quantity) || 0) + (Number(quantity) || 0) * f;
59
+ // manteniamo categoria e unit della riga esistente
60
+ }
61
+ } else {
62
+ // nuova riga con HACCP traceability
63
+ window.STATE.inventory.push({ name, unit, quantity, category, price, lot_number, expiry_date });
64
+ }
65
+ };
66
+
67
+ document.addEventListener('DOMContentLoaded', () => {
68
+ // ===== AI TOOLBAR FUNCTIONALITY =====
69
+
70
+ // ===================================================================
71
+ // AI ASSISTANT TOOLBAR
72
+ // ===================================================================
73
+ // All AI functionality (voice, text, commands) is now handled by ai-assistant.js
74
+ // - Voice button opens the AI chat with voice recognition
75
+ // - Send button sends commands through AI chat
76
+ // - No more browser prompt()/alert() - all conversational UI
77
+ // - See ai-assistant.js for full implementation
78
+ // ===================================================================
79
+
80
+ // Upload button (connects to OCR)
81
+ const aiUploadBtn = document.getElementById('ai-upload-btn');
82
+ if (aiUploadBtn) {
83
+ aiUploadBtn.addEventListener('click', () => {
84
+ // Open OCR modal if available
85
+ if (window.ocrModal) {
86
+ window.ocrModal.openModal();
87
+ } else {
88
+ alert('📤 Upload functionality - Coming soon!');
89
+ }
90
+ });
91
+ }
92
+
93
+
94
+
95
+ // ---------- Helpers ----------
96
+ const el = (id) => document.getElementById(id);
97
+ const q = (sel) => document.querySelector(sel);
98
+ const qa = (sel) => Array.from(document.querySelectorAll(sel));
99
+
100
+ function safe(fn){ try { return fn(); } catch(e){ console.warn(e); return undefined; } }
101
+
102
+ // ---------- Storage ----------
103
+ window.STATE = {
104
+ inventory: [], // [{name, unit, quantity, category, price}]
105
+ recipes: {}, // { "Carbonara": { items:[{name, qty, unit}] } }
106
+ tasks: [], // [{id, recipe, quantity, assignedTo, status}]
107
+ nextTaskId: 1
108
+ };
109
+
110
+ // Remove load/save/localStorage. Always sync with backend.
111
+ window.updateInventoryToBackend = async function() {
112
+ try {
113
+ const apiKey = window.CHEFCODE_CONFIG?.API_KEY || '';
114
+ console.log('🔄 Syncing to backend...', {
115
+ recipes: Object.keys(window.STATE.recipes || {}).length,
116
+ inventory: (window.STATE.inventory || []).length,
117
+ tasks: (window.STATE.tasks || []).length
118
+ });
119
+
120
+ const syncData = {
121
+ inventory: window.STATE.inventory || [],
122
+ recipes: window.STATE.recipes || {},
123
+ tasks: window.STATE.tasks || []
124
+ };
125
+
126
+ const response = await fetch('http://localhost:8000/api/sync-data', {
127
+ method: 'POST',
128
+ headers: {
129
+ 'Content-Type': 'application/json',
130
+ 'X-API-Key': apiKey
131
+ },
132
+ body: JSON.stringify(syncData)
133
+ });
134
+
135
+ if (!response.ok) {
136
+ const errorText = await response.text();
137
+ console.error('❌ Sync failed:', response.status, errorText);
138
+ throw new Error(`Sync failed: ${response.status} - ${errorText}`);
139
+ }
140
+
141
+ console.log('✅ Sync successful!');
142
+ // Removed fetchInventoryFromBackend() to prevent race condition
143
+ // Frontend already has the latest STATE, no need to fetch again
144
+ } catch (err) {
145
+ console.error('❌ Backend sync failed:', err.message);
146
+ alert(`Failed to save to database: ${err.message}\n\nYour changes may not be saved!`);
147
+ throw err; // Re-throw so callers know sync failed
148
+ }
149
+ }
150
+
151
+ async function fetchInventoryFromBackend() {
152
+ try {
153
+ const dataRes = await fetch('http://localhost:8000/api/data');
154
+
155
+ if (!dataRes.ok) {
156
+ throw new Error(`Backend returned ${dataRes.status}: ${dataRes.statusText}`);
157
+ }
158
+
159
+ const latest = await dataRes.json();
160
+
161
+ // Validate response structure
162
+ if (!latest || typeof latest !== 'object') {
163
+ throw new Error('Invalid response format from backend');
164
+ }
165
+
166
+ window.STATE = {
167
+ inventory: Array.isArray(latest.inventory) ? latest.inventory : [],
168
+ recipes: latest.recipes || {},
169
+ tasks: Array.isArray(latest.tasks) ? latest.tasks : [],
170
+ nextTaskId: window.STATE.nextTaskId || 1
171
+ };
172
+
173
+ // Debug: Log recipes loaded
174
+ console.log(`✅ Data loaded from backend: ${Object.keys(window.STATE.recipes).length} recipes`);
175
+ if (Object.keys(window.STATE.recipes).length > 0) {
176
+ console.log('Recipes:', Object.keys(window.STATE.recipes));
177
+ }
178
+
179
+ // Synchronize production tasks with STATE.tasks
180
+ if (Array.isArray(window.STATE.tasks)) {
181
+ window.productionTasks = window.STATE.tasks;
182
+ }
183
+
184
+ window.renderInventory();
185
+ } catch (err) {
186
+ console.error('⚠️ Backend fetch failed:', err.message);
187
+ // Keep existing STATE if fetch fails - don't clear user's data
188
+ }
189
+ }
190
+
191
+
192
+ // ---------- Selectors ----------
193
+ const chefcodeLogoBtn = el('chefcode-logo-btn');
194
+
195
+ const stepSelectionPage = el('step-selection-page');
196
+ const inputDetailPage = el('input-detail-page');
197
+ const inputPagesContainer = el('input-pages-container');
198
+ const bigStepButtons = qa('.big-step-button[data-step]');
199
+
200
+ // Production tab panels (for visibility control)
201
+ const prodPanels = [
202
+ ...Array.from(qa('#production-content .production-tabs')),
203
+ ...Array.from(qa('#production-content .production-tasks-tabbed'))
204
+ ];
205
+
206
+ // Account
207
+ const accountButton = q('.account-button');
208
+ const accountDropdownContent= q('.account-menu .dropdown-content');
209
+
210
+ // Goods In – Camera/OCR (sim)
211
+ const cameraViewfinder = q('.camera-viewfinder');
212
+ const cameraOutput = q('.camera-output');
213
+
214
+ // Goods In – Voice (sim + process)
215
+ const microphoneBtn = el('microphone-btn');
216
+ const micLabel = el('mic-label');
217
+ const voiceStatus = el('voice-status');
218
+ const voiceRecognizedText = el('voice-recognized-text');
219
+ const recognizedTextContent = el('recognized-text-content');
220
+ const processVoiceBtn = el('process-voice-btn');
221
+
222
+ // Goods In – Manual input
223
+ const manualEntryForm = el('manual-entry-form');
224
+ const inventoryTableBody= el('inventory-table-body');
225
+ const inventoryTotalVal = el('inventory-total-value');
226
+
227
+ // Inventory – search/filter/expand
228
+ const inventorySearch = el('inventory-search');
229
+ const categoryFilter = el('inventory-category-filter');
230
+ const expandTableBtn = el('expand-table-btn');
231
+ const inventoryTableCtr = q('#inventory-page-content .inventory-table-container');
232
+
233
+ // Recipe setup
234
+ const ingredientSelect = el('ingredient-select');
235
+ const ingredientQty = el('ingredient-qty');
236
+ const ingredientUnit = el('ingredient-unit');
237
+ const addIngredientBtn = el('add-ingredient-btn');
238
+ const recipeIngredientsList = el('recipe-ingredients-list');
239
+ const saveRecipeBtn = el('save-recipe-btn');
240
+ const recipeNameInput = el('recipe-name');
241
+
242
+ // Production
243
+ const recipeSelectProd = el('recipe-select-prod');
244
+ const productionQty = el('production-qty');
245
+ const assignTo = el('assign-to');
246
+ const initialStatusSelect = el('initial-status');
247
+ const addTaskBtn = el('add-task-btn');
248
+ const todoTasksContainer = el('todo-tasks');
249
+ const inprogressTasksContainer= el('inprogress-tasks');
250
+ const completedTasksList = el('completed-tasks-list');
251
+
252
+ // === Production state bootstrap (safe) ===
253
+ // garantisci che l'array esista sempre e che l'id parta da >0
254
+ window.productionTasks = Array.isArray(window.productionTasks) ? window.productionTasks : [];
255
+ window.taskIdCounter = typeof window.taskIdCounter === 'number' ? window.taskIdCounter : 0;
256
+
257
+ // runtime temp for recipe building
258
+ let currentRecipeIngredients = [];
259
+ let RECIPES = {}; // mappa: { [recipeName]: { items:[{name, quantity, unit}] } }
260
+ // --- Helpers per unità/nome e parsing ---
261
+ const normUnit = (u) => {
262
+ u = String(u || '').trim().toLowerCase();
263
+ if (u === 'l') u = 'lt';
264
+ if (u === 'gr') u = 'g';
265
+ if (u === 'pz.' || u === 'pcs' || u === 'pc') u = 'pz';
266
+ return u;
267
+ };
268
+ const convertFactor = (from, to) => {
269
+ from = normUnit(from); to = normUnit(to);
270
+ if (from === to) return 1;
271
+ if (from === 'kg' && to === 'g') return 1000;
272
+ if (from === 'g' && to === 'kg') return 1/1000;
273
+ if (from === 'lt' && to === 'ml') return 1000;
274
+ if (from === 'ml' && to === 'lt') return 1/1000;
275
+ // MVP: pz <-> bt 1:1
276
+ if ((from === 'pz' && to === 'bt') || (from === 'bt' && to === 'pz')) return 1;
277
+ return null; // non convertibili
278
+ };
279
+ const normName = (s) =>
280
+ String(s || '')
281
+ .toLowerCase()
282
+ .normalize('NFD').replace(/[\u0300-\u036f]/g,'')
283
+ .replace(/[^a-z0-9\s]/g,' ')
284
+ .replace(/\s+/g,' ')
285
+ .trim();
286
+ const parseNumber = (t) => {
287
+ if (!t) return 0;
288
+ t = String(t).replace('€','').replace(/\s/g,'');
289
+ // togli separa-migliaia e usa il punto come decimale
290
+ t = t.replace(/\./g,'').replace(',', '.');
291
+ const n = parseFloat(t);
292
+ return isNaN(n) ? 0 : n;
293
+ };
294
+ // ==== Merge Inventory: stesso nome + stesso prezzo => somma quantità ====
295
+ // Usa le funzioni già presenti: normName, normUnit, convertFactor
296
+
297
+ let isRecording = false;
298
+
299
+
300
+
301
+ // ---------- Routing ----------
302
+ function normalizeToken(s){ return String(s||'').toLowerCase().replace(/[^a-z0-9]/g,''); }
303
+
304
+ function findPageIdForStep(stepToken){
305
+ const token = normalizeToken(stepToken);
306
+ // Prova id esatto "<step>-content"
307
+ const direct = `${stepToken}-content`;
308
+ if (el(direct)) return direct;
309
+ // Cerca qualunque .input-page che contenga il token “normalizzato”
310
+ const pages = qa('.input-page');
311
+ for (const page of pages){
312
+ const pid = page.id || '';
313
+ if (normalizeToken(pid).includes(token)) return pid;
314
+ }
315
+ return null;
316
+ }
317
+
318
+ function showPage(pageId){
319
+ if (!stepSelectionPage || !inputDetailPage || !inputPagesContainer) return;
320
+ qa('.input-page').forEach(p => p.classList.remove('active'));
321
+ stepSelectionPage.classList.remove('active');
322
+ inputDetailPage.classList.remove('active');
323
+ const target = el(pageId);
324
+ if (target){ target.classList.add('active'); inputDetailPage.classList.add('active'); }
325
+ else { stepSelectionPage.classList.add('active'); }
326
+ // Mostra i tab solo se sei nella pagina production
327
+ prodPanels.forEach(el => {
328
+ el.style.display = (pageId === 'production-content') ? '' : 'none';
329
+ });
330
+
331
+ // Render recipe catalogue when showing that page
332
+ if (pageId === 'recipe-catalogue-content' && window.renderRecipeCatalogue) {
333
+ window.renderRecipeCatalogue();
334
+ }
335
+ }
336
+
337
+ bigStepButtons.forEach(btn => {
338
+ btn.addEventListener('click', () => {
339
+ const step = btn.getAttribute('data-step'); // es: goodsin / goods-in
340
+ const pid = findPageIdForStep(step || '');
341
+ if (pid) showPage(pid);
342
+ else showPage('step-selection-page');
343
+ /* ==== PATCH LAYOUT-4x2 — pulizia stili inline sul Back (append-only) ==== */
344
+ (function enforceHomeGridOnBack(){
345
+ const home = document.getElementById('step-selection-page');
346
+ if (!home) return;
347
+ const grid = home.querySelector('.step-buttons-grid');
348
+ if (!grid) return;
349
+
350
+ function cleanInline() {
351
+ // rimuove qualsiasi style inline che possa stringere i riquadri
352
+ grid.removeAttribute('style');
353
+ if (grid.style) {
354
+ grid.style.gridTemplateColumns = '';
355
+ grid.style.gridTemplateRows = '';
356
+ grid.style.gap = '';
357
+ }
358
+ }
359
+
360
+ // Dopo qualunque click su un back-button, quando la home è visibile ripulisci
361
+ document.addEventListener('click', (e) => {
362
+ const back = e.target.closest('.back-button');
363
+ if (!back) return;
364
+ const targetId = back.dataset.backTarget || '';
365
+ setTimeout(() => {
366
+ if (targetId === 'step-selection-page' || home.classList.contains('active')) {
367
+ cleanInline(); // il CSS sopra fa il resto (4x2 responsive)
368
+ }
369
+ }, 0);
370
+ }, true);
371
+
372
+ // Safety net: se la home diventa active per altri motivi
373
+ const mo = new MutationObserver(() => {
374
+ if (home.classList.contains('active')) cleanInline();
375
+ });
376
+ mo.observe(home, { attributes: true, attributeFilter: ['class'] });
377
+ })();
378
+ /* === PATCH 1.1.6 — Forza il centro della dashboard al ritorno (append-only) === */
379
+ (function centerHomeGridOnActivate(){
380
+ const home = document.getElementById('step-selection-page');
381
+ const grid = home ? home.querySelector('.step-buttons-grid') : null;
382
+ if (!home || !grid) return;
383
+
384
+ function centerNow(){
385
+ // nessuna misura fissa: centratura a contenuto (resta responsive)
386
+ grid.style.width = 'fit-content';
387
+ grid.style.marginLeft = 'auto';
388
+ grid.style.marginRight = 'auto';
389
+ grid.style.justifyContent = 'center';
390
+ }
391
+
392
+ // Quando premi "Back" e torni alla home, centra
393
+ document.addEventListener('click', (e) => {
394
+ const back = e.target.closest('.back-button');
395
+ if (!back) return;
396
+ setTimeout(() => {
397
+ if (home.classList.contains('active')) centerNow();
398
+ }, 0);
399
+ }, true);
400
+
401
+ // Safety net: qualsiasi volta la home diventa active, centra
402
+ const mo = new MutationObserver(() => {
403
+ if (home.classList.contains('active')) centerNow();
404
+ });
405
+ mo.observe(home, { attributes: true, attributeFilter: ['class'] });
406
+ })();
407
+
408
+ });
409
+ });
410
+
411
+ if (chefcodeLogoBtn){
412
+ chefcodeLogoBtn.addEventListener('click', () => showPage('step-selection-page'));
413
+ }
414
+
415
+ // Account menu
416
+ if (accountButton && accountDropdownContent){
417
+ accountButton.addEventListener('click', () => {
418
+ accountDropdownContent.style.display = accountDropdownContent.style.display === 'block' ? 'none' : 'block';
419
+ });
420
+ document.addEventListener('click', (e) => {
421
+ if (!accountButton.contains(e.target) && !accountDropdownContent.contains(e.target)) {
422
+ accountDropdownContent.style.display = 'none';
423
+ }
424
+ /* ============ PATCH 1.1.3 — Goods In click fix + Back grid 2x4 ============ */
425
+ /* SOLO aggiunte, nessuna modifica al tuo codice esistente */
426
+
427
+ // 1) Goods In: cattura in modo robusto i click sui 3 pulsanti interni
428
+ (function rebindGoodsInButtons(){
429
+ const goodsInContent = document.getElementById('goods-in-content');
430
+ if (!goodsInContent) return;
431
+
432
+ // Usiamo capture=true per intercettare il click anche se ci sono figli (icona/span)
433
+ goodsInContent.addEventListener('click', (e) => {
434
+ const btn = e.target.closest('.big-step-button[data-action]');
435
+ if (!btn) return;
436
+
437
+ const action = btn.dataset.action;
438
+ if (!action) return;
439
+
440
+ // Skip invoice-photo action - handled by OCR modal
441
+ if (action === 'invoice-photo') {
442
+ return; // Let the OCR modal handle this action
443
+ }
444
+
445
+ // Mappa azione -> id pagina interna (sono gli ID che hai già in index.html)
446
+ const map = {
447
+ 'voice-input' : 'voice-input-page-content',
448
+ 'manual-input' : 'manual-input-content'
449
+ };
450
+ const targetId = map[action];
451
+ if (targetId && typeof showPage === 'function') {
452
+ e.preventDefault();
453
+ showPage(targetId);
454
+ }
455
+ }, true);
456
+ })();
457
+
458
+ // 2) Back: quando torni alla dashboard, forziamo la griglia 2×4 come all’inizio
459
+ (function fixBackGrid(){
460
+ // intercettiamo TUTTI i back-button già presenti in pagina
461
+ document.addEventListener('click', (e) => {
462
+ const back = e.target.closest('.back-button');
463
+ if (!back) return;
464
+
465
+ // Lasciamo che il tuo handler faccia showPage(...). Poi sistemiamo la griglia.
466
+ setTimeout(() => {
467
+ const targetId = back.dataset.backTarget || '';
468
+ // Se torni alla home, rimetti 4 colonne fisse (2 righe x 4)
469
+ if (targetId === 'step-selection-page' || document.getElementById('step-selection-page')?.classList.contains('active')) {
470
+ const grid = document.querySelector('#step-selection-page .step-buttons-grid');
471
+ if (grid) grid.style.gridTemplateColumns = 'repeat(4, 1fr)'; // 2 file da 4 come da origine
472
+ }
473
+ }, 0);
474
+ }, true);
475
+ })();
476
+
477
+ });
478
+ }
479
+
480
+ // ---------- Camera/OCR (sim) ----------
481
+ function renderCameraIdle(){
482
+ if (!cameraViewfinder) return;
483
+ cameraViewfinder.innerHTML = `
484
+ <div class="camera-overlay">
485
+ <div class="camera-guides"></div>
486
+ <div class="camera-guides"></div>
487
+ <div class="camera-guides"></div>
488
+ </div>
489
+ <div class="camera-cta">
490
+ <button id="take-photo-btn" class="camera-btn"><i class="fas fa-camera"></i></button>
491
+ </div>`;
492
+ }
493
+ if (cameraViewfinder){
494
+ renderCameraIdle();
495
+ let capturedFile = null;
496
+ cameraViewfinder.addEventListener('click', (e) => {
497
+ if (e.target.closest('#take-photo-btn')) {
498
+ // Create a real file input for image selection
499
+ const fileInput = document.createElement('input');
500
+ fileInput.type = 'file';
501
+ fileInput.accept = 'image/*';
502
+ fileInput.style.display = 'none';
503
+ fileInput.onchange = (ev) => {
504
+ const file = ev.target.files[0];
505
+ if (file) {
506
+ capturedFile = file;
507
+ const reader = new FileReader();
508
+ reader.onload = function(evt) {
509
+ cameraViewfinder.innerHTML = `
510
+ <div class="camera-shot">
511
+ <img src="${evt.target.result}" alt="Invoice" style="max-width:100%;max-height:220px;object-fit:contain;" />
512
+ </div>
513
+ <div class="camera-cta">
514
+ <button id="retake-photo-btn" class="camera-btn secondary"><i class="fas fa-redo"></i></button>
515
+ <button id="confirm-photo-btn" class="camera-btn primary"><i class="fas fa-check"></i></button>
516
+ </div>`;
517
+ };
518
+ reader.readAsDataURL(file);
519
+ }
520
+ };
521
+ document.body.appendChild(fileInput);
522
+ fileInput.click();
523
+ document.body.removeChild(fileInput);
524
+ }
525
+ if (e.target.closest('#retake-photo-btn')) {
526
+ capturedFile = null;
527
+ renderCameraIdle();
528
+ }
529
+ if (e.target.closest('#confirm-photo-btn') && cameraOutput) {
530
+ if (!capturedFile) {
531
+ alert('No image selected. Please take a photo.');
532
+ return;
533
+ }
534
+ // Show loading
535
+ cameraOutput.innerHTML = `<div class="ocr-result"><h4>Processing invoice...</h4></div>`;
536
+ cameraOutput.style.display = '';
537
+ // Upload to backend
538
+ const formData = new FormData();
539
+ formData.append('file', capturedFile);
540
+ const apiKey = window.CHEFCODE_CONFIG?.API_KEY || '';
541
+ fetch('http://localhost:8000/api/ocr-invoice', {
542
+ method: 'POST',
543
+ headers: {
544
+ 'X-API-Key': apiKey
545
+ },
546
+ body: formData
547
+ })
548
+ .then(res => res.json())
549
+ .then(data => {
550
+ if (data.status === 'success' && Array.isArray(data.items)) {
551
+ let added = [];
552
+ data.items.forEach(item => {
553
+ // OCR may extract HACCP fields; if not, they can be added manually later
554
+ addOrMergeInventoryItem({
555
+ name: item.name,
556
+ unit: item.unit,
557
+ quantity: item.quantity,
558
+ category: item.category || 'Other',
559
+ price: item.price,
560
+ lot_number: item.lot_number || '',
561
+ expiry_date: item.expiry_date || ''
562
+ });
563
+ added.push(`${item.name} (${item.quantity} ${item.unit} @ €${item.price})`);
564
+ });
565
+ updateInventoryToBackend();
566
+ renderInventory();
567
+ cameraOutput.innerHTML = `<div class="ocr-result"><h4>OCR Extraction Result</h4><ul>${added.map(x => `<li>${x}</li>`).join('')}</ul><p style="color:#888; margin-top:10px;">💡 Tip: Add lot numbers and expiry dates via Manual Input for HACCP compliance</p></div>`;
568
+ } else {
569
+ cameraOutput.innerHTML = `<div class="ocr-result"><h4>OCR failed</h4><div>${data.message || 'Could not extract items.'}</div></div>`;
570
+ }
571
+ })
572
+ .catch(err => {
573
+ cameraOutput.innerHTML = `<div class="ocr-result"><h4>OCR error</h4><div>${err.message}</div></div>`;
574
+ });
575
+ }
576
+ });
577
+ }
578
+
579
+ // ---------- Voice (sim + process) ----------
580
+ if (microphoneBtn){
581
+ microphoneBtn.addEventListener('click', () => {
582
+ isRecording = !isRecording;
583
+ if (isRecording){
584
+ if (voiceStatus) voiceStatus.textContent = 'Listening...';
585
+ if (micLabel) micLabel.textContent = 'Stop Recording';
586
+ setTimeout(() => {
587
+ if (recognizedTextContent) recognizedTextContent.textContent = '"pomodori 20 chili 2 euro e 50"';
588
+ if (voiceRecognizedText) voiceRecognizedText.style.display = 'block';
589
+ }, 1200);
590
+ } else {
591
+ if (micLabel) micLabel.textContent = 'Start Recording';
592
+ if (voiceStatus) voiceStatus.textContent = 'Press the microphone to start speaking...';
593
+ if (voiceRecognizedText) voiceRecognizedText.style.display = 'none';
594
+ if (recognizedTextContent) recognizedTextContent.textContent = '';
595
+ }
596
+ });
597
+ }
598
+
599
+ function parseItalianGoods(text){
600
+ const t = (text||'').toLowerCase().replace(/"/g,' ').replace(/\s+/g,' ').trim();
601
+ // prezzo: "2 euro e 50" | "€2,50"
602
+ let price = 0;
603
+ const pm = t.match(/(\d+[.,]?\d*)\s*(?:€|euro)?(?:\s*e\s*(\d{1,2}))?/);
604
+ if (pm){
605
+ const euros = parseFloat(pm[1].replace(',','.'));
606
+ const cents = pm[2] ? parseInt(pm[2]) : 0;
607
+ price = (isNaN(euros)?0:euros) + (isNaN(cents)?0:cents)/100;
608
+ }
609
+ const unitMap = { chili:'kg', chilo:'kg', chilogrammi:'kg', kg:'kg', grammi:'g', g:'g', litro:'l', litri:'l', lt:'l', l:'l', millilitri:'ml', ml:'ml', pezzi:'pz', pezzo:'pz', uova:'pz', pz:'pz', bt:'bt' };
610
+ const qm = t.match(/(\d+[.,]?\d*)\s*(kg|g|l|lt|ml|pz|bt|litro|litri|chili|chilo|chilogrammi|grammi|millilitri|pezzi|pezzo|uova)\b/);
611
+ const qty = qm ? parseFloat(qm[1].replace(',','.')) : 1;
612
+ const unit = qm ? (unitMap[qm[2]] || qm[2]) : 'pz';
613
+ const name = qm ? t.slice(0, t.indexOf(qm[0])).trim() : t;
614
+ return { name: name || 'item', qty: isNaN(qty)?1:qty, unit, price: isNaN(price)?0:price };
615
+ }
616
+
617
+ if (processVoiceBtn && recognizedTextContent){
618
+ processVoiceBtn.addEventListener('click', () => {
619
+ const text = recognizedTextContent.textContent || '';
620
+ const { name, qty, unit, price } = parseItalianGoods(text);
621
+ if (!name){ alert('Voice: nessun nome articolo rilevato'); return; }
622
+ addOrMergeInventoryItem({ name, unit, quantity: qty, category: 'Other', price });
623
+ renderInventory(); // Update display immediately
624
+ updateInventoryToBackend();
625
+ alert(`Voice→Inventory: ${name} — ${qty} ${unit} @ €${price.toFixed(2)}`);
626
+ });
627
+ }
628
+
629
+ // ---------- Inventory ----------
630
+ function rowToItem(tr){
631
+ const tds = tr?.querySelectorAll('td'); if (!tds || tds.length < 6) return null;
632
+ const name = tds[0].textContent.trim();
633
+ const priceText = tds[1].textContent.replace('€','').trim();
634
+ const unit = tds[2].textContent.trim();
635
+ const quantityText = tds[3].textContent.trim();
636
+ const category = tds[4].textContent.trim();
637
+ const price = parseFloat(priceText.replace(',','.')) || 0;
638
+ const quantity = parseFloat(quantityText.replace(',','.')) || 0;
639
+ return { name, unit, quantity, category, price };
640
+ }
641
+
642
+ window.renderInventory = function(){
643
+ if (!inventoryTableBody) return;
644
+
645
+ // Validate STATE and inventory array exist
646
+ if (!window.STATE || !Array.isArray(window.STATE.inventory)) {
647
+ console.warn('⚠️ Invalid STATE or inventory array');
648
+ window.STATE = window.STATE || { inventory: [], recipes: {}, tasks: [], nextTaskId: 1 };
649
+ window.STATE.inventory = [];
650
+ }
651
+
652
+ inventoryTableBody.innerHTML = '';
653
+ let total = 0;
654
+ const today = new Date();
655
+ today.setHours(0, 0, 0, 0);
656
+
657
+ window.STATE.inventory.forEach(item => {
658
+ // Validate item structure
659
+ if (!item || typeof item !== 'object') return;
660
+ const rowTotal = (item.price||0) * (item.quantity||0);
661
+ total += rowTotal;
662
+ const tr = document.createElement('tr');
663
+
664
+ // Calculate expiry alert level
665
+ let expiryHTML = '-';
666
+ let expiryClass = '';
667
+ if (item.expiry_date) {
668
+ const expiryDate = new Date(item.expiry_date);
669
+ const daysUntilExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24));
670
+
671
+ if (daysUntilExpiry < 2) {
672
+ expiryClass = 'expiry-critical'; // Red
673
+ expiryHTML = `<span class="${expiryClass}">${window.escapeHtml(item.expiry_date)} (${daysUntilExpiry}d)</span>`;
674
+ } else if (daysUntilExpiry < 7) {
675
+ expiryClass = 'expiry-warning'; // Yellow
676
+ expiryHTML = `<span class="${expiryClass}">${window.escapeHtml(item.expiry_date)} (${daysUntilExpiry}d)</span>`;
677
+ } else {
678
+ expiryHTML = window.escapeHtml(item.expiry_date);
679
+ }
680
+ }
681
+
682
+ tr.innerHTML = `
683
+ <td>${window.escapeHtml(item.name || '')}</td>
684
+ <td>€${(item.price ?? 0).toFixed(2)}</td>
685
+ <td>${window.escapeHtml(item.unit || '-')}</td>
686
+ <td>${item.quantity ?? 0}</td>
687
+ <td>${window.escapeHtml(item.category || '-')}</td>
688
+ <td>${window.escapeHtml(item.lot_number || '-')}</td>
689
+ <td>${expiryHTML}</td>
690
+ <td>€${rowTotal.toFixed(2)}</td>
691
+ <td><button class="delete-btn" onclick="deleteInventoryItem(${item.id})" title="Delete item"><i class="fas fa-trash"></i></button></td>`;
692
+ inventoryTableBody.appendChild(tr);
693
+ });
694
+ if (inventoryTotalVal) inventoryTotalVal.textContent = `€${total.toFixed(2)}`;
695
+ populateIngredientSelect(); // tiene il select aggiornato
696
+ }
697
+
698
+ // Bootstrap inventory: always fetch from backend, do not import from DOM or localStorage
699
+ if (inventoryTableBody){
700
+ fetchInventoryFromBackend();
701
+ }
702
+
703
+ // Manual Input (sovrascrive submit per evitare doppie append)
704
+ if (manualEntryForm){
705
+ manualEntryForm.addEventListener('submit', async (e) => {
706
+ e.preventDefault();
707
+ const name = (el('item-name')?.value || '').trim();
708
+ const qty = parseFloat(el('item-quantity')?.value || '0');
709
+ const unit = el('item-unit')?.value || 'pz';
710
+ const price= parseFloat(el('item-price')?.value || '0');
711
+ const cat = el('item-category')?.value || 'Other';
712
+ const lotNumber = el('item-lot-number')?.value || '';
713
+ const expiryDate = el('item-expiry-date')?.value || '';
714
+ if (!name || isNaN(qty) || isNaN(price)){ alert('Inserisci nome, quantità e prezzo validi.'); return; }
715
+ // Validate expiry date is not in the past (if provided)
716
+ if (expiryDate) {
717
+ const expiry = new Date(expiryDate);
718
+ const today = new Date();
719
+ today.setHours(0, 0, 0, 0);
720
+ if (expiry < today) {
721
+ alert('Expiry date cannot be in the past!');
722
+ return;
723
+ }
724
+ }
725
+ addOrMergeInventoryItem({
726
+ name,
727
+ unit,
728
+ quantity: qty,
729
+ category: cat,
730
+ price,
731
+ lot_number: lotNumber,
732
+ expiry_date: expiryDate
733
+ });
734
+ renderInventory(); // Update display immediately
735
+ await updateInventoryToBackend();
736
+ safe(()=>manualEntryForm.reset());
737
+ safe(()=>el('item-name').focus());
738
+ alert(`"${name}" aggiunto in inventario`);
739
+ });
740
+ }
741
+
742
+ // Search / Filter
743
+ function applyInventoryFilters(){
744
+ if (!inventoryTableBody) return;
745
+ const term = (inventorySearch?.value || '').toLowerCase();
746
+ const cat = (categoryFilter?.value || 'All');
747
+ let total = 0;
748
+ qa('#inventory-table-body tr').forEach(row => {
749
+ const name = row.children[0]?.textContent.toLowerCase() || '';
750
+ const rc = row.children[4]?.textContent || '';
751
+ const isAll = cat.toLowerCase() === 'all';
752
+ const ok = (!term || name.includes(term)) && (isAll || cat === '' || rc === cat);
753
+ row.style.display = ok ? '' : 'none';
754
+ if (ok){
755
+ const tv = row.children[7]?.textContent.replace('€','').trim();
756
+ const val= parseFloat(tv?.replace('.','').replace(',','.')) || 0;
757
+ total += val;
758
+ }
759
+ });
760
+ if (inventoryTotalVal) inventoryTotalVal.textContent = `€${total.toFixed(2)}`;
761
+ }
762
+
763
+ // Delete inventory item function - attach to window for global access
764
+ window.deleteInventoryItem = async function(itemId) {
765
+ if (!confirm('Are you sure you want to delete this item? This action cannot be undone.')) {
766
+ return;
767
+ }
768
+
769
+ try {
770
+ const apiKey = window.CHEFCODE_CONFIG?.API_KEY;
771
+ if (!apiKey) {
772
+ alert('API key not configured');
773
+ return;
774
+ }
775
+
776
+ const response = await fetch('http://localhost:8000/api/inventory/delete', {
777
+ method: 'DELETE',
778
+ headers: {
779
+ 'Content-Type': 'application/json',
780
+ 'X-API-Key': apiKey
781
+ },
782
+ body: JSON.stringify({ id: itemId })
783
+ });
784
+
785
+ if (response.ok) {
786
+ // Remove from local state
787
+ window.STATE.inventory = window.STATE.inventory.filter(item => item.id !== itemId);
788
+ window.renderInventory();
789
+ // No need for full sync after successful delete - local state is already updated
790
+ console.log('Item deleted successfully');
791
+ } else {
792
+ let errorMessage = 'Unknown error';
793
+ try {
794
+ const error = await response.json();
795
+ errorMessage = error.detail || error.message || 'Unknown error';
796
+ } catch (e) {
797
+ errorMessage = `Server error (${response.status}): ${response.statusText}`;
798
+ }
799
+ alert(`Failed to delete item: ${errorMessage}`);
800
+ }
801
+ } catch (error) {
802
+ console.error('Delete error:', error);
803
+ alert('Failed to delete item. Please try again.');
804
+ }
805
+ };
806
+
807
+ if (inventorySearch) inventorySearch.addEventListener('input', applyInventoryFilters);
808
+ if (categoryFilter) categoryFilter.addEventListener('change', applyInventoryFilters);
809
+ if (expandTableBtn && inventoryTableCtr){
810
+ expandTableBtn.addEventListener('click', () => inventoryTableCtr.classList.toggle('expanded'));
811
+ }
812
+
813
+ // ---------- Recipes ----------
814
+ function renderIngredientsList(){
815
+ if (!recipeIngredientsList) return;
816
+ recipeIngredientsList.innerHTML = '';
817
+ currentRecipeIngredients.forEach((ing, i) => {
818
+ const li = document.createElement('li');
819
+ li.innerHTML = `<span>${ing.name} - ${ing.quantity} ${ing.unit}</span>
820
+ <button class="remove-ingredient-btn" data-index="${i}">&times;</button>`;
821
+ recipeIngredientsList.appendChild(li);
822
+ });
823
+ }
824
+
825
+ function populateIngredientSelect(){
826
+ if (!ingredientSelect) return;
827
+ const current = ingredientSelect.value;
828
+ ingredientSelect.innerHTML = `<option value="" disabled selected>-- choose item --</option>`;
829
+ const seen = new Set();
830
+ window.STATE.inventory.forEach(it => {
831
+ if (seen.has(it.name)) return;
832
+ seen.add(it.name);
833
+ const opt = document.createElement('option');
834
+ opt.value = it.name; opt.textContent = it.name;
835
+ ingredientSelect.appendChild(opt);
836
+ });
837
+ if (current && seen.has(current)) ingredientSelect.value = current;
838
+ updateRecipeSelects(); // mantiene in sync Produzione
839
+ }
840
+
841
+ if (addIngredientBtn){
842
+ addIngredientBtn.addEventListener('click', () => {
843
+ const name = ingredientSelect?.value || '';
844
+ const qty = parseFloat(ingredientQty?.value || '0');
845
+ const unit = ingredientUnit?.value || 'g';
846
+ if (!name || !qty){ alert('Seleziona ingrediente e quantità.'); return; }
847
+ currentRecipeIngredients.push({ name, quantity: qty, unit });
848
+ renderIngredientsList();
849
+ if (ingredientSelect) ingredientSelect.value = '';
850
+ if (ingredientQty) ingredientQty.value = '';
851
+ });
852
+ }
853
+
854
+ if (recipeIngredientsList){
855
+ recipeIngredientsList.addEventListener('click', (e) => {
856
+ const btn = e.target.closest('.remove-ingredient-btn');
857
+ if (!btn) return;
858
+ const idx = parseInt(btn.dataset.index, 10);
859
+ if (!isNaN(idx)) currentRecipeIngredients.splice(idx, 1);
860
+ renderIngredientsList();
861
+ });
862
+ }
863
+
864
+ function updateRecipeSelects(){
865
+ if (!recipeSelectProd) return;
866
+ const current = recipeSelectProd.value;
867
+ recipeSelectProd.innerHTML = '<option value="" disabled selected>-- Choose a recipe --</option>';
868
+ Object.keys(window.STATE.recipes).forEach(name => {
869
+ const opt = document.createElement('option');
870
+ opt.value = name; opt.textContent = name;
871
+ recipeSelectProd.appendChild(opt);
872
+ });
873
+ if (window.STATE.recipes[current]) recipeSelectProd.value = current;
874
+ }
875
+ if (saveRecipeBtn) {
876
+ saveRecipeBtn.addEventListener('click', async () => {
877
+ const recipeName = (document.getElementById('recipe-name')?.value || '').trim();
878
+ if (!recipeName) { alert('Please enter a name for the recipe.'); return; }
879
+ if (!currentRecipeIngredients.length) { alert('Please add at least one ingredient.'); return; }
880
+
881
+ // Get yield data
882
+ const yieldQty = parseFloat(el('recipe-yield-qty')?.value || 0);
883
+ const yieldUnit = el('recipe-yield-unit')?.value || 'pz';
884
+
885
+ console.log(`💾 Saving recipe: ${recipeName} with yield:`, yieldQty > 0 ? `${yieldQty} ${yieldUnit}` : 'none');
886
+
887
+ // Salva nel motore usato dalla Production: STATE.recipes
888
+ // e usa il campo "qty" (non "quantity") perché la deduzione legge ing.qty
889
+ window.STATE.recipes[recipeName] = {
890
+ items: currentRecipeIngredients.map(i => ({
891
+ name: i.name,
892
+ qty: parseFloat(i.quantity) || 0, // quantità per 1 batch
893
+ unit: i.unit
894
+ })),
895
+ // Save yield information
896
+ yield: yieldQty > 0 ? { qty: yieldQty, unit: yieldUnit } : null
897
+ };
898
+
899
+ try {
900
+ await updateInventoryToBackend();
901
+ } catch (error) {
902
+ console.error('Failed to save recipe:', error);
903
+ return; // Don't continue if save failed
904
+ }
905
+
906
+ // Aggiorna il menu a tendina in Production
907
+ updateRecipeSelects();
908
+
909
+ // Feedback
910
+ let summary = `Recipe Saved: ${recipeName}\n\nIngredients:\n`;
911
+ window.STATE.recipes[recipeName].items.forEach(ing => {
912
+ summary += `- ${ing.name}: ${ing.qty} ${ing.unit}\n`;
913
+ });
914
+ alert(summary);
915
+
916
+ // Reset form ricetta
917
+ currentRecipeIngredients = [];
918
+ renderIngredientsList();
919
+ const rn = document.getElementById('recipe-name'); if (rn) rn.value = '';
920
+ const ri = document.getElementById('recipe-instructions'); if (ri) ri.value = '';
921
+ const yq = el('recipe-yield-qty'); if (yq) yq.value = '';
922
+ const yu = el('recipe-yield-unit'); if (yu) yu.value = 'pz';
923
+ if (ingredientSelect) ingredientSelect.value = '';
924
+ if (ingredientQty) ingredientQty.value = '';
925
+ });
926
+ }
927
+
928
+ // Primo allineamento selects
929
+ populateIngredientSelect();
930
+ updateRecipeSelects();
931
+
932
+ // ---------- Recipe Catalogue ----------
933
+ const recipeCatalogueBody = el('recipe-catalogue-body');
934
+ const recipeCatalogueEmpty = el('recipe-catalogue-empty');
935
+ const recipeSearchInput = el('recipe-search');
936
+
937
+ // Render recipe catalogue table
938
+ window.renderRecipeCatalogue = function() {
939
+ console.log('📖 Rendering recipe catalogue...');
940
+ if (!recipeCatalogueBody) {
941
+ console.warn('⚠️ Recipe catalogue body element not found');
942
+ return;
943
+ }
944
+
945
+ recipeCatalogueBody.innerHTML = '';
946
+ const recipes = window.STATE.recipes || {};
947
+ const recipeNames = Object.keys(recipes);
948
+
949
+ console.log(`Found ${recipeNames.length} recipes in STATE:`, recipeNames);
950
+
951
+ // Show/hide empty state
952
+ if (recipeCatalogueEmpty) {
953
+ recipeCatalogueEmpty.style.display = recipeNames.length === 0 ? 'block' : 'none';
954
+ }
955
+
956
+ if (recipeNames.length === 0) {
957
+ console.log('No recipes to display');
958
+ return;
959
+ }
960
+
961
+ // Get search term
962
+ const searchTerm = recipeSearchInput?.value.toLowerCase() || '';
963
+
964
+ recipeNames.forEach(recipeName => {
965
+ // Apply search filter
966
+ if (searchTerm && !recipeName.toLowerCase().includes(searchTerm)) {
967
+ return;
968
+ }
969
+
970
+ const recipe = recipes[recipeName];
971
+ const ingredients = recipe.items || [];
972
+ const ingredientCount = ingredients.length;
973
+
974
+ // Build full ingredients list for display
975
+ const ingredientsList = ingredients.map(ing => {
976
+ const qty = ing.qty !== undefined && ing.qty !== null ? ing.qty : '?';
977
+ const unit = ing.unit !== undefined && ing.unit !== null ? ing.unit : '';
978
+ return `<li><span class="ing-name">${window.escapeHtml(ing.name)}</span> <span class="ing-qty">${qty} ${unit}</span></li>`;
979
+ }).join('');
980
+
981
+ // Get yield info
982
+ const yieldInfo = recipe.yield ? `${recipe.yield.qty} ${recipe.yield.unit}` : 'Not specified';
983
+
984
+ // Create card element
985
+ const card = document.createElement('div');
986
+ card.className = 'recipe-card';
987
+ card.innerHTML = `
988
+ <div class="recipe-card-header">
989
+ <h4 class="recipe-card-title">${window.escapeHtml(recipeName)}</h4>
990
+ <span class="recipe-card-badge">${ingredientCount} ingredient${ingredientCount !== 1 ? 's' : ''}</span>
991
+ </div>
992
+
993
+ <div class="recipe-card-body">
994
+ <div class="recipe-ingredients-section">
995
+ <h5><i class="fas fa-list"></i> Ingredients</h5>
996
+ <ul class="recipe-ingredients-list">
997
+ ${ingredientsList}
998
+ </ul>
999
+ </div>
1000
+
1001
+ <div class="recipe-yield-section">
1002
+ <i class="fas fa-utensils"></i>
1003
+ <span class="yield-label">Yield:</span>
1004
+ <span class="yield-value">${window.escapeHtml(yieldInfo)}</span>
1005
+ </div>
1006
+ </div>
1007
+
1008
+ <div class="recipe-card-footer">
1009
+ <button class="recipe-card-btn edit-recipe-btn" data-recipe="${window.escapeHtml(recipeName)}" title="Edit Recipe">
1010
+ <i class="fas fa-pen"></i> Edit
1011
+ </button>
1012
+ <button class="recipe-card-btn delete-recipe-btn" data-recipe="${window.escapeHtml(recipeName)}" title="Delete Recipe">
1013
+ <i class="fas fa-trash"></i> Delete
1014
+ </button>
1015
+ </div>
1016
+ `;
1017
+ recipeCatalogueBody.appendChild(card);
1018
+ });
1019
+ };
1020
+
1021
+ // Delete recipe
1022
+ async function deleteRecipe(recipeName) {
1023
+ if (!confirm(`Are you sure you want to delete the recipe "${recipeName}"?`)) {
1024
+ return;
1025
+ }
1026
+
1027
+ console.log(`🗑️ Deleting recipe: ${recipeName}`);
1028
+ delete window.STATE.recipes[recipeName];
1029
+
1030
+ try {
1031
+ await updateInventoryToBackend();
1032
+ renderRecipeCatalogue();
1033
+ updateRecipeSelects(); // Update dropdowns in production
1034
+ alert(`Recipe "${recipeName}" has been deleted and saved to database.`);
1035
+ } catch (error) {
1036
+ console.error('Failed to delete recipe:', error);
1037
+ // Recipe already deleted from STATE, but sync failed
1038
+ }
1039
+ }
1040
+
1041
+ // Edit recipe - navigate to add recipe page and populate form
1042
+ function editRecipe(recipeName) {
1043
+ const recipe = window.STATE.recipes[recipeName];
1044
+ if (!recipe) return;
1045
+
1046
+ // Store recipe name for editing
1047
+ window.editingRecipeName = recipeName;
1048
+
1049
+ // Navigate to add recipe page
1050
+ const addRecipePage = el('add-recipe-content');
1051
+ if (addRecipePage) {
1052
+ // Clear current state
1053
+ qa('.input-page').forEach(p => p.classList.remove('active'));
1054
+ el('step-selection-page')?.classList.remove('active');
1055
+ el('input-detail-page')?.classList.add('active');
1056
+ addRecipePage.classList.add('active');
1057
+
1058
+ // Populate form
1059
+ const recipeNameInput = el('recipe-name');
1060
+ if (recipeNameInput) {
1061
+ recipeNameInput.value = recipeName;
1062
+ recipeNameInput.disabled = true; // Don't allow name change during edit
1063
+ }
1064
+
1065
+ // Populate yield fields
1066
+ const yieldQtyInput = el('recipe-yield-qty');
1067
+ const yieldUnitInput = el('recipe-yield-unit');
1068
+ if (recipe.yield) {
1069
+ if (yieldQtyInput) yieldQtyInput.value = recipe.yield.qty || '';
1070
+ if (yieldUnitInput) yieldUnitInput.value = recipe.yield.unit || 'pz';
1071
+ } else {
1072
+ if (yieldQtyInput) yieldQtyInput.value = '';
1073
+ if (yieldUnitInput) yieldUnitInput.value = 'pz';
1074
+ }
1075
+
1076
+ // Populate ingredients
1077
+ currentRecipeIngredients = recipe.items.map(ing => ({
1078
+ name: ing.name,
1079
+ quantity: ing.qty,
1080
+ unit: ing.unit
1081
+ }));
1082
+ renderIngredientsList();
1083
+
1084
+ // Change button text
1085
+ if (saveRecipeBtn) {
1086
+ saveRecipeBtn.innerHTML = '<i class="fas fa-save"></i> Update Recipe';
1087
+ }
1088
+
1089
+ alert(`Editing recipe: ${recipeName}\nModify ingredients and click "Update Recipe" to save changes.`);
1090
+ }
1091
+ }
1092
+
1093
+ // Handle edit/delete button clicks
1094
+ if (recipeCatalogueBody) {
1095
+ recipeCatalogueBody.addEventListener('click', (e) => {
1096
+ const editBtn = e.target.closest('.edit-recipe-btn');
1097
+ const deleteBtn = e.target.closest('.delete-recipe-btn');
1098
+
1099
+ if (editBtn) {
1100
+ const recipeName = editBtn.dataset.recipe;
1101
+ editRecipe(recipeName);
1102
+ } else if (deleteBtn) {
1103
+ const recipeName = deleteBtn.dataset.recipe;
1104
+ deleteRecipe(recipeName);
1105
+ }
1106
+ });
1107
+ }
1108
+
1109
+ // Search recipes
1110
+ if (recipeSearchInput) {
1111
+ recipeSearchInput.addEventListener('input', () => {
1112
+ renderRecipeCatalogue();
1113
+ });
1114
+ }
1115
+
1116
+ // Update save recipe button to handle edit mode
1117
+ if (saveRecipeBtn) {
1118
+ const originalClickHandler = saveRecipeBtn.onclick;
1119
+ saveRecipeBtn.addEventListener('click', () => {
1120
+ // After saving, reset edit mode
1121
+ if (window.editingRecipeName) {
1122
+ const recipeNameInput = el('recipe-name');
1123
+ if (recipeNameInput) {
1124
+ recipeNameInput.disabled = false;
1125
+ }
1126
+ delete window.editingRecipeName;
1127
+ if (saveRecipeBtn) {
1128
+ saveRecipeBtn.innerHTML = '<i class="fas fa-save"></i> Save Recipe';
1129
+ }
1130
+ }
1131
+
1132
+ // Update catalogue if it's visible
1133
+ setTimeout(() => {
1134
+ renderRecipeCatalogue();
1135
+ }, 100);
1136
+ });
1137
+ }
1138
+
1139
+ // ---------- Production ----------
1140
+ const renderProductionTasks = () => {
1141
+ if (!todoTasksContainer || !completedTasksList) return;
1142
+ // Tab To Do
1143
+ todoTasksContainer.innerHTML = '<h4 class="tab-title">To Do</h4>';
1144
+ // Tab Completed
1145
+ completedTasksList.innerHTML = '<h4 class="tab-title">Completed</h4>';
1146
+ window.productionTasks.forEach(task => {
1147
+ const card = document.createElement('div');
1148
+ card.className = 'task-card';
1149
+ card.dataset.id = String(task.id);
1150
+ card.innerHTML = `
1151
+ <h5>${task.recipe} (${task.quantity})</h5>
1152
+ <p>Assegnato a: ${task.assignedTo || '—'}</p>
1153
+ <div class="task-card-footer">
1154
+ ${task.status === 'todo' ? '<button class="task-action-btn" type="button">Convalida</button>' : '<span class="task-completed-label">Completata</span>'}
1155
+ </div>
1156
+ `;
1157
+ if (task.status === 'todo') {
1158
+ todoTasksContainer.appendChild(card);
1159
+ } else if (task.status === 'completed') {
1160
+ completedTasksList.appendChild(card);
1161
+ }
1162
+ });
1163
+ };
1164
+
1165
+
1166
+ // ==== Helpers per deduzione inventario (unità + nomi) ====
1167
+ function ccNormUnit(u){
1168
+ u = String(u || '').trim().toLowerCase();
1169
+ if (u === 'l') u = 'lt';
1170
+ if (u === 'gr') u = 'g';
1171
+ if (u === 'pcs' || u === 'pc' || u === 'pz.') u = 'pz';
1172
+ return u;
1173
+ }
1174
+ function ccConvertFactor(from, to){
1175
+ from = ccNormUnit(from); to = ccNormUnit(to);
1176
+ if (from === to) return 1;
1177
+ if (from === 'kg' && to === 'g') return 1000;
1178
+ if (from === 'g' && to === 'kg') return 1/1000;
1179
+ if (from === 'lt' && to === 'ml') return 1000;
1180
+ if (from === 'ml' && to === 'lt') return 1/1000;
1181
+ // equivalenza “di comodo” se tratti bottle/pezzi come unità contabili
1182
+ if ((from === 'pz' && to === 'bt') || (from === 'bt' && to === 'pz')) return 1;
1183
+ return null; // incompatibili
1184
+ }
1185
+ function ccNormName(s){ return String(s || '').trim().toLowerCase(); }
1186
+ function ccFindInventoryItemByName(name){
1187
+ const wanted = ccNormName(name);
1188
+ return window.STATE.inventory.find(it => ccNormName(it.name) === wanted) || null;
1189
+ }
1190
+
1191
+ function consumeInventoryForTask(task){
1192
+ const r = window.STATE.recipes[task.recipe];
1193
+ if (!r){ alert(`Ricetta non trovata: ${task.recipe}`); return; }
1194
+
1195
+ const batches = Number(task.quantity) || 1; // quante “unità ricetta” produci
1196
+ let changed = false;
1197
+ const skipped = [];
1198
+
1199
+ r.items.forEach(ing => {
1200
+ const inv = ccFindInventoryItemByName(ing.name);
1201
+ if (!inv){ skipped.push(`${ing.name} (non in inventario)`); return; }
1202
+
1203
+ const invU = ccNormUnit(inv.unit);
1204
+ const ingU = ccNormUnit(ing.unit || invU);
1205
+ const f = ccConvertFactor(ingU, invU);
1206
+ if (f === null){ skipped.push(`${ing.name} (${ingU}→${invU} incompatibile)`); return; }
1207
+
1208
+ const perBatch = Number(ing.qty) || 0;
1209
+ const toConsume = perBatch * batches * f;
1210
+
1211
+ inv.quantity = Math.max(0, (Number(inv.quantity) || 0) - toConsume);
1212
+ changed = true;
1213
+ });
1214
+
1215
+ if (changed){ updateInventoryToBackend(); renderInventory(); }
1216
+ if (skipped.length){ console.warn('Ingredienti non scalati:', skipped); }
1217
+ }
1218
+
1219
+
1220
+ function onTaskActionClick(e){
1221
+ const btn = e.target.closest('.task-action-btn');
1222
+ if (!btn) return;
1223
+ const card = btn.closest('.task-card'); if (!card) return;
1224
+ const id = Number(card.dataset.id);
1225
+ const task = window.STATE.tasks.find(t => t.id === id);
1226
+ if (!task) return;
1227
+ if (task.status === 'todo'){ task.status = 'inprogress'; }
1228
+ else if (task.status === 'inprogress'){ task.status = 'completed'; consumeInventoryForTask(task); }
1229
+ renderInventory(); // Update inventory display after consumption
1230
+ window.updateInventoryToBackend();
1231
+ renderProductionTasks();
1232
+ }
1233
+
1234
+ if (addTaskBtn) {
1235
+ // evita comportamenti da "submit" nel caso in cui il bottone fosse dentro un form
1236
+ addTaskBtn.setAttribute('type','button');
1237
+
1238
+ addTaskBtn.addEventListener('click', (e) => {
1239
+ e.preventDefault();
1240
+
1241
+ const recipe = recipeSelectProd && recipeSelectProd.value;
1242
+ const quantity = productionQty && productionQty.value;
1243
+ const assignedToVal = assignTo && assignTo.value;
1244
+ const initialStatus = (initialStatusSelect && initialStatusSelect.value) || 'todo';
1245
+
1246
+ if (!recipe || !quantity) {
1247
+ alert('Please select a recipe and specify the quantity.');
1248
+ return;
1249
+ }
1250
+
1251
+ window.taskIdCounter += 1;
1252
+ const newTask = {
1253
+ id: window.taskIdCounter,
1254
+ recipe,
1255
+ quantity: Number(quantity),
1256
+ assignedTo: assignedToVal || '',
1257
+ status: (initialStatus === 'completed') ? 'completed' : 'todo'
1258
+
1259
+ };
1260
+ // Se l'utente ha scelto "Completed", scala subito l'inventario
1261
+ if (initialStatus === 'completed') {
1262
+ try {
1263
+ consumeInventoryForTask(newTask);
1264
+ } catch (e) {
1265
+ console.warn('consume-on-create failed', e);
1266
+ }
1267
+ }
1268
+ window.productionTasks.push(newTask);
1269
+ renderProductionTasks();
1270
+
1271
+ if (productionQty) productionQty.value = '';
1272
+ if (recipeSelectProd) recipeSelectProd.value = '';
1273
+ if (initialStatusSelect) initialStatusSelect.value = 'todo';
1274
+ });
1275
+
1276
+ }
1277
+
1278
+
1279
+ // Gestione click su To Do: convalida task
1280
+ if (todoTasksContainer) {
1281
+ todoTasksContainer.addEventListener('click', function(event) {
1282
+ const btn = event.target.closest('button.task-action-btn');
1283
+ if (!btn) return;
1284
+ const card = btn.closest('.task-card');
1285
+ const taskId = parseInt(card.dataset.id, 10);
1286
+ const task = window.productionTasks.find(t => t.id === taskId);
1287
+ if (!task) return;
1288
+ // Deduzione ingredienti SOLO ora
1289
+ try { consumeInventoryForTask(task); } catch(e){}
1290
+ task.status = 'completed';
1291
+ renderProductionTasks();
1292
+ });
1293
+ }
1294
+
1295
+ // Tab switching logic
1296
+ if (typeof prodTabBtns !== 'undefined' && prodTabBtns.length) {
1297
+ prodTabBtns.forEach(btn => {
1298
+ btn.addEventListener('click', function() {
1299
+ prodTabBtns.forEach(b => b.classList.remove('active'));
1300
+ prodTabPanels.forEach(p => p.classList.remove('active'));
1301
+ btn.classList.add('active');
1302
+ const tab = btn.getAttribute('data-tab');
1303
+ if (tab === 'todo') {
1304
+ todoTasksContainer.classList.add('active');
1305
+ } else if (tab === 'completed') {
1306
+ completedTasksList.classList.add('active');
1307
+ }
1308
+ });
1309
+ });
1310
+ }
1311
+
1312
+ // Inizializza tasks view e riallinea contatore
1313
+ try {
1314
+ const maxExisting = window.STATE.tasks.reduce((m, t) => {
1315
+ const id = Number(t?.id) || 0;
1316
+ return id > m ? id : m;
1317
+ }, 0);
1318
+ const current = Number(window.STATE.nextTaskId) || 1;
1319
+ window.STATE.nextTaskId = Math.max(current, maxExisting + 1);
1320
+ } catch (e) {
1321
+ console.warn('Reindex nextTaskId failed', e);
1322
+ window.STATE.nextTaskId = Number(window.STATE.nextTaskId) || 1;
1323
+ }
1324
+
1325
+ renderProductionTasks();
1326
+
1327
+ // ---------- Back buttons ----------
1328
+ qa('.back-button').forEach(btn => {
1329
+ btn.addEventListener('click', () => {
1330
+ const target = btn.dataset.backTarget || 'step-selection-page';
1331
+ showPage(target);
1332
+ });
1333
+ });
1334
+
1335
+ // ---------- Dev reset ----------
1336
+ window.addEventListener('keydown', (e) => {
1337
+ if (e.ctrlKey && e.altKey && String(e.key).toLowerCase() === 'r'){
1338
+ if (confirm('Reset ChefCode data?')){
1339
+ localStorage.removeItem(STORAGE_KEY);
1340
+ location.reload();
1341
+ }
1342
+ }
1343
+ });
1344
+ });
1345
+ /* ===== CHEFCODE PATCH BACK-RESTORE 1.0 — START ===== */
1346
+ (function(){
1347
+ const home = document.getElementById('step-selection-page');
1348
+ const inputDetail = document.getElementById('input-detail-page');
1349
+ const homeGrid = home ? home.querySelector('.step-buttons-grid') : null;
1350
+ if (!home || !inputDetail || !homeGrid) return;
1351
+
1352
+ // Snapshot dello stato iniziale (quello giusto che vedi all'apertura)
1353
+ const ORIGINAL_CLASS = homeGrid.className;
1354
+ const HAD_STYLE = homeGrid.hasAttribute('style');
1355
+ const ORIGINAL_STYLE = homeGrid.getAttribute('style');
1356
+
1357
+ function restoreHome(){
1358
+ // 1) Mostra la home, nascondi area dettagli e qualsiasi sotto-pagina ancora attiva
1359
+ document.querySelectorAll('#input-pages-container .input-page.active')
1360
+ .forEach(p => p.classList.remove('active'));
1361
+ inputDetail.classList.remove('active');
1362
+ home.classList.add('active');
1363
+
1364
+ // 2) Ripristina la griglia ESATTAMENTE come all'avvio
1365
+ homeGrid.className = ORIGINAL_CLASS;
1366
+ if (HAD_STYLE) {
1367
+ homeGrid.setAttribute('style', ORIGINAL_STYLE || '');
1368
+ } else {
1369
+ homeGrid.removeAttribute('style');
1370
+ }
1371
+ // 3) Pulisci eventuali proprietà inline appiccicate da patch vecchie
1372
+ if (homeGrid.style) {
1373
+ [
1374
+ 'grid-template-columns','grid-template-rows','width',
1375
+ 'margin-left','margin-right','left','right','transform',
1376
+ 'justify-content','max-width'
1377
+ ].forEach(prop => homeGrid.style.removeProperty(prop));
1378
+ }
1379
+ }
1380
+
1381
+ // Intercetta TUTTI i "Back" e, dopo che i tuoi handler hanno girato, ripristina la home
1382
+ document.addEventListener('click', (e) => {
1383
+ const back = e.target.closest('.back-button');
1384
+ if (!back) return;
1385
+ setTimeout(() => {
1386
+ const targetId = back.getAttribute('data-back-target') || '';
1387
+ if (targetId === 'step-selection-page') restoreHome();
1388
+ }, 0);
1389
+ }, true);
1390
+ /* ===== CHEFCODE PATCH HOME-RESTORE 1.0 — START ===== */
1391
+ (function(){
1392
+ const home = document.getElementById('step-selection-page');
1393
+ const inputDetail = document.getElementById('input-detail-page');
1394
+ const grid = home ? home.querySelector('.step-buttons-grid') : null;
1395
+ const homeBtn = document.getElementById('chefcode-logo-btn');
1396
+ if (!home || !inputDetail || !grid || !homeBtn) return;
1397
+
1398
+ // Prendiamo uno snapshot della griglia com'è all'apertura (stato "buono")
1399
+ const ORIGINAL_CLASS = grid.className;
1400
+ const HAD_STYLE = grid.hasAttribute('style');
1401
+ const ORIGINAL_STYLE = grid.getAttribute('style');
1402
+
1403
+ function cleanGridToInitial(){
1404
+ // Classi originali
1405
+ grid.className = ORIGINAL_CLASS;
1406
+
1407
+ // Stile inline: se all’inizio non c’era, lo togliamo; altrimenti rimettiamo il valore originale
1408
+ if (HAD_STYLE) grid.setAttribute('style', ORIGINAL_STYLE || '');
1409
+ else grid.removeAttribute('style');
1410
+
1411
+ // Rimuovi qualsiasi proprietà inline residua che possa decentrarla o rimpicciolirla
1412
+ if (grid.style){
1413
+ [
1414
+ 'grid-template-columns','grid-template-rows','width','max-width',
1415
+ 'margin-left','margin-right','left','right','transform','justify-content'
1416
+ ].forEach(p => grid.style.removeProperty(p));
1417
+ }
1418
+ }
1419
+
1420
+ function restoreHome(){
1421
+ // Chiudi eventuali sotto-pagine attive
1422
+ document.querySelectorAll('#input-pages-container .input-page.active')
1423
+ .forEach(p => p.classList.remove('active'));
1424
+ inputDetail.classList.remove('active');
1425
+ home.classList.add('active');
1426
+
1427
+ // Ripristina la griglia allo stato iniziale (come al primo load)
1428
+ cleanGridToInitial();
1429
+ }
1430
+
1431
+ // Quando clicchi la "casetta", lasciamo agire il tuo handler e poi ripristiniamo (come fatto per il Back)
1432
+ homeBtn.addEventListener('click', () => {
1433
+ setTimeout(restoreHome, 0);
1434
+ }, true);
1435
+ })();
1436
+
1437
+ // ===== OCR MODAL FUNCTIONALITY =====
1438
+ class OCRModal {
1439
+ constructor() {
1440
+ this.modal = document.getElementById('ocr-modal');
1441
+ this.currentStream = null;
1442
+ this.currentFile = null;
1443
+ this.ocrResults = null;
1444
+ this.currentScreen = 'selection';
1445
+
1446
+ this.initializeEventListeners();
1447
+ }
1448
+
1449
+ initializeEventListeners() {
1450
+ // Modal open/close
1451
+ document.addEventListener('click', (e) => {
1452
+ if (e.target.closest('[data-action="invoice-photo"]')) {
1453
+ e.preventDefault();
1454
+ this.openModal();
1455
+ }
1456
+ });
1457
+
1458
+ // Close modal
1459
+ document.getElementById('ocr-modal-close-btn').addEventListener('click', () => {
1460
+ this.closeModal();
1461
+ });
1462
+
1463
+ // Close on overlay click
1464
+ this.modal.addEventListener('click', (e) => {
1465
+ if (e.target === this.modal) {
1466
+ this.closeModal();
1467
+ }
1468
+ });
1469
+
1470
+ // Selection screen
1471
+ document.getElementById('camera-option').addEventListener('click', () => {
1472
+ this.showCameraScreen();
1473
+ });
1474
+
1475
+ document.getElementById('upload-option').addEventListener('click', () => {
1476
+ this.showFileUpload();
1477
+ });
1478
+
1479
+ // Camera screen
1480
+ document.getElementById('ocr-camera-back').addEventListener('click', () => {
1481
+ this.showSelectionScreen();
1482
+ });
1483
+
1484
+ document.getElementById('ocr-camera-capture').addEventListener('click', () => {
1485
+ this.capturePhoto();
1486
+ });
1487
+
1488
+ document.getElementById('ocr-camera-switch').addEventListener('click', () => {
1489
+ this.switchCamera();
1490
+ });
1491
+
1492
+ // Preview screen
1493
+ document.getElementById('ocr-preview-back').addEventListener('click', () => {
1494
+ this.showCameraScreen();
1495
+ });
1496
+
1497
+ document.getElementById('ocr-preview-process').addEventListener('click', () => {
1498
+ this.processInvoice();
1499
+ });
1500
+
1501
+ // Results screen
1502
+ document.getElementById('ocr-results-back').addEventListener('click', () => {
1503
+ this.showSelectionScreen();
1504
+ });
1505
+
1506
+ document.getElementById('ocr-results-confirm').addEventListener('click', () => {
1507
+ this.confirmAndAddToInventory();
1508
+ });
1509
+
1510
+ // Success screen
1511
+ document.getElementById('ocr-success-close').addEventListener('click', () => {
1512
+ this.closeModal();
1513
+ });
1514
+ }
1515
+
1516
+ openModal() {
1517
+ this.modal.style.display = 'flex';
1518
+ setTimeout(() => {
1519
+ this.modal.classList.add('show');
1520
+ }, 10);
1521
+ document.body.style.overflow = 'hidden';
1522
+ this.showSelectionScreen();
1523
+ }
1524
+
1525
+ closeModal() {
1526
+ this.modal.classList.remove('show');
1527
+ setTimeout(() => {
1528
+ this.modal.style.display = 'none';
1529
+ document.body.style.overflow = '';
1530
+ this.cleanup();
1531
+ }, 300);
1532
+ }
1533
+
1534
+ showScreen(screenId) {
1535
+ // Hide all screens
1536
+ document.querySelectorAll('.ocr-screen').forEach(screen => {
1537
+ screen.style.display = 'none';
1538
+ });
1539
+
1540
+ // Show target screen
1541
+ document.getElementById(screenId).style.display = 'flex';
1542
+ this.currentScreen = screenId;
1543
+ }
1544
+
1545
+ showSelectionScreen() {
1546
+ this.showScreen('ocr-selection-screen');
1547
+ this.cleanup();
1548
+ }
1549
+
1550
+ showCameraScreen() {
1551
+ this.showScreen('ocr-camera-screen');
1552
+ this.initializeCamera();
1553
+ }
1554
+
1555
+ showPreviewScreen() {
1556
+ this.showScreen('ocr-preview-screen');
1557
+ }
1558
+
1559
+ showProcessingScreen() {
1560
+ this.showScreen('ocr-processing-screen');
1561
+ }
1562
+
1563
+ showResultsScreen() {
1564
+ this.showScreen('ocr-results-screen');
1565
+ }
1566
+
1567
+ showSuccessScreen() {
1568
+ this.showScreen('ocr-success-screen');
1569
+ }
1570
+
1571
+ async initializeCamera() {
1572
+ try {
1573
+ this.currentStream = await navigator.mediaDevices.getUserMedia({
1574
+ video: {
1575
+ facingMode: 'environment',
1576
+ width: { ideal: 1280 },
1577
+ height: { ideal: 720 }
1578
+ }
1579
+ });
1580
+
1581
+ const video = document.getElementById('ocr-camera-preview');
1582
+ video.srcObject = this.currentStream;
1583
+ video.play();
1584
+ } catch (error) {
1585
+ console.error('Camera access denied:', error);
1586
+ alert('Camera access is required to take photos. Please allow camera access and try again.');
1587
+ this.showSelectionScreen();
1588
+ }
1589
+ }
1590
+
1591
+ switchCamera() {
1592
+ // Simple camera switch - in a real implementation, you'd cycle through available cameras
1593
+ if (this.currentStream) {
1594
+ this.currentStream.getTracks().forEach(track => track.stop());
1595
+ }
1596
+ this.initializeCamera();
1597
+ }
1598
+
1599
+ capturePhoto() {
1600
+ const video = document.getElementById('ocr-camera-preview');
1601
+ const canvas = document.createElement('canvas');
1602
+ const context = canvas.getContext('2d');
1603
+
1604
+ canvas.width = video.videoWidth;
1605
+ canvas.height = video.videoHeight;
1606
+ context.drawImage(video, 0, 0);
1607
+
1608
+ canvas.toBlob((blob) => {
1609
+ this.currentFile = new File([blob], 'captured-photo.jpg', { type: 'image/jpeg' });
1610
+ this.showPreviewScreen();
1611
+
1612
+ // Display preview
1613
+ const previewImg = document.getElementById('ocr-preview-image');
1614
+ previewImg.src = URL.createObjectURL(blob);
1615
+ }, 'image/jpeg', 0.8);
1616
+ }
1617
+
1618
+ showFileUpload() {
1619
+ const input = document.createElement('input');
1620
+ input.type = 'file';
1621
+ input.accept = 'image/*,.pdf';
1622
+ input.style.display = 'none';
1623
+
1624
+ input.onchange = (e) => {
1625
+ const file = e.target.files[0];
1626
+ if (file) {
1627
+ this.currentFile = file;
1628
+ this.showPreviewScreen();
1629
+
1630
+ // Display preview
1631
+ const previewImg = document.getElementById('ocr-preview-image');
1632
+ if (file.type.startsWith('image/')) {
1633
+ previewImg.src = URL.createObjectURL(file);
1634
+ } else {
1635
+ previewImg.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2Y4ZjlmYSIvPjx0ZXh0IHg9IjUwIiB5PSI1MCIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0IiBmaWxsPSIjN2Y4YzhkIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSI+UERGIEZpbGU8L3RleHQ+PC9zdmc+';
1636
+ }
1637
+ }
1638
+ };
1639
+
1640
+ document.body.appendChild(input);
1641
+ input.click();
1642
+ document.body.removeChild(input);
1643
+ }
1644
+
1645
+ async processInvoice() {
1646
+ if (!this.currentFile) {
1647
+ alert('No file selected');
1648
+ return;
1649
+ }
1650
+
1651
+ this.showProcessingScreen();
1652
+
1653
+ try {
1654
+ const formData = new FormData();
1655
+ formData.append('file', this.currentFile);
1656
+
1657
+ const apiKey = window.CHEFCODE_CONFIG?.API_KEY || '';
1658
+ const response = await fetch('http://localhost:8000/api/ocr-invoice', {
1659
+ method: 'POST',
1660
+ headers: {
1661
+ 'X-API-Key': apiKey
1662
+ },
1663
+ body: formData
1664
+ });
1665
+
1666
+ const data = await response.json();
1667
+
1668
+ // Handle service unavailable (503) - OCR not configured
1669
+ if (response.status === 503) {
1670
+ alert('⚠️ OCR Service Not Available\n\n' +
1671
+ 'The OCR feature requires Google Cloud credentials.\n\n' +
1672
+ 'Please use Manual Input instead or contact your administrator to configure:\n' +
1673
+ '• Google Cloud Project ID\n' +
1674
+ '• Document AI Processor\n' +
1675
+ '• Gemini API Key');
1676
+ this.showSelectionScreen();
1677
+ return;
1678
+ }
1679
+
1680
+ if (!response.ok) {
1681
+ throw new Error(data.detail || `Server error: ${response.status}`);
1682
+ }
1683
+
1684
+ if (data.status === 'success' && Array.isArray(data.items)) {
1685
+ this.ocrResults = data;
1686
+ this.displayResults(data);
1687
+ this.showResultsScreen();
1688
+ } else {
1689
+ throw new Error(data.message || 'OCR processing failed');
1690
+ }
1691
+ } catch (error) {
1692
+ console.error('OCR processing error:', error);
1693
+ alert(`OCR processing failed: ${error.message}`);
1694
+ this.showSelectionScreen();
1695
+ }
1696
+ }
1697
+
1698
+ displayResults(data) {
1699
+ // Update metadata with null checks
1700
+ const supplierElement = document.getElementById('ocr-supplier-name');
1701
+ if (supplierElement) {
1702
+ supplierElement.textContent = `Supplier: ${data.supplier || 'Unknown'}`;
1703
+ }
1704
+
1705
+ const dateElement = document.getElementById('ocr-invoice-date');
1706
+ if (dateElement) {
1707
+ dateElement.textContent = `Date: ${data.date || 'Unknown'}`;
1708
+ }
1709
+
1710
+ // Populate results table with EDITABLE cells
1711
+ const tbody = document.getElementById('ocr-results-tbody');
1712
+ if (tbody && Array.isArray(data.items)) {
1713
+ tbody.innerHTML = '';
1714
+
1715
+ data.items.forEach((item, index) => {
1716
+ const row = document.createElement('tr');
1717
+ row.dataset.index = index;
1718
+ row.innerHTML = `
1719
+ <td><input type="text" class="ocr-edit-input" data-field="name" value="${window.escapeHtml(item.name || 'Unknown')}" /></td>
1720
+ <td><input type="number" class="ocr-edit-input ocr-number-input" data-field="quantity" value="${item.quantity || 0}" step="0.01" /></td>
1721
+ <td>
1722
+ <select class="ocr-edit-input ocr-select-input" data-field="unit">
1723
+ <option value="kg" ${item.unit === 'kg' ? 'selected' : ''}>kg</option>
1724
+ <option value="g" ${item.unit === 'g' ? 'selected' : ''}>g</option>
1725
+ <option value="lt" ${item.unit === 'lt' ? 'selected' : ''}>lt</option>
1726
+ <option value="ml" ${item.unit === 'ml' ? 'selected' : ''}>ml</option>
1727
+ <option value="pz" ${item.unit === 'pz' || !item.unit ? 'selected' : ''}>pz</option>
1728
+ <option value="bt" ${item.unit === 'bt' ? 'selected' : ''}>bt</option>
1729
+ </select>
1730
+ </td>
1731
+ <td><input type="number" class="ocr-edit-input ocr-number-input" data-field="price" value="${item.price || 0}" step="0.01" /></td>
1732
+ <td><input type="text" class="ocr-edit-input" data-field="lot_number" value="${window.escapeHtml(item.lot_number || '')}" placeholder="Enter lot #" /></td>
1733
+ <td><input type="date" class="ocr-edit-input ocr-date-input" data-field="expiry_date" value="${item.expiry_date || ''}" placeholder="YYYY-MM-DD" /></td>
1734
+ `;
1735
+ tbody.appendChild(row);
1736
+ });
1737
+
1738
+ // Add event listeners to sync changes back to data
1739
+ tbody.querySelectorAll('.ocr-edit-input').forEach(input => {
1740
+ input.addEventListener('change', (e) => {
1741
+ const row = e.target.closest('tr');
1742
+ const index = parseInt(row.dataset.index);
1743
+ const field = e.target.dataset.field;
1744
+ let value = e.target.value;
1745
+
1746
+ // Convert numeric fields
1747
+ if (field === 'quantity' || field === 'price') {
1748
+ value = parseFloat(value) || 0;
1749
+ }
1750
+
1751
+ // Update the data object
1752
+ if (this.ocrResults && this.ocrResults.items[index]) {
1753
+ this.ocrResults.items[index][field] = value;
1754
+ }
1755
+ });
1756
+ });
1757
+ }
1758
+ }
1759
+
1760
+ async confirmAndAddToInventory() {
1761
+ if (!this.ocrResults || !this.ocrResults.items) {
1762
+ alert('No OCR results to add');
1763
+ return;
1764
+ }
1765
+
1766
+ try {
1767
+ // Add items to inventory
1768
+ let addedCount = 0;
1769
+ this.ocrResults.items.forEach(item => {
1770
+ window.addOrMergeInventoryItem({
1771
+ name: item.name,
1772
+ unit: item.unit,
1773
+ quantity: item.quantity,
1774
+ category: item.category || 'Other',
1775
+ price: item.price,
1776
+ lot_number: item.lot_number || '',
1777
+ expiry_date: item.expiry_date || ''
1778
+ });
1779
+ addedCount++;
1780
+ });
1781
+
1782
+ // Sync to backend
1783
+ await window.updateInventoryToBackend();
1784
+ window.renderInventory();
1785
+
1786
+ this.showSuccessScreen();
1787
+ } catch (error) {
1788
+ console.error('Error adding to inventory:', error);
1789
+ alert(`Failed to add items to inventory: ${error.message}`);
1790
+ }
1791
+ }
1792
+
1793
+ cleanup() {
1794
+ // Stop camera stream
1795
+ if (this.currentStream) {
1796
+ this.currentStream.getTracks().forEach(track => track.stop());
1797
+ this.currentStream = null;
1798
+ }
1799
+
1800
+ // Clear file references
1801
+ this.currentFile = null;
1802
+ this.ocrResults = null;
1803
+
1804
+ // Clear preview image
1805
+ const previewImg = document.getElementById('ocr-preview-image');
1806
+ if (previewImg.src && previewImg.src.startsWith('blob:')) {
1807
+ URL.revokeObjectURL(previewImg.src);
1808
+ previewImg.src = '';
1809
+ }
1810
+ }
1811
+
1812
+ }
1813
+
1814
+ // Global utility function for HTML escaping
1815
+ window.escapeHtml = function(text) {
1816
+ const div = document.createElement('div');
1817
+ div.textContent = text;
1818
+ return div.innerHTML;
1819
+ };
1820
+
1821
+ // Initialize OCR Modal
1822
+ const ocrModal = new OCRModal();
1823
+
1824
+ // QuickAddPopup removed - ready for new AI toolbar implementation
1825
+
1826
+ // Make OCR modal globally accessible
1827
+ window.ocrModal = ocrModal;
1828
+
1829
+ })();
1830
+ window.CHEFCODE_RESET = () => { alert('ChefCode storage azzerato'); location.reload(); };
frontend/style.css ADDED
@@ -0,0 +1,3251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .tab-title {
2
+ margin: 0 0 12px 0;
3
+ font-size: 1.1em;
4
+ color: #0078d7;
5
+ text-align: left;
6
+ border-bottom: 1px solid #e0e0e0;
7
+ padding-bottom: 4px;
8
+ }
9
+ /* Mostra i tab Production SOLO nella pagina Production attiva */
10
+ .production-tabs,
11
+ .production-tasks-tabbed {
12
+ display: none;
13
+ }
14
+ #production-content.active .production-tabs {
15
+ display: flex;
16
+ }
17
+ #production-content.active .production-tasks-tabbed {
18
+ display: block;
19
+ }
20
+ /* --- Production Tabs --- */
21
+ .production-tabs {
22
+ display: flex;
23
+ gap: 8px;
24
+ margin: 16px 0 8px 0;
25
+ justify-content: center;
26
+ }
27
+ .prod-tab-btn {
28
+ background: #f5f5f5;
29
+ border: 1px solid #ccc;
30
+ border-radius: 6px 6px 0 0;
31
+ padding: 8px 24px;
32
+ cursor: pointer;
33
+ font-weight: bold;
34
+ color: #333;
35
+ outline: none;
36
+ transition: background 0.2s, color 0.2s;
37
+ }
38
+ .prod-tab-btn.active {
39
+ background: #fff;
40
+ color: #0078d7;
41
+ border-bottom: 1px solid #fff;
42
+ }
43
+ .production-tasks-tabbed {
44
+ display: flex;
45
+ flex-direction: row;
46
+ gap: 24px;
47
+ background: #fff;
48
+ border: 1px solid #ccc;
49
+ border-radius: 0 8px 8px 8px;
50
+ min-height: 180px;
51
+ padding: 16px 8px 8px 8px;
52
+ justify-content: space-between;
53
+ }
54
+ .prod-tab-panel {
55
+ flex: 1 1 0;
56
+ display: flex;
57
+ #voice-footer {
58
+ padding: 8px 0;
59
+ font-size: 0.95em;
60
+ }
61
+ #voice-btn {
62
+ background: linear-gradient(145deg, #ffffff, #e8e8e8);
63
+ border: 2px solid #ffffff;
64
+ color: #2c3e50;
65
+ border-radius: 50%;
66
+ width: 60px;
67
+ height: 60px;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
72
+ transition: all 0.3s ease;
73
+ cursor: pointer;
74
+ }
75
+
76
+ #voice-btn:hover {
77
+ background: linear-gradient(145deg, #f8f9fa, #ffffff);
78
+ border-color: #a9cce3;
79
+ transform: translateY(-2px);
80
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
81
+ color: #1e3a8a;
82
+ }
83
+
84
+ #voice-btn:active {
85
+ transform: translateY(0px);
86
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
87
+ }
88
+
89
+ #voice-btn.recording {
90
+ background: linear-gradient(145deg, #ff6b6b, #ff5252);
91
+ border-color: #ff4444;
92
+ color: #ffffff;
93
+ animation: pulse 1.5s infinite;
94
+ }
95
+
96
+ @keyframes pulse {
97
+ 0% { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 rgba(255, 107, 107, 0.7); }
98
+ 70% { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 10px rgba(255, 107, 107, 0); }
99
+ 100% { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15), 0 0 0 0 rgba(255, 107, 107, 0); }
100
+ }
101
+ #voice-mic-icon {
102
+ font-size: 1.7em;
103
+ }
104
+ flex-direction: column;
105
+ min-width: 0;
106
+ }
107
+ .task-list .task-card { margin-bottom: 12px; }
108
+ .task-card-footer { margin-top: 8px; }
109
+ .task-completed-label { color: #28a745; font-weight: bold; }
110
+ /* General Styles */
111
+ body {
112
+ font-family: 'Roboto', sans-serif;
113
+ margin: 0;
114
+ background-color: #f0f2f5;
115
+ color: #34495e;
116
+ display: flex;
117
+ flex-direction: column;
118
+ min-height: 100vh;
119
+ }
120
+
121
+ /* Scrollbar personalizzato per una migliore UX */
122
+ .inventory-table-container::-webkit-scrollbar,
123
+ .main-container::-webkit-scrollbar {
124
+ width: 8px;
125
+ height: 8px;
126
+ }
127
+
128
+ .inventory-table-container::-webkit-scrollbar-track,
129
+ .main-container::-webkit-scrollbar-track {
130
+ background: #f1f1f1;
131
+ border-radius: 4px;
132
+ }
133
+
134
+ .inventory-table-container::-webkit-scrollbar-thumb,
135
+ .main-container::-webkit-scrollbar-thumb {
136
+ background: #c1c1c1;
137
+ border-radius: 4px;
138
+ }
139
+
140
+ .inventory-table-container::-webkit-scrollbar-thumb:hover,
141
+ .main-container::-webkit-scrollbar-thumb:hover {
142
+ background: #a8a8a8;
143
+ }
144
+
145
+ /* Header */
146
+ .header {
147
+ background-color: #2E4E6F;
148
+ color: #ffffff;
149
+ padding: 15px 30px;
150
+ align-items: center;
151
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
152
+ display: grid;
153
+ grid-template-columns: 1fr auto 1fr;
154
+ }
155
+ .header-left { justify-self: start; }
156
+ .header-center { justify-self: center; }
157
+ .header-right {
158
+ justify-self: end;
159
+ display: flex;
160
+ align-items: center;
161
+ gap: 15px;
162
+ }
163
+ .logo { font-size: 30px; font-weight: 700; color: #ffffff; letter-spacing: 0.5px; margin: 0; }
164
+ .home-button { background: none; border: none; color: #ffffff; font-size: 26px; cursor: pointer; transition: color 0.3s ease; padding: 0; }
165
+ .home-button:hover { color: #a9cce3; }
166
+
167
+ /* Account Menu */
168
+ .account-menu { position: relative; display: inline-block; }
169
+ .account-button {
170
+ background-color: rgba(255,255,255,0.1); color: #ffffff;
171
+ border: 1px solid rgba(255,255,255,0.2); padding: 10px 20px;
172
+ border-radius: 25px; cursor: pointer; font-size: 16px;
173
+ display: flex; align-items: center; transition: background-color 0.3s ease;
174
+ }
175
+ .account-button:hover { background-color: rgba(255,255,255,0.2); }
176
+ .account-button .arrow-down { margin-left: 8px; font-size: 12px; }
177
+ .dropdown-content {
178
+ display: none; position: absolute; background-color: #ffffff;
179
+ min-width: 180px; box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
180
+ z-index: 100; border-radius: 8px; right: 0; top: 100%;
181
+ margin-top: 10px; overflow: hidden; border: 1px solid #e0e0e0;
182
+ }
183
+ .dropdown-content a { color: #3d3d3d; padding: 12px 16px; text-decoration: none; display: block; transition: all 0.2s ease; }
184
+ .dropdown-content a:hover { background-color: #f0f2f5; color: #2E4E6F; }
185
+ .account-menu .dropdown-content.show { display: block; }
186
+
187
+
188
+ /* Page Layout */
189
+ .main-container {
190
+ flex: 1;
191
+ padding: 30px;
192
+ display: flex;
193
+ justify-content: center;
194
+ overflow-y: auto;
195
+ min-height: calc(100vh - 200px); /* Garantisce spazio sufficiente per lo scroll */
196
+ }
197
+ .page-section {
198
+ width: 100%;
199
+ max-width: 1100px;
200
+ text-align: center;
201
+ box-sizing: border-box;
202
+ display: none;
203
+ opacity: 0;
204
+ padding-bottom: 40px; /* Spazio extra per il footer */
205
+ transform: translateY(10px);
206
+ transition: opacity 0.5s ease-out, transform 0.5s ease-out;
207
+ }
208
+ .page-section.active { display: block; opacity: 1; transform: translateY(0); }
209
+ #input-detail-page { text-align: left; }
210
+ #input-pages-container { position: relative; }
211
+
212
+ .input-page {
213
+ background-color: #ffffff;
214
+ border-radius: 12px;
215
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.08);
216
+ padding: 40px;
217
+ display: none;
218
+ flex-direction: column;
219
+ opacity: 0;
220
+ transition: opacity 0.4s ease-out;
221
+ }
222
+ .input-page.active { display: flex; opacity: 1; z-index: 10; }
223
+
224
+ /* Page Headers */
225
+ .page-header-with-back {
226
+ display: grid;
227
+ grid-template-columns: auto 1fr;
228
+ align-items: center;
229
+ gap: 15px;
230
+ margin-bottom: 25px;
231
+ border-bottom: 2px solid #e0e0e0;
232
+ padding-bottom: 15px;
233
+ flex-shrink: 0;
234
+ width: 100%;
235
+ }
236
+ .page-header-with-back h3 {
237
+ margin: 0;
238
+ color: #2E4E6F;
239
+ font-size: 28px;
240
+ font-weight: 700;
241
+ text-align: left;
242
+ }
243
+ .back-button {
244
+ background-color: #7b8a9b; color: #ffffff; border: none;
245
+ padding: 10px 18px; border-radius: 25px; cursor: pointer;
246
+ font-size: 16px; display: flex; align-items: center; gap: 8px;
247
+ transition: background-color 0.3s ease;
248
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
249
+ }
250
+ .back-button:hover { background-color: #5c6a79; }
251
+
252
+ /* Big Step Buttons */
253
+ .step-buttons-grid, .goods-in-buttons-grid {
254
+ /* === HOME DASHBOARD 4×2 centrata (solo per la home) === */
255
+ #step-selection-page .step-buttons-grid {
256
+ /* riempi orizzontalmente ma con un tetto, così si può centrare */
257
+ width: 100%;
258
+ max-width: 1100px; /* uguale alla max-width della .page-section */
259
+ margin-inline: auto; /* ⬅️ QUESTA riga la centra */
260
+
261
+ /* 4 colonne fisse su desktop (rettangolo 4×2) */
262
+ display: grid; /* non inline-grid qui: meglio grid pieno */
263
+ grid-template-columns: repeat(4, 1fr);
264
+ gap: 25px;
265
+ }
266
+
267
+ /* Tablet: 2 colonne centrate */
268
+ @media (max-width: 1200px) {
269
+ #step-selection-page .step-buttons-grid {
270
+ grid-template-columns: repeat(2, 1fr);
271
+ }
272
+ }
273
+
274
+ /* Mobile: 1 colonna centrata */
275
+ @media (max-width: 768px) {
276
+ #step-selection-page .step-buttons-grid {
277
+ grid-template-columns: 1fr;
278
+ }
279
+ }
280
+
281
+ display: inline-grid;
282
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
283
+ gap: 25px;
284
+ width: 100%;
285
+ }
286
+ .big-step-button {
287
+ background-color: #ffffff; border: 1px solid #e0e0e0;
288
+ color: #34495e; padding: 25px 20px; border-radius: 8px;
289
+ font-size: 18px; font-weight: 700; text-align: center;
290
+ cursor: pointer; transition: all 0.3s ease; display: flex;
291
+ flex-direction: column; align-items: center; justify-content: center;
292
+ min-height: 150px; box-shadow: 0 4px 6px rgba(0,0,0,0.05);
293
+ }
294
+
295
+ .big-step-button span {
296
+ display: block;
297
+ text-align: center;
298
+ width: 100%;
299
+ line-height: 1.2;
300
+ }
301
+ .big-step-button i {
302
+ font-size: 45px;
303
+ margin-bottom: 12px;
304
+ margin-top: 5px;
305
+ color: #2E4E6F;
306
+ transition: color 0.3s ease;
307
+ display: block;
308
+ text-align: center;
309
+ width: 100%;
310
+ }
311
+ .big-step-button:hover {
312
+ transform: translateY(-5px); box-shadow: 0 8px 15px rgba(0,0,0,0.1);
313
+ border-color: #e67e22;
314
+ }
315
+ .big-step-button:hover i { color: #e67e22; }
316
+
317
+ /* Manual Form Styles */
318
+ .manual-form { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; }
319
+ .form-group { display: flex; flex-direction: column; }
320
+ .form-group.full-width { grid-column: 1 / -1; }
321
+ .form-group label { margin-bottom: 8px; font-weight: 600; color: #2E4E6F; font-size: 14px; text-align: left; }
322
+ .form-group input, .form-group select, .form-group textarea {
323
+ width: 100%; padding: 12px; border: 1px solid #ccc; border-radius: 8px;
324
+ background-color: #f8f9fa; font-size: 16px; box-sizing: border-box;
325
+ transition: border-color 0.3s, box-shadow 0.3s;
326
+ font-family: 'Roboto', sans-serif;
327
+ }
328
+ .form-group input:focus, .form-group select:focus, .form-group textarea:focus {
329
+ outline: none; border-color: #2E4E6F;
330
+ box-shadow: 0 0 0 3px rgba(46, 78, 111, 0.2);
331
+ }
332
+ .submit-btn {
333
+ width: 100%; padding: 15px; border: none; border-radius: 8px; background-color: #27ae60;
334
+ color: white; font-size: 18px; font-weight: 600; cursor: pointer; transition: all 0.3s;
335
+ display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 10px;
336
+ }
337
+ .submit-btn:hover { background-color: #219150; transform: translateY(-2px); }
338
+
339
+ /* --- STILI PAGINE SPECIFICHE --- */
340
+ .inventory-controls { display: flex; gap: 15px; margin-bottom: 20px; align-items: center; }
341
+ .inventory-controls input[type="search"], .inventory-controls select { padding: 10px; border: 1px solid #ccc; border-radius: 8px; font-size: 14px; }
342
+ .inventory-controls input[type="search"] { flex-grow: 1; }
343
+ .icon-btn {
344
+ background: #f0f2f5; border: 1px solid #ccc; color: #555;
345
+ padding: 0; width: 40px; height: 40px; border-radius: 8px;
346
+ cursor: pointer; font-size: 16px; transition: all 0.2s ease;
347
+ }
348
+ .icon-btn:hover { background-color: #e0e0e0; border-color: #aaa; }
349
+
350
+ .inventory-table-container.collapsed-view {
351
+ max-height: 250px;
352
+ }
353
+ .inventory-table-container {
354
+ max-height: 400px;
355
+ overflow-y: auto;
356
+ border: 1px solid #e0e0e0;
357
+ border-radius: 8px;
358
+ -webkit-overflow-scrolling: touch;
359
+ }
360
+
361
+ .inventory-table-container table {
362
+ width: 100%;
363
+ border-collapse: collapse;
364
+ }
365
+
366
+ .inventory-table-container th, .inventory-table-container td {
367
+ padding: 12px 15px;
368
+ text-align: left;
369
+ border-bottom: 1px solid #ecf0f1;
370
+ }
371
+
372
+ .inventory-table-container thead th {
373
+ background-color: #ecf0f1;
374
+ color: #34495e;
375
+ font-weight: 600;
376
+ font-size: 14px;
377
+ position: sticky;
378
+ top: 0;
379
+ z-index: 10;
380
+ }
381
+ .inventory-summary {
382
+ margin-top: 20px;
383
+ padding: 15px 20px;
384
+ background-color: #e9ecef;
385
+ border-radius: 8px;
386
+ text-align: right;
387
+ }
388
+ .inventory-summary h4 { margin: 0; font-size: 18px; color: #2c3e50; font-weight: 500; }
389
+ .inventory-summary span { font-weight: 700; margin-left: 15px; }
390
+
391
+ #camera-simulation-page, #voice-input-page-content { align-items: center; text-align: center; }
392
+ .camera-preview-container {
393
+ width: 100%; max-width: 400px; margin-bottom: 20px;
394
+ aspect-ratio: 1 / 1; display: flex; justify-content: center;
395
+ align-items: center; background-color: #34495e; border-radius: 10px;
396
+ }
397
+ .camera-viewfinder { width: 90%; height: 90%; border: 2px dashed #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; color: #ffffff; }
398
+ .camera-viewfinder i { font-size: 80px; margin-bottom: 15px; }
399
+ .camera-controls { display: flex; justify-content: center; gap: 20px; width: 100%; }
400
+ .input-btn {
401
+ background-color: #e67e22; color: white; border: none;
402
+ padding: 12px 25px; border-radius: 8px; cursor: pointer;
403
+ font-size: 16px; font-weight: 600; transition: all 0.3s;
404
+ display: flex; align-items: center; gap: 8px; white-space: nowrap;
405
+ }
406
+ .input-btn:hover { background-color: #d35400; transform: translateY(-2px); }
407
+
408
+ .voice-input-container { display: flex; flex-direction: column; align-items: center; gap: 30px; margin-top: 50px; flex-grow: 1; justify-content: center; text-align: center; }
409
+ .voice-mic-container { display: flex; flex-direction: column; align-items: center; gap: 15px; }
410
+ #microphone-btn { background-color: #3498db; color: #ffffff; border-radius: 50%; width: 120px; height: 120px; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; box-shadow: 0 5px 15px rgba(0,0,0,0.2); border: none; cursor: pointer; }
411
+ #microphone-btn i { font-size: 50px; color: #ffffff; }
412
+ #microphone-btn:hover { background-color: #2980b9; transform: scale(1.05); }
413
+ #microphone-btn.recording { background-color: #c0392b; animation: pulse-red 1.5s infinite; }
414
+ #mic-label { font-size: 16px; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
415
+ .output-section { background-color: #eaf6ec; border-left: 5px solid #27ae60; padding: 25px; border-radius: 8px; margin-top: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); text-align: left; flex-shrink: 0; }
416
+ .output-section h4 { color: #27ae60; font-size: 20px; margin-top: 0; margin-bottom: 15px; font-weight: 600;}
417
+ @keyframes pulse-red { 0% { box-shadow: 0 0 0 0 rgba(192, 57, 43, 0.7); } 70% { box-shadow: 0 0 0 20px rgba(192, 57, 43, 0); } 100% { box-shadow: 0 0 0 0 rgba(192, 57, 43, 0); } }
418
+
419
+ /* --- STILI DASHBOARD --- */
420
+ #kitchen-dashboard-content,
421
+ #management-dashboard-content { background-color: transparent; padding: 0; box-shadow: none; }
422
+ #kitchen-dashboard-content .page-header-with-back,
423
+ #management-dashboard-content .page-header-with-back { padding: 0 0 15px 0; margin: 0 0 20px 0; border-color: #dce1e6; }
424
+ .new-dashboard-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
425
+ .new-dashboard-grid.kitchen-dash { grid-template-columns: 1fr 1fr; }
426
+ .new-dash-card { background-color: #ffffff; border-radius: 8px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border: 1px solid #e0e0e0; text-align: left; display: flex; flex-direction: column; }
427
+ .new-dash-card.full-width { grid-column: 1 / -1; }
428
+ .new-dash-card.tall { grid-row: span 2; }
429
+ .new-card-title { margin: 0 0 20px 0; font-size: 16px; font-weight: 700; color: #2E4E6F; flex-shrink: 0; }
430
+ .kpi-card { text-align: center; justify-content: center; }
431
+ .kpi-title { font-size: 14px; color: #7f8c8d; margin: 0 0 5px 0; font-weight: 700; text-transform: uppercase; }
432
+ .kpi-amount { font-size: 36px; color: #2c3e50; margin: 0; font-weight: 700; }
433
+ .kpi-change { font-size: 14px; }
434
+ .kpi-change.up { color: #27ae60; }
435
+ .kpi-change.down { color: #c0392b; }
436
+ .bar-chart-v { display: flex; justify-content: space-around; align-items: flex-end; width: 100%; flex-grow: 1; }
437
+ .bar-wrapper { display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; }
438
+ .bar-chart-v .bar { width: 25px; background-color: #3498db; border-radius: 4px 4px 0 0; }
439
+ .bar-label-month { font-size: 12px; color: #7f8c8d; margin-top: 6px; }
440
+ .bar-chart-h { display: flex; flex-direction: column; gap: 15px; flex-grow: 1; justify-content: center; }
441
+ .bar-item { display: flex; align-items: center; gap: 10px; font-size: 14px; }
442
+ .bar-label { width: 70px; color: #555; }
443
+ .bar-bg { flex-grow: 1; background-color: #ecf0f1; border-radius: 4px; height: 20px; }
444
+ .bar-fg { height: 100%; border-radius: 4px; }
445
+ .bar-perc { font-weight: 700; width: 35px; text-align: right; }
446
+ .bar-fg.food { background-color: #f39c12; }
447
+ .bar-fg.beverage { background-color: #16a085; }
448
+ .bar-fg.dessert { background-color: #27ae60; }
449
+ .bar-fg.other { background-color: #7f8c8d; }
450
+ .prep-list-table, .hot-items-table { width: 100%; border-collapse: collapse; font-size: 14px; }
451
+ .prep-list-table th, .prep-list-table td, .hot-items-table th, .hot-items-table td { text-align: left; padding: 10px 5px; border-bottom: 1px solid #ecf0f1; }
452
+ .priority.high { color: #c0392b; font-weight: 600; }
453
+ .priority.medium { color: #f39c12; font-weight: 600; }
454
+ .status.done { color: #27ae60; }
455
+ .status.pending { color: #7f8c8d; }
456
+ .inventory-control { display: flex; gap: 20px; }
457
+ .inventory-column { width: 50%; }
458
+ .inventory-column h5 { margin: 0 0 10px 0; font-size: 14px; color: #2c3e50; }
459
+ .dash-list { margin: 0; padding-left: 0; font-size: 14px; color: #555; list-style-type: none;}
460
+ .dash-list li { margin-bottom: 5px; }
461
+ .inventory-column h5:first-of-type { color: #c0392b; }
462
+ .menu-engineering-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
463
+ .quadrant { border: 1px solid #ecf0f1; border-radius: 6px; padding: 15px; text-align: center; }
464
+ .quadrant h6 { margin: 0 0 8px 0; font-size: 14px; font-weight: 700; }
465
+ .quadrant p { font-size: 13px; margin: 5px 0 0 0; }
466
+ .quadrant small { font-size: 12px; font-style: italic; color: #7f8c8d; display: block; margin-bottom: 10px; }
467
+ .quadrant.star { background-color: #fef9e7; border-color: #f39c12; }
468
+ .quadrant.star h6 { color: #f39c12; }
469
+ .quadrant.plow-horse { background-color: #eaf6ec; border-color: #27ae60; }
470
+ .quadrant.plow-horse h6 { color: #27ae60; }
471
+ .quadrant.puzzle { background-color: #ddebf8; border-color: #3498db; }
472
+ .quadrant.puzzle h6 { color: #3498db; }
473
+ .quadrant.dog { background-color: #f9eaea; border-color: #c0392b; }
474
+ .quadrant.dog h6 { color: #c0392b; }
475
+ .hot-items-table .margin.good { color: #27ae60; font-weight: 600; }
476
+ .hot-items-table .margin.ok { color: #f39c12; font-weight: 600; }
477
+ .chart-with-legend { display: flex; flex-direction: column; align-items: center; gap: 20px; flex-grow: 1; justify-content: center; }
478
+ .pie-chart { width: 150px; height: 150px; border-radius: 50%; background-image: conic-gradient( #3498db 0% 70%, #f39c12 70% 85%, #16a085 85% 100% ); flex-shrink: 0; }
479
+ .channel-list { display: flex; flex-direction: column; gap: 15px; width: 100%;}
480
+ .channel-item { display: flex; align-items: center; gap: 10px; }
481
+ .channel-icon { font-size: 10px; }
482
+ .channel-icon.dine-in { color: #3498db; }
483
+ .channel-icon.takeaway { color: #f39c12; }
484
+ .channel-icon.delivery { color: #16a085; }
485
+ .channel-label { flex-grow: 1; font-weight: 600; color: #34495e; font-size: 16px; }
486
+ .channel-perc { font-size: 16px; font-weight: 700; color: #2c3e50; }
487
+
488
+ .add-ingredient-section {
489
+ display: grid;
490
+ grid-template-columns: 3fr 1fr 1fr auto;
491
+ gap: 15px;
492
+ align-items: flex-end;
493
+ background-color: #f8f9fa;
494
+ padding: 20px;
495
+ border-radius: 8px;
496
+ margin-bottom: 20px;
497
+ }
498
+ .ingredients-list-container {
499
+ background-color: #fdfdfd;
500
+ border: 1px solid #e0e0e0;
501
+ border-radius: 8px;
502
+ padding: 20px;
503
+ min-height: 100px;
504
+ margin-bottom: 20px;
505
+ }
506
+ #recipe-ingredients-list { list-style: none; padding: 0; margin: 0; }
507
+ #recipe-ingredients-list li {
508
+ display: flex;
509
+ justify-content: space-between;
510
+ align-items: center;
511
+ padding: 10px;
512
+ border-bottom: 1px solid #ecf0f1;
513
+ }
514
+ #recipe-ingredients-list li:last-child { border-bottom: none; }
515
+ .remove-ingredient-btn {
516
+ background: none; border: none;
517
+ color: #c0392b; cursor: pointer;
518
+ font-size: 20px; padding: 0 5px;
519
+ }
520
+
521
+ .add-production-task-section {
522
+ display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr; gap: 15px;
523
+ align-items: flex-end; background-color: #f8f9fa; padding: 20px;
524
+ border-radius: 8px; margin-bottom: 30px; border: 1px solid #e0e0e0;
525
+ }
526
+ /* Production: metti "Add to List" sulla riga sotto, sotto "Recipe to Prepare" */
527
+ .add-production-task-section #add-task-btn{
528
+ grid-column: 1 / -1; /* occupa tutta la riga sotto */
529
+ grid-row: 2;
530
+ width: 100%;
531
+ margin-top: 4px;
532
+ }
533
+
534
+ /* === PRODUCTION: mostra i pannelli SOLO nella pagina Production === */
535
+ .add-production-task-section,
536
+ .production-board,
537
+ .completed-tasks-section {
538
+ display: none !important; /* nascondi ovunque di default */
539
+ visibility: hidden !important;
540
+ }
541
+
542
+ #production-content.input-page.active .add-production-task-section {
543
+ display: grid !important; /* mostra SOLO quando #production-content è active */
544
+ visibility: visible !important;
545
+ }
546
+ #production-content.input-page.active .production-board {
547
+ display: grid !important;
548
+ visibility: visible !important;
549
+ }
550
+ #production-content.input-page.active .completed-tasks-section {
551
+ display: block !important;
552
+ visibility: visible !important;
553
+ }
554
+
555
+ .submit-btn.small-btn { padding: 10px 20px; font-size: 16px; height: 46px; }
556
+ .production-board { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 30px; }
557
+ .production-column h4 { margin-top: 0; padding-bottom: 10px; border-bottom: 2px solid #e0e0e0; color: #2E4E6F; }
558
+ .task-list { background-color: #f8f9fa; border-radius: 8px; padding: 15px; min-height: 200px; display: flex; flex-direction: column; gap: 15px; }
559
+ .task-card { background-color: #ffffff; border-radius: 6px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border-left: 5px solid #3498db; }
560
+ .task-card.inprogress { border-left-color: #f39c12; }
561
+ .task-card h5 { margin: 0 0 5px 0; font-size: 16px; }
562
+ .task-card p { margin: 0 0 15px 0; font-size: 14px; color: #7f8c8d; }
563
+ .task-card-footer { display: flex; justify-content: flex-end; }
564
+ .task-action-btn { background-color: #3498db; color: white; border: none; border-radius: 6px; padding: 8px 12px; cursor: pointer; font-size: 12px; font-weight: 600; transition: background-color 0.2s; }
565
+ .task-card.inprogress .task-action-btn { background-color: #27ae60; }
566
+ .task-action-btn:hover { opacity: 0.9; }
567
+ .completed-tasks-section { background-color: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; }
568
+ .completed-tasks-section h4 { margin-top: 0; padding-bottom: 10px; border-bottom: 2px solid #e0e0e0; color: #27ae60; }
569
+ #completed-tasks-list { list-style: none; padding: 0; margin: 0; font-size: 14px; }
570
+ #completed-tasks-list li { padding: 8px 0; border-bottom: 1px dashed #dce1e6; color: #7f8c8d; }
571
+ #completed-tasks-list li:last-child { border-bottom: none; }
572
+ #completed-tasks-list li strong { color: #34495e; }
573
+
574
+
575
+ /* MEDIA QUERIES */
576
+ @media (max-width: 768px) {
577
+ .header-center { display: none; }
578
+ .page-header-with-back {
579
+ grid-template-columns: auto 1fr;
580
+ }
581
+ .page-header-with-back h3 {
582
+ text-align: left;
583
+ }
584
+ .new-dashboard-grid, .new-dashboard-grid.kitchen-dash, .manual-form, .step-buttons-grid, .goods-in-buttons-grid, .add-production-task-section, .production-board, .add-ingredient-section { grid-template-columns: 1fr; }
585
+ .main-container { padding: 10px; }
586
+ .inventory-control { flex-direction: column; }
587
+ .inventory-column { width: 100%; }
588
+ }
589
+ /* ==== PATCH LAYOUT-4x2 — dashboard sempre 4x2, responsive ==== */
590
+ /* Applica SOLO quando la home è attiva */
591
+ #step-selection-page.active .step-buttons-grid {
592
+ display: grid !important;
593
+ /* Desktop: 4 colonne grandi */
594
+ grid-template-columns: repeat(4, minmax(260px, 1fr)) !important;
595
+ gap: 32px !important;
596
+ align-items: stretch !important;
597
+ justify-items: stretch !important;
598
+ }
599
+
600
+ /* Tablet: 2 colonne */
601
+ @media (max-width: 1200px) {
602
+ #step-selection-page.active .step-buttons-grid {
603
+ grid-template-columns: repeat(2, minmax(260px, 1fr)) !important;
604
+ }
605
+ }
606
+
607
+ /* Mobile: 1 colonna */
608
+ @media (max-width: 640px) {
609
+ #step-selection-page.active .step-buttons-grid {
610
+ grid-template-columns: 1fr !important;
611
+ }
612
+ }
613
+
614
+ /* Mantiene i tile belli grandi */
615
+ #step-selection-page.active .big-step-button {
616
+ min-height: 160px;
617
+ }
618
+ /* === PATCH LAYOUT 4x2 — centratura esatta e responsive (append-only) === */
619
+ /* Centro il contenitore della home quando è visibile */
620
+ #step-selection-page.active {
621
+ text-align: center; /* centra gli elementi inline/inline-grid dentro */
622
+ }
623
+
624
+ /* Rendo la griglia un inline-grid (si centra nella riga) e tolgo il centraggio del testo interno */
625
+ #step-selection-page.active .step-buttons-grid {
626
+ display: inline-grid !important; /* si comporta come un "blocco" centrabile */
627
+ text-align: initial !important; /* evita testo centrato dentro i tile */
628
+ gap: 32px !important;
629
+
630
+ /* Desktop: 4 colonne */
631
+ grid-template-columns: repeat(4, minmax(260px, 1fr)) !important;
632
+ }
633
+
634
+ /* Tablet: 2 colonne */
635
+ @media (max-width: 1200px) {
636
+ #step-selection-page.active .step-buttons-grid {
637
+ grid-template-columns: repeat(2, minmax(260px, 1fr)) !important;
638
+ }
639
+ }
640
+
641
+ /* Mobile: 1 colonna */
642
+ @media (max-width: 640px) {
643
+ #step-selection-page.active .step-buttons-grid {
644
+ grid-template-columns: minmax(240px, 1fr) !important;
645
+ }
646
+ }
647
+
648
+ /* Mantiene i tile belli grandi */
649
+ #step-selection-page.active .big-step-button{
650
+ min-height: 160px;
651
+ }
652
+ /* === PATCH 1.1.6 — Dashboard 4×2 centrata, responsive (append-only) === */
653
+ #step-selection-page.active .step-buttons-grid{
654
+ /* lascia la griglia com’è, ma permetti il centraggio “a contenuto” */
655
+ width: -webkit-fit-content;
656
+ width: -moz-fit-content;
657
+ width: fit-content;
658
+ margin-left: auto;
659
+ margin-right: auto;
660
+ /* se qualcuno avesse cambiato l’allineamento dei track, recentra */
661
+ justify-content: center;
662
+ gap: 32px; /* non cambia i tuoi tile, solo per sicurezza */
663
+ }
664
+
665
+ /* Tablet: 2 colonne centrate */
666
+ @media (max-width: 1200px){
667
+ #step-selection-page.active .step-buttons-grid{
668
+ grid-template-columns: repeat(2, minmax(260px, 1fr));
669
+ }
670
+ }
671
+
672
+ /* Mobile: 1 colonna centrata */
673
+ @media (max-width: 640px){
674
+ #step-selection-page.active .step-buttons-grid{
675
+ grid-template-columns: minmax(240px, 1fr);
676
+ }
677
+ }
678
+ /* ===== CHEFCODE PATCH LAYOUT CENTER v2 — dashboard 4×2 centrata ===== */
679
+ #step-selection-page.active .step-buttons-grid {
680
+ /* 1) centratura a prova di bomba */
681
+ width: max-content !important;
682
+ margin-left: auto !important;
683
+ margin-right: auto !important;
684
+
685
+ /* 2) usa la tua griglia, ma centrata */
686
+ display: grid !important;
687
+ gap: 32px !important;
688
+ justify-content: center !important;
689
+
690
+ #step-selection-page.active .step-buttons-grid {
691
+ max-width: 1400px; /* o quanto serve per le 4 colonne */
692
+ }
693
+
694
+ width: max-content;
695
+ margin-inline: auto;
696
+ }
697
+
698
+ /* Desktop: 4 colonne (resti 4×2, responsive) */
699
+ #step-selection-page.active .step-buttons-grid {
700
+ grid-template-columns: repeat(4, minmax(260px, 1fr)) !important;
701
+ }
702
+
703
+ /* Tablet: 2 colonne */
704
+ @media (max-width: 1200px) {
705
+ #step-selection-page.active .step-buttons-grid {
706
+ grid-template-columns: repeat(2, minmax(260px, 1fr)) !important;
707
+ }
708
+ }
709
+
710
+ /* Mobile: 1 colonna */
711
+ @media (max-width: 640px) {
712
+ #step-selection-page.active .step-buttons-grid {
713
+ grid-template-columns: minmax(240px, 1fr) !important;
714
+ }
715
+ }
716
+
717
+ /* Mantieni i tile grandi */
718
+ #step-selection-page.active .big-step-button { min-height: 160px; }
719
+ /* ===== CHEFCODE PATCH MOBILE-CENTER 1.0 — SOLO HOME, SOLO MOBILE ===== */
720
+ @media (max-width: 768px){
721
+ /* Home: griglia centrata anche dopo il Back */
722
+ #step-selection-page.active .step-buttons-grid{
723
+ display: inline-grid !important; /* resta inline per poter essere centrata */
724
+ grid-template-columns: 1fr !important; /* 1 colonna su mobile */
725
+ gap: 25px !important;
726
+
727
+ width: auto !important; /* <-- annulla width:100% */
728
+ margin-inline: auto !important; /* <-- CENTRA il blocco */
729
+ }
730
+ }
731
+ /* ===== CHEFCODE MOBILE TILE RECT 1.0 — SOLO HOME ===== */
732
+ @media (max-width: 768px){
733
+ /* 1) Home: una colonna e tile che riempiono tutta la riga */
734
+ #step-selection-page.active .step-buttons-grid{
735
+ grid-template-columns: 1fr !important;
736
+ justify-items: stretch !important; /* i tile si allargano a tutta larghezza */
737
+ gap: 20px !important; /* leggermente più compatto su mobile */
738
+ }
739
+
740
+ /* 2) Tile rettangolari (non quadrati), stile “stretched” come nelle funzioni */
741
+ #step-selection-page.active .big-step-button{
742
+ width: 100% !important;
743
+ aspect-ratio: 18 / 7; /* << rende il riquadro rettangolare */
744
+ min-height: 140px; /* fallback per browser senza aspect-ratio */
745
+ border-radius: 12px;
746
+ padding: 20px 18px;
747
+ }
748
+
749
+ /* 3) Icona e testo un filo più “importanti” su mobile */
750
+ #step-selection-page.active .big-step-button i{
751
+ font-size: 48px;
752
+ margin-bottom: 10px;
753
+ }
754
+ #step-selection-page.active .big-step-button span{
755
+ font-size: 18px;
756
+ font-weight: 700;
757
+ line-height: 1.2;
758
+ }
759
+ }
760
+ /* Logo come sfondo dell'H1 */
761
+ .header-center .logo{
762
+ background-image: url('logo.svg'); /* <— aggiorna il percorso se serve */
763
+ background-repeat: no-repeat;
764
+ background-position: center;
765
+ background-size: contain;
766
+
767
+ /* dimensioni del logo: regola qui */
768
+ width: clamp(420px, 18vw, 220px);
769
+ height: clamp(70px, 5vh, 40px);
770
+
771
+ /* nascondo il testo mantenendo l'elemento */
772
+ color: transparent;
773
+ text-indent: -9999px;
774
+ overflow: hidden;
775
+
776
+ display: inline-block;
777
+ margin: 0;
778
+ }
779
+
780
+ /* (Opzionale) mostra il logo anche su mobile se attualmente è nascosto */
781
+ @media (max-width: 768px){
782
+ .header-center{ display:block; }
783
+ .header-center .logo{ height: 70px; } /* puoi aumentare/diminuire */
784
+ /* LOGO centrato su mobile */
785
+ @media (max-width: 768px){
786
+ /* 1) la colonna centrale dell’header resta visibile e centrata */
787
+ .header-center{
788
+ display: block !important;
789
+ justify-self: center !important; /* ⬅️ centra la cella della griglia */
790
+ text-align: center; /* fallback: centra il contenuto inline */
791
+ }
792
+
793
+ /* 2A) Se usi l'H1 .logo con background-image (metodo CSS-only) */
794
+ .header-center .logo{
795
+ display: inline-block; /* così text-align:center la centra */
796
+ margin: 0 auto; /* doppia sicurezza */
797
+ height: 70px; /* <-- regola l’altezza che vuoi */
798
+ width: 200px; /* <-- dai anche una larghezza esplicita */
799
+ background-position: center;
800
+ background-size: contain;
801
+ background-repeat: no-repeat;
802
+ color: transparent; text-indent: -9999px; overflow: hidden;
803
+ }
804
+
805
+ /* 2B) Se invece usi <img class="brand-logo"> */
806
+ .brand-logo{
807
+ display: block;
808
+ margin: auto; /* centra l’immagine */
809
+ height: 70px; /* <-- regola qui */
810
+ width: auto;
811
+ }
812
+ }
813
+
814
+ #production-content .add-production-task-section #add-task-btn{
815
+ grid-column: 1;
816
+ grid-row: 2;
817
+ width: 100%;
818
+
819
+ }
820
+ }
821
+ /* ==== CHEFCODE — PRODUCTION VISIBILITY (final, CSS-only) ==== */
822
+ /* Nascondi SEMPRE questi pannelli… */
823
+ .add-production-task-section,
824
+ .production-board,
825
+ .completed-tasks-section {
826
+ display: none !important;
827
+ visibility: hidden !important;
828
+ }
829
+
830
+ /* …tranne quando stai davvero nella pagina Production attiva */
831
+ #production-content.input-page.active .add-production-task-section {
832
+ display: grid !important;
833
+ visibility: visible !important;
834
+ }
835
+ #production-content.input-page.active .production-board {
836
+ display: grid !important;
837
+ visibility: visible !important;
838
+ }
839
+ #production-content.input-page.active .completed-tasks-section {
840
+ display: block !important;
841
+ visibility: visible !important;
842
+ }
843
+
844
+ /* Assicura che il riquadro bianco di Production si allarghi per contenere tutto */
845
+ #production-content.input-page.active {
846
+ display: block !important; /* invece del flex: evita “accorciamenti” strani */
847
+ }
848
+
849
+ /* Evita che stili generici riaccendano i pannelli fuori da Production */
850
+ .page-section:not(#production-content) .add-production-task-section,
851
+ .page-section:not(#production-content) .production-board,
852
+ .page-section:not(#production-content) .completed-tasks-section {
853
+ display: none !important;
854
+ visibility: hidden !important;
855
+ }
856
+
857
+
858
+
859
+
860
+ /* HACCP Traceability - Expiry Date Alerts */
861
+ .expiry-warning {
862
+ color: #ff9800;
863
+ font-weight: 600;
864
+ background: #fff3cd;
865
+ padding: 2px 6px;
866
+ border-radius: 4px;
867
+ }
868
+
869
+ .expiry-critical {
870
+ color: #dc3545;
871
+ font-weight: 700;
872
+ background: #f8d7da;
873
+ padding: 2px 6px;
874
+ border-radius: 4px;
875
+ animation: pulse-critical 2s infinite;
876
+ }
877
+
878
+ @keyframes pulse-critical {
879
+ 0%, 100% { opacity: 1; }
880
+ 50% { opacity: 0.7; }
881
+ }
882
+
883
+ /* Delete Button Styling */
884
+ .delete-btn {
885
+ background: #dc3545;
886
+ color: white;
887
+ border: none;
888
+ border-radius: 4px;
889
+ padding: 6px 8px;
890
+ cursor: pointer;
891
+ font-size: 12px;
892
+ transition: all 0.2s ease;
893
+ display: inline-flex;
894
+ align-items: center;
895
+ justify-content: center;
896
+ min-width: 28px;
897
+ height: 28px;
898
+ }
899
+
900
+ .delete-btn:hover {
901
+ background: #c82333;
902
+ transform: scale(1.05);
903
+ }
904
+
905
+ .delete-btn:active {
906
+ transform: scale(0.95);
907
+ }
908
+
909
+ .delete-btn i {
910
+ font-size: 10px;
911
+ }
912
+
913
+ /* ===== OCR MODAL STYLES ===== */
914
+ .ocr-modal-overlay {
915
+ position: fixed;
916
+ top: 0;
917
+ left: 0;
918
+ right: 0;
919
+ bottom: 0;
920
+ background: rgba(0, 0, 0, 0.4);
921
+ backdrop-filter: blur(4px);
922
+ display: flex;
923
+ align-items: center;
924
+ justify-content: center;
925
+ z-index: 10000;
926
+ opacity: 0;
927
+ transition: opacity 0.3s ease;
928
+ }
929
+
930
+ .ocr-modal-overlay.show {
931
+ opacity: 1;
932
+ }
933
+
934
+ .ocr-modal-content {
935
+ background: white;
936
+ border-radius: 24px;
937
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
938
+ max-width: 90vw;
939
+ max-height: 90vh;
940
+ width: 600px;
941
+ overflow: hidden;
942
+ transform: scale(0.9) translateY(20px);
943
+ transition: transform 0.3s ease;
944
+ }
945
+
946
+ .ocr-modal-overlay.show .ocr-modal-content {
947
+ transform: scale(1) translateY(0);
948
+ }
949
+
950
+ .ocr-modal-header {
951
+ padding: 24px 24px 16px 24px;
952
+ border-bottom: 1px solid #f0f0f0;
953
+ position: relative;
954
+ }
955
+
956
+ .ocr-modal-title {
957
+ font-size: 1.5em;
958
+ font-weight: 600;
959
+ color: #2c3e50;
960
+ margin: 0 0 8px 0;
961
+ }
962
+
963
+ .ocr-modal-subtitle {
964
+ font-size: 0.9em;
965
+ color: #7f8c8d;
966
+ margin: 0;
967
+ line-height: 1.4;
968
+ }
969
+
970
+ .ocr-modal-close {
971
+ position: absolute;
972
+ top: 20px;
973
+ right: 20px;
974
+ background: none;
975
+ border: none;
976
+ font-size: 1.2em;
977
+ color: #bdc3c7;
978
+ cursor: pointer;
979
+ padding: 8px;
980
+ border-radius: 50%;
981
+ transition: all 0.2s ease;
982
+ }
983
+
984
+ .ocr-modal-close:hover {
985
+ background: #f8f9fa;
986
+ color: #2c3e50;
987
+ }
988
+
989
+ .ocr-modal-body {
990
+ padding: 24px;
991
+ min-height: 400px;
992
+ }
993
+
994
+ .ocr-screen {
995
+ display: flex;
996
+ flex-direction: column;
997
+ align-items: center;
998
+ justify-content: center;
999
+ min-height: 400px;
1000
+ }
1001
+
1002
+ /* Selection Screen */
1003
+ .ocr-option-grid {
1004
+ display: grid;
1005
+ grid-template-columns: 1fr 1fr;
1006
+ gap: 20px;
1007
+ width: 100%;
1008
+ max-width: 400px;
1009
+ }
1010
+
1011
+ .ocr-option-card {
1012
+ background: #f8f9fa;
1013
+ border: 2px solid #e9ecef;
1014
+ border-radius: 16px;
1015
+ padding: 32px 24px;
1016
+ text-align: center;
1017
+ cursor: pointer;
1018
+ transition: all 0.3s ease;
1019
+ position: relative;
1020
+ overflow: hidden;
1021
+ }
1022
+
1023
+ .ocr-option-card:hover {
1024
+ transform: translateY(-4px);
1025
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
1026
+ border-color: #0078d7;
1027
+ }
1028
+
1029
+ .ocr-option-card:active {
1030
+ transform: translateY(-2px);
1031
+ }
1032
+
1033
+ .ocr-option-icon {
1034
+ font-size: 2.5em;
1035
+ color: #0078d7;
1036
+ margin-bottom: 16px;
1037
+ }
1038
+
1039
+ .ocr-option-card h3 {
1040
+ font-size: 1.1em;
1041
+ font-weight: 600;
1042
+ color: #2c3e50;
1043
+ margin: 0 0 8px 0;
1044
+ }
1045
+
1046
+ .ocr-option-card p {
1047
+ font-size: 0.9em;
1048
+ color: #7f8c8d;
1049
+ margin: 0;
1050
+ }
1051
+
1052
+ /* Camera Screen */
1053
+ .ocr-camera-container {
1054
+ width: 100%;
1055
+ max-width: 500px;
1056
+ position: relative;
1057
+ background: #000;
1058
+ border-radius: 16px;
1059
+ overflow: hidden;
1060
+ }
1061
+
1062
+ #ocr-camera-preview {
1063
+ width: 100%;
1064
+ height: 300px;
1065
+ object-fit: cover;
1066
+ }
1067
+
1068
+ .ocr-camera-overlay {
1069
+ position: absolute;
1070
+ top: 0;
1071
+ left: 0;
1072
+ right: 0;
1073
+ bottom: 0;
1074
+ pointer-events: none;
1075
+ }
1076
+
1077
+ .ocr-camera-guides {
1078
+ position: absolute;
1079
+ top: 50%;
1080
+ left: 50%;
1081
+ transform: translate(-50%, -50%);
1082
+ width: 200px;
1083
+ height: 120px;
1084
+ border: 2px solid rgba(255, 255, 255, 0.8);
1085
+ border-radius: 8px;
1086
+ }
1087
+
1088
+ .ocr-guide-line {
1089
+ position: absolute;
1090
+ background: rgba(255, 255, 255, 0.6);
1091
+ }
1092
+
1093
+ .ocr-guide-line:nth-child(1) {
1094
+ top: 0;
1095
+ left: 0;
1096
+ right: 0;
1097
+ height: 1px;
1098
+ }
1099
+
1100
+ .ocr-guide-line:nth-child(2) {
1101
+ bottom: 0;
1102
+ left: 0;
1103
+ right: 0;
1104
+ height: 1px;
1105
+ }
1106
+
1107
+ .ocr-guide-line:nth-child(3) {
1108
+ top: 0;
1109
+ left: 0;
1110
+ bottom: 0;
1111
+ width: 1px;
1112
+ }
1113
+
1114
+ .ocr-guide-line:nth-child(4) {
1115
+ top: 0;
1116
+ right: 0;
1117
+ bottom: 0;
1118
+ width: 1px;
1119
+ }
1120
+
1121
+ .ocr-camera-controls {
1122
+ position: absolute;
1123
+ bottom: 20px;
1124
+ left: 50%;
1125
+ transform: translateX(-50%);
1126
+ display: flex;
1127
+ gap: 16px;
1128
+ align-items: center;
1129
+ }
1130
+
1131
+ .ocr-camera-btn {
1132
+ width: 50px;
1133
+ height: 50px;
1134
+ border-radius: 50%;
1135
+ border: none;
1136
+ display: flex;
1137
+ align-items: center;
1138
+ justify-content: center;
1139
+ font-size: 1.2em;
1140
+ cursor: pointer;
1141
+ transition: all 0.2s ease;
1142
+ }
1143
+
1144
+ .ocr-camera-btn.primary {
1145
+ background: #0078d7;
1146
+ color: white;
1147
+ width: 60px;
1148
+ height: 60px;
1149
+ font-size: 1.4em;
1150
+ }
1151
+
1152
+ .ocr-camera-btn.secondary {
1153
+ background: rgba(255, 255, 255, 0.9);
1154
+ color: #2c3e50;
1155
+ }
1156
+
1157
+ .ocr-camera-btn:hover {
1158
+ transform: scale(1.1);
1159
+ }
1160
+
1161
+ /* Preview Screen */
1162
+ .ocr-preview-container {
1163
+ width: 100%;
1164
+ max-width: 500px;
1165
+ text-align: center;
1166
+ }
1167
+
1168
+ #ocr-preview-image {
1169
+ width: 100%;
1170
+ max-height: 300px;
1171
+ object-fit: contain;
1172
+ border-radius: 12px;
1173
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
1174
+ margin-bottom: 24px;
1175
+ }
1176
+
1177
+ .ocr-preview-controls {
1178
+ display: flex;
1179
+ gap: 16px;
1180
+ justify-content: center;
1181
+ }
1182
+
1183
+ .ocr-preview-btn {
1184
+ padding: 12px 24px;
1185
+ border-radius: 8px;
1186
+ border: none;
1187
+ font-weight: 500;
1188
+ cursor: pointer;
1189
+ transition: all 0.2s ease;
1190
+ display: flex;
1191
+ align-items: center;
1192
+ gap: 8px;
1193
+ }
1194
+
1195
+ .ocr-preview-btn.primary {
1196
+ background: #0078d7;
1197
+ color: white;
1198
+ }
1199
+
1200
+ .ocr-preview-btn.secondary {
1201
+ background: #f8f9fa;
1202
+ color: #2c3e50;
1203
+ border: 1px solid #e9ecef;
1204
+ }
1205
+
1206
+ .ocr-preview-btn:hover {
1207
+ transform: translateY(-2px);
1208
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1209
+ }
1210
+
1211
+ /* Processing Screen */
1212
+ .ocr-processing-container {
1213
+ text-align: center;
1214
+ padding: 40px 20px;
1215
+ }
1216
+
1217
+ .ocr-spinner {
1218
+ width: 60px;
1219
+ height: 60px;
1220
+ border: 4px solid #f3f3f3;
1221
+ border-top: 4px solid #0078d7;
1222
+ border-radius: 50%;
1223
+ animation: spin 1s linear infinite;
1224
+ margin: 0 auto 24px auto;
1225
+ }
1226
+
1227
+ @keyframes spin {
1228
+ 0% { transform: rotate(0deg); }
1229
+ 100% { transform: rotate(360deg); }
1230
+ }
1231
+
1232
+ .ocr-processing-container h3 {
1233
+ font-size: 1.3em;
1234
+ color: #2c3e50;
1235
+ margin: 0 0 8px 0;
1236
+ }
1237
+
1238
+ .ocr-processing-container p {
1239
+ color: #7f8c8d;
1240
+ margin: 0;
1241
+ }
1242
+
1243
+ /* Results Screen */
1244
+ .ocr-results-container {
1245
+ width: 100%;
1246
+ max-width: 600px;
1247
+ }
1248
+
1249
+ .ocr-results-header {
1250
+ text-align: center;
1251
+ margin-bottom: 24px;
1252
+ }
1253
+
1254
+ .ocr-results-header h3 {
1255
+ font-size: 1.3em;
1256
+ color: #2c3e50;
1257
+ margin: 0 0 12px 0;
1258
+ }
1259
+
1260
+ .ocr-results-meta {
1261
+ display: flex;
1262
+ gap: 24px;
1263
+ justify-content: center;
1264
+ font-size: 0.9em;
1265
+ color: #7f8c8d;
1266
+ }
1267
+
1268
+ .ocr-results-table-container {
1269
+ max-height: 300px;
1270
+ overflow-y: auto;
1271
+ border: 1px solid #e9ecef;
1272
+ border-radius: 8px;
1273
+ margin-bottom: 24px;
1274
+ }
1275
+
1276
+ .ocr-results-table {
1277
+ width: 100%;
1278
+ border-collapse: collapse;
1279
+ font-size: 0.9em;
1280
+ }
1281
+
1282
+ .ocr-results-table th {
1283
+ background: #f8f9fa;
1284
+ padding: 12px 8px;
1285
+ text-align: left;
1286
+ font-weight: 600;
1287
+ color: #2c3e50;
1288
+ border-bottom: 1px solid #e9ecef;
1289
+ position: sticky;
1290
+ top: 0;
1291
+ }
1292
+
1293
+ .ocr-results-table td {
1294
+ padding: 12px 8px;
1295
+ border-bottom: 1px solid #f0f0f0;
1296
+ color: #2c3e50;
1297
+ }
1298
+
1299
+ .ocr-results-table tr:hover {
1300
+ background: #f8f9fa;
1301
+ }
1302
+
1303
+ /* Editable OCR Input Fields */
1304
+ .ocr-edit-input {
1305
+ width: 100%;
1306
+ padding: 6px 8px;
1307
+ border: 1px solid #e9ecef;
1308
+ border-radius: 4px;
1309
+ font-size: 0.9em;
1310
+ font-family: inherit;
1311
+ color: #2c3e50;
1312
+ background: white;
1313
+ transition: all 0.2s ease;
1314
+ box-sizing: border-box;
1315
+ }
1316
+
1317
+ .ocr-edit-input:focus {
1318
+ outline: none;
1319
+ border-color: #0078d7;
1320
+ background: #f8f9ff;
1321
+ box-shadow: 0 0 0 3px rgba(0, 120, 215, 0.1);
1322
+ }
1323
+
1324
+ .ocr-edit-input:hover {
1325
+ border-color: #ced4da;
1326
+ }
1327
+
1328
+ .ocr-number-input {
1329
+ text-align: right;
1330
+ }
1331
+
1332
+ .ocr-select-input {
1333
+ cursor: pointer;
1334
+ padding-right: 24px;
1335
+ background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
1336
+ background-repeat: no-repeat;
1337
+ background-position: right 4px center;
1338
+ background-size: 16px;
1339
+ appearance: none;
1340
+ -webkit-appearance: none;
1341
+ -moz-appearance: none;
1342
+ }
1343
+
1344
+ .ocr-date-input {
1345
+ cursor: pointer;
1346
+ }
1347
+
1348
+ .ocr-results-table td {
1349
+ padding: 8px 6px;
1350
+ border-bottom: 1px solid #f0f0f0;
1351
+ color: #2c3e50;
1352
+ vertical-align: middle;
1353
+ }
1354
+
1355
+ .ocr-results-actions {
1356
+ display: flex;
1357
+ gap: 16px;
1358
+ justify-content: center;
1359
+ }
1360
+
1361
+ .ocr-results-btn {
1362
+ padding: 12px 24px;
1363
+ border-radius: 8px;
1364
+ border: none;
1365
+ font-weight: 500;
1366
+ cursor: pointer;
1367
+ transition: all 0.2s ease;
1368
+ display: flex;
1369
+ align-items: center;
1370
+ gap: 8px;
1371
+ }
1372
+
1373
+ .ocr-results-btn.primary {
1374
+ background: #28a745;
1375
+ color: white;
1376
+ }
1377
+
1378
+ .ocr-results-btn.secondary {
1379
+ background: #f8f9fa;
1380
+ color: #2c3e50;
1381
+ border: 1px solid #e9ecef;
1382
+ }
1383
+
1384
+ .ocr-results-btn:hover {
1385
+ transform: translateY(-2px);
1386
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1387
+ }
1388
+
1389
+ /* Success Screen */
1390
+ .ocr-success-container {
1391
+ text-align: center;
1392
+ padding: 40px 20px;
1393
+ }
1394
+
1395
+ .ocr-success-icon {
1396
+ font-size: 4em;
1397
+ color: #28a745;
1398
+ margin-bottom: 24px;
1399
+ }
1400
+
1401
+ .ocr-success-container h3 {
1402
+ font-size: 1.4em;
1403
+ color: #2c3e50;
1404
+ margin: 0 0 12px 0;
1405
+ }
1406
+
1407
+ .ocr-success-container p {
1408
+ color: #7f8c8d;
1409
+ margin: 0 0 32px 0;
1410
+ }
1411
+
1412
+ .ocr-success-btn {
1413
+ padding: 16px 32px;
1414
+ border-radius: 8px;
1415
+ border: none;
1416
+ font-weight: 500;
1417
+ cursor: pointer;
1418
+ transition: all 0.2s ease;
1419
+ display: flex;
1420
+ align-items: center;
1421
+ gap: 8px;
1422
+ margin: 0 auto;
1423
+ }
1424
+
1425
+ .ocr-success-btn.primary {
1426
+ background: #0078d7;
1427
+ color: white;
1428
+ }
1429
+
1430
+ .ocr-success-btn:hover {
1431
+ transform: translateY(-2px);
1432
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1433
+ }
1434
+
1435
+ /* Quick Add Popup Styles - Minimal Floating Menu */
1436
+ .quick-add-popup {
1437
+ position: fixed;
1438
+ bottom: 80px;
1439
+ right: 50%;
1440
+ transform: translateX(50%);
1441
+ background: white;
1442
+ border-radius: 12px;
1443
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1444
+ z-index: 10000;
1445
+ overflow: hidden;
1446
+ min-width: 200px;
1447
+ opacity: 0;
1448
+ transform: translateX(50%) translateY(10px);
1449
+ transition: opacity 0.2s ease, transform 0.2s ease;
1450
+ pointer-events: none;
1451
+ }
1452
+
1453
+ .quick-add-popup.show {
1454
+ opacity: 1;
1455
+ transform: translateX(50%) translateY(0);
1456
+ pointer-events: all;
1457
+ }
1458
+
1459
+ .quick-add-popup-option {
1460
+ display: flex;
1461
+ align-items: center;
1462
+ gap: 12px;
1463
+ padding: 14px 20px;
1464
+ cursor: pointer;
1465
+ transition: all 0.2s ease;
1466
+ border-bottom: 1px solid #f0f0f0;
1467
+ color: #2c3e50;
1468
+ font-size: 0.95em;
1469
+ }
1470
+
1471
+ .quick-add-popup-option:last-child {
1472
+ border-bottom: none;
1473
+ }
1474
+
1475
+ .quick-add-popup-option:hover {
1476
+ background: #f8f9fa;
1477
+ color: #0078d7;
1478
+ }
1479
+
1480
+ .quick-add-popup-option:active {
1481
+ background: #e9ecef;
1482
+ }
1483
+
1484
+ .quick-add-popup-option i {
1485
+ font-size: 1.1em;
1486
+ width: 20px;
1487
+ text-align: center;
1488
+ color: #7f8c8d;
1489
+ }
1490
+
1491
+ .quick-add-popup-option:hover i {
1492
+ color: #0078d7;
1493
+ }
1494
+
1495
+ .quick-add-popup-option span {
1496
+ font-weight: 500;
1497
+ user-select: none;
1498
+ }
1499
+
1500
+ /* Mobile Responsive for Quick Add Popup */
1501
+ @media (max-width: 768px) {
1502
+ .quick-add-popup {
1503
+ bottom: 70px;
1504
+ min-width: 180px;
1505
+ }
1506
+
1507
+ .quick-add-popup-option {
1508
+ padding: 12px 16px;
1509
+ font-size: 0.9em;
1510
+ }
1511
+ }
1512
+
1513
+ /* Mobile Responsive */
1514
+ @media (max-width: 768px) {
1515
+ .ocr-modal-content {
1516
+ width: 95vw;
1517
+ max-height: 95vh;
1518
+ border-radius: 16px;
1519
+ }
1520
+
1521
+ .ocr-option-grid {
1522
+ grid-template-columns: 1fr;
1523
+ gap: 16px;
1524
+ }
1525
+
1526
+ .ocr-option-card {
1527
+ padding: 24px 20px;
1528
+ }
1529
+
1530
+ .ocr-results-meta {
1531
+ flex-direction: column;
1532
+ gap: 8px;
1533
+ }
1534
+
1535
+ .ocr-results-actions {
1536
+ flex-direction: column;
1537
+ }
1538
+
1539
+ .ocr-camera-controls {
1540
+ bottom: 16px;
1541
+ }
1542
+
1543
+ .ocr-camera-btn {
1544
+ width: 45px;
1545
+ height: 45px;
1546
+ font-size: 1.1em;
1547
+ }
1548
+
1549
+ .ocr-camera-btn.primary {
1550
+ width: 55px;
1551
+ height: 55px;
1552
+ font-size: 1.3em;
1553
+ }
1554
+ }
1555
+
1556
+ /* ============================================
1557
+ AI TOOLBAR FOOTER - Design Only
1558
+ ============================================ */
1559
+
1560
+ /* Main container adjustment for footer space */
1561
+ .main-container {
1562
+ padding-bottom: 80px; /* Space for fixed footer */
1563
+ }
1564
+
1565
+ /* AI Toolbar Footer */
1566
+ .ai-toolbar-footer {
1567
+ position: fixed;
1568
+ bottom: 0;
1569
+ left: 0;
1570
+ right: 0;
1571
+ background-color: #2E4E6F;
1572
+ border-top: 1px solid #1f3c5d;
1573
+ padding: 10px 20px;
1574
+ z-index: 1000;
1575
+ box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
1576
+ }
1577
+
1578
+ /* Toolbar container - flexbox layout */
1579
+ .ai-toolbar-container {
1580
+ display: flex;
1581
+ align-items: center;
1582
+ justify-content: center;
1583
+ gap: 12px;
1584
+ max-width: 1200px;
1585
+ margin: 0 auto;
1586
+ }
1587
+
1588
+ /* Command input field */
1589
+ .ai-command-input {
1590
+ flex: 1;
1591
+ max-width: 70%;
1592
+ background: #ffffff;
1593
+ border: none;
1594
+ border-radius: 8px;
1595
+ padding: 12px 16px;
1596
+ font-size: 0.95em;
1597
+ font-family: 'Roboto', sans-serif;
1598
+ color: #2c3e50;
1599
+ outline: none;
1600
+ transition: box-shadow 0.3s ease;
1601
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
1602
+ }
1603
+
1604
+ .ai-command-input::placeholder {
1605
+ color: #95a5a6;
1606
+ font-style: italic;
1607
+ }
1608
+
1609
+ .ai-command-input:focus {
1610
+ box-shadow: 0 2px 8px rgba(31, 60, 93, 0.15);
1611
+ }
1612
+
1613
+ /* Toolbar buttons - shared styles */
1614
+ .ai-toolbar-btn {
1615
+ width: 48px;
1616
+ height: 48px;
1617
+ border-radius: 50%;
1618
+ border: none;
1619
+ background-color: #1f3c5d;
1620
+ color: #ffffff;
1621
+ font-size: 1.2em;
1622
+ cursor: pointer;
1623
+ display: flex;
1624
+ align-items: center;
1625
+ justify-content: center;
1626
+ transition: all 0.3s ease;
1627
+ box-shadow: 0 2px 6px rgba(31, 60, 93, 0.2);
1628
+ }
1629
+
1630
+ .ai-toolbar-btn:hover {
1631
+ background-color: #2e4e6f;
1632
+ transform: translateY(-2px);
1633
+ box-shadow: 0 4px 12px rgba(31, 60, 93, 0.3);
1634
+ }
1635
+
1636
+ .ai-toolbar-btn:active {
1637
+ transform: translateY(0);
1638
+ box-shadow: 0 2px 4px rgba(31, 60, 93, 0.2);
1639
+ }
1640
+
1641
+ .ai-toolbar-btn i {
1642
+ pointer-events: none;
1643
+ }
1644
+
1645
+ /* Send button specific */
1646
+ .ai-send-btn {
1647
+ /* Uses shared styles */
1648
+ }
1649
+
1650
+ /* Voice button specific */
1651
+ .ai-voice-btn {
1652
+ /* Uses shared styles */
1653
+ }
1654
+
1655
+ .ai-voice-btn.listening {
1656
+ background-color: #e74c3c !important;
1657
+ animation: pulse 1.5s infinite;
1658
+ }
1659
+
1660
+ @keyframes pulse {
1661
+ 0%, 100% {
1662
+ box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7);
1663
+ }
1664
+ 50% {
1665
+ box-shadow: 0 0 0 10px rgba(231, 76, 60, 0);
1666
+ }
1667
+ }
1668
+
1669
+ /* ============================================================================
1670
+ AI ASSISTANT CHAT INTERFACE
1671
+ ============================================================================ */
1672
+
1673
+ /* Chat Overlay */
1674
+ .ai-chat-overlay {
1675
+ position: fixed;
1676
+ top: 0;
1677
+ right: 0;
1678
+ bottom: 0;
1679
+ width: 420px;
1680
+ background: linear-gradient(135deg, #1E2A3A 0%, #2d3e52 100%);
1681
+ z-index: 9999;
1682
+ display: flex;
1683
+ flex-direction: column;
1684
+ box-shadow: -4px 0 20px rgba(0, 0, 0, 0.3);
1685
+ animation: slideInRight 0.3s ease;
1686
+ }
1687
+
1688
+ @keyframes slideInRight {
1689
+ from {
1690
+ transform: translateX(100%);
1691
+ }
1692
+ to {
1693
+ transform: translateX(0);
1694
+ }
1695
+ }
1696
+
1697
+ .ai-chat-container {
1698
+ display: flex;
1699
+ flex-direction: column;
1700
+ height: 100%;
1701
+ }
1702
+
1703
+ .ai-chat-header {
1704
+ display: flex;
1705
+ justify-content: space-between;
1706
+ align-items: center;
1707
+ padding: 20px;
1708
+ border-bottom: 2px solid rgba(0, 119, 182, 0.3);
1709
+ background: rgba(0, 0, 0, 0.2);
1710
+ }
1711
+
1712
+ .ai-chat-header h3 {
1713
+ margin: 0;
1714
+ color: white;
1715
+ font-size: 1.3em;
1716
+ font-weight: 600;
1717
+ }
1718
+
1719
+ .ai-chat-header p {
1720
+ margin: 4px 0 0 0;
1721
+ color: #94a3b8;
1722
+ font-size: 0.85em;
1723
+ }
1724
+
1725
+ .ai-chat-close {
1726
+ background: rgba(255, 255, 255, 0.1);
1727
+ border: none;
1728
+ border-radius: 8px;
1729
+ width: 32px;
1730
+ height: 32px;
1731
+ color: white;
1732
+ cursor: pointer;
1733
+ display: flex;
1734
+ align-items: center;
1735
+ justify-content: center;
1736
+ transition: all 0.2s;
1737
+ }
1738
+
1739
+ .ai-chat-close:hover {
1740
+ background: rgba(255, 255, 255, 0.2);
1741
+ }
1742
+
1743
+ /* Chat Messages */
1744
+ .ai-chat-messages {
1745
+ flex: 1;
1746
+ overflow-y: auto;
1747
+ padding: 20px;
1748
+ display: flex;
1749
+ flex-direction: column;
1750
+ gap: 12px;
1751
+ }
1752
+
1753
+ .ai-message {
1754
+ display: flex;
1755
+ animation: messageSlideIn 0.3s ease;
1756
+ }
1757
+
1758
+ @keyframes messageSlideIn {
1759
+ from {
1760
+ opacity: 0;
1761
+ transform: translateY(10px);
1762
+ }
1763
+ to {
1764
+ opacity: 1;
1765
+ transform: translateY(0);
1766
+ }
1767
+ }
1768
+
1769
+ .ai-message-user {
1770
+ justify-content: flex-end;
1771
+ }
1772
+
1773
+ .ai-message-assistant {
1774
+ justify-content: flex-start;
1775
+ }
1776
+
1777
+ .ai-message-bubble {
1778
+ max-width: 80%;
1779
+ padding: 12px 16px;
1780
+ border-radius: 16px;
1781
+ font-size: 0.95em;
1782
+ line-height: 1.5;
1783
+ word-wrap: break-word;
1784
+ }
1785
+
1786
+ .ai-message-user .ai-message-bubble {
1787
+ background: linear-gradient(135deg, #0077B6 0%, #00B4D8 100%);
1788
+ color: white;
1789
+ border-bottom-right-radius: 4px;
1790
+ }
1791
+
1792
+ .ai-message-assistant .ai-message-bubble {
1793
+ background: rgba(255, 255, 255, 0.95);
1794
+ color: #1E2A3A;
1795
+ border-bottom-left-radius: 4px;
1796
+ }
1797
+
1798
+ /* Typing Indicator */
1799
+ .ai-typing-indicator {
1800
+ display: flex;
1801
+ gap: 4px;
1802
+ padding: 4px 0;
1803
+ }
1804
+
1805
+ .ai-typing-indicator span {
1806
+ width: 8px;
1807
+ height: 8px;
1808
+ background: #94a3b8;
1809
+ border-radius: 50%;
1810
+ animation: typing 1.4s infinite;
1811
+ }
1812
+
1813
+ .ai-typing-indicator span:nth-child(2) {
1814
+ animation-delay: 0.2s;
1815
+ }
1816
+
1817
+ .ai-typing-indicator span:nth-child(3) {
1818
+ animation-delay: 0.4s;
1819
+ }
1820
+
1821
+ @keyframes typing {
1822
+ 0%, 60%, 100% {
1823
+ transform: translateY(0);
1824
+ }
1825
+ 30% {
1826
+ transform: translateY(-10px);
1827
+ }
1828
+ }
1829
+
1830
+ /* Chat Footer */
1831
+ .ai-chat-footer {
1832
+ padding: 16px 20px;
1833
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
1834
+ background: rgba(0, 0, 0, 0.2);
1835
+ display: flex;
1836
+ flex-direction: column;
1837
+ align-items: center;
1838
+ gap: 12px;
1839
+ }
1840
+
1841
+ .ai-chat-voice-btn {
1842
+ width: 56px;
1843
+ height: 56px;
1844
+ border-radius: 50%;
1845
+ border: 2px solid rgba(0, 119, 182, 0.5);
1846
+ background: linear-gradient(135deg, #0077B6 0%, #00B4D8 100%);
1847
+ color: white;
1848
+ font-size: 1.4em;
1849
+ cursor: pointer;
1850
+ display: flex;
1851
+ align-items: center;
1852
+ justify-content: center;
1853
+ transition: all 0.3s ease;
1854
+ box-shadow: 0 4px 12px rgba(0, 119, 182, 0.3);
1855
+ }
1856
+
1857
+ .ai-chat-voice-btn:hover {
1858
+ transform: scale(1.05);
1859
+ box-shadow: 0 6px 20px rgba(0, 119, 182, 0.5);
1860
+ }
1861
+
1862
+ .ai-chat-voice-btn:active {
1863
+ transform: scale(0.95);
1864
+ }
1865
+
1866
+ .ai-chat-voice-btn.listening {
1867
+ background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
1868
+ border-color: rgba(231, 76, 60, 0.8);
1869
+ animation: voicePulse 1.5s infinite;
1870
+ }
1871
+
1872
+ @keyframes voicePulse {
1873
+ 0%, 100% {
1874
+ box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7);
1875
+ }
1876
+ 50% {
1877
+ box-shadow: 0 0 0 15px rgba(231, 76, 60, 0);
1878
+ }
1879
+ }
1880
+
1881
+ .ai-chat-hint {
1882
+ margin: 0;
1883
+ color: #94a3b8;
1884
+ font-size: 0.85em;
1885
+ text-align: center;
1886
+ font-style: italic;
1887
+ }
1888
+
1889
+ /* ============================================================================
1890
+ CONFIRMATION DIALOG
1891
+ ============================================================================ */
1892
+
1893
+ .ai-confirmation-dialog {
1894
+ position: fixed;
1895
+ top: 0;
1896
+ left: 0;
1897
+ right: 0;
1898
+ bottom: 0;
1899
+ background: rgba(0, 0, 0, 0.75);
1900
+ display: flex;
1901
+ align-items: center;
1902
+ justify-content: center;
1903
+ z-index: 10001;
1904
+ backdrop-filter: blur(4px);
1905
+ animation: fadeIn 0.2s ease;
1906
+ }
1907
+
1908
+ @keyframes fadeIn {
1909
+ from {
1910
+ opacity: 0;
1911
+ }
1912
+ to {
1913
+ opacity: 1;
1914
+ }
1915
+ }
1916
+
1917
+ .ai-confirmation-content {
1918
+ background: white;
1919
+ border-radius: 16px;
1920
+ padding: 32px;
1921
+ max-width: 500px;
1922
+ width: 90%;
1923
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
1924
+ animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
1925
+ }
1926
+
1927
+ @keyframes scaleIn {
1928
+ from {
1929
+ transform: scale(0.9);
1930
+ opacity: 0;
1931
+ }
1932
+ to {
1933
+ transform: scale(1);
1934
+ opacity: 1;
1935
+ }
1936
+ }
1937
+
1938
+ .ai-confirmation-icon {
1939
+ font-size: 3em;
1940
+ text-align: center;
1941
+ margin-bottom: 16px;
1942
+ }
1943
+
1944
+ .ai-confirmation-message {
1945
+ color: #1E2A3A;
1946
+ font-size: 1.1em;
1947
+ line-height: 1.6;
1948
+ margin-bottom: 24px;
1949
+ text-align: center;
1950
+ }
1951
+
1952
+ .ai-confirmation-actions {
1953
+ display: flex;
1954
+ gap: 12px;
1955
+ justify-content: center;
1956
+ }
1957
+
1958
+ /* ============================================================================
1959
+ SEARCH RESULTS OVERLAY
1960
+ ============================================================================ */
1961
+
1962
+ .ai-search-results-overlay {
1963
+ position: fixed;
1964
+ top: 0;
1965
+ left: 0;
1966
+ right: 0;
1967
+ bottom: 0;
1968
+ background: rgba(0, 0, 0, 0.75);
1969
+ display: flex;
1970
+ align-items: center;
1971
+ justify-content: center;
1972
+ z-index: 10000;
1973
+ backdrop-filter: blur(6px);
1974
+ padding: 20px;
1975
+ animation: fadeIn 0.3s ease;
1976
+ }
1977
+
1978
+ .ai-search-container {
1979
+ background: white;
1980
+ border-radius: 16px;
1981
+ max-width: 1000px;
1982
+ width: 100%;
1983
+ max-height: 80vh;
1984
+ display: flex;
1985
+ flex-direction: column;
1986
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
1987
+ }
1988
+
1989
+ .ai-search-header {
1990
+ display: flex;
1991
+ justify-content: space-between;
1992
+ align-items: center;
1993
+ padding: 24px;
1994
+ border-bottom: 2px solid #e2e8f0;
1995
+ }
1996
+
1997
+ .ai-search-header h3 {
1998
+ margin: 0;
1999
+ color: #1E2A3A;
2000
+ font-size: 1.5em;
2001
+ font-weight: 600;
2002
+ }
2003
+
2004
+ .ai-search-close {
2005
+ background: #f8fafc;
2006
+ border: none;
2007
+ border-radius: 8px;
2008
+ width: 36px;
2009
+ height: 36px;
2010
+ cursor: pointer;
2011
+ display: flex;
2012
+ align-items: center;
2013
+ justify-content: center;
2014
+ transition: all 0.2s;
2015
+ }
2016
+
2017
+ .ai-search-close:hover {
2018
+ background: #e2e8f0;
2019
+ }
2020
+
2021
+ .ai-search-grid {
2022
+ padding: 24px;
2023
+ overflow-y: auto;
2024
+ display: grid;
2025
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
2026
+ gap: 20px;
2027
+ }
2028
+
2029
+ /* Recipe Cards in Search */
2030
+ .ai-recipe-card {
2031
+ background: white;
2032
+ border: 2px solid #e2e8f0;
2033
+ border-radius: 12px;
2034
+ overflow: hidden;
2035
+ transition: all 0.3s ease;
2036
+ cursor: pointer;
2037
+ }
2038
+
2039
+ .ai-recipe-card:hover {
2040
+ transform: translateY(-4px);
2041
+ box-shadow: 0 8px 20px rgba(0, 119, 182, 0.15);
2042
+ border-color: #0077B6;
2043
+ }
2044
+
2045
+ .ai-recipe-image {
2046
+ width: 100%;
2047
+ height: 150px;
2048
+ object-fit: cover;
2049
+ background: linear-gradient(135deg, #1E2A3A 0%, #0077B6 100%);
2050
+ }
2051
+
2052
+ .ai-recipe-info {
2053
+ padding: 16px;
2054
+ }
2055
+
2056
+ .ai-recipe-info h4 {
2057
+ margin: 0 0 8px 0;
2058
+ color: #1E2A3A;
2059
+ font-size: 1.1em;
2060
+ font-weight: 600;
2061
+ }
2062
+
2063
+ .ai-recipe-cuisine {
2064
+ color: #64748b;
2065
+ font-size: 0.9em;
2066
+ margin: 0 0 12px 0;
2067
+ }
2068
+
2069
+ /* ============================================================================
2070
+ AI BUTTONS
2071
+ ============================================================================ */
2072
+
2073
+ .ai-btn {
2074
+ padding: 10px 20px;
2075
+ border: none;
2076
+ border-radius: 8px;
2077
+ font-size: 1em;
2078
+ font-weight: 600;
2079
+ cursor: pointer;
2080
+ transition: all 0.3s ease;
2081
+ font-family: 'Poppins', 'Inter', sans-serif;
2082
+ }
2083
+
2084
+ .ai-btn-primary {
2085
+ background: linear-gradient(135deg, #0077B6 0%, #00B4D8 100%);
2086
+ color: white;
2087
+ box-shadow: 0 4px 12px rgba(0, 119, 182, 0.2);
2088
+ }
2089
+
2090
+ .ai-btn-primary:hover {
2091
+ transform: translateY(-2px);
2092
+ box-shadow: 0 6px 20px rgba(0, 119, 182, 0.35);
2093
+ }
2094
+
2095
+ .ai-btn-secondary {
2096
+ background: white;
2097
+ color: #1E2A3A;
2098
+ border: 2px solid #e2e8f0;
2099
+ }
2100
+
2101
+ .ai-btn-secondary:hover {
2102
+ background: #f8fafc;
2103
+ }
2104
+
2105
+ .ai-import-btn {
2106
+ width: 100%;
2107
+ padding: 8px 16px;
2108
+ font-size: 0.9em;
2109
+ }
2110
+
2111
+ /* ============================================================================
2112
+ TOAST NOTIFICATIONS
2113
+ ============================================================================ */
2114
+
2115
+ .ai-toast {
2116
+ position: fixed;
2117
+ bottom: 100px;
2118
+ left: 50%;
2119
+ transform: translateX(-50%) translateY(100px);
2120
+ background: #1E2A3A;
2121
+ color: white;
2122
+ padding: 16px 24px;
2123
+ border-radius: 12px;
2124
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
2125
+ z-index: 10002;
2126
+ opacity: 0;
2127
+ transition: all 0.3s ease;
2128
+ font-weight: 500;
2129
+ }
2130
+
2131
+ .ai-toast.show {
2132
+ opacity: 1;
2133
+ transform: translateX(-50%) translateY(0);
2134
+ }
2135
+
2136
+ .ai-toast-success {
2137
+ background: linear-gradient(135deg, #10b981 0%, #059669 100%);
2138
+ }
2139
+
2140
+ .ai-toast-error {
2141
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
2142
+ }
2143
+
2144
+ /* ============================================================================
2145
+ RESPONSIVE
2146
+ ============================================================================ */
2147
+
2148
+ @media (max-width: 768px) {
2149
+ .ai-chat-overlay {
2150
+ width: 100%;
2151
+ }
2152
+
2153
+ .ai-search-grid {
2154
+ grid-template-columns: 1fr;
2155
+ }
2156
+
2157
+ .ai-confirmation-content {
2158
+ padding: 24px;
2159
+ }
2160
+ }
2161
+
2162
+ /* Upload button specific */
2163
+ .ai-upload-btn {
2164
+ /* Uses shared styles */
2165
+ }
2166
+
2167
+ /* Mobile responsive adjustments */
2168
+ @media (max-width: 768px) {
2169
+ .ai-toolbar-footer {
2170
+ padding: 8px 12px;
2171
+ }
2172
+
2173
+ .ai-toolbar-container {
2174
+ gap: 8px;
2175
+ }
2176
+
2177
+ .ai-command-input {
2178
+ max-width: 75%;
2179
+ padding: 10px 14px;
2180
+ font-size: 0.9em;
2181
+ }
2182
+
2183
+ .ai-toolbar-btn {
2184
+ width: 44px;
2185
+ height: 44px;
2186
+ font-size: 1.1em;
2187
+ }
2188
+ }
2189
+
2190
+ @media (max-width: 480px) {
2191
+ .ai-command-input {
2192
+ max-width: 65%;
2193
+ font-size: 0.85em;
2194
+ }
2195
+
2196
+ .ai-toolbar-btn {
2197
+ width: 40px;
2198
+ height: 40px;
2199
+ font-size: 1em;
2200
+ }
2201
+ }
2202
+
2203
+ /* ============================================
2204
+ RECIPE CATALOGUE - MODERN CARD DESIGN
2205
+ ============================================ */
2206
+
2207
+ .recipe-catalogue-container {
2208
+ padding: 30px;
2209
+ max-width: 1400px;
2210
+ margin: 0 auto;
2211
+ background: #f8fafc;
2212
+ min-height: 60vh;
2213
+ }
2214
+
2215
+ /* Modern Search Bar */
2216
+ .recipe-search-wrapper {
2217
+ position: relative;
2218
+ max-width: 600px;
2219
+ margin: 0 auto 30px;
2220
+ }
2221
+
2222
+ .recipe-search-wrapper .search-icon {
2223
+ position: absolute;
2224
+ left: 18px;
2225
+ top: 50%;
2226
+ transform: translateY(-50%);
2227
+ color: #94a3b8;
2228
+ font-size: 1.1em;
2229
+ pointer-events: none;
2230
+ }
2231
+
2232
+ .recipe-search-input {
2233
+ width: 100%;
2234
+ padding: 14px 20px 14px 50px;
2235
+ border: 2px solid #e2e8f0;
2236
+ border-radius: 12px;
2237
+ font-size: 1em;
2238
+ font-family: 'Roboto', sans-serif;
2239
+ background: white;
2240
+ transition: all 0.3s ease;
2241
+ outline: none;
2242
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
2243
+ }
2244
+
2245
+ .recipe-search-input:focus {
2246
+ border-color: #0ea5e9;
2247
+ box-shadow: 0 4px 12px rgba(14, 165, 233, 0.15);
2248
+ }
2249
+
2250
+ .recipe-search-input::placeholder {
2251
+ color: #94a3b8;
2252
+ }
2253
+
2254
+ /* Recipe Cards Grid */
2255
+ .recipe-cards-grid {
2256
+ display: grid;
2257
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
2258
+ gap: 24px;
2259
+ margin-top: 20px;
2260
+ }
2261
+
2262
+ /* Individual Recipe Card */
2263
+ .recipe-card {
2264
+ background: white;
2265
+ border-radius: 16px;
2266
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
2267
+ overflow: hidden;
2268
+ transition: all 0.3s ease;
2269
+ border: 1px solid #e2e8f0;
2270
+ display: flex;
2271
+ flex-direction: column;
2272
+ }
2273
+
2274
+ .recipe-card:hover {
2275
+ transform: translateY(-4px);
2276
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
2277
+ border-color: #0ea5e9;
2278
+ }
2279
+
2280
+ /* Card Header */
2281
+ .recipe-card-header {
2282
+ padding: 20px 24px;
2283
+ background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
2284
+ color: white;
2285
+ display: flex;
2286
+ justify-content: space-between;
2287
+ align-items: center;
2288
+ gap: 12px;
2289
+ }
2290
+
2291
+ .recipe-card-title {
2292
+ margin: 0;
2293
+ font-size: 1.25em;
2294
+ font-weight: 600;
2295
+ color: white;
2296
+ flex: 1;
2297
+ overflow: hidden;
2298
+ text-overflow: ellipsis;
2299
+ white-space: nowrap;
2300
+ }
2301
+
2302
+ .recipe-card-badge {
2303
+ background: rgba(255, 255, 255, 0.2);
2304
+ color: white;
2305
+ padding: 6px 12px;
2306
+ border-radius: 20px;
2307
+ font-size: 0.85em;
2308
+ font-weight: 500;
2309
+ white-space: nowrap;
2310
+ backdrop-filter: blur(10px);
2311
+ }
2312
+
2313
+ /* Card Body */
2314
+ .recipe-card-body {
2315
+ padding: 20px 24px;
2316
+ flex: 1;
2317
+ display: flex;
2318
+ flex-direction: column;
2319
+ gap: 16px;
2320
+ }
2321
+
2322
+ .recipe-ingredients-section h5 {
2323
+ margin: 0 0 12px 0;
2324
+ font-size: 0.95em;
2325
+ font-weight: 600;
2326
+ color: #475569;
2327
+ display: flex;
2328
+ align-items: center;
2329
+ gap: 8px;
2330
+ }
2331
+
2332
+ .recipe-ingredients-section h5 i {
2333
+ color: #0ea5e9;
2334
+ font-size: 0.9em;
2335
+ }
2336
+
2337
+ .recipe-ingredients-list {
2338
+ list-style: none;
2339
+ padding: 0;
2340
+ margin: 0;
2341
+ display: flex;
2342
+ flex-direction: column;
2343
+ gap: 8px;
2344
+ max-height: 180px;
2345
+ overflow-y: auto;
2346
+ }
2347
+
2348
+ .recipe-ingredients-list li {
2349
+ padding: 8px 12px;
2350
+ background: #f8fafc;
2351
+ border-radius: 8px;
2352
+ display: flex;
2353
+ justify-content: space-between;
2354
+ align-items: center;
2355
+ font-size: 0.9em;
2356
+ border-left: 3px solid #0ea5e9;
2357
+ }
2358
+
2359
+ .ing-name {
2360
+ color: #1e293b;
2361
+ font-weight: 500;
2362
+ flex: 1;
2363
+ }
2364
+
2365
+ .ing-qty {
2366
+ color: #64748b;
2367
+ font-weight: 600;
2368
+ font-size: 0.95em;
2369
+ margin-left: 12px;
2370
+ white-space: nowrap;
2371
+ }
2372
+
2373
+ /* Yield Section */
2374
+ .recipe-yield-section {
2375
+ padding: 12px 16px;
2376
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
2377
+ border-radius: 10px;
2378
+ display: flex;
2379
+ align-items: center;
2380
+ gap: 10px;
2381
+ border: 1px solid #bae6fd;
2382
+ }
2383
+
2384
+ .recipe-yield-section i {
2385
+ color: #0ea5e9;
2386
+ font-size: 1.1em;
2387
+ }
2388
+
2389
+ .yield-label {
2390
+ font-weight: 600;
2391
+ color: #0c4a6e;
2392
+ font-size: 0.9em;
2393
+ }
2394
+
2395
+ .yield-value {
2396
+ color: #0369a1;
2397
+ font-weight: 700;
2398
+ font-size: 1em;
2399
+ }
2400
+
2401
+ /* Card Footer */
2402
+ .recipe-card-footer {
2403
+ padding: 16px 24px;
2404
+ background: #f8fafc;
2405
+ border-top: 1px solid #e2e8f0;
2406
+ display: flex;
2407
+ gap: 12px;
2408
+ justify-content: flex-end;
2409
+ }
2410
+
2411
+ .recipe-card-btn {
2412
+ padding: 10px 20px;
2413
+ border: none;
2414
+ border-radius: 8px;
2415
+ font-size: 0.9em;
2416
+ font-weight: 600;
2417
+ cursor: pointer;
2418
+ transition: all 0.2s ease;
2419
+ display: flex;
2420
+ align-items: center;
2421
+ gap: 6px;
2422
+ font-family: 'Roboto', sans-serif;
2423
+ }
2424
+
2425
+ .recipe-card-btn i {
2426
+ font-size: 0.9em;
2427
+ }
2428
+
2429
+ .edit-recipe-btn {
2430
+ background: #0ea5e9;
2431
+ color: white;
2432
+ }
2433
+
2434
+ .edit-recipe-btn:hover {
2435
+ background: #0284c7;
2436
+ transform: translateY(-1px);
2437
+ box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);
2438
+ }
2439
+
2440
+ .delete-recipe-btn {
2441
+ background: white;
2442
+ color: #dc2626;
2443
+ border: 2px solid #fecaca;
2444
+ }
2445
+
2446
+ .delete-recipe-btn:hover {
2447
+ background: #fef2f2;
2448
+ border-color: #dc2626;
2449
+ transform: translateY(-1px);
2450
+ }
2451
+
2452
+ /* Empty State */
2453
+ .recipe-empty-state {
2454
+ text-align: center;
2455
+ padding: 80px 20px;
2456
+ background: white;
2457
+ border-radius: 16px;
2458
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
2459
+ margin: 20px auto;
2460
+ max-width: 500px;
2461
+ }
2462
+
2463
+ .recipe-empty-state i {
2464
+ font-size: 4em;
2465
+ color: #cbd5e1;
2466
+ margin-bottom: 20px;
2467
+ display: block;
2468
+ }
2469
+
2470
+ .recipe-empty-state h3 {
2471
+ margin: 0 0 12px 0;
2472
+ color: #1e293b;
2473
+ font-size: 1.5em;
2474
+ }
2475
+
2476
+ .recipe-empty-state p {
2477
+ color: #64748b;
2478
+ font-size: 1.1em;
2479
+ margin: 0 0 24px 0;
2480
+ }
2481
+
2482
+ .empty-state-btn {
2483
+ padding: 14px 28px;
2484
+ background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
2485
+ color: white;
2486
+ border: none;
2487
+ border-radius: 10px;
2488
+ font-size: 1em;
2489
+ font-weight: 600;
2490
+ cursor: pointer;
2491
+ transition: all 0.3s ease;
2492
+ display: inline-flex;
2493
+ align-items: center;
2494
+ gap: 8px;
2495
+ box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);
2496
+ }
2497
+
2498
+ .empty-state-btn:hover {
2499
+ transform: translateY(-2px);
2500
+ box-shadow: 0 6px 20px rgba(14, 165, 233, 0.4);
2501
+ }
2502
+
2503
+ /* Responsive Design */
2504
+ @media (max-width: 1024px) {
2505
+ .recipe-cards-grid {
2506
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
2507
+ gap: 20px;
2508
+ }
2509
+ }
2510
+
2511
+ @media (max-width: 768px) {
2512
+ .recipe-catalogue-container {
2513
+ padding: 20px 15px;
2514
+ }
2515
+
2516
+ .recipe-cards-grid {
2517
+ grid-template-columns: 1fr;
2518
+ gap: 16px;
2519
+ }
2520
+
2521
+ .recipe-card-header {
2522
+ padding: 16px 20px;
2523
+ }
2524
+
2525
+ .recipe-card-body {
2526
+ padding: 16px 20px;
2527
+ }
2528
+
2529
+ .recipe-card-footer {
2530
+ padding: 12px 20px;
2531
+ flex-direction: column;
2532
+ }
2533
+
2534
+ .recipe-card-btn {
2535
+ width: 100%;
2536
+ justify-content: center;
2537
+ }
2538
+
2539
+ .recipe-search-input {
2540
+ font-size: 16px; /* Prevents zoom on mobile */
2541
+ }
2542
+
2543
+ .recipe-ingredients-list {
2544
+ max-height: 150px;
2545
+ }
2546
+ }
2547
+
2548
+ @media (max-width: 480px) {
2549
+ .recipe-card-title {
2550
+ font-size: 1.1em;
2551
+ }
2552
+
2553
+ .recipe-search-wrapper {
2554
+ margin-bottom: 20px;
2555
+ }
2556
+
2557
+ .recipe-empty-state {
2558
+ padding: 60px 20px;
2559
+ }
2560
+
2561
+ .recipe-empty-state i {
2562
+ font-size: 3em;
2563
+ }
2564
+ }
2565
+
2566
+ /* ============================================================================
2567
+ WEB RECIPE SEARCH MODAL STYLES - ChefCode Theme
2568
+ ============================================================================ */
2569
+
2570
+ /* Modal Overlay */
2571
+ .web-recipe-modal-overlay {
2572
+ position: fixed;
2573
+ top: 0;
2574
+ left: 0;
2575
+ width: 100%;
2576
+ height: 100%;
2577
+ background: rgba(0, 0, 0, 0.75);
2578
+ backdrop-filter: blur(6px);
2579
+ z-index: 10000;
2580
+ display: flex;
2581
+ align-items: center;
2582
+ justify-content: center;
2583
+ padding: 20px;
2584
+ animation: fadeInOverlay 0.3s ease;
2585
+ }
2586
+
2587
+ @keyframes fadeInOverlay {
2588
+ from {
2589
+ opacity: 0;
2590
+ }
2591
+ to {
2592
+ opacity: 1;
2593
+ }
2594
+ }
2595
+
2596
+ /* Modal Content */
2597
+ .web-recipe-modal-content {
2598
+ background: white;
2599
+ border-radius: 16px;
2600
+ max-width: 1200px;
2601
+ width: 100%;
2602
+ max-height: 90vh;
2603
+ display: flex;
2604
+ flex-direction: column;
2605
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1), 0 2px 10px rgba(0, 0, 0, 0.05);
2606
+ animation: modalSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
2607
+ font-family: 'Poppins', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
2608
+ }
2609
+
2610
+ @keyframes modalSlideIn {
2611
+ from {
2612
+ transform: scale(0.9) translateY(20px);
2613
+ opacity: 0;
2614
+ }
2615
+ to {
2616
+ transform: scale(1) translateY(0);
2617
+ opacity: 1;
2618
+ }
2619
+ }
2620
+
2621
+ /* Modal Header */
2622
+ .web-recipe-modal-header {
2623
+ display: flex;
2624
+ justify-content: space-between;
2625
+ align-items: center;
2626
+ padding: 20px 32px;
2627
+ border-bottom: 1px solid #e5e7eb;
2628
+ background: linear-gradient(135deg, #1E2A3A 0%, #2d3e52 100%);
2629
+ border-radius: 16px 16px 0 0;
2630
+ position: relative;
2631
+ }
2632
+
2633
+ .web-recipe-modal-header::after {
2634
+ content: '';
2635
+ position: absolute;
2636
+ bottom: 0;
2637
+ left: 0;
2638
+ right: 0;
2639
+ height: 3px;
2640
+ background: linear-gradient(90deg, #0077B6 0%, #00B4D8 100%);
2641
+ }
2642
+
2643
+ .web-recipe-modal-title {
2644
+ margin: 0;
2645
+ font-size: 1.5em;
2646
+ color: white;
2647
+ font-weight: 600;
2648
+ display: flex;
2649
+ flex-direction: column;
2650
+ align-items: flex-start;
2651
+ gap: 4px;
2652
+ }
2653
+
2654
+ .web-recipe-modal-subtitle {
2655
+ font-size: 0.55em;
2656
+ color: #94a3b8;
2657
+ font-weight: 400;
2658
+ margin-top: 4px;
2659
+ letter-spacing: 0.3px;
2660
+ }
2661
+
2662
+ .web-recipe-modal-close {
2663
+ background: rgba(255, 255, 255, 0.1);
2664
+ border: 1px solid rgba(255, 255, 255, 0.2);
2665
+ border-radius: 10px;
2666
+ width: 36px;
2667
+ height: 36px;
2668
+ display: flex;
2669
+ align-items: center;
2670
+ justify-content: center;
2671
+ cursor: pointer;
2672
+ color: white;
2673
+ font-size: 1.2em;
2674
+ transition: all 0.2s ease;
2675
+ flex-shrink: 0;
2676
+ }
2677
+
2678
+ .web-recipe-modal-close:hover {
2679
+ background: rgba(255, 255, 255, 0.2);
2680
+ transform: scale(1.05);
2681
+ }
2682
+
2683
+ /* Modal Body */
2684
+ .web-recipe-modal-body {
2685
+ padding: 32px;
2686
+ overflow-y: auto;
2687
+ flex: 1;
2688
+ background: #fafbfc;
2689
+ }
2690
+
2691
+ /* Screens */
2692
+ .web-recipe-screen {
2693
+ display: none;
2694
+ }
2695
+
2696
+ .web-recipe-screen.active {
2697
+ display: block;
2698
+ }
2699
+
2700
+ /* Search Container */
2701
+ .web-recipe-search-container {
2702
+ margin-bottom: 32px;
2703
+ }
2704
+
2705
+ .web-recipe-search-box {
2706
+ display: flex;
2707
+ gap: 12px;
2708
+ margin-bottom: 16px;
2709
+ }
2710
+
2711
+ .web-recipe-search-input {
2712
+ flex: 1;
2713
+ padding: 16px 20px;
2714
+ border: 2px solid #e2e8f0;
2715
+ border-radius: 12px;
2716
+ font-size: 1.05em;
2717
+ transition: all 0.3s ease;
2718
+ font-family: 'Poppins', 'Inter', sans-serif;
2719
+ font-weight: 400;
2720
+ background: white;
2721
+ color: #1E2A3A;
2722
+ }
2723
+
2724
+ .web-recipe-search-input::placeholder {
2725
+ color: #94a3b8;
2726
+ }
2727
+
2728
+ .web-recipe-search-input:focus {
2729
+ outline: none;
2730
+ border-color: #0077B6;
2731
+ box-shadow: 0 0 0 4px rgba(0, 119, 182, 0.1);
2732
+ background: white;
2733
+ }
2734
+
2735
+ .web-recipe-search-button {
2736
+ padding: 16px 32px;
2737
+ background: linear-gradient(135deg, #0077B6 0%, #00B4D8 100%);
2738
+ color: white;
2739
+ border: none;
2740
+ border-radius: 12px;
2741
+ font-size: 1.05em;
2742
+ font-weight: 600;
2743
+ cursor: pointer;
2744
+ transition: all 0.3s ease;
2745
+ display: flex;
2746
+ align-items: center;
2747
+ gap: 8px;
2748
+ box-shadow: 0 4px 12px rgba(0, 119, 182, 0.2);
2749
+ }
2750
+
2751
+ .web-recipe-search-button:hover {
2752
+ transform: translateY(-2px);
2753
+ box-shadow: 0 6px 20px rgba(0, 119, 182, 0.35);
2754
+ background: linear-gradient(135deg, #005f8f 0%, #0099b8 100%);
2755
+ }
2756
+
2757
+ .web-recipe-search-button:active {
2758
+ transform: translateY(0);
2759
+ }
2760
+
2761
+ /* Loading Spinner */
2762
+ .web-recipe-loading {
2763
+ display: flex;
2764
+ flex-direction: column;
2765
+ align-items: center;
2766
+ justify-content: center;
2767
+ padding: 60px 20px;
2768
+ text-align: center;
2769
+ }
2770
+
2771
+ .web-recipe-spinner {
2772
+ width: 50px;
2773
+ height: 50px;
2774
+ border: 4px solid #e2e8f0;
2775
+ border-top: 4px solid #0077B6;
2776
+ border-radius: 50%;
2777
+ animation: spin 1s linear infinite;
2778
+ margin-bottom: 20px;
2779
+ }
2780
+
2781
+ @keyframes spin {
2782
+ 0% {
2783
+ transform: rotate(0deg);
2784
+ }
2785
+ 100% {
2786
+ transform: rotate(360deg);
2787
+ }
2788
+ }
2789
+
2790
+ /* Results Grid */
2791
+ .web-recipe-results-grid {
2792
+ display: grid;
2793
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
2794
+ gap: 24px;
2795
+ margin-top: 24px;
2796
+ }
2797
+
2798
+ .web-recipe-card {
2799
+ background: white;
2800
+ border: 2px solid #e2e8f0;
2801
+ border-radius: 16px;
2802
+ overflow: hidden;
2803
+ transition: all 0.3s ease;
2804
+ cursor: pointer;
2805
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
2806
+ }
2807
+
2808
+ .web-recipe-card:hover {
2809
+ transform: translateY(-6px);
2810
+ box-shadow: 0 12px 32px rgba(0, 119, 182, 0.15);
2811
+ border-color: #0077B6;
2812
+ }
2813
+
2814
+ .web-recipe-card-image {
2815
+ width: 100%;
2816
+ height: 200px;
2817
+ object-fit: cover;
2818
+ background: linear-gradient(135deg, #1E2A3A 0%, #0077B6 100%);
2819
+ }
2820
+
2821
+ .web-recipe-card-content {
2822
+ padding: 20px;
2823
+ }
2824
+
2825
+ .web-recipe-card-title {
2826
+ font-size: 1.3em;
2827
+ font-weight: 600;
2828
+ color: #1E2A3A;
2829
+ margin: 0 0 12px 0;
2830
+ line-height: 1.3;
2831
+ }
2832
+
2833
+ .web-recipe-card-meta {
2834
+ display: flex;
2835
+ gap: 16px;
2836
+ margin-bottom: 12px;
2837
+ }
2838
+
2839
+ .web-recipe-card-badge {
2840
+ display: inline-flex;
2841
+ align-items: center;
2842
+ gap: 6px;
2843
+ padding: 6px 12px;
2844
+ background: #f8f9fa;
2845
+ border-radius: 8px;
2846
+ font-size: 0.9em;
2847
+ color: #666;
2848
+ }
2849
+
2850
+ .web-recipe-card-actions {
2851
+ display: flex;
2852
+ gap: 12px;
2853
+ margin-top: 16px;
2854
+ }
2855
+
2856
+ /* Empty State */
2857
+ .web-recipe-empty-state {
2858
+ text-align: center;
2859
+ padding: 80px 20px;
2860
+ color: #999;
2861
+ }
2862
+
2863
+ .web-recipe-empty-state i {
2864
+ font-size: 4em;
2865
+ color: #ddd;
2866
+ margin-bottom: 20px;
2867
+ }
2868
+
2869
+ .web-recipe-empty-state h3 {
2870
+ font-size: 1.5em;
2871
+ color: #666;
2872
+ margin: 0 0 12px 0;
2873
+ }
2874
+
2875
+ /* Detail Screen */
2876
+ .web-recipe-detail-container {
2877
+ max-width: 900px;
2878
+ margin: 0 auto;
2879
+ }
2880
+
2881
+ .web-recipe-back-btn {
2882
+ background: #f8f9fa;
2883
+ border: 1px solid #e0e0e0;
2884
+ padding: 12px 20px;
2885
+ border-radius: 10px;
2886
+ cursor: pointer;
2887
+ display: inline-flex;
2888
+ align-items: center;
2889
+ gap: 8px;
2890
+ font-size: 1em;
2891
+ color: #666;
2892
+ margin-bottom: 24px;
2893
+ transition: all 0.2s;
2894
+ }
2895
+
2896
+ .web-recipe-back-btn:hover {
2897
+ background: #e9ecef;
2898
+ color: #333;
2899
+ }
2900
+
2901
+ #web-recipe-detail-content {
2902
+ background: #f8f9fa;
2903
+ padding: 32px;
2904
+ border-radius: 16px;
2905
+ margin-bottom: 24px;
2906
+ }
2907
+
2908
+ .web-recipe-detail-image {
2909
+ width: 100%;
2910
+ max-height: 400px;
2911
+ object-fit: cover;
2912
+ border-radius: 12px;
2913
+ margin-bottom: 24px;
2914
+ }
2915
+
2916
+ .web-recipe-detail-title {
2917
+ font-size: 2.2em;
2918
+ font-weight: 700;
2919
+ color: #2c3e50;
2920
+ margin: 0 0 16px 0;
2921
+ }
2922
+
2923
+ .web-recipe-detail-meta {
2924
+ display: flex;
2925
+ gap: 20px;
2926
+ margin-bottom: 32px;
2927
+ flex-wrap: wrap;
2928
+ }
2929
+
2930
+ .web-recipe-detail-section {
2931
+ margin-bottom: 32px;
2932
+ }
2933
+
2934
+ .web-recipe-detail-section h4 {
2935
+ font-size: 1.4em;
2936
+ color: #0077B6;
2937
+ margin: 0 0 16px 0;
2938
+ font-weight: 600;
2939
+ }
2940
+
2941
+ .web-recipe-ingredients-list {
2942
+ list-style: none;
2943
+ padding: 0;
2944
+ display: grid;
2945
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
2946
+ gap: 12px;
2947
+ }
2948
+
2949
+ .web-recipe-ingredients-list li {
2950
+ background: white;
2951
+ padding: 12px 16px;
2952
+ border-radius: 8px;
2953
+ border-left: 4px solid #0077B6;
2954
+ display: flex;
2955
+ align-items: center;
2956
+ gap: 10px;
2957
+ }
2958
+
2959
+ .web-recipe-ingredients-list li i {
2960
+ color: #0077B6;
2961
+ }
2962
+
2963
+ .web-recipe-instructions {
2964
+ background: white;
2965
+ padding: 24px;
2966
+ border-radius: 12px;
2967
+ line-height: 1.8;
2968
+ color: #555;
2969
+ white-space: pre-wrap;
2970
+ }
2971
+
2972
+ .web-recipe-detail-actions {
2973
+ display: flex;
2974
+ justify-content: center;
2975
+ gap: 16px;
2976
+ }
2977
+
2978
+ /* Buttons */
2979
+ .web-recipe-btn {
2980
+ padding: 14px 32px;
2981
+ border: none;
2982
+ border-radius: 12px;
2983
+ font-size: 1.05em;
2984
+ font-weight: 600;
2985
+ cursor: pointer;
2986
+ display: inline-flex;
2987
+ align-items: center;
2988
+ gap: 10px;
2989
+ transition: all 0.3s ease;
2990
+ font-family: 'Poppins', 'Inter', sans-serif;
2991
+ }
2992
+
2993
+ .web-recipe-btn.primary {
2994
+ background: linear-gradient(135deg, #0077B6 0%, #00B4D8 100%);
2995
+ color: white;
2996
+ box-shadow: 0 4px 12px rgba(0, 119, 182, 0.2);
2997
+ }
2998
+
2999
+ .web-recipe-btn.primary:hover {
3000
+ transform: translateY(-2px);
3001
+ box-shadow: 0 6px 20px rgba(0, 119, 182, 0.35);
3002
+ background: linear-gradient(135deg, #005f8f 0%, #0099b8 100%);
3003
+ }
3004
+
3005
+ .web-recipe-btn.secondary {
3006
+ background: white;
3007
+ color: #1E2A3A;
3008
+ border: 2px solid #e2e8f0;
3009
+ }
3010
+
3011
+ .web-recipe-btn.secondary:hover {
3012
+ background: #f8fafc;
3013
+ border-color: #cbd5e1;
3014
+ }
3015
+
3016
+ /* Ingredient Mapping */
3017
+ .web-recipe-mapping-container {
3018
+ max-width: 800px;
3019
+ margin: 0 auto;
3020
+ }
3021
+
3022
+ .web-recipe-mapping-subtitle {
3023
+ text-align: center;
3024
+ color: #64748b;
3025
+ font-size: 1.1em;
3026
+ margin-bottom: 32px;
3027
+ line-height: 1.6;
3028
+ }
3029
+
3030
+ .web-recipe-mapping-legend {
3031
+ display: flex;
3032
+ justify-content: center;
3033
+ gap: 24px;
3034
+ margin-bottom: 32px;
3035
+ flex-wrap: wrap;
3036
+ padding: 20px;
3037
+ background: white;
3038
+ border-radius: 12px;
3039
+ border: 2px solid #e2e8f0;
3040
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
3041
+ }
3042
+
3043
+ .mapping-legend-item {
3044
+ display: flex;
3045
+ align-items: center;
3046
+ gap: 8px;
3047
+ font-size: 0.95em;
3048
+ color: #666;
3049
+ }
3050
+
3051
+ .mapping-badge {
3052
+ display: inline-block;
3053
+ width: 16px;
3054
+ height: 16px;
3055
+ border-radius: 4px;
3056
+ }
3057
+
3058
+ .mapping-badge.exact {
3059
+ background: #10b981;
3060
+ }
3061
+
3062
+ .mapping-badge.substitute {
3063
+ background: #f59e0b;
3064
+ }
3065
+
3066
+ .mapping-badge.missing {
3067
+ background: #ef4444;
3068
+ }
3069
+
3070
+ .web-recipe-mapping-list {
3071
+ display: flex;
3072
+ flex-direction: column;
3073
+ gap: 16px;
3074
+ margin-bottom: 32px;
3075
+ }
3076
+
3077
+ .web-recipe-mapping-item {
3078
+ background: white;
3079
+ border: 2px solid #f0f0f0;
3080
+ border-radius: 12px;
3081
+ padding: 20px;
3082
+ display: flex;
3083
+ align-items: center;
3084
+ gap: 16px;
3085
+ transition: all 0.3s;
3086
+ }
3087
+
3088
+ .web-recipe-mapping-item.exact {
3089
+ border-left: 4px solid #10b981;
3090
+ background: linear-gradient(to right, rgba(16, 185, 129, 0.05), white);
3091
+ }
3092
+
3093
+ .web-recipe-mapping-item.substitute {
3094
+ border-left: 4px solid #f59e0b;
3095
+ background: linear-gradient(to right, rgba(245, 158, 11, 0.05), white);
3096
+ }
3097
+
3098
+ .web-recipe-mapping-item.missing {
3099
+ border-left: 4px solid #ef4444;
3100
+ background: linear-gradient(to right, rgba(239, 68, 68, 0.05), white);
3101
+ }
3102
+
3103
+ .mapping-item-badge {
3104
+ flex-shrink: 0;
3105
+ width: 40px;
3106
+ height: 40px;
3107
+ border-radius: 50%;
3108
+ display: flex;
3109
+ align-items: center;
3110
+ justify-content: center;
3111
+ font-size: 1.3em;
3112
+ }
3113
+
3114
+ .mapping-item-badge.exact {
3115
+ background: #10b981;
3116
+ color: white;
3117
+ }
3118
+
3119
+ .mapping-item-badge.substitute {
3120
+ background: #f59e0b;
3121
+ color: white;
3122
+ }
3123
+
3124
+ .mapping-item-badge.missing {
3125
+ background: #ef4444;
3126
+ color: white;
3127
+ }
3128
+
3129
+ .mapping-item-content {
3130
+ flex: 1;
3131
+ }
3132
+
3133
+ .mapping-item-recipe {
3134
+ font-weight: 700;
3135
+ color: #2c3e50;
3136
+ font-size: 1.1em;
3137
+ margin-bottom: 4px;
3138
+ }
3139
+
3140
+ .mapping-item-inventory {
3141
+ color: #666;
3142
+ font-size: 0.95em;
3143
+ }
3144
+
3145
+ .mapping-item-note {
3146
+ margin-top: 8px;
3147
+ padding: 8px 12px;
3148
+ background: rgba(255, 255, 255, 0.7);
3149
+ border-radius: 6px;
3150
+ font-size: 0.9em;
3151
+ color: #555;
3152
+ font-style: italic;
3153
+ }
3154
+
3155
+ .web-recipe-mapping-actions {
3156
+ display: flex;
3157
+ justify-content: center;
3158
+ gap: 16px;
3159
+ }
3160
+
3161
+ /* Success Screen */
3162
+ .web-recipe-success-container {
3163
+ text-align: center;
3164
+ padding: 60px 20px;
3165
+ max-width: 600px;
3166
+ margin: 0 auto;
3167
+ }
3168
+
3169
+ .web-recipe-success-icon {
3170
+ font-size: 5em;
3171
+ color: #10b981;
3172
+ margin-bottom: 24px;
3173
+ animation: successPop 0.5s ease;
3174
+ }
3175
+
3176
+ @keyframes successPop {
3177
+ 0% {
3178
+ transform: scale(0);
3179
+ opacity: 0;
3180
+ }
3181
+ 50% {
3182
+ transform: scale(1.1);
3183
+ }
3184
+ 100% {
3185
+ transform: scale(1);
3186
+ opacity: 1;
3187
+ }
3188
+ }
3189
+
3190
+ .web-recipe-success-container h3 {
3191
+ font-size: 1.8em;
3192
+ color: #1E2A3A;
3193
+ margin: 0 0 12px 0;
3194
+ font-weight: 600;
3195
+ }
3196
+
3197
+ .web-recipe-success-container p {
3198
+ font-size: 1.1em;
3199
+ color: #64748b;
3200
+ margin-bottom: 32px;
3201
+ line-height: 1.6;
3202
+ }
3203
+
3204
+ /* Responsive */
3205
+ @media (max-width: 768px) {
3206
+ .web-recipe-modal-content {
3207
+ max-height: 95vh;
3208
+ border-radius: 12px;
3209
+ }
3210
+
3211
+ .web-recipe-modal-header {
3212
+ padding: 16px 20px;
3213
+ }
3214
+
3215
+ .web-recipe-modal-title {
3216
+ font-size: 1.2em;
3217
+ }
3218
+
3219
+ .web-recipe-modal-body {
3220
+ padding: 24px 20px;
3221
+ }
3222
+
3223
+ .web-recipe-results-grid {
3224
+ grid-template-columns: 1fr;
3225
+ gap: 16px;
3226
+ }
3227
+
3228
+ .web-recipe-search-box {
3229
+ flex-direction: column;
3230
+ }
3231
+
3232
+ .web-recipe-search-button {
3233
+ width: 100%;
3234
+ justify-content: center;
3235
+ }
3236
+
3237
+ .web-recipe-detail-meta {
3238
+ flex-direction: column;
3239
+ gap: 12px;
3240
+ }
3241
+
3242
+ .web-recipe-ingredients-list {
3243
+ grid-template-columns: 1fr;
3244
+ }
3245
+
3246
+ .web-recipe-mapping-legend {
3247
+ flex-direction: column;
3248
+ align-items: flex-start;
3249
+ gap: 12px;
3250
+ }
3251
+ }
frontend/utils.js ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ChefCode Utility Functions - Condivise tra Web e Mobile
3
+ */
4
+
5
+ // ===== STORAGE UTILS =====
6
+ const storage = {
7
+ // Chiave principale per localStorage/AsyncStorage
8
+ key: 'chefcode:v1',
9
+
10
+ // Salva dati (Web: localStorage, Mobile: AsyncStorage)
11
+ async save(data) {
12
+ try {
13
+ const serialized = JSON.stringify(data);
14
+ if (typeof window !== 'undefined' && window.localStorage) {
15
+ // Web
16
+ localStorage.setItem(this.key, serialized);
17
+ } else if (typeof AsyncStorage !== 'undefined') {
18
+ // React Native
19
+ await AsyncStorage.setItem(this.key, serialized);
20
+ }
21
+ return true;
22
+ } catch (error) {
23
+ console.error('Errore salvataggio:', error);
24
+ return false;
25
+ }
26
+ },
27
+
28
+ // Carica dati
29
+ async load() {
30
+ try {
31
+ let data = null;
32
+ if (typeof window !== 'undefined' && window.localStorage) {
33
+ // Web
34
+ data = localStorage.getItem(this.key);
35
+ } else if (typeof AsyncStorage !== 'undefined') {
36
+ // React Native
37
+ data = await AsyncStorage.getItem(this.key);
38
+ }
39
+
40
+ return data ? JSON.parse(data) : null;
41
+ } catch (error) {
42
+ console.error('Errore caricamento:', error);
43
+ return null;
44
+ }
45
+ },
46
+
47
+ // Reset completo
48
+ async reset() {
49
+ try {
50
+ if (typeof window !== 'undefined' && window.localStorage) {
51
+ // Web
52
+ localStorage.removeItem(this.key);
53
+ } else if (typeof AsyncStorage !== 'undefined') {
54
+ // React Native
55
+ await AsyncStorage.removeItem(this.key);
56
+ }
57
+ return true;
58
+ } catch (error) {
59
+ console.error('Errore reset:', error);
60
+ return false;
61
+ }
62
+ }
63
+ };
64
+
65
+ // ===== PARSING UTILS =====
66
+ const parser = {
67
+ // Parse comando vocale italiano (funziona per web e mobile)
68
+ parseItalianCommand(text) {
69
+ const lowerPrompt = text.toLowerCase();
70
+ console.log('🔍 Parsing comando:', lowerPrompt);
71
+
72
+ // Rimuovi "aggiungi" dall'inizio
73
+ const cleanPrompt = lowerPrompt.replace(/^aggiungi\s+/, '').trim();
74
+
75
+ // Trova prezzo con valute
76
+ const currencyPattern = /(€|\$|euro|dollaro|usd|eur)\s*(\d+(?:[.,]\d+)?)|(\d+(?:[.,]\d+)?)\s*(€|\$|euro|dollaro|usd|eur)/g;
77
+ let price = 0;
78
+ let priceMatch;
79
+
80
+ while ((priceMatch = currencyPattern.exec(cleanPrompt)) !== null) {
81
+ const priceValue = priceMatch[2] || priceMatch[3];
82
+ if (priceValue) {
83
+ price = parseFloat(priceValue.replace(',', '.'));
84
+ break;
85
+ }
86
+ }
87
+
88
+ // Rimuovi prezzo dal prompt
89
+ let promptWithoutPrice = cleanPrompt.replace(currencyPattern, '').trim();
90
+
91
+ // Trova quantità + unità
92
+ let quantity = 0;
93
+ let unit = 'pz';
94
+
95
+ const qtyUnitMatch = promptWithoutPrice.match(/(\d+(?:[.,]\d+)?)\s*(kg|g|grammi|kilogrammi|lt|l|litri|ml|millilitri|pz|pezzi|pcs|confezioni|bottiglie|lattine)/);
96
+ if (qtyUnitMatch) {
97
+ quantity = parseFloat(qtyUnitMatch[1].replace(',', '.'));
98
+ unit = qtyUnitMatch[2];
99
+ promptWithoutPrice = promptWithoutPrice.replace(qtyUnitMatch[0], '').trim();
100
+ } else {
101
+ const qtyMatch = promptWithoutPrice.match(/^(\d+(?:[.,]\d+)?)\s+/);
102
+ if (qtyMatch) {
103
+ quantity = parseFloat(qtyMatch[1].replace(',', '.'));
104
+ promptWithoutPrice = promptWithoutPrice.replace(qtyMatch[0], '').trim();
105
+ }
106
+ }
107
+
108
+ // Nome (rimuovi articoli)
109
+ let name = promptWithoutPrice
110
+ .replace(/^(di|del|della|dei|degli|delle|il|la|lo|gli|le|un|una|uno)\s+/g, '')
111
+ .replace(/\s+(di|del|della|dei|degli|delle|il|la|lo|gli|le|un|una|uno)\s+/g, ' ')
112
+ .trim();
113
+
114
+ return { quantity, unit, name, price };
115
+ },
116
+
117
+ // Normalizza unità di misura
118
+ normalizeUnit(unit) {
119
+ const unitMap = {
120
+ 'chili': 'kg', 'chilo': 'kg', 'chilogrammi': 'kg',
121
+ 'grammi': 'g', 'gr': 'g',
122
+ 'litro': 'l', 'litri': 'l', 'lt': 'l',
123
+ 'millilitri': 'ml', 'ml': 'ml',
124
+ 'pezzi': 'pz', 'pezzo': 'pz', 'pcs': 'pz',
125
+ 'bottiglie': 'bt', 'bottiglia': 'bt'
126
+ };
127
+
128
+ return unitMap[unit.toLowerCase()] || unit;
129
+ }
130
+ };
131
+
132
+ // ===== VALIDATION UTILS =====
133
+ const validator = {
134
+ // Valida ingrediente
135
+ validateIngredient(item) {
136
+ const errors = [];
137
+
138
+ if (!item.name || item.name.trim().length === 0) {
139
+ errors.push('Nome ingrediente richiesto');
140
+ }
141
+
142
+ if (!item.quantity || item.quantity <= 0) {
143
+ errors.push('Quantità deve essere maggiore di 0');
144
+ }
145
+
146
+ if (!item.unit || item.unit.trim().length === 0) {
147
+ errors.push('Unità di misura richiesta');
148
+ }
149
+
150
+ if (item.price < 0) {
151
+ errors.push('Prezzo non può essere negativo');
152
+ }
153
+
154
+ return {
155
+ isValid: errors.length === 0,
156
+ errors
157
+ };
158
+ },
159
+
160
+ // Valida ricetta
161
+ validateRecipe(recipe) {
162
+ const errors = [];
163
+
164
+ if (!recipe.name || recipe.name.trim().length === 0) {
165
+ errors.push('Nome ricetta richiesto');
166
+ }
167
+
168
+ if (!recipe.items || !Array.isArray(recipe.items) || recipe.items.length === 0) {
169
+ errors.push('Ricetta deve avere almeno un ingrediente');
170
+ }
171
+
172
+ recipe.items?.forEach((item, index) => {
173
+ const itemValidation = this.validateIngredient(item);
174
+ if (!itemValidation.isValid) {
175
+ errors.push(`Ingrediente ${index + 1}: ${itemValidation.errors.join(', ')}`);
176
+ }
177
+ });
178
+
179
+ return {
180
+ isValid: errors.length === 0,
181
+ errors
182
+ };
183
+ }
184
+ };
185
+
186
+ // ===== FORMAT UTILS =====
187
+ const formatter = {
188
+ // Formatta prezzo
189
+ currency(amount, currency = '€') {
190
+ return `${currency}${parseFloat(amount || 0).toFixed(2)}`;
191
+ },
192
+
193
+ // Formatta data
194
+ date(date) {
195
+ return new Intl.DateTimeFormat('it-IT', {
196
+ weekday: 'long',
197
+ year: 'numeric',
198
+ month: 'long',
199
+ day: 'numeric'
200
+ }).format(new Date(date));
201
+ },
202
+
203
+ // Formatta quantità
204
+ quantity(qty, unit) {
205
+ return `${parseFloat(qty || 0)} ${unit || 'pz'}`;
206
+ }
207
+ };
208
+
209
+ // ===== DEVICE UTILS =====
210
+ const device = {
211
+ // Rileva piattaforma
212
+ isMobile() {
213
+ return typeof window !== 'undefined' && /Mobi|Android/i.test(navigator.userAgent);
214
+ },
215
+
216
+ isWeb() {
217
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
218
+ },
219
+
220
+ isReactNative() {
221
+ return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
222
+ },
223
+
224
+ // Info piattaforma
225
+ getPlatform() {
226
+ if (this.isReactNative()) return 'react-native';
227
+ if (this.isMobile()) return 'mobile-web';
228
+ if (this.isWeb()) return 'web';
229
+ return 'unknown';
230
+ }
231
+ };
232
+
233
+ // Export per browser
234
+ if (typeof window !== 'undefined') {
235
+ window.ChefCodeUtils = { storage, parser, validator, formatter, device };
236
+ // Export individuali per retrocompatibilità
237
+ window.storage = storage;
238
+ window.parser = parser;
239
+ window.validator = validator;
240
+ window.formatter = formatter;
241
+ window.device = device;
242
+ }
frontend/web-recipe-search.js ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Web Recipe Search Module
3
+ * Handles recipe search from TheMealDB and AI-powered ingredient mapping
4
+ */
5
+
6
+ (function() {
7
+ 'use strict';
8
+
9
+ // State management
10
+ let currentRecipe = null;
11
+ let searchResults = [];
12
+ let ingredientMappings = [];
13
+
14
+ // API endpoints (use existing ChefCode API)
15
+ const API_BASE = window.CHEFCODE_CONFIG?.API_URL || 'http://localhost:8000';
16
+ const API_KEY = window.CHEFCODE_CONFIG?.API_KEY || '';
17
+
18
+ // DOM Elements
19
+ const modal = document.getElementById('web-recipe-modal');
20
+ const searchBtn = document.getElementById('search-web-recipe-btn');
21
+ const closeBtn = document.getElementById('web-recipe-close-btn');
22
+ const searchInput = document.getElementById('web-recipe-search-input');
23
+ const searchButton = document.getElementById('web-recipe-search-btn');
24
+
25
+ // Screens
26
+ const searchScreen = document.getElementById('web-recipe-search-screen');
27
+ const detailScreen = document.getElementById('web-recipe-detail-screen');
28
+ const mappingScreen = document.getElementById('web-recipe-mapping-screen');
29
+ const successScreen = document.getElementById('web-recipe-success-screen');
30
+
31
+ // Screen elements
32
+ const loadingElement = document.getElementById('web-recipe-loading');
33
+ const resultsContainer = document.getElementById('web-recipe-results-container');
34
+ const emptyState = document.getElementById('web-recipe-empty');
35
+ const detailContent = document.getElementById('web-recipe-detail-content');
36
+ const mappingList = document.getElementById('web-recipe-mapping-list');
37
+ const mappingLoading = document.getElementById('web-recipe-mapping-loading');
38
+ const mappingResults = document.getElementById('web-recipe-mapping-results');
39
+
40
+ // Initialize
41
+ function init() {
42
+ attachEventListeners();
43
+ console.log('✅ Web Recipe Search module initialized');
44
+ }
45
+
46
+ // Event Listeners
47
+ function attachEventListeners() {
48
+ // Open modal
49
+ if (searchBtn) {
50
+ searchBtn.addEventListener('click', openModal);
51
+ }
52
+
53
+ // Close modal
54
+ if (closeBtn) {
55
+ closeBtn.addEventListener('click', closeModal);
56
+ }
57
+
58
+ // Close on overlay click
59
+ if (modal) {
60
+ modal.addEventListener('click', (e) => {
61
+ if (e.target === modal) {
62
+ closeModal();
63
+ }
64
+ });
65
+ }
66
+
67
+ // Search functionality
68
+ if (searchButton) {
69
+ searchButton.addEventListener('click', performSearch);
70
+ }
71
+
72
+ if (searchInput) {
73
+ searchInput.addEventListener('keypress', (e) => {
74
+ if (e.key === 'Enter') {
75
+ performSearch();
76
+ }
77
+ });
78
+ }
79
+
80
+ // Back buttons
81
+ const detailBackBtn = document.getElementById('web-recipe-detail-back');
82
+ if (detailBackBtn) {
83
+ detailBackBtn.addEventListener('click', () => showScreen('search'));
84
+ }
85
+
86
+ const mappingBackBtn = document.getElementById('web-recipe-mapping-back');
87
+ if (mappingBackBtn) {
88
+ mappingBackBtn.addEventListener('click', () => showScreen('detail'));
89
+ }
90
+
91
+ // Import button
92
+ const importBtn = document.getElementById('web-recipe-import-btn');
93
+ if (importBtn) {
94
+ importBtn.addEventListener('click', startImportProcess);
95
+ }
96
+
97
+ // Mapping actions
98
+ const mappingCancelBtn = document.getElementById('web-recipe-mapping-cancel');
99
+ if (mappingCancelBtn) {
100
+ mappingCancelBtn.addEventListener('click', () => showScreen('detail'));
101
+ }
102
+
103
+ const mappingConfirmBtn = document.getElementById('web-recipe-mapping-confirm');
104
+ if (mappingConfirmBtn) {
105
+ mappingConfirmBtn.addEventListener('click', saveRecipe);
106
+ }
107
+
108
+ // Success close
109
+ const successCloseBtn = document.getElementById('web-recipe-success-close');
110
+ if (successCloseBtn) {
111
+ successCloseBtn.addEventListener('click', closeModal);
112
+ }
113
+ }
114
+
115
+ // Modal Management
116
+ function openModal() {
117
+ if (modal) {
118
+ modal.style.display = 'flex';
119
+ showScreen('search');
120
+ if (searchInput) {
121
+ searchInput.focus();
122
+ }
123
+ }
124
+ }
125
+
126
+ function closeModal() {
127
+ if (modal) {
128
+ modal.style.display = 'none';
129
+ resetModal();
130
+ }
131
+ }
132
+
133
+ function resetModal() {
134
+ currentRecipe = null;
135
+ searchResults = [];
136
+ ingredientMappings = [];
137
+ if (searchInput) searchInput.value = '';
138
+ if (resultsContainer) resultsContainer.innerHTML = '';
139
+ showScreen('search');
140
+ }
141
+
142
+ function showScreen(screenName) {
143
+ console.log('Switching to screen:', screenName);
144
+
145
+ // Hide all screens
146
+ [searchScreen, detailScreen, mappingScreen, successScreen].forEach(screen => {
147
+ if (screen) screen.classList.remove('active');
148
+ });
149
+
150
+ // Show requested screen
151
+ switch(screenName) {
152
+ case 'search':
153
+ if (searchScreen) {
154
+ searchScreen.classList.add('active');
155
+ console.log('Search screen activated');
156
+ }
157
+ break;
158
+ case 'detail':
159
+ if (detailScreen) {
160
+ detailScreen.classList.add('active');
161
+ console.log('Detail screen activated');
162
+ } else {
163
+ console.error('Detail screen element not found!');
164
+ }
165
+ break;
166
+ case 'mapping':
167
+ if (mappingScreen) {
168
+ mappingScreen.classList.add('active');
169
+ console.log('Mapping screen activated');
170
+ }
171
+ break;
172
+ case 'success':
173
+ if (successScreen) {
174
+ successScreen.classList.add('active');
175
+ console.log('Success screen activated');
176
+ }
177
+ break;
178
+ }
179
+ }
180
+
181
+ // Search Functionality
182
+ async function performSearch() {
183
+ const query = searchInput?.value.trim();
184
+ if (!query) {
185
+ alert('Please enter a search term');
186
+ return;
187
+ }
188
+
189
+ try {
190
+ // Show loading
191
+ if (loadingElement) loadingElement.style.display = 'flex';
192
+ if (resultsContainer) resultsContainer.style.display = 'none';
193
+ if (emptyState) emptyState.style.display = 'none';
194
+
195
+ // Call backend to search recipes
196
+ const response = await fetch(`${API_BASE}/api/web-recipes/search_recipes`, {
197
+ method: 'POST',
198
+ headers: {
199
+ 'Content-Type': 'application/json',
200
+ 'X-API-Key': API_KEY
201
+ },
202
+ body: JSON.stringify({
203
+ query: query,
204
+ cuisine: null,
205
+ restrictions: []
206
+ })
207
+ });
208
+
209
+ if (!response.ok) {
210
+ throw new Error('Search failed');
211
+ }
212
+
213
+ const recipes = await response.json();
214
+ searchResults = recipes;
215
+
216
+ // Hide loading
217
+ if (loadingElement) loadingElement.style.display = 'none';
218
+
219
+ // Display results or empty state
220
+ if (recipes && recipes.length > 0) {
221
+ displaySearchResults(recipes);
222
+ } else {
223
+ if (emptyState) emptyState.style.display = 'block';
224
+ }
225
+
226
+ } catch (error) {
227
+ console.error('Search error:', error);
228
+ if (loadingElement) loadingElement.style.display = 'none';
229
+ alert('Failed to search recipes. Please try again.');
230
+ }
231
+ }
232
+
233
+ function displaySearchResults(recipes) {
234
+ if (!resultsContainer) return;
235
+
236
+ resultsContainer.innerHTML = '';
237
+ resultsContainer.style.display = 'grid';
238
+
239
+ recipes.forEach(recipe => {
240
+ const card = createRecipeCard(recipe);
241
+ resultsContainer.appendChild(card);
242
+ });
243
+ }
244
+
245
+ function createRecipeCard(recipe) {
246
+ const card = document.createElement('div');
247
+ card.className = 'web-recipe-card';
248
+ card.onclick = () => {
249
+ console.log('Recipe card clicked:', recipe.name);
250
+ showRecipeDetail(recipe);
251
+ };
252
+
253
+ const imageUrl = recipe.image || '';
254
+ const category = recipe.category || 'Other';
255
+ const cuisine = recipe.area || 'International';
256
+ const ingredientCount = recipe.ingredients?.length || 0;
257
+
258
+ card.innerHTML = `
259
+ <img src="${imageUrl}" alt="${recipe.name}" class="web-recipe-card-image"
260
+ onerror="this.src='data:image/svg+xml,%3Csvg xmlns=\\'http://www.w3.org/2000/svg\\' width=\\'400\\' height=\\'200\\'%3E%3Crect fill=\\'%23667eea\\' width=\\'400\\' height=\\'200\\'/%3E%3Ctext fill=\\'white\\' x=\\'50%25\\' y=\\'50%25\\' text-anchor=\\'middle\\' dominant-baseline=\\'middle\\' font-size=\\'24\\' font-family=\\'Arial\\'%3E${recipe.name}%3C/text%3E%3C/svg%3E'">
261
+ <div class="web-recipe-card-content">
262
+ <h3 class="web-recipe-card-title">${recipe.name}</h3>
263
+ <div class="web-recipe-card-meta">
264
+ <span class="web-recipe-card-badge">
265
+ <i class="fas fa-utensils"></i> ${category}
266
+ </span>
267
+ <span class="web-recipe-card-badge">
268
+ <i class="fas fa-globe"></i> ${cuisine}
269
+ </span>
270
+ </div>
271
+ <p style="color: #666; font-size: 0.95em; margin-top: 8px;">
272
+ <i class="fas fa-list"></i> ${ingredientCount} ingredients
273
+ </p>
274
+ </div>
275
+ `;
276
+
277
+ return card;
278
+ }
279
+
280
+ function showRecipeDetail(recipe) {
281
+ console.log('showRecipeDetail called for:', recipe.name);
282
+ currentRecipe = recipe;
283
+
284
+ if (!detailContent) {
285
+ console.error('detailContent element not found!');
286
+ return;
287
+ }
288
+
289
+ const imageUrl = recipe.image || '';
290
+ const category = recipe.category || 'Other';
291
+ const cuisine = recipe.area || 'International';
292
+
293
+ let ingredientsHTML = '<ul class="web-recipe-ingredients-list">';
294
+ recipe.ingredients.forEach(ing => {
295
+ ingredientsHTML += `
296
+ <li>
297
+ <i class="fas fa-check-circle"></i>
298
+ <span><strong>${ing.name}</strong> ${ing.measure ? '- ' + ing.measure : ''}</span>
299
+ </li>
300
+ `;
301
+ });
302
+ ingredientsHTML += '</ul>';
303
+
304
+ detailContent.innerHTML = `
305
+ <img src="${imageUrl}" alt="${recipe.name}" class="web-recipe-detail-image"
306
+ onerror="this.style.display='none'">
307
+ <h2 class="web-recipe-detail-title">${recipe.name}</h2>
308
+ <div class="web-recipe-detail-meta">
309
+ <span class="web-recipe-card-badge" style="font-size: 1em;">
310
+ <i class="fas fa-utensils"></i> ${category}
311
+ </span>
312
+ <span class="web-recipe-card-badge" style="font-size: 1em;">
313
+ <i class="fas fa-globe"></i> ${cuisine}
314
+ </span>
315
+ <span class="web-recipe-card-badge" style="font-size: 1em;">
316
+ <i class="fas fa-list"></i> ${recipe.ingredients.length} ingredients
317
+ </span>
318
+ </div>
319
+ <div class="web-recipe-detail-section">
320
+ <h4><i class="fas fa-carrot"></i> Ingredients</h4>
321
+ ${ingredientsHTML}
322
+ </div>
323
+ <div class="web-recipe-detail-section">
324
+ <h4><i class="fas fa-clipboard-list"></i> Instructions</h4>
325
+ <div class="web-recipe-instructions">${recipe.instructions || 'No instructions available.'}</div>
326
+ </div>
327
+ `;
328
+
329
+ showScreen('detail');
330
+ }
331
+
332
+ // Import Process
333
+ async function startImportProcess() {
334
+ console.log('=== Starting Import Process ===');
335
+
336
+ if (!currentRecipe) {
337
+ console.error('No current recipe!');
338
+ return;
339
+ }
340
+
341
+ console.log('Current recipe:', currentRecipe);
342
+ showScreen('mapping');
343
+
344
+ if (mappingLoading) mappingLoading.style.display = 'flex';
345
+ if (mappingResults) mappingResults.style.display = 'none';
346
+
347
+ try {
348
+ // Prepare ingredients for mapping
349
+ const recipeIngredients = currentRecipe.ingredients.map(ing => ({
350
+ name: ing.name,
351
+ quantity: ing.measure.split(' ')[0] || '1',
352
+ unit: ing.measure.split(' ').slice(1).join(' ') || 'pz'
353
+ }));
354
+
355
+ console.log('Prepared ingredients:', recipeIngredients);
356
+ console.log('API URL:', `${API_BASE}/api/web-recipes/map_ingredients`);
357
+ console.log('API Key present:', !!API_KEY);
358
+
359
+ // Call backend to map ingredients
360
+ const response = await fetch(`${API_BASE}/api/web-recipes/map_ingredients`, {
361
+ method: 'POST',
362
+ headers: {
363
+ 'Content-Type': 'application/json',
364
+ 'X-API-Key': API_KEY
365
+ },
366
+ body: JSON.stringify({
367
+ recipe_id: currentRecipe.id,
368
+ recipe_ingredients: recipeIngredients
369
+ })
370
+ });
371
+
372
+ if (!response.ok) {
373
+ const errorText = await response.text();
374
+ console.error('API Response Error:', response.status, errorText);
375
+ throw new Error(`Ingredient mapping failed: ${response.status} - ${errorText}`);
376
+ }
377
+
378
+ const result = await response.json();
379
+ console.log('Mapping result:', result);
380
+
381
+ if (!result.mappings || !Array.isArray(result.mappings)) {
382
+ console.error('Invalid mappings format:', result);
383
+ throw new Error('Invalid response format from server');
384
+ }
385
+
386
+ ingredientMappings = result.mappings;
387
+
388
+ // Display mappings
389
+ displayIngredientMappings(result.mappings);
390
+
391
+ if (mappingLoading) mappingLoading.style.display = 'none';
392
+ if (mappingResults) mappingResults.style.display = 'block';
393
+
394
+ } catch (error) {
395
+ console.error('Mapping error details:', error);
396
+ console.error('Error stack:', error.stack);
397
+ alert(`Failed to map ingredients: ${error.message}\n\nCheck console for details.`);
398
+ showScreen('detail');
399
+ }
400
+ }
401
+
402
+ function displayIngredientMappings(mappings) {
403
+ if (!mappingList) return;
404
+
405
+ mappingList.innerHTML = '';
406
+
407
+ mappings.forEach(mapping => {
408
+ const item = createMappingItem(mapping);
409
+ mappingList.appendChild(item);
410
+ });
411
+ }
412
+
413
+ function createMappingItem(mapping) {
414
+ const div = document.createElement('div');
415
+ const matchType = mapping.match_type || 'missing';
416
+
417
+ div.className = `web-recipe-mapping-item ${matchType}`;
418
+
419
+ let badgeIcon = '';
420
+ let mappedText = '';
421
+
422
+ switch(matchType) {
423
+ case 'exact':
424
+ badgeIcon = '✅';
425
+ mappedText = `Matched to: <strong>${mapping.mapped_to}</strong>`;
426
+ break;
427
+ case 'substitute':
428
+ badgeIcon = '🔄';
429
+ mappedText = `Substitute: <strong>${mapping.mapped_to}</strong>`;
430
+ break;
431
+ case 'missing':
432
+ badgeIcon = '❌';
433
+ mappedText = '<span style="color: #ef4444;">Not in inventory</span>';
434
+ break;
435
+ }
436
+
437
+ div.innerHTML = `
438
+ <div class="mapping-item-badge ${matchType}">${badgeIcon}</div>
439
+ <div class="mapping-item-content">
440
+ <div class="mapping-item-recipe">
441
+ ${mapping.recipe_ingredient}
442
+ <span style="font-weight: normal; color: #999;">
443
+ (${mapping.recipe_quantity} ${mapping.recipe_unit})
444
+ </span>
445
+ </div>
446
+ <div class="mapping-item-inventory">${mappedText}</div>
447
+ ${mapping.note ? `<div class="mapping-item-note">${mapping.note}</div>` : ''}
448
+ </div>
449
+ `;
450
+
451
+ return div;
452
+ }
453
+
454
+ // Save Recipe
455
+ async function saveRecipe() {
456
+ if (!currentRecipe || !ingredientMappings) return;
457
+
458
+ try {
459
+ // Prepare data for saving
460
+ const saveData = {
461
+ recipe_id: currentRecipe.id,
462
+ name: currentRecipe.name,
463
+ instructions: currentRecipe.instructions || '',
464
+ cuisine: currentRecipe.area || null,
465
+ image_url: currentRecipe.image || null,
466
+ source_url: currentRecipe.source_url || `https://www.themealdb.com/meal/${currentRecipe.id}`,
467
+ ingredients_raw: currentRecipe.ingredients,
468
+ ingredients_mapped: ingredientMappings
469
+ };
470
+
471
+ // Call backend to save recipe
472
+ const response = await fetch(`${API_BASE}/api/web-recipes/save_recipe`, {
473
+ method: 'POST',
474
+ headers: {
475
+ 'Content-Type': 'application/json',
476
+ 'X-API-Key': API_KEY
477
+ },
478
+ body: JSON.stringify(saveData)
479
+ });
480
+
481
+ if (!response.ok) {
482
+ const error = await response.json();
483
+ throw new Error(error.detail || 'Failed to save recipe');
484
+ }
485
+
486
+ const result = await response.json();
487
+ console.log('✅ Recipe saved:', result);
488
+
489
+ // Show success screen
490
+ showScreen('success');
491
+
492
+ // Refresh recipe catalogue if on that page
493
+ if (typeof loadRecipeCatalogue === 'function') {
494
+ setTimeout(() => {
495
+ loadRecipeCatalogue();
496
+ }, 1000);
497
+ }
498
+
499
+ } catch (error) {
500
+ console.error('Save error:', error);
501
+ alert(error.message || 'Failed to save recipe. Please try again.');
502
+ }
503
+ }
504
+
505
+ // Initialize when DOM is ready
506
+ if (document.readyState === 'loading') {
507
+ document.addEventListener('DOMContentLoaded', init);
508
+ } else {
509
+ init();
510
+ }
511
+
512
+ // Public API for external modules (like AI Assistant)
513
+ window.WEB_RECIPE_SEARCH = {
514
+ openModal: openModal,
515
+ closeModal: closeModal,
516
+ searchWithQuery: function(query) {
517
+ openModal();
518
+ if (searchInput) {
519
+ searchInput.value = query;
520
+ // Trigger search automatically
521
+ setTimeout(() => performSearch(), 300);
522
+ }
523
+ }
524
+ };
525
+
526
+ })();
527
+
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI and server
2
+ fastapi==0.104.1
3
+ uvicorn[standard]==0.24.0
4
+ python-multipart==0.0.6
5
+
6
+ # Database
7
+ sqlalchemy==2.0.23
8
+ databases[sqlite]==0.9.0
9
+ aiosqlite==0.19.0
10
+
11
+ # AI and ML
12
+ openai>=1.0.0
13
+ pydantic==2.5.0
14
+
15
+ # HTTP requests
16
+ httpx>=0.25.0
17
+
18
+ # File handling
19
+ aiofiles>=23.0.0
20
+
21
+ # Environment
22
+ python-dotenv==1.0.0
23
+
24
+ # Optional: Google services (if needed)
25
+ google-cloud-documentai>=2.20.0
26
+ google-api-core>=2.11.0
27
+ google-generativeai>=0.8.0
28
+ pillow>=10.0.0