Spaces:
Runtime error
Runtime error
Upload 31 files
Browse files- Dockerfile +39 -0
- README.md +68 -10
- app.py +102 -0
- backend/database.py +15 -0
- backend/main.py +68 -0
- backend/models.py +55 -0
- backend/requirements.txt +15 -0
- backend/routes/__init__.py +1 -0
- backend/routes/actions.py +262 -0
- backend/routes/ai_assistant.py +830 -0
- backend/routes/chat.py +162 -0
- backend/routes/data.py +65 -0
- backend/routes/inventory.py +103 -0
- backend/routes/ocr.py +166 -0
- backend/routes/recipes.py +143 -0
- backend/routes/tasks.py +97 -0
- backend/routes/web_recipes.py +400 -0
- backend/schemas.py +100 -0
- backend/services/__init__.py +3 -0
- backend/services/ai_assistant_service.py +337 -0
- backend/services/ai_service.py +289 -0
- backend/services/mealdb_service.py +200 -0
- frontend/ai-assistant.js +443 -0
- frontend/api.js +233 -0
- frontend/config.js +27 -0
- frontend/index.html +675 -0
- frontend/script.js +1830 -0
- frontend/style.css +3251 -0
- frontend/utils.js +242 -0
- frontend/web-recipe-search.js +527 -0
- requirements.txt +28 -0
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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">▼</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}">×</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
|