chore(v2): restructure monorepo to src/ layout with uv

Aligns the repo with the python-project-spec-design.md template chosen
for V2.0.0. Big move, no logic changes. The 3 pre-existing test
failures (test_recipes::test_update_recipe, test_recipes::
test_recipe_versioning, test_tasks::test_reorder_tasks, plus the
client test_save_measurement_proxy) survive unchanged.

Layout changes
- server/        -> src/backend/
- server/middleware/ -> src/backend/api/middleware/
- server/routers/    -> src/backend/api/routers/
- server/models/     -> src/backend/models/orm/
- server/schemas/    -> src/backend/models/api/
- server/uploads/    -> uploads/ (project root, mounted volume)
- server/tests/      -> src/backend/tests/
- client/            -> src/frontend/flask_app/ (Flask kept; React
  deroga is documented in CLAUDE.md, justified by tablet UX, USB
  caliper/barcode workflow and Fabric.js integration)

Tooling
- pyproject.toml: monorepo with [project] core deps and
  optional-dependencies server / client / dev. Replaces both
  server/requirements.txt and client/requirements.txt.
- uv.lock + .python-version (3.11) committed for reproducible builds.
- Dockerfile (root, backend) and Dockerfile.frontend rewritten to use
  uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles
  preserved as Dockerfile.legacy for reference but excluded from build
  context via .dockerignore.
- docker-compose.dev.yml + docker-compose.yml: build context now ".",
  dockerfile pointing to the root files.

Code adjustments forced by the move
- Every "from config|database|models|schemas|services|routers|middleware
  import ..." rewritten to its src.backend.* equivalent (50+ files
  including indented inline imports inside test bodies).
- src/backend/migrations/env.py: insert project root into sys.path so
  alembic can resolve src.backend.* imports regardless of cwd.
- src/backend/config.py: env_file ../../.env (was ../.env), upload_path
  resolves project root via parents[2].
- src/backend/tests/conftest.py + tests: import ... from src.backend.*
  instead of bare names; old per-directory pytest.ini files removed in
  favor of root pyproject.toml [tool.pytest.ini_options].
- .gitignore: uploads/ at root, src/frontend/flask_app/static/css/
  tailwind.css path; .dockerignore tightened.
- CLAUDE.md: rewrote sections "Layout del repository", "Comandi di
  Sviluppo", "Database & Migrations", "Test", "i18n", and all path
  references throughout the architecture sections.

Verified
- uv lock resolves 77 packages; uv sync --extra server --extra client
  --extra dev installs cleanly.
- uv run pytest: 171 passed, 4 pre-existing failures.
- uv run alembic -c src/backend/migrations/alembic.ini check loads
  config and metadata (errors only on the absent local MySQL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:26:47 +02:00
parent 86df67f2e5
commit 1a0431366f
174 changed files with 2568 additions and 308 deletions
+33
View File
@@ -0,0 +1,33 @@
FROM python:3.11-slim AS base
# Installa Node.js per Tailwind CSS build
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . .
# Install and build Tailwind CSS
RUN npm install tailwindcss@3 && npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
# Compile Flask-Babel translations
RUN pybabel compile -d translations
EXPOSE 5000
CMD ["gunicorn", \
"--workers", "5", \
"--threads", "4", \
"--worker-class", "gthread", \
"--timeout", "60", \
"--bind", "0.0.0.0:5000", \
"--access-logfile", "-", \
"--forwarded-allow-ips", "*", \
"app:create_app()"]
+240
View File
@@ -0,0 +1,240 @@
# TieMeasureFlow i18n Setup Guide
## Overview
TieMeasureFlow supports complete internationalization (i18n) with:
- **Server-side**: Flask-Babel for Python/Jinja2 templates
- **Client-side**: alpinejs-i18n for JavaScript/Alpine.js
- **Supported languages**: Italian (IT - default) and English (EN)
## Directory Structure
```
client/
├── translations/ # Flask-Babel translations
│ ├── babel.cfg # Extraction config
│ ├── it/LC_MESSAGES/
│ │ ├── messages.po # Italian strings
│ │ └── messages.mo # Compiled binary (generated)
│ └── en/LC_MESSAGES/
│ ├── messages.po # English translations
│ └── messages.mo # Compiled binary (generated)
├── static/js/locales/ # Alpine.js i18n
│ ├── it.json # Italian client-side strings
│ └── en.json # English client-side strings
└── compile_translations.py # Utility to compile .po → .mo
```
## How It Works
### Language Selection
1. **User session**: Language stored in `session["language"]` (persistent)
2. **Browser fallback**: Uses `Accept-Language` header if no session
3. **Default**: Italian (IT)
### Server-Side (Flask-Babel)
**In Python code:**
```python
from flask_babel import gettext as _
flash(_("Profilo aggiornato con successo"))
```
**In Jinja2 templates:**
```jinja2
<h1>{{ _('Accedi al sistema') }}</h1>
{# With variables #}
<p>{{ _('Benvenuto, %(name)s!', name=user.display_name) }}</p>
```
### Client-Side (Alpine.js i18n)
**Load i18n in base template:**
```html
<script src="/static/js/lib/alpinejs-i18n.min.js"></script>
<script>
// Initialize i18n with current language
Alpine.plugin(AlpineI18n);
Alpine.store('i18n', {
locale: '{{ current_language }}',
fallbackLocale: 'it',
messages: {}
});
// Load locale files
fetch('/static/js/locales/{{ current_language }}.json')
.then(r => r.json())
.then(data => Alpine.store('i18n').messages['{{ current_language }}'] = data);
</script>
```
**Use in templates:**
```html
<button @click="submit" x-text="$t('numpad.submit')"></button>
<p x-text="$t('auth.welcome', {name: userName})"></p>
```
## Workflow
### 1. Add New Strings
**Python/Templates:**
1. Wrap strings with `_()` or `{{ _() }}`
2. Extract strings: `pybabel extract -F translations/babel.cfg -o translations/messages.pot .`
3. Update catalogs: `pybabel update -i translations/messages.pot -d translations`
4. Edit `translations/*/LC_MESSAGES/messages.po` files
5. Compile: `python compile_translations.py`
**JavaScript/Alpine:**
1. Add keys to `static/js/locales/it.json` (Italian strings)
2. Add translations to `static/js/locales/en.json`
3. No compilation needed (loaded at runtime)
### 2. Compile Translations
After editing .po files:
```bash
cd client
python compile_translations.py
```
This generates `.mo` binary files that Flask-Babel uses at runtime.
### 3. Language Switching
**Endpoint**: `GET /set-language/<lang>`
**Example**:
```html
<a href="{{ url_for('set_language', lang='en') }}">English</a>
<a href="{{ url_for('set_language', lang='it') }}">Italiano</a>
```
**With Alpine.js**:
```html
<button @click="$store.i18n.locale = 'en'; window.location.href='/set-language/en'">
English
</button>
```
## Translation Keys
### Server-Side (messages.po)
56 strings covering:
- **Login**: "Accedi al sistema", "Credenziali non valide", etc.
- **Navbar**: "Misure", "Ricette", "Statistiche", etc.
- **Profile**: "Profilo Utente", "Salva Modifiche", etc.
- **Flash messages**: "Benvenuto, %(name)s!", "Logout effettuato", etc.
- **Common UI**: "Caricamento...", "Salva", "Annulla", "Conferma", etc.
- **Measurements**: "Conforme", "Non Conforme", etc.
### Client-Side (locales/*.json)
Structured keys:
```json
{
"numpad": { "title", "clear", "submit", "decimal", "close" },
"measurement": { "pass", "warning", "fail", "value", "next" },
"common": { "loading", "save", "cancel", "confirm", "delete", "edit", ... },
"theme": { "light", "dark", "toggle" },
"language": { "switch", "it", "en" },
"auth": { "login", "username", "password", "signIn", ... },
"nav": { "measurements", "recipes", "statistics", ... },
"profile": { "title", "displayName", "language", "theme", ... }
}
```
## Configuration
**config.py** must define:
```python
BABEL_DEFAULT_LOCALE = 'it'
BABEL_TRANSLATION_DIRECTORIES = 'translations'
LANGUAGES = {
'it': 'Italiano',
'en': 'English'
}
```
**app.py** provides:
- `get_locale()`: Language selector function
- `/set-language/<lang>`: Language switch endpoint
- `inject_globals()`: Exposes `current_language` and `languages` to templates
## Best Practices
1. **Always use Italian as msgid**: Italian strings are the canonical keys
2. **Keep client/server keys synced**: Use similar naming across .po and .json
3. **Test both languages**: Switch languages and verify all pages
4. **Recompile after editing**: Run `compile_translations.py` after changing .po files
5. **Use variables for dynamic content**: `_('Welcome, %(name)s!', name=user)` or `$t('auth.welcome', {name})`
6. **Avoid hardcoded strings**: Always use translation functions
## Troubleshooting
**Translations not appearing:**
- Verify `.mo` files exist: `find translations -name "*.mo"`
- Check session language: `session["language"]` or browser console
- Recompile: `python compile_translations.py`
- Restart Flask server
**Client-side i18n not working:**
- Check browser console for fetch errors
- Verify JSON files exist: `static/js/locales/{it,en}.json`
- Check Alpine.js i18n plugin is loaded
- Verify `Alpine.store('i18n')` is initialized
**Missing translations show as keys:**
- Add missing keys to .po or .json files
- Recompile .po files if server-side
- Reload page if client-side
## Commands Reference
```bash
# Extract translatable strings from code
pybabel extract -F translations/babel.cfg -o translations/messages.pot .
# Initialize new language (first time)
pybabel init -i translations/messages.pot -d translations -l en
# Update existing catalogs with new strings
pybabel update -i translations/messages.pot -d translations
# Compile .po to .mo (required after editing)
python compile_translations.py
# Check translation statistics
msgfmt --statistics translations/it/LC_MESSAGES/messages.po
msgfmt --statistics translations/en/LC_MESSAGES/messages.po
```
## Future Expansion
To add a new language (e.g., German):
1. **Server-side**:
```bash
pybabel init -i translations/messages.pot -d translations -l de
# Edit translations/de/LC_MESSAGES/messages.po
python compile_translations.py
```
2. **Client-side**:
- Create `static/js/locales/de.json`
- Add to config: `LANGUAGES = {..., 'de': 'Deutsch'}`
3. **Update loader**: Modify base template to support new locale
## Resources
- Flask-Babel: https://python-babel.github.io/flask-babel/
- Babel: http://babel.pocoo.org/
- Alpine.js i18n: https://github.com/alpine-collective/alpine-i18n
- PO file format: https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
+106
View File
@@ -0,0 +1,106 @@
"""TieMeasureFlow Client - Flask Entry Point."""
import json
import os
from flask import Flask, redirect, url_for, session, request
from flask_babel import Babel
from flask_wtf.csrf import CSRFProtect
from markupsafe import Markup
from werkzeug.middleware.proxy_fix import ProxyFix
from config import Config
def get_locale():
"""Get user's preferred language from session or Accept-Language header."""
# 1. User preference in session
if "language" in session:
return session["language"]
# 2. Browser Accept-Language
return request.accept_languages.best_match(
Config.LANGUAGES.keys(), default="it"
)
def create_app() -> Flask:
"""Application factory."""
app = Flask(__name__)
app.config.from_object(Config)
# Trust one reverse-proxy hop (Nginx in dev, Traefik in prod) so that
# request.remote_addr returns the real tablet IP rather than the proxy IP.
# The APIClient forwards that IP to FastAPI for accurate rate limiting.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Initialize CSRF protection
csrf = CSRFProtect(app)
# Initialize Flask-Babel
Babel(app, locale_selector=get_locale)
# Register blueprints
from blueprints.auth import auth_bp
from blueprints.measure import measure_bp
from blueprints.maker import maker_bp
from blueprints.statistics import statistics_bp
from blueprints.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(measure_bp, url_prefix="/measure")
app.register_blueprint(maker_bp, url_prefix="/maker")
app.register_blueprint(statistics_bp, url_prefix="/statistics")
app.register_blueprint(admin_bp, url_prefix="/admin")
@app.route("/")
def index():
"""Root redirect to login or dashboard based on session."""
if "user" in session:
return redirect(url_for("measure.select_recipe"))
return redirect(url_for("auth.login"))
@app.route("/set-language/<lang>")
def set_language(lang):
"""Set user's preferred language and store in session."""
if lang in Config.LANGUAGES:
session["language"] = lang
return redirect(request.referrer or url_for("auth.login"))
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
Unlike |tojson, this escapes double quotes to &#34; so the output
can be safely embedded inside double-quoted HTML attributes.
The browser decodes the entities before Alpine.js evaluates them.
"""
rv = json.dumps(value, ensure_ascii=False)
rv = (
rv.replace("&", "\\u0026")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("'", "\\u0027")
.replace('"', "&#34;")
)
return Markup(rv)
@app.context_processor
def inject_globals():
"""Inject global variables into all templates."""
return {
"current_user": session.get("user"),
"current_theme": session.get("theme", "light"),
"current_language": get_locale(),
"languages": Config.LANGUAGES,
"company_logo": session.get("company_logo"),
}
return app
if __name__ == "__main__":
app = create_app()
app.run(
host=os.getenv("CLIENT_HOST", "0.0.0.0"),
port=int(os.getenv("CLIENT_PORT", "5000")),
debug=True,
)
+200
View File
@@ -0,0 +1,200 @@
"""Admin blueprint - user management."""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from blueprints.auth import login_required
from services.api_client import api_client
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
def admin_required(f):
"""Decorator to require admin privileges."""
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not user.get("is_admin"):
flash(_("Accesso non autorizzato"), "error")
return redirect(url_for("measure.select_recipe"))
return f(*args, **kwargs)
return decorated
# ============================================================================
# PAGINE (GET)
# ============================================================================
@admin_bp.route("/users")
@login_required
@admin_required
def user_list():
"""User management page."""
resp = api_client.get("/api/users")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
users = []
elif isinstance(resp, list):
users = resp
else:
users = []
return render_template("admin/users.html", users=users)
@admin_bp.route("/stations")
@login_required
@admin_required
def station_list():
"""Station management page."""
resp = api_client.get("/api/stations")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento delle stazioni: %(error)s", error=resp.get("detail", "")), "error")
stations = []
elif isinstance(resp, list):
stations = resp
else:
stations = []
recipes_resp = api_client.get("/api/recipes")
if isinstance(recipes_resp, list):
all_recipes = recipes_resp
elif isinstance(recipes_resp, dict) and isinstance(recipes_resp.get("items"), list):
all_recipes = recipes_resp["items"]
else:
all_recipes = []
return render_template("admin/stations.html", stations=stations, all_recipes=all_recipes)
# ============================================================================
# API PROXY AJAX (JSON)
# ============================================================================
@admin_bp.route("/api/users", methods=["POST"])
@login_required
@admin_required
def api_create_user():
"""Proxy: Create new user."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/users", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/users/<int:user_id>", methods=["PUT"])
@login_required
@admin_required
def api_update_user(user_id: int):
"""Proxy: Update user."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/users/{user_id}", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/password", methods=["PUT"])
@login_required
@admin_required
def api_change_password(user_id: int):
"""Proxy: Change user password."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/users/{user_id}/password", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
@login_required
@admin_required
def api_toggle_active(user_id: int):
"""Proxy: Toggle user active status."""
data = request.get_json(silent=True) or {}
active = data.get("active", True)
resp = api_client.put(f"/api/users/{user_id}", data={"active": active})
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# --- Stations ---
@admin_bp.route("/api/stations", methods=["POST"])
@login_required
@admin_required
def api_create_station():
"""Proxy: Create a new station."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/stations", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/stations/<int:station_id>", methods=["PUT"])
@login_required
@admin_required
def api_update_station(station_id: int):
"""Proxy: Update a station."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/stations/{station_id}", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/stations/<int:station_id>", methods=["DELETE"])
@login_required
@admin_required
def api_delete_station(station_id: int):
"""Proxy: Delete a station."""
resp = api_client.delete(f"/api/stations/{station_id}")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify({"deleted": True}), 200
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["GET"])
@login_required
@admin_required
def api_list_station_recipes(station_id: int):
"""Proxy: List recipes assigned to a station."""
resp = api_client.get(f"/api/stations/{station_id}/recipes")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/stations/<int:station_id>/recipes", methods=["POST"])
@login_required
@admin_required
def api_assign_recipe(station_id: int):
"""Proxy: Assign a recipe to a station."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/stations/{station_id}/recipes", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@admin_bp.route("/api/stations/<int:station_id>/recipes/<int:recipe_id>", methods=["DELETE"])
@login_required
@admin_required
def api_unassign_recipe(station_id: int, recipe_id: int):
"""Proxy: Remove a recipe assignment from a station."""
resp = api_client.delete(f"/api/stations/{station_id}/recipes/{recipe_id}")
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify({"deleted": True}), 200
+160
View File
@@ -0,0 +1,160 @@
"""Authentication blueprint - login, logout, profile."""
from functools import wraps
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from services.api_client import api_client
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
def login_required(f):
"""Decorator to require login for protected routes."""
@wraps(f)
def decorated(*args, **kwargs):
if not session.get("api_key"):
flash(_("Effettua il login per continuare"), "warning")
return redirect(url_for("auth.login"))
return f(*args, **kwargs)
return decorated
def role_required(*roles):
"""Decorator to require one of the specified roles.
Usage:
@role_required("MeasurementTec", "Metrologist")
def my_view(): ...
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
user_roles = user.get("roles", [])
if not any(r in user_roles for r in roles):
abort(403)
return f(*args, **kwargs)
return decorated
return decorator
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page and form handler."""
# Se già loggato, redirect a home
if session.get("api_key"):
try:
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
flash(_("Inserisci username e password"), "error")
return render_template("auth/login.html")
try:
response = api_client.post("/api/auth/login", data={"username": username, "password": password})
if response.get("error"):
flash(response.get("detail", _("Credenziali non valide")), "error")
return render_template("auth/login.html")
# Salva in sessione
session["api_key"] = response["api_key"]
session["user"] = response["user"]
session["user_id"] = response["user"]["id"]
# Applica preferenze utente
user = response["user"]
if user.get("language_pref"):
session["language"] = user["language_pref"]
if user.get("theme_pref"):
session["theme"] = user["theme_pref"]
# Carica logo aziendale dalle impostazioni di sistema
settings_resp = api_client.get("/api/settings")
if not settings_resp.get("error"):
logo_path = settings_resp.get("company_logo_path")
if logo_path:
session["company_logo"] = logo_path
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
# Redirect dopo login
try:
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
return render_template("auth/login.html")
return render_template("auth/login.html")
@auth_bp.route("/logout", methods=["GET", "POST"])
def logout():
"""Clear session and redirect to login."""
# Invalida il token sul server
if session.get("api_key"):
try:
api_client.post("/api/auth/logout")
except Exception:
pass # Ignora errori durante logout
session.clear()
flash(_("Logout effettuato"), "info")
return redirect(url_for("auth.login"))
@auth_bp.route("/profile", methods=["GET", "POST"])
@login_required
def profile():
"""User profile page - change display name, language, theme."""
if request.method == "POST":
display_name = request.form.get("display_name", "").strip()
language_pref = request.form.get("language_pref", "").strip()
theme_pref = request.form.get("theme_pref", "").strip()
try:
data = {}
if display_name:
data["display_name"] = display_name
if language_pref:
data["language_pref"] = language_pref
if theme_pref:
data["theme_pref"] = theme_pref
response = api_client.put("/api/auth/me", data=data)
if response.get("error"):
flash(response.get("detail", _("Errore durante l'aggiornamento del profilo")), "error")
else:
# Aggiorna sessione
session["user"] = response
if language_pref:
session["language"] = language_pref
if theme_pref:
session["theme"] = theme_pref
flash(_("Profilo aggiornato con successo"), "success")
return redirect(url_for("auth.profile"))
except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
# GET - carica dati profilo
user_data = api_client.get("/api/auth/me")
if user_data.get("error"):
flash(_("Errore nel caricamento del profilo: %(error)s", error=user_data.get("detail", "")), "error")
user_data = session.get("user", {})
else:
session["user"] = user_data
return render_template("auth/profile.html", user=user_data)
+426
View File
@@ -0,0 +1,426 @@
"""Maker blueprint - recipe creation and editing."""
import requests as http_requests
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
# ============================================================================
# PAGINE (GET)
# ============================================================================
@maker_bp.route("/recipes")
@login_required
@role_required("Maker")
def recipe_list():
"""List all recipes with filters."""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 100, type=int)
search = request.args.get("search", "", type=str)
params = {"page": page, "per_page": per_page}
if search:
params["search"] = search
resp = api_client.get("/api/recipes", params=params)
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento delle ricette: %(error)s", error=resp.get("detail", "")), "error")
recipes = []
total = 0
pages = 0
else:
recipes = resp.get("items", []) if isinstance(resp, dict) else resp
total = resp.get("total", 0) if isinstance(resp, dict) else len(recipes)
pages = resp.get("pages", 1) if isinstance(resp, dict) else 1
return render_template(
"maker/recipe_list.html",
recipes=recipes,
page=page,
per_page=per_page,
total=total,
pages=pages,
search=search
)
@maker_bp.route("/recipes/new")
@login_required
@role_required("Maker")
def recipe_new():
"""Create new recipe."""
return render_template("maker/recipe_editor.html", recipe=None)
@maker_bp.route("/recipes/<int:recipe_id>/edit")
@login_required
@role_required("Maker")
def recipe_edit(recipe_id: int):
"""Edit existing recipe."""
# Carica recipe con dettagli completi
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica anche le versioni
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
if isinstance(versions_resp, list):
recipe["versions"] = versions_resp
else:
recipe["versions"] = []
return render_template("maker/recipe_editor.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/tasks")
@login_required
@role_required("Maker")
def task_editor(recipe_id: int):
"""Task/subtask editor with tolerances."""
# Carica recipe con task/subtask
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica tasks con subtasks
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
recipe["tasks"] = tasks_resp
else:
recipe["tasks"] = []
return render_template("maker/task_editor.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
@login_required
@role_required("Maker")
def task_drawing(recipe_id: int, task_id: int):
"""Annotation editor for a specific task."""
# Load recipe for breadcrumb context
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=recipe_resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
# Load task details
task_resp = api_client.get(f"/api/tasks/{task_id}")
if task_resp.get("error"):
flash(_("Task non trovato: %(error)s", error=task_resp.get("detail", "")), "error")
return redirect(url_for("maker.task_editor", recipe_id=recipe_id))
return render_template(
"maker/task_drawing.html",
recipe=recipe_resp,
task=task_resp,
)
@maker_bp.route("/recipes/<int:recipe_id>/preview")
@login_required
@role_required("Maker")
def recipe_preview(recipe_id: int):
"""Preview recipe as MeasurementTec would see it."""
# Carica recipe completo
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica tasks con subtasks
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
recipe["tasks"] = tasks_resp
else:
recipe["tasks"] = []
return render_template("maker/recipe_preview.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/versions")
@login_required
@role_required("Maker")
def version_history(recipe_id: int):
"""Version history with diff."""
# Carica recipe base
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica versioni
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
if isinstance(versions_resp, list):
versions = versions_resp
else:
versions = []
flash(_("Errore nel caricamento delle versioni: %(error)s",
error=versions_resp.get("detail", "") if isinstance(versions_resp, dict) else ""), "warning")
return render_template("maker/version_history.html", recipe=recipe, versions=versions)
# ============================================================================
# API PROXY AJAX (JSON)
# ============================================================================
@maker_bp.route("/api/recipes", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_recipe():
"""Proxy: Create new recipe."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/recipes", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_recipe(recipe_id: int):
"""Proxy: Update recipe (creates new version)."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/recipes/{recipe_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_recipe(recipe_id: int):
"""Proxy: Delete recipe (soft delete)."""
resp = api_client.delete(f"/api/recipes/{recipe_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ============================================================================
# TASK API PROXY
# ============================================================================
@maker_bp.route("/api/recipes/<int:recipe_id>/tasks", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_task(recipe_id: int):
"""Proxy: Create new task."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/tasks", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/tasks/<int:task_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_task(task_id: int):
"""Proxy: Update task."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/tasks/{task_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/tasks/<int:task_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_task(task_id: int):
"""Proxy: Delete task."""
resp = api_client.delete(f"/api/tasks/{task_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
@maker_bp.route("/api/tasks/reorder", methods=["PUT"])
@login_required
@role_required("Maker")
def api_reorder_tasks():
"""Proxy: Reorder tasks."""
data = request.get_json(silent=True) or {}
resp = api_client.put("/api/tasks/reorder", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ============================================================================
# SUBTASK API PROXY
# ============================================================================
@maker_bp.route("/api/tasks/<int:task_id>/subtasks", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_subtask(task_id: int):
"""Proxy: Create new subtask."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/tasks/{task_id}/subtasks", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_subtask(subtask_id: int):
"""Proxy: Update subtask."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/subtasks/{subtask_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_subtask(subtask_id: int):
"""Proxy: Delete subtask."""
resp = api_client.delete(f"/api/subtasks/{subtask_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
# ============================================================================
# FILE API PROXY
# ============================================================================
@maker_bp.route("/api/upload", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_file():
"""Proxy: Upload file (multipart forward)."""
# Controlla se c'è un file
if "file" not in request.files:
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": True, "detail": _("Nome file vuoto")}), 400
# Prepara multipart data
recipe_id = request.form.get("recipe_id")
version_id = request.form.get("version_id")
files = {"file": (file.filename, file.stream, file.content_type)}
data = {}
if recipe_id:
data["recipe_id"] = recipe_id
if version_id:
data["version_id"] = version_id
# Forward multipart request
resp = api_client.post("/api/files/upload", data=data, files=files)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "")
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_file(file_path: str):
"""Proxy: Delete file."""
resp = api_client.delete(f"/api/files/{file_path}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
# ============================================================================
# VERSION API PROXY
# ============================================================================
@maker_bp.route("/api/recipes/<int:recipe_id>/versions/<int:version_number>/measurement-count", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_measurement_count(recipe_id: int, version_number: int):
"""Proxy: Get measurement count for a specific version."""
resp = api_client.get(f"/api/recipes/{recipe_id}/versions/{version_number}/measurement-count")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@@ -0,0 +1,343 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import requests as http_requests
from flask import (
Blueprint, Response, flash, jsonify, redirect, render_template,
request, session, url_for,
)
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
measure_bp = Blueprint("measure", __name__)
# ---------------------------------------------------------------------------
# Route: Recipe selection
# ---------------------------------------------------------------------------
@measure_bp.route("/select")
@login_required
@role_required("MeasurementTec")
def select_recipe():
"""Recipe selection page with search and barcode support."""
# Fail-fast if STATION_CODE is not configured
if not Config.STATION_CODE:
return render_template("errors/station_not_configured.html"), 503
# Load recipes filtered by station
try:
resp = api_client.get_station_recipes(Config.STATION_CODE)
except Exception as e:
return render_template(
"errors/station_not_configured.html", error=str(e),
), 502
if isinstance(resp, dict) and resp.get("error"):
flash(
_("Errore nel caricamento delle ricette: %(detail)s",
detail=resp.get("detail", "")),
"error",
)
recipes = []
else:
# API may return paginated envelope or plain list
recipes = resp.get("items", resp) if isinstance(resp, dict) else resp
# Auto-fill from query params
auto_recipe_code = request.args.get("recipe", "")
auto_lot = request.args.get("lot", session.get("lot_number", ""))
auto_serial = request.args.get("serial", session.get("serial_number", ""))
return render_template(
"measure/select_recipe.html",
recipes=recipes,
station_code=Config.STATION_CODE,
auto_recipe_code=auto_recipe_code,
auto_lot=auto_lot,
auto_serial=auto_serial,
)
# ---------------------------------------------------------------------------
# Route: Task list for a recipe
# ---------------------------------------------------------------------------
@measure_bp.route("/tasks/<int:recipe_id>")
@login_required
@role_required("MeasurementTec")
def task_list(recipe_id: int):
"""Task list for selected recipe."""
# Persist lot/serial from query params into session
lot_number = request.args.get(
"lot_number", session.get("lot_number", ""),
)
serial_number = request.args.get(
"serial_number", session.get("serial_number", ""),
)
if lot_number:
session["lot_number"] = lot_number
if serial_number:
session["serial_number"] = serial_number
# Load recipe details
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(
_("Ricetta non trovata: %(detail)s",
detail=recipe_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
# Load tasks for this recipe
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, dict) and tasks_resp.get("error"):
flash(
_("Errore nel caricamento dei task: %(detail)s",
detail=tasks_resp.get("detail", "")),
"error",
)
tasks = []
else:
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
return render_template(
"measure/task_list.html",
recipe=recipe_resp,
tasks=tasks,
lot_number=lot_number,
serial_number=serial_number,
)
# ---------------------------------------------------------------------------
# Route: Task execution (measurement input)
# ---------------------------------------------------------------------------
@measure_bp.route("/execute/<int:task_id>")
@login_required
@role_required("MeasurementTec")
def task_execute(task_id: int):
"""Execute measurements for a task."""
# Load task + subtasks
task_resp = api_client.get(f"/api/tasks/{task_id}")
if task_resp.get("error"):
flash(
_("Task non trovato: %(detail)s",
detail=task_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
# Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id")
all_task_ids = []
if recipe_id:
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
all_task_ids = [t["id"] for t in sorted_tasks]
return render_template(
"measure/task_execute.html",
task=task_resp,
lot_number=lot_number,
serial_number=serial_number,
all_task_ids=all_task_ids,
)
# ---------------------------------------------------------------------------
# Route: Task completion summary
# ---------------------------------------------------------------------------
@measure_bp.route("/complete/<int:recipe_id>")
@login_required
@role_required("MeasurementTec")
def task_complete(recipe_id: int):
"""Task completion summary with all measurements."""
# Retrieve version_id from query params
version_id = request.args.get("version_id")
# Load recipe for context
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(
_("Ricetta non trovata: %(detail)s",
detail=recipe_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
# Load tasks+subtasks for this recipe to build subtask and task lookup
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
subtask_map = {}
subtask_task_map = {} # subtask_id → task info
if isinstance(tasks_resp, list):
for task in tasks_resp:
for st in task.get("subtasks", []):
subtask_map[st["id"]] = st
subtask_task_map[st["id"]] = {
"id": task["id"],
"title": task.get("title", ""),
"order_index": task.get("order_index", 0),
}
# Load measurements if version_id provided
measurements = []
if version_id:
meas_resp = api_client.get(
"/api/measurements",
params={"version_id": version_id, "per_page": 500},
)
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
raw = (
meas_resp if isinstance(meas_resp, list)
else meas_resp.get("items", [])
)
# Enrich each measurement with nested subtask data
for m in raw:
st = subtask_map.get(m.get("subtask_id"), {})
m["subtask"] = st
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
# Compute deviation if not present
if m.get("deviation") is None and st.get("nominal") is not None:
try:
m["deviation"] = m["value"] - st["nominal"]
except (TypeError, KeyError):
m["deviation"] = 0.0
measurements = raw
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
return render_template(
"measure/task_complete.html",
recipe=recipe_resp,
measurements=measurements,
lot_number=lot_number,
serial_number=serial_number,
)
# ---------------------------------------------------------------------------
# Route: Barcode lookup (AJAX)
# ---------------------------------------------------------------------------
@measure_bp.route("/lookup-barcode", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def lookup_barcode():
"""Look up a recipe by barcode/code. Returns JSON for AJAX calls."""
data = request.get_json(silent=True) or {}
code = data.get("code", "").strip()
if not code:
return jsonify({"error": True, "detail": _("Codice non fornito")}), 400
resp = api_client.get(f"/api/recipes/code/{code}")
if resp.get("error"):
return jsonify({
"error": True,
"detail": resp.get("detail", _("Ricetta non trovata")),
}), 404
return jsonify(resp)
# ---------------------------------------------------------------------------
# Route: Save lot/serial to session (AJAX)
# ---------------------------------------------------------------------------
@measure_bp.route("/save-traceability", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def save_traceability():
"""Save lot_number and serial_number to session."""
data = request.get_json(silent=True) or {}
lot = data.get("lot_number", "").strip()
serial = data.get("serial_number", "").strip()
if lot:
session["lot_number"] = lot
if serial:
session["serial_number"] = serial
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Route: Save measurement (AJAX proxy to FastAPI)
# ---------------------------------------------------------------------------
@measure_bp.route("/save-measurement", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def save_measurement():
"""Save a single measurement value via API proxy.
Expects JSON body:
subtask_id: int
task_id: int
value: float
pass_fail: str ('pass' | 'warning' | 'fail')
deviation: float
lot_number: str (optional)
serial_number: str (optional)
Returns JSON with the created measurement or error.
"""
data = request.get_json(silent=True) or {}
# Validate required fields
subtask_id = data.get("subtask_id")
version_id = data.get("version_id")
value = data.get("value")
if subtask_id is None or version_id is None or value is None:
return jsonify({
"error": True,
"detail": _("Dati mancanti: subtask_id, version_id e value sono obbligatori"),
}), 400
# Build payload for the FastAPI backend
payload = {
"subtask_id": subtask_id,
"version_id": version_id,
"value": value,
"lot_number": data.get("lot_number", session.get("lot_number", "")),
"serial_number": data.get("serial_number", session.get("serial_number", "")),
"input_method": data.get("input_method", "manual"),
}
resp = api_client.post("/api/measurements", data=payload)
if resp.get("error"):
status_code = resp.get("status_code", 500)
return jsonify({
"error": True,
"detail": resp.get("detail", _("Errore nel salvataggio")),
}), status_code if status_code >= 400 else 500
return jsonify(resp), 201
# ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly)
# ---------------------------------------------------------------------------
@measure_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "")
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@@ -0,0 +1,178 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
statistics_bp = Blueprint("statistics", __name__)
# ============================================================================
# PAGINE
# ============================================================================
@statistics_bp.route("/dashboard")
@login_required
@role_required("Metrologist")
def dashboard():
"""SPC dashboard overview — loads recipes for filter dropdown."""
resp = api_client.get("/api/recipes", params={"per_page": 100})
if isinstance(resp, dict) and resp.get("error"):
recipes = []
else:
recipes = resp.get("items", []) if isinstance(resp, dict) else []
return render_template("statistics/dashboard.html", recipes=recipes)
# ============================================================================
# PROXY API AJAX → FastAPI
# ============================================================================
def _proxy_statistics(endpoint: str):
"""Forward query params to a FastAPI statistics endpoint."""
params = {}
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number", "n_bins",
]:
val = request.args.get(key)
if val is not None and val != "":
params[key] = val
resp = api_client.get(f"/api/statistics/{endpoint}", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/summary")
@login_required
@role_required("Metrologist")
def api_summary():
"""Proxy: summary pass/fail/warning."""
return _proxy_statistics("summary")
@statistics_bp.route("/api/capability")
@login_required
@role_required("Metrologist")
def api_capability():
"""Proxy: capability indices."""
return _proxy_statistics("capability")
@statistics_bp.route("/api/control-chart")
@login_required
@role_required("Metrologist")
def api_control_chart():
"""Proxy: control chart data."""
return _proxy_statistics("control-chart")
@statistics_bp.route("/api/histogram")
@login_required
@role_required("Metrologist")
def api_histogram():
"""Proxy: histogram data."""
return _proxy_statistics("histogram")
@statistics_bp.route("/api/subtasks")
@login_required
@role_required("Metrologist")
def api_subtasks():
"""Proxy: get subtasks for recipe filter."""
params = {}
recipe_id = request.args.get("recipe_id")
if recipe_id:
params["recipe_id"] = recipe_id
version_id = request.args.get("version_id")
if version_id:
params["version_id"] = version_id
resp = api_client.get("/api/statistics/subtasks", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
# Response is a list
if isinstance(resp, list):
return jsonify(resp)
return jsonify(resp)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
def _proxy_report(endpoint: str):
"""Forward query params to a FastAPI reports endpoint and return PDF."""
params = {}
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number",
]:
val = request.args.get(key)
if val is not None and val != "":
params[key] = val
base_url = Config.API_SERVER_URL.rstrip("/")
headers = {}
api_key = session.get("api_key")
if api_key:
headers["X-API-Key"] = api_key
try:
resp = http_requests.get(
f"{base_url}/api/reports/{endpoint}",
headers=headers,
params=params,
timeout=120,
)
except (http_requests.ConnectionError, http_requests.Timeout) as e:
return jsonify({"error": True, "detail": str(e)}), 502
if not resp.ok:
try:
err = resp.json()
detail = err.get("detail", f"HTTP {resp.status_code}")
except Exception:
detail = f"HTTP {resp.status_code}"
return jsonify({"error": True, "detail": detail}), resp.status_code
from flask import Response as FlaskResponse
return FlaskResponse(
resp.content,
mimetype="application/pdf",
headers={
"Content-Disposition": resp.headers.get(
"Content-Disposition", f'attachment; filename="report.pdf"'
)
},
)
@statistics_bp.route("/api/report-spc")
@login_required
@role_required("Metrologist")
def api_report_spc():
"""Proxy: download SPC PDF report."""
return _proxy_report("spc")
@statistics_bp.route("/api/report-measurements")
@login_required
@role_required("Metrologist")
def api_report_measurements():
"""Proxy: download measurements PDF report."""
return _proxy_report("measurements")
@@ -0,0 +1,43 @@
#!/usr/bin/env python
"""Compile .po files to .mo files for Flask-Babel."""
from pathlib import Path
from babel.messages.mofile import write_mo
from babel.messages.pofile import read_po
def compile_po_file(po_path: Path) -> None:
"""Compile a single .po file to .mo."""
mo_path = po_path.with_suffix('.mo')
print(f"Compiling {po_path} -> {mo_path}")
with open(po_path, 'rb') as po_file:
catalog = read_po(po_file)
with open(mo_path, 'wb') as mo_file:
write_mo(mo_file, catalog)
print(f" {len(catalog)} messages compiled")
def main():
"""Find and compile all .po files in translations directory."""
translations_dir = Path(__file__).parent / 'translations'
if not translations_dir.exists():
print(f"ERROR: {translations_dir} does not exist")
return
po_files = list(translations_dir.rglob('*.po'))
if not po_files:
print(f"No .po files found in {translations_dir}")
return
print(f"Found {len(po_files)} .po file(s)")
for po_file in po_files:
compile_po_file(po_file)
print("\n✓ All translations compiled successfully")
if __name__ == '__main__':
main()
+32
View File
@@ -0,0 +1,32 @@
"""TieMeasureFlow Client Configuration."""
import os
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
class Config:
"""Flask client configuration."""
# Flask
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", "change-this-to-another-random-secret-key")
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
# API Server connection
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
# Session
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = not DEBUG # Only secure cookies in production (HTTPS)
PERMANENT_SESSION_LIFETIME = 28800 # 8 hours
WTF_CSRF_TIME_LIMIT = 3600 # 1 hour
# Station identity: each deployed client container sets this to the station
# code it belongs to. Empty/None means "not configured".
STATION_CODE: str | None = os.getenv("STATION_CODE") or None
# Babel i18n
BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
LANGUAGES = {"it": "Italiano", "en": "English"}
@@ -0,0 +1,161 @@
"""API Client - wrapper for HTTP requests to FastAPI server."""
from typing import Any
import requests
from flask import has_request_context, request, session
from config import Config
class APIClient:
"""HTTP client for TieMeasureFlow API server."""
def __init__(self):
self.base_url = Config.API_SERVER_URL.rstrip("/")
self.timeout = 30
@staticmethod
def _real_client_ip() -> str | None:
"""Best-effort tablet IP for downstream rate limiting.
Behind Nginx + ProxyFix, ``request.remote_addr`` already resolves to
the tablet IP. Outside a request context (background tasks, tests),
return None so we don't forge a bogus header.
"""
if not has_request_context():
return None
return request.remote_addr
@property
def _headers(self) -> dict[str, str]:
"""Build request headers with API key + real client IP from session."""
headers = {"Content-Type": "application/json"}
api_key = session.get("api_key")
if api_key:
headers["X-API-Key"] = api_key
client_ip = self._real_client_ip()
if client_ip:
headers["X-Forwarded-For"] = client_ip
headers["X-Real-IP"] = client_ip
return headers
def _handle_response(self, response: requests.Response) -> dict[str, Any]:
"""Parse response and return normalized dict.
Returns:
- If 2xx and not 204: response JSON
- If 204 No Content: empty dict
- If 4xx/5xx: {"error": True, "status_code": ..., "detail": "..."}
"""
if response.ok:
if response.status_code == 204:
return {}
return response.json()
# Non-OK response (4xx/5xx)
try:
error_body = response.json()
detail = error_body.get("detail", str(error_body))
# FastAPI 422 returns detail as a list of validation errors
if isinstance(detail, list):
detail = "; ".join(
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
) or str(detail)
except Exception:
detail = response.text or f"HTTP {response.status_code}"
return {
"error": True,
"status_code": response.status_code,
"detail": detail
}
def get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
"""GET request to API server."""
try:
response = requests.get(
f"{self.base_url}{endpoint}",
headers=self._headers,
params=params,
timeout=self.timeout,
)
return self._handle_response(response)
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
"status_code": 0,
"detail": f"Errore di connessione al server: {str(e)}"
}
def post(self, endpoint: str, data: dict | None = None, files: dict | None = None) -> dict[str, Any]:
"""POST request to API server."""
try:
if files:
headers = {"X-API-Key": session.get("api_key", "")}
client_ip = self._real_client_ip()
if client_ip:
headers["X-Forwarded-For"] = client_ip
headers["X-Real-IP"] = client_ip
response = requests.post(
f"{self.base_url}{endpoint}",
headers=headers,
data=data,
files=files,
timeout=self.timeout,
)
else:
response = requests.post(
f"{self.base_url}{endpoint}",
headers=self._headers,
json=data,
timeout=self.timeout,
)
return self._handle_response(response)
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
"status_code": 0,
"detail": f"Errore di connessione al server: {str(e)}"
}
def put(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
"""PUT request to API server."""
try:
response = requests.put(
f"{self.base_url}{endpoint}",
headers=self._headers,
json=data,
timeout=self.timeout,
)
return self._handle_response(response)
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
"status_code": 0,
"detail": f"Errore di connessione al server: {str(e)}"
}
def delete(self, endpoint: str) -> dict[str, Any]:
"""DELETE request to API server."""
try:
response = requests.delete(
f"{self.base_url}{endpoint}",
headers=self._headers,
timeout=self.timeout,
)
return self._handle_response(response)
except (requests.ConnectionError, requests.Timeout) as e:
return {
"error": True,
"status_code": 0,
"detail": f"Errore di connessione al server: {str(e)}"
}
# --- Domain helpers ---
def get_station_recipes(self, station_code: str) -> dict[str, Any]:
"""Return the list of active recipes assigned to the given station."""
return self.get(f"/api/stations/by-code/{station_code}/recipes")
api_client = APIClient()
@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tablet Touch Targets (44px minimum) */
@layer components {
.touch-target {
min-height: 44px;
min-width: 44px;
}
}
@media (pointer: coarse) {
button,
.btn,
[role="button"],
a.inline-flex {
min-height: 44px;
}
/* Flash message dismiss button */
.fixed button[x-data] {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
@@ -0,0 +1,55 @@
/**
* Numpad Component Styles
* Additional styles not covered by TailwindCSS utility classes
*/
/* Touch optimization - prevent text selection and improve tap responsiveness */
.numpad-container button {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
/* Ensure touch-manipulation class works */
.touch-manipulation {
touch-action: manipulation;
}
/* Prevent tap highlight on mobile devices */
.numpad-container button:active {
-webkit-tap-highlight-color: transparent;
}
/* Improve button focus for accessibility */
.numpad-container button:focus-visible {
outline: 2px solid var(--color-primary, #0D47A1);
outline-offset: 2px;
}
/* Ensure consistent sizing on all devices */
.numpad-container {
-webkit-tap-highlight-color: transparent;
}
/* Value display - ensure proper spacing and alignment */
.value-display {
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
/* Prevent zoom on double-tap for iOS Safari */
@media (max-width: 768px) {
.numpad-container button {
touch-action: manipulation;
}
}
/* Dark mode adjustments for better contrast */
.dark .value-display {
background-color: var(--bg-card, rgb(51, 65, 85));
border-color: var(--border-color, rgb(71, 85, 105));
}
@@ -0,0 +1,422 @@
/* ============================================================
TieMeasureFlow - Theme System
CSS Custom Properties for Light / Dark mode
============================================================ */
/* ---- Light Theme (Default) ---- */
:root {
--color-primary: #2563EB;
--color-primary-dark: #1E40AF;
--color-primary-light: #3B82F6;
--color-secondary: #64748B;
--color-accent: #1E40AF;
--color-pass: #059669;
--color-warning: #D97706;
--color-fail: #DC2626;
--bg-primary: #F8FAFC;
--bg-secondary: #F1F5F9;
--bg-card: #FFFFFF;
--bg-card-hover: #F8FAFC;
--text-primary: #0F172A;
--text-secondary: #475569;
--text-muted: #94A3B8;
--border-color: #E2E8F0;
--border-focus: #2563EB;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--scrollbar-track: #F1F5F9;
--scrollbar-thumb: #CBD5E1;
--scrollbar-thumb-hover: #94A3B8;
}
/* ---- Dark Theme ---- */
.dark {
--color-primary: #60A5FA;
--color-primary-dark: #3B82F6;
--color-primary-light: #93C5FD;
--color-accent: #93C5FD;
--color-pass: #34D399;
--color-warning: #FBBF24;
--color-fail: #F87171;
--bg-primary: #0F172A;
--bg-secondary: #1E293B;
--bg-card: #334155;
--bg-card-hover: #3B4A63;
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--border-color: #475569;
--border-focus: #60A5FA;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--scrollbar-track: #1E293B;
--scrollbar-thumb: #475569;
--scrollbar-thumb-hover: #64748B;
}
/* ============================================================
Global Transitions
============================================================ */
*,
*::before,
*::after {
transition-property: background-color, border-color, color, box-shadow;
transition-duration: 0s;
}
/* Enable smooth transitions only after page load (avoid flash) */
html.theme-transitions *,
html.theme-transitions *::before,
html.theme-transitions *::after {
transition-duration: 0.3s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
Focus Ring
============================================================ */
*:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
border-radius: 4px;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--border-focus);
}
/* ============================================================
Custom Scrollbar
============================================================ */
/* Webkit (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* ============================================================
Utility: Card
============================================================ */
.tmf-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.tmf-card:hover {
box-shadow: var(--shadow-md);
}
.tmf-card-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.tmf-card-body {
padding: 1.25rem;
}
.tmf-card-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 0 0 0.75rem 0.75rem;
}
/* ============================================================
Utility: Badges (Pass / Warning / Fail)
============================================================ */
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
line-height: 1.5;
white-space: nowrap;
}
.badge-pass {
background: rgba(5, 150, 105, 0.1);
color: #059669;
border: 1px solid rgba(5, 150, 105, 0.25);
}
.dark .badge-pass {
background: rgba(5, 150, 105, 0.15);
color: #34D399;
border-color: rgba(5, 150, 105, 0.3);
}
.badge-warning {
background: rgba(217, 119, 6, 0.1);
color: #D97706;
border: 1px solid rgba(217, 119, 6, 0.25);
}
.dark .badge-warning {
background: rgba(217, 119, 6, 0.15);
color: #FBBF24;
border-color: rgba(217, 119, 6, 0.3);
}
.badge-fail {
background: rgba(220, 38, 38, 0.1);
color: #DC2626;
border: 1px solid rgba(220, 38, 38, 0.25);
}
.dark .badge-fail {
background: rgba(220, 38, 38, 0.15);
color: #F87171;
border-color: rgba(220, 38, 38, 0.3);
}
.badge-neutral {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
/* ============================================================
Utility: Buttons
============================================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
color: #FFFFFF;
border-color: var(--color-primary);
}
.dark .btn-primary {
background: #3B82F6;
color: #FFFFFF;
border-color: #3B82F6;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
}
.dark .btn-primary:hover:not(:disabled) {
background: #2563EB;
border-color: #2563EB;
}
.btn-secondary {
background: transparent;
color: var(--text-secondary);
border-color: var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-secondary);
}
.btn-danger {
background: var(--color-fail);
color: #FFFFFF;
border-color: var(--color-fail);
}
.btn-danger:hover:not(:disabled) {
background: #B91C1C;
border-color: #B91C1C;
}
/* ============================================================
Utility: Form Inputs
============================================================ */
.tmf-input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.5;
color: var(--text-primary);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.tmf-input::placeholder {
color: var(--text-muted);
}
.tmf-input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
}
.dark .tmf-input:focus {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.tmf-label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
/* ============================================================
Utility: Measurement Value Display
============================================================ */
.measure-value {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-weight: 500;
letter-spacing: -0.025em;
font-variant-numeric: tabular-nums;
}
/* ============================================================
Utility: Table
============================================================ */
.tmf-table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
}
.tmf-table th {
padding: 0.625rem 1rem;
text-align: left;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.tmf-table td {
padding: 0.625rem 1rem;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.tmf-table tbody tr:hover {
background: var(--bg-card-hover);
}
/* ============================================================
Alpine.js x-cloak (hide until Alpine loads)
============================================================ */
[x-cloak] {
display: none !important;
}
/* ============================================================
Tablet Touch Targets (44px minimum per WCAG 2.5.5)
============================================================ */
.touch-target {
min-height: 44px;
min-width: 44px;
}
@media (pointer: coarse) {
button,
.btn,
[role="button"],
a.inline-flex {
min-height: 44px;
}
/* Flash message dismiss button */
.fixed button[x-data] {
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
}
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Stylized Caliper Icon for Favicon -->
<!-- Caliper outer frame -->
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
<!-- Sliding jaw -->
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<!-- Depth rod -->
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
<!-- Scale markings -->
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<!-- Flow arrow -->
<path d="M22 16 C25 16, 26.5 11, 29 11"
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M27 8.5 L29.5 11 L27 13.5"
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
<!-- Stylized Caliper Icon -->
<g transform="translate(2, 2)">
<!-- Caliper outer frame -->
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
<!-- Caliper sliding jaw -->
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
<!-- Caliper depth rod -->
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
<!-- Scale markings -->
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<!-- Flow arrow (smooth curve) -->
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</g>
<!-- Text: TieMeasureFlow -->
<g transform="translate(48, 0)">
<!-- "Tie" in bold primary -->
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Tie</text>
<!-- "Measure" in medium dark -->
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
letter-spacing="-0.5">Measure</text>
<!-- "Flow" in bold primary -->
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Flow</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,126 @@
/**
* TieMeasureFlow - Alpine.js Theme Initialization
*
* This script MUST load before Alpine.js (which uses defer).
* It reads the saved theme from localStorage or the system preference,
* applies the 'dark' class to <html> immediately (no flash), and
* registers an Alpine.store('theme') for reactive toggling.
*/
(function () {
'use strict';
// ---- Immediate theme application (before paint) ----
var STORAGE_KEY = 'tmf-theme';
/**
* Resolve the initial dark-mode state.
* Priority: localStorage > system preference > light (default).
*/
function getInitialDark() {
var stored = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch (_) {
// localStorage may be unavailable (private browsing, etc.)
}
if (stored === 'dark') return true;
if (stored === 'light') return false;
// Fall back to OS preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return true;
}
return false;
}
var isDark = getInitialDark();
// Apply immediately to prevent flash of wrong theme
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// ---- Enable smooth transitions after first paint ----
// Use requestAnimationFrame to wait one frame, then enable transitions
// so the initial theme application does not animate.
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
document.documentElement.classList.add('theme-transitions');
});
});
}
// ---- Alpine.js Store Registration ----
// Alpine.store is registered via the alpine:init event, which fires
// when Alpine begins initialization but before it processes x-data.
document.addEventListener('alpine:init', function () {
Alpine.store('theme', {
dark: isDark,
toggle: function () {
this.dark = !this.dark;
this._apply();
},
set: function (dark) {
this.dark = dark;
this._apply();
},
_apply: function () {
if (this.dark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
try {
localStorage.setItem(STORAGE_KEY, this.dark ? 'dark' : 'light');
} catch (_) {
// Silently ignore storage errors
}
}
});
});
// ---- Listen for system preference changes ----
if (window.matchMedia) {
try {
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', function (e) {
// Only auto-switch if the user hasn't manually set a preference
var stored = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch (_) {}
if (!stored) {
var newDark = e.matches;
if (typeof Alpine !== 'undefined' && Alpine.store('theme')) {
Alpine.store('theme').set(newDark);
} else {
// Alpine not loaded yet, apply directly
if (newDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
}
});
} catch (_) {
// Older browsers may not support addEventListener on matchMedia
}
}
})();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,530 @@
/**
* Annotation Viewer - Display-only viewer for image annotations
*
* Alpine.js component for viewing measurement markers on technical drawings.
* Supports point markers and rectangle markers with highlighting.
*
* Annotation JSON Format:
* {
* "markers": [
* {
* "id": 1,
* "marker_number": 1,
* "subtask_id": 42,
* "x": 150,
* "y": 200,
* "type": "point",
* "label": "D1"
* },
* {
* "id": 2,
* "marker_number": 2,
* "subtask_id": 43,
* "x": 300,
* "y": 150,
* "width": 100,
* "height": 50,
* "type": "rectangle",
* "label": "L1"
* }
* ],
* "imageWidth": 800,
* "imageHeight": 600
* }
*
* Usage:
* <div x-data="annotationViewer()"
* x-init="imageUrl = '/path/to/image.jpg'; annotations = {...}; init()">
* <canvas x-ref="annotationCanvas" @click="handleClick($event)"></canvas>
* </div>
*/
function annotationViewer() {
return {
// Canvas elements
canvas: null,
ctx: null,
// Data
annotations: null, // { markers: [...], imageWidth, imageHeight }
imageUrl: null, // URL dell'immagine da visualizzare
activeMarker: null, // marker_number del marker correntemente selezionato
// State
imageLoaded: false,
scale: 1, // scala di ridimensionamento immagine
/**
* Inizializzazione component
*/
init() {
this.canvas = this.$refs.annotationCanvas;
if (!this.canvas) {
console.error('AnnotationViewer: canvas ref not found');
return;
}
this.ctx = this.canvas.getContext('2d');
// Load image if URL is provided
if (this.imageUrl) {
this.loadImage();
}
// Responsive resize
window.addEventListener('resize', () => {
if (this.imageLoaded) {
this.loadImage();
}
});
},
/**
* Carica e renderizza l'immagine (o la prima pagina PDF) con scaling
*/
loadImage() {
var isPdf = this.imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
this._loadPdf();
} else {
this._loadRasterImage();
}
},
/**
* Render first page of a PDF via PDF.js
*/
_loadPdf() {
const self = this;
pdfjsLib.getDocument(this.imageUrl).promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
const pdfScale = 2; // render at 2x for clarity
const viewport = page.getViewport({ scale: pdfScale });
const offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
const offCtx = offCanvas.getContext('2d');
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
// Use rendered PDF page as if it were an image
self._drawImageOnCanvas(offCanvas, viewport.width, viewport.height);
});
}).catch(function (err) {
console.error('AnnotationViewer: failed to load PDF:', err);
});
},
/**
* Load a raster image (PNG, JPG, etc.)
*/
_loadRasterImage() {
const self = this;
const img = new Image();
img.onload = function () {
self._drawImageOnCanvas(img, img.width, img.height);
};
img.onerror = function () {
console.error('AnnotationViewer: failed to load image:', self.imageUrl);
};
img.src = this.imageUrl;
},
/**
* Draw an image source (Image or Canvas) scaled to fit the container
*/
_drawImageOnCanvas(source, srcWidth, srcHeight) {
const container = this.canvas.parentElement;
if (!container) {
console.error('AnnotationViewer: parent container not found');
return;
}
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight || 500;
this.scale = Math.min(
containerWidth / srcWidth,
containerHeight / srcHeight,
1
);
this.canvas.width = srcWidth * this.scale;
this.canvas.height = srcHeight * this.scale;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height);
this.imageLoaded = true;
this.drawAnnotations();
},
/**
* Disegna tutti i markers sulle annotazioni
*/
drawAnnotations() {
if (!this.annotations) return;
// Editor format (objects array from annotation-editor.js)
if (this.annotations.objects) {
var previewScale = this.scale;
var imgScale = this.annotations.imageScale;
// v2+: use imageScale for correct canvas→image→preview mapping
// v1 fallback: annoScale = previewWidth / editorWidth
var annoScale = imgScale
? (previewScale / imgScale)
: (this.canvas.width / (this.annotations.width || this.canvas.width));
_drawEditorAnnotations(this.ctx, this.annotations, annoScale);
return;
}
// Legacy format (markers array)
if (!this.annotations.markers) return;
for (const marker of this.annotations.markers) {
const isActive = this.activeMarker === marker.marker_number;
this.drawMarker(marker, isActive);
}
},
/**
* Disegna un singolo marker
* @param {Object} marker - marker data
* @param {boolean} isActive - se il marker è attualmente selezionato
*/
drawMarker(marker, isActive) {
const x = marker.x * this.scale;
const y = marker.y * this.scale;
const color = isActive ? '#2563EB' : '#64748B'; // primary : steel
const radius = isActive ? 16 : 12;
if (marker.type === 'point') {
// Cerchio numerato
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.globalAlpha = isActive ? 0.9 : 0.7;
this.ctx.fill();
this.ctx.globalAlpha = 1;
// Bordo bianco
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 2;
this.ctx.stroke();
// Numero
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = `bold ${isActive ? 14 : 11}px Inter, sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(marker.marker_number.toString(), x, y);
} else if (marker.type === 'rectangle') {
const w = (marker.width || 50) * this.scale;
const h = (marker.height || 30) * this.scale;
// Rettangolo
this.ctx.strokeStyle = color;
this.ctx.lineWidth = isActive ? 3 : 2;
this.ctx.setLineDash(isActive ? [] : [5, 3]);
this.ctx.strokeRect(x, y, w, h);
this.ctx.setLineDash([]);
// Label badge
const badgeWidth = 30;
const badgeHeight = 18;
this.ctx.fillStyle = color;
this.ctx.globalAlpha = 0.8;
this.ctx.fillRect(x, y - 20, badgeWidth, badgeHeight);
this.ctx.globalAlpha = 1;
// Numero nella badge
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = 'bold 11px Inter, sans-serif';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(marker.marker_number.toString(), x + badgeWidth / 2, y - 11);
}
},
/**
* Evidenzia un marker specifico
* @param {number} markerNumber - numero del marker da evidenziare
*/
setActiveMarker(markerNumber) {
this.activeMarker = markerNumber;
if (this.imageLoaded) {
// Ridisegna tutto
this.loadImage();
}
},
/**
* Gestisce click sul canvas per selezionare markers
* @param {MouseEvent} e - evento click
*/
handleClick(e) {
if (!this.annotations || !this.annotations.markers) return;
const rect = this.canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Check click su ogni marker (reverse order per priorità visuale)
for (let i = this.annotations.markers.length - 1; i >= 0; i--) {
const marker = this.annotations.markers[i];
const mx = marker.x * this.scale;
const my = marker.y * this.scale;
let hit = false;
if (marker.type === 'point') {
const dist = Math.sqrt((clickX - mx) ** 2 + (clickY - my) ** 2);
hit = dist < 20;
} else if (marker.type === 'rectangle') {
const w = (marker.width || 50) * this.scale;
const h = (marker.height || 30) * this.scale;
hit = clickX >= mx && clickX <= mx + w &&
clickY >= my && clickY <= my + h;
}
if (hit) {
// Dispatch event per parent component
this.$dispatch('marker-click', {
marker_number: marker.marker_number,
subtask_id: marker.subtask_id,
marker: marker
});
// Auto-evidenzia
this.setActiveMarker(marker.marker_number);
return;
}
}
},
/**
* Pulisce il canvas
*/
clear() {
if (this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.imageLoaded = false;
}
},
/**
* Aggiorna le annotazioni e ridisegna
* @param {Object} newAnnotations - nuovo oggetto annotations
*/
updateAnnotations(newAnnotations) {
this.annotations = newAnnotations;
if (this.imageLoaded) {
this.loadImage();
}
}
};
}
/**
* Standalone function to render an image with annotations overlay.
* For use in read-only previews (e.g. task cards) without Alpine.js.
*
* @param {HTMLCanvasElement} canvas - the canvas element to draw on
* @param {string} imageUrl - URL of the image to load
* @param {Object|string|null} annotationsJson - annotations data from the editor
* @param {Object} [options] - optional settings
* @param {number} [options.maxHeight=192] - max canvas height in pixels
*/
function renderAnnotationPreview(canvas, imageUrl, annotationsJson, options) {
if (!canvas || !imageUrl) return;
var opts = options || {};
var maxHeight = opts.maxHeight || 192;
var ctx = canvas.getContext('2d');
var annotations = null;
if (annotationsJson) {
try {
annotations = (typeof annotationsJson === 'string')
? JSON.parse(annotationsJson)
: annotationsJson;
} catch (e) {
console.error('renderAnnotationPreview: invalid annotations JSON');
}
}
var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
pdfjsLib.getDocument(imageUrl).promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
var viewport = page.getViewport({ scale: 2 });
var offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
var offCtx = offCanvas.getContext('2d');
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
_drawPreview(canvas, ctx, offCanvas, viewport.width, viewport.height, annotations, maxHeight);
});
}).catch(function (err) {
console.error('renderAnnotationPreview: PDF load failed:', err);
});
} else {
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
_drawPreview(canvas, ctx, img, img.width, img.height, annotations, maxHeight);
};
img.onerror = function () {
console.error('renderAnnotationPreview: image load failed:', imageUrl);
};
img.src = imageUrl;
}
}
/**
* Internal: draw image + annotations on canvas, scaled to fit.
*/
function _drawPreview(canvas, ctx, source, srcW, srcH, annotations, maxHeight) {
var container = canvas.parentElement;
var containerWidth = container ? container.clientWidth : 400;
var scale = Math.min(containerWidth / srcW, maxHeight / srcH, 1);
canvas.width = Math.round(srcW * scale);
canvas.height = Math.round(srcH * scale);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
if (annotations && annotations.objects) {
var imgScale = annotations.imageScale;
// v2+: use imageScale for correct canvas→image→preview mapping
// v1 fallback: annoScale = previewWidth / editorWidth
var annoScale = imgScale
? (scale / imgScale)
: (canvas.width / (annotations.width || srcW));
_drawEditorAnnotations(ctx, annotations, annoScale);
} else if (annotations && annotations.markers) {
_drawLegacyAnnotations(ctx, annotations, scale);
}
}
/**
* Draw annotations in the editor format (objects: marker/arrow/area).
*/
function _drawEditorAnnotations(ctx, data, scale) {
var objects = data.objects || [];
// Calculate annotation scale: annotations were placed on a canvas of
// data.width x data.height, but we're drawing on a scaled canvas.
// The image was scaled to fit data.width x data.height in the editor,
// and now we scale again for preview. Use overall scale factor.
var annoScale = scale;
for (var i = 0; i < objects.length; i++) {
var obj = objects[i];
if (obj.type === 'marker') {
_drawPreviewMarker(ctx, obj, annoScale);
} else if (obj.type === 'arrow') {
_drawPreviewArrow(ctx, obj, annoScale);
} else if (obj.type === 'area') {
_drawPreviewArea(ctx, obj, annoScale);
}
}
}
function _drawPreviewMarker(ctx, obj, scale) {
var x = obj.left * scale;
var y = obj.top * scale;
var color = obj.fill || '#2563EB';
var radius = 14 * Math.min(scale, 1);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.stroke();
if (obj.markerNumber) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold ' + Math.max(10, Math.round(12 * Math.min(scale, 1))) + 'px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(obj.markerNumber.toString(), x, y);
}
}
function _drawPreviewArrow(ctx, obj, scale) {
var x1 = (obj.x1 != null ? obj.x1 : obj.left) * scale;
var y1 = (obj.y1 != null ? obj.y1 : obj.top) * scale;
var x2 = (obj.x2 != null ? obj.x2 : (obj.left + (obj.width || 100))) * scale;
var y2 = (obj.y2 != null ? obj.y2 : (obj.top + (obj.height || 50))) * scale;
var color = obj.stroke || '#DC2626';
var lineWidth = obj.strokeWidth || 2;
var lineDash = obj.lineDash || [];
ctx.setLineDash(lineDash);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
ctx.setLineDash([]);
// Arrowhead
var angle = Math.atan2(y2 - y1, x2 - x1);
var headLen = 8 + lineWidth * 2;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
function _drawPreviewArea(ctx, obj, scale) {
var x = obj.left * scale;
var y = obj.top * scale;
var w = (obj.width || 150) * (obj.scaleX || 1) * scale;
var h = (obj.height || 100) * (obj.scaleY || 1) * scale;
var color = obj.stroke || '#2563EB';
var lineWidth = obj.strokeWidth || 2;
var lineDash = obj.lineDash || [5, 3];
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.setLineDash(lineDash);
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
}
/**
* Draw annotations in the legacy format (markers: point/rectangle).
*/
function _drawLegacyAnnotations(ctx, data, scale) {
var markers = data.markers || [];
for (var i = 0; i < markers.length; i++) {
var m = markers[i];
if (m.type === 'point') {
_drawPreviewMarker(ctx, { left: m.x, top: m.y, markerNumber: m.marker_number, fill: '#64748B' }, scale);
} else if (m.type === 'rectangle') {
_drawPreviewArea(ctx, { left: m.x, top: m.y, width: m.width, height: m.height, stroke: '#64748B' }, scale);
}
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Barcode Scanner Manager
* Integration with html5-qrcode library
* Supports QR codes, barcodes, and camera scanning
*/
function barcodeScanner() {
const _t = (key) => (window.BARCODE_I18N && window.BARCODE_I18N[key]) || key;
return {
scanning: false,
result: null,
error: null,
scanner: null,
cameraId: null,
async startScan() {
this.scanning = true;
this.result = null;
this.error = null;
// Check library availability
if (!window.Html5Qrcode) {
this.error = _t('scanner_lib_not_loaded');
this.scanning = false;
return;
}
try {
// Get available cameras
const devices = await Html5Qrcode.getCameras();
if (!devices || devices.length === 0) {
this.error = _t('no_camera_available');
this.scanning = false;
return;
}
// Prefer back camera
const backCamera = devices.find(d => d.label.toLowerCase().includes('back'));
this.cameraId = backCamera ? backCamera.id : devices[0].id;
// Initialize scanner
this.scanner = new Html5Qrcode("barcode-reader");
// Start scanning
await this.scanner.start(
this.cameraId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0
},
(decodedText, decodedResult) => {
// Success callback
this.result = decodedText;
this.stopScan();
// Dispatch event for parent components
this.$dispatch('barcode-scanned', {
code: decodedText,
format: decodedResult.result?.format?.formatName || 'unknown',
timestamp: new Date().toISOString()
});
},
(errorMessage) => {
// Error callback - ignore continuous scanning errors
// Only log non-routine errors
if (!errorMessage.includes('NotFoundException')) {
console.debug('Scan frame error:', errorMessage);
}
}
);
} catch (err) {
console.error('Scanner initialization error:', err);
this.error = _t('camera_access_error');
this.scanning = false;
}
},
async stopScan() {
if (this.scanner) {
try {
await this.scanner.stop();
await this.scanner.clear();
} catch (err) {
console.error('Stop scan error:', err);
}
this.scanner = null;
}
this.scanning = false;
},
clear() {
this.result = null;
this.error = null;
},
// Cleanup on component destroy
destroy() {
this.stopScan();
}
};
}
/**
* Barcode Input Helper
* For integrating barcode scanning into input fields
*/
function barcodeInput() {
return {
value: '',
showScanner: false,
openScanner() {
this.showScanner = true;
},
handleScan(event) {
this.value = event.detail.code;
this.showScanner = false;
// Dispatch value change event
this.$dispatch('input', { value: this.value });
this.$dispatch('change', { value: this.value });
},
clear() {
this.value = '';
this.$dispatch('input', { value: '' });
}
};
}
@@ -0,0 +1,45 @@
/**
* Caliper Status - Passive HID Monitor
* USB digital calipers act as keyboard emulators (HID),
* sending digits + Enter as keystrokes. This component
* monitors numpad-confirm events with inputMethod === 'usb_caliper'
* and provides visual feedback (no Web Serial needed).
*/
function caliperStatus() {
return {
active: false, // Flash active (for animation)
lastReading: null, // Last value read from caliper
readingCount: 0, // Session reading counter
_flashTimeout: null,
_onConfirm: null,
init() {
// Listen for numpad confirmations with usb_caliper method
this._onConfirm = (e) => {
if (e.detail && e.detail.inputMethod === 'usb_caliper') {
this.registerReading(e.detail.value);
}
};
window.addEventListener('numpad-confirm', this._onConfirm);
},
registerReading(value) {
this.lastReading = value;
this.readingCount++;
this.active = true;
clearTimeout(this._flashTimeout);
this._flashTimeout = setTimeout(() => {
this.active = false;
}, 1500);
},
destroy() {
if (this._onConfirm) {
window.removeEventListener('numpad-confirm', this._onConfirm);
}
clearTimeout(this._flashTimeout);
}
};
}
@@ -0,0 +1,270 @@
/**
* CSV Export Utility
* Client-side CSV generation and download with Italian locale support
* Uses Blob API for efficient file generation
*/
function csvExport() {
const _t = (key) => (window.CSV_I18N && window.CSV_I18N[key]) || key;
return {
// Locale-specific settings
delimiter: ';', // Italian Excel standard
decimalSeparator: ',', // Italian number format
dateFormat: 'dd/MM/yyyy HH:mm:ss',
/**
* Export measurements to CSV file
* @param {Array} measurements - Array of measurement objects
* @param {String} filename - Optional filename (auto-generated if not provided)
*/
exportMeasurements(measurements, filename = null) {
if (!measurements || !Array.isArray(measurements) || measurements.length === 0) {
console.warn('No measurements to export');
return;
}
// Build CSV content
const csv = this.buildMeasurementCSV(measurements);
// Download file
this.downloadCSV(csv, filename || this.generateFilename('misure'));
},
/**
* Export task execution summary
* @param {Object} task - Task object with measurements
* @param {String} filename - Optional filename
*/
exportTaskSummary(task, filename = null) {
if (!task || !task.measurements || task.measurements.length === 0) {
console.warn('No task data to export');
return;
}
const csv = this.buildTaskSummaryCSV(task);
this.downloadCSV(csv, filename || this.generateFilename(`task_${task.id}`));
},
/**
* Build CSV content for measurements
*/
buildMeasurementCSV(measurements) {
const headers = [
'ID',
_t('subtask_id'),
_t('subtask_name'),
_t('measured_value'),
_t('unit'),
_t('nominal_value'),
_t('tolerance_plus'),
_t('tolerance_minus'),
_t('deviation'),
_t('result'),
_t('lot_number'),
_t('serial_number'),
_t('input_method'),
_t('measurement_date'),
_t('operator')
];
let csv = headers.join(this.delimiter) + '\n';
for (const m of measurements) {
const row = [
m.id || '',
m.subtask_id || '',
this.escapeCsvValue(m.subtask_name || ''),
this.formatNumber(m.value),
this.escapeCsvValue(m.unit || 'mm'),
this.formatNumber(m.nominal),
this.formatNumber(m.tolerance_plus),
this.formatNumber(m.tolerance_minus),
this.formatNumber(m.deviation),
m.pass_fail || '',
this.escapeCsvValue(m.lot_number || ''),
this.escapeCsvValue(m.serial_number || ''),
m.input_method || '',
this.formatDateTime(m.measured_at),
this.escapeCsvValue(m.operator_name || '')
];
csv += row.join(this.delimiter) + '\n';
}
return csv;
},
/**
* Build CSV content for task summary
*/
buildTaskSummaryCSV(task) {
// Header section
let csv = _t('task_summary_title') + '\n\n';
csv += `${_t('task_id')}${this.delimiter}${task.id}\n`;
csv += `${_t('task_name')}${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
csv += `${_t('recipe')}${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
csv += `${_t('operator')}${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
csv += `${_t('start_date')}${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
csv += `${_t('end_date')}${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
csv += `${_t('status')}${this.delimiter}${task.status || ''}\n`;
csv += `${_t('lot')}${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
csv += `${_t('serial')}${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
// Statistics
csv += `\n${_t('statistics')}\n`;
const stats = this.calculateStats(task.measurements);
csv += `${_t('total_measurements')}${this.delimiter}${stats.total}\n`;
csv += `${_t('passed')}${this.delimiter}${stats.passed}\n`;
csv += `${_t('failed')}${this.delimiter}${stats.failed}\n`;
csv += `${_t('pass_rate')}${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
// Measurements detail
csv += `\n${_t('measurement_details')}\n`;
csv += this.buildMeasurementCSV(task.measurements);
return csv;
},
/**
* Calculate statistics from measurements
*/
calculateStats(measurements) {
const total = measurements.length;
const passed = measurements.filter(m => (m.pass_fail || '').toLowerCase() === 'pass').length;
const failed = total - passed;
const passRate = total > 0 ? (passed / total * 100) : 0;
return { total, passed, failed, passRate };
},
/**
* Download CSV content as file
*/
downloadCSV(csvContent, filename) {
// Add UTF-8 BOM for Excel compatibility
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], {
type: 'text/csv;charset=utf-8;'
});
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
// Trigger download
document.body.appendChild(link);
link.click();
// Cleanup
document.body.removeChild(link);
URL.revokeObjectURL(url);
},
/**
* Format number with Italian decimal separator
*/
formatNumber(value) {
if (value === null || value === undefined || value === '') {
return '';
}
const num = typeof value === 'number' ? value : parseFloat(value);
if (isNaN(num)) {
return '';
}
// Convert to string and replace decimal separator
return num.toString().replace('.', this.decimalSeparator);
},
/**
* Format datetime for Italian locale
*/
formatDateTime(dateStr) {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '';
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
} catch (err) {
console.error('Date format error:', err);
return dateStr;
}
},
/**
* Escape CSV value (handle quotes, delimiters)
*/
escapeCsvValue(value) {
if (value === null || value === undefined) {
return '';
}
const str = String(value);
// If contains delimiter, newline, or quote, wrap in quotes
if (str.includes(this.delimiter) || str.includes('\n') || str.includes('"')) {
// Escape existing quotes by doubling them
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
},
/**
* Generate filename with timestamp
*/
generateFilename(prefix) {
const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`;
}
};
}
/**
* CSV Export Button Component
* Reusable Alpine component for export functionality
*/
function csvExportButton() {
return {
exporting: false,
async exportData(data, type = 'measurements') {
this.exporting = true;
try {
const exporter = csvExport();
if (type === 'measurements') {
exporter.exportMeasurements(data);
} else if (type === 'task') {
exporter.exportTaskSummary(data);
}
// Show success feedback
this.$dispatch('export-success', { type });
} catch (err) {
console.error('Export error:', err);
this.$dispatch('export-error', { error: err.message });
} finally {
this.exporting = false;
}
}
};
}
@@ -0,0 +1,76 @@
{
"numpad": {
"title": "Numeric Keypad",
"clear": "Clear",
"submit": "Confirm",
"decimal": "Decimal",
"close": "Close"
},
"measurement": {
"pass": "Pass",
"warning": "Warning",
"fail": "Fail",
"value": "Value",
"next": "Next measurement"
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"search": "Search...",
"noResults": "No results",
"close": "Close",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"page": "Page",
"of": "of",
"previous": "Previous",
"next": "Next"
},
"theme": {
"light": "Light",
"dark": "Dark",
"toggle": "Toggle theme"
},
"language": {
"switch": "Switch language",
"it": "Italian",
"en": "English"
},
"auth": {
"login": "Sign in to your account",
"username": "Username",
"password": "Password",
"signIn": "Sign In",
"invalidCredentials": "Invalid credentials",
"enterCredentials": "Enter username and password",
"forgotPassword": "Forgot password? Contact administrator",
"welcome": "Welcome, {name}!",
"logoutSuccess": "Logged out successfully",
"loginRequired": "Please log in to continue"
},
"nav": {
"measurements": "Measurements",
"recipes": "Recipes",
"statistics": "Statistics",
"admin": "Admin",
"users": "Users",
"settings": "Settings",
"profile": "Profile",
"logout": "Logout"
},
"profile": {
"title": "User Profile",
"displayName": "Display Name",
"language": "Language",
"theme": "Theme",
"roles": "Roles",
"saveChanges": "Save Changes",
"updateSuccess": "Profile updated successfully"
}
}
@@ -0,0 +1,76 @@
{
"numpad": {
"title": "Tastierino Numerico",
"clear": "Cancella",
"submit": "Conferma",
"decimal": "Virgola",
"close": "Chiudi"
},
"measurement": {
"pass": "Conforme",
"warning": "Attenzione",
"fail": "Non Conforme",
"value": "Valore",
"next": "Prossima misura"
},
"common": {
"loading": "Caricamento...",
"save": "Salva",
"cancel": "Annulla",
"confirm": "Conferma",
"delete": "Elimina",
"edit": "Modifica",
"search": "Cerca...",
"noResults": "Nessun risultato",
"close": "Chiudi",
"error": "Errore",
"success": "Successo",
"warning": "Attenzione",
"info": "Info",
"page": "Pagina",
"of": "di",
"previous": "Precedente",
"next": "Successivo"
},
"theme": {
"light": "Chiaro",
"dark": "Scuro",
"toggle": "Cambia tema"
},
"language": {
"switch": "Cambia lingua",
"it": "Italiano",
"en": "English"
},
"auth": {
"login": "Accedi al sistema",
"username": "Username",
"password": "Password",
"signIn": "Accedi",
"invalidCredentials": "Credenziali non valide",
"enterCredentials": "Inserisci username e password",
"forgotPassword": "Hai dimenticato la password? Contatta l'amministratore",
"welcome": "Benvenuto, {name}!",
"logoutSuccess": "Logout effettuato",
"loginRequired": "Effettua il login per continuare"
},
"nav": {
"measurements": "Misure",
"recipes": "Ricette",
"statistics": "Statistiche",
"admin": "Admin",
"users": "Utenti",
"settings": "Impostazioni",
"profile": "Profilo",
"logout": "Esci"
},
"profile": {
"title": "Profilo Utente",
"displayName": "Nome Visualizzato",
"language": "Lingua",
"theme": "Tema",
"roles": "Ruoli",
"saveChanges": "Salva Modifiche",
"updateSuccess": "Profilo aggiornato con successo"
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Numpad Component - Alpine.js component for touch-friendly numeric input
* Used for measurement data entry in task_execute.html
*/
function numpad() {
return {
// State
value: '', // String representation of the current value
negative: false, // Whether the value is negative
hasDecimal: false, // Whether a decimal point has been entered
unit: 'mm', // Unit of measurement (can be set externally)
maxIntDigits: 6, // Maximum integer digits
maxDecDigits: 6, // Maximum decimal digits
// HID burst detection (USB caliper vs manual typing)
_lastKeyTime: 0, // Timestamp of last keystroke
_burstCount: 0, // Consecutive fast keystrokes
/**
* Get the display value with sign
*/
get displayValue() {
if (!this.value) return '';
return (this.negative ? '-' : '') + this.value;
},
/**
* Get the numeric value as a number
*/
get numericValue() {
if (!this.value) return null;
const v = parseFloat(this.value);
return this.negative ? -v : v;
},
/**
* Check if a valid value has been entered
*/
get hasValue() {
return this.value.length > 0 && this.value !== '.';
},
/**
* Add a digit to the current value
* @param {string} d - The digit to add (0-9)
*/
addDigit(d) {
// Validate: don't exceed max digits
const parts = this.value.split('.');
if (this.hasDecimal) {
// Check decimal part length
if (parts[1] && parts[1].length >= this.maxDecDigits) return;
} else {
// Check integer part length
if (parts[0] && parts[0].length >= this.maxIntDigits) return;
}
// Prevent leading zeros (except "0.")
if (this.value === '0' && d !== '.') {
this.value = d;
return;
}
this.value += d;
},
/**
* Add a decimal point to the current value
*/
addDecimal() {
// Don't add decimal if one already exists
if (this.hasDecimal) return;
// If value is empty, start with "0."
if (!this.value) this.value = '0';
this.value += '.';
this.hasDecimal = true;
},
/**
* Toggle the sign of the current value
*/
toggleSign() {
if (!this.value) return;
this.negative = !this.negative;
},
/**
* Remove the last character from the current value
*/
backspace() {
if (!this.value) return;
const removed = this.value.charAt(this.value.length - 1);
// If removing decimal point, update flag
if (removed === '.') this.hasDecimal = false;
this.value = this.value.slice(0, -1);
},
/**
* Clear all entered data
*/
clearAll() {
this.value = '';
this.negative = false;
this.hasDecimal = false;
},
/**
* Confirm the current value and dispatch event
*/
confirm() {
if (!this.hasValue) return;
const val = this.numericValue;
// Determine input method: 3+ fast keystrokes = USB caliper burst
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
// Dispatch custom event for parent component to handle
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
// Reset after confirmation
this.clearAll();
this._burstCount = 0;
this._lastKeyTime = 0;
},
/**
* Set a value programmatically (e.g., from USB caliper)
* @param {number} val - The numeric value to set
*/
setValue(val) {
this.clearAll();
// Handle negative values
if (val < 0) {
this.negative = true;
val = Math.abs(val);
}
this.value = val.toString();
// Update decimal flag if value contains decimal point
if (this.value.includes('.')) this.hasDecimal = true;
},
/**
* Set the unit of measurement
* @param {string} newUnit - The unit to set (e.g., 'mm', 'cm', 'in')
*/
setUnit(newUnit) {
this.unit = newUnit;
},
/**
* Handle keyboard input for physical keyboard support
* @param {KeyboardEvent} e - The keyboard event
*/
handleKeydown(e) {
// Number keys
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
this._trackBurst();
this.addDigit(e.key);
}
// Decimal point (both . and ,)
else if (e.key === '.' || e.key === ',') {
e.preventDefault();
this._trackBurst();
this.addDecimal();
}
// Backspace
else if (e.key === 'Backspace') {
e.preventDefault();
this.backspace();
}
// Escape - clear all
else if (e.key === 'Escape') {
e.preventDefault();
this.clearAll();
}
// Enter - confirm
else if (e.key === 'Enter') {
e.preventDefault();
this.confirm();
}
// Minus sign - toggle sign
else if (e.key === '-') {
e.preventDefault();
this.toggleSign();
}
},
/**
* Track keystroke timing for HID burst detection.
* USB calipers send digits in rapid succession (<80ms apart).
* Human typing is much slower (>100ms between keys).
*/
_trackBurst() {
const now = performance.now();
const gap = now - this._lastKeyTime;
if (this._lastKeyTime > 0 && gap < 80) {
this._burstCount++;
} else {
this._burstCount = 1;
}
this._lastKeyTime = now;
}
};
}
@@ -0,0 +1,296 @@
/**
* SPC Charts — Plotly.js helper functions for Metrologist dashboard.
*
* Requires Plotly.js loaded via CDN before this script.
* Requires window.SPC_I18N object set in the template for translations.
*
* Brand colors:
* Primary: #2563EB
* Pass: #10b981
* Warning: #f59e0b
* Fail: #ef4444
*/
// i18n helper — falls back to Italian defaults
const _t = (key) => (window.SPC_I18N && window.SPC_I18N[key]) || key;
const SPC_COLORS = {
primary: '#2563EB',
primaryLight: '#3B82F6',
pass: '#10b981',
warning: '#f59e0b',
fail: '#ef4444',
steel: '#64748B',
steelLight: '#94A3B8',
nominal: '#8b5cf6',
ucl_lcl: '#ef4444',
uwl_lwl: '#f59e0b',
utl_ltl: '#dc2626',
bg: '#ffffff',
gridColor: '#e2e8f0',
textColor: '#334155',
};
const PLOTLY_LAYOUT_DEFAULTS = {
font: { family: 'Inter, system-ui, sans-serif', color: SPC_COLORS.textColor },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
margin: { t: 20, r: 30, b: 50, l: 60 },
xaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
};
const PLOTLY_CONFIG = {
responsive: true,
displayModeBar: 'hover',
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false,
};
/**
* Render a control chart (X-bar) with tolerance and control limits.
*
* @param {string} containerId - DOM element ID for the chart.
* @param {Object} data - ControlChartData from API.
*/
function renderControlChart(containerId, data) {
if (!data || !data.values || data.values.length === 0) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
return;
}
const n = data.values.length;
const xLabels = data.timestamps.map((t, i) => i + 1);
// Separate in-control and out-of-control points
const oocSet = new Set(data.out_of_control || []);
const inControlX = [], inControlY = [];
const oocX = [], oocY = [];
for (let i = 0; i < n; i++) {
if (oocSet.has(i)) {
oocX.push(xLabels[i]);
oocY.push(data.values[i]);
} else {
inControlX.push(xLabels[i]);
inControlY.push(data.values[i]);
}
}
const traces = [
// In-control points
{
x: inControlX, y: inControlY,
mode: 'markers',
type: 'scatter',
name: _t('inControl'),
marker: { color: SPC_COLORS.primary, size: 6 },
},
// Out-of-control points
{
x: oocX, y: oocY,
mode: 'markers',
type: 'scatter',
name: _t('outOfControl'),
marker: { color: SPC_COLORS.fail, size: 9, symbol: 'diamond' },
},
// Line connecting all points
{
x: xLabels, y: data.values,
mode: 'lines',
type: 'scatter',
name: '',
line: { color: SPC_COLORS.steelLight, width: 1 },
showlegend: false,
},
];
// Helper to add horizontal line
const shapes = [];
function addLine(yVal, color, dash) {
if (yVal == null) return;
shapes.push({
type: 'line', xref: 'paper', x0: 0, x1: 1, y0: yVal, y1: yVal,
line: { color, width: 1.5, dash },
});
}
// Mean line
addLine(data.mean, SPC_COLORS.primary, 'solid');
// UCL / LCL
addLine(data.ucl, SPC_COLORS.ucl_lcl, 'dash');
addLine(data.lcl, SPC_COLORS.ucl_lcl, 'dash');
// UTL / LTL
addLine(data.utl, SPC_COLORS.utl_ltl, 'dot');
addLine(data.ltl, SPC_COLORS.utl_ltl, 'dot');
// UWL / LWL
addLine(data.uwl, SPC_COLORS.uwl_lwl, 'dashdot');
addLine(data.lwl, SPC_COLORS.uwl_lwl, 'dashdot');
// Nominal
addLine(data.nominal, SPC_COLORS.nominal, 'longdash');
// Build annotations for limit labels
const annotations = [];
function addLabel(yVal, text, color) {
if (yVal == null) return;
annotations.push({
x: 1, xref: 'paper', xanchor: 'left',
y: yVal, yanchor: 'middle',
text: text,
showarrow: false,
font: { size: 10, color },
});
}
addLabel(data.mean, 'X\u0304', SPC_COLORS.primary);
addLabel(data.ucl, 'UCL', SPC_COLORS.ucl_lcl);
addLabel(data.lcl, 'LCL', SPC_COLORS.ucl_lcl);
addLabel(data.utl, 'UTL', SPC_COLORS.utl_ltl);
addLabel(data.ltl, 'LTL', SPC_COLORS.utl_ltl);
addLabel(data.uwl, 'UWL', SPC_COLORS.uwl_lwl);
addLabel(data.lwl, 'LWL', SPC_COLORS.uwl_lwl);
addLabel(data.nominal, 'NOM', SPC_COLORS.nominal);
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') },
shapes,
annotations,
showlegend: true,
legend: { orientation: 'h', y: -0.15 },
margin: { ...PLOTLY_LAYOUT_DEFAULTS.margin, r: 50 },
};
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
}
/**
* Render histogram with normal curve overlay.
*
* @param {string} containerId - DOM element ID.
* @param {Object} data - HistogramData from API.
* @param {Object} [tol] - Optional tolerance limits {utl, ltl, nominal}.
*/
function renderHistogram(containerId, data, tol) {
if (!data || !data.bins || data.bins.length === 0 || data.n === 0) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
return;
}
// Bin centers for bar chart
const binCenters = [];
const binWidth = data.bins[1] - data.bins[0];
for (let i = 0; i < data.counts.length; i++) {
binCenters.push((data.bins[i] + data.bins[i + 1]) / 2);
}
const traces = [
// Histogram bars
{
x: binCenters, y: data.counts,
type: 'bar',
name: _t('frequency'),
marker: { color: SPC_COLORS.primaryLight, opacity: 0.7 },
width: binWidth * 0.9,
},
];
// Normal curve overlay
if (data.normal_x && data.normal_x.length > 0) {
traces.push({
x: data.normal_x, y: data.normal_y,
type: 'scatter',
mode: 'lines',
name: _t('normalCurve'),
line: { color: SPC_COLORS.fail, width: 2 },
});
}
const shapes = [];
function addVertLine(xVal, color, dash) {
if (xVal == null) return;
shapes.push({
type: 'line', yref: 'paper', y0: 0, y1: 1, x0: xVal, x1: xVal,
line: { color, width: 1.5, dash },
});
}
if (tol) {
addVertLine(tol.utl, SPC_COLORS.utl_ltl, 'dot');
addVertLine(tol.ltl, SPC_COLORS.utl_ltl, 'dot');
addVertLine(tol.nominal, SPC_COLORS.nominal, 'longdash');
}
// Mean line
addVertLine(data.mean, SPC_COLORS.primary, 'dash');
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') },
shapes,
bargap: 0.05,
showlegend: true,
legend: { orientation: 'h', y: -0.15 },
};
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
}
/**
* Render a capability gauge (horizontal bar indicator for Cpk).
*
* @param {string} containerId - DOM element ID.
* @param {Object} data - CapabilityData from API.
*/
function renderCapabilityGauge(containerId, data) {
if (!data || data.cpk == null) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-8">' + _t('indicesNotAvailable') + '</p>';
return;
}
const cpk = data.cpk;
// Color zones: <1.0 = fail, 1.0-1.33 = warning, >=1.33 = pass
let barColor = SPC_COLORS.fail;
if (cpk >= 1.33) barColor = SPC_COLORS.pass;
else if (cpk >= 1.0) barColor = SPC_COLORS.warning;
const trace = {
type: 'indicator',
mode: 'gauge+number',
value: cpk,
title: { text: 'Cpk', font: { size: 14 } },
number: { font: { family: 'JetBrains Mono, monospace', size: 28 } },
gauge: {
axis: { range: [0, 2.5], tickwidth: 1 },
bar: { color: barColor, thickness: 0.6 },
bgcolor: '#f1f5f9',
borderwidth: 0,
steps: [
{ range: [0, 1.0], color: '#fecaca' },
{ range: [1.0, 1.33], color: '#fef3c7' },
{ range: [1.33, 2.5], color: '#d1fae5' },
],
threshold: {
line: { color: SPC_COLORS.primary, width: 3 },
thickness: 0.75,
value: cpk,
},
},
};
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
height: 200,
margin: { t: 30, r: 20, b: 10, l: 20 },
};
Plotly.newPlot(containerId, [trace], layout, { ...PLOTLY_CONFIG, displayModeBar: false });
}
View File
+54
View File
@@ -0,0 +1,54 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./templates/**/*.html",
"./static/js/**/*.js",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#2563EB',
dark: '#1E40AF',
light: '#3B82F6',
50: '#EFF6FF',
100: '#DBEAFE',
200: '#BFDBFE',
300: '#93C5FD',
400: '#60A5FA',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
800: '#1E40AF',
900: '#1E3A8A',
},
steel: {
DEFAULT: '#64748B',
light: '#94A3B8',
dark: '#475569',
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A',
},
measure: {
pass: '#059669',
warning: '#D97706',
fail: '#DC2626',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
}
@@ -0,0 +1,568 @@
{% extends "base.html" %}
{% block title %}{{ _('Gestione Stazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<script>
window.__stations = {{ stations|tojson }};
window.__allRecipes = {{ all_recipes|tojson }};
</script>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
x-data="stationManagement(window.__stations, window.__allRecipes)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Stazioni') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci stazioni di misurazione e relative ricette assegnate') }}</p>
</div>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<div class="relative w-full sm:w-80">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" x-model="search"
placeholder="{{ _('Cerca stazione...') }}"
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] placeholder-[var(--text-secondary)]
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
</div>
<button @click="openCreateModal()"
class="flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Nuova Stazione') }}
</button>
</div>
<!-- Stations Table -->
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Codice') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-color)]">
<template x-for="station in filteredStations" :key="station.id">
<tr class="hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
@click="openEditModal(station)">
<td class="px-4 py-3">
<span class="font-mono text-sm font-semibold text-[var(--text-primary)]" x-text="station.code"></span>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-primary)]" x-text="station.name"></td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="station.location || '-'"></td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
x-text="station.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'"></span>
</td>
<td class="px-4 py-3 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<button @click="openAssignmentsModal(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
:title="'{{ _('Gestisci ricette') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</button>
<button @click="openEditModal(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
:title="'{{ _('Modifica') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="confirmDelete(station)"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
:title="'{{ _('Elimina') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<template x-if="filteredStations.length === 0">
<div class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto text-[var(--text-secondary)] opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
<p class="mt-3 text-sm text-[var(--text-secondary)]">{{ _('Nessuna stazione trovata') }}</p>
</div>
</template>
</div>
<div class="mt-3 text-xs text-[var(--text-secondary)]">
<span x-text="filteredStations.length"></span> {{ _('stazioni') }}
</div>
<!-- Modal: Create / Edit Station -->
<div x-show="showModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeModal()">
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<h2 class="text-lg font-semibold text-[var(--text-primary)]"
x-text="isEditing ? '{{ _('Modifica Stazione') }}' : '{{ _('Nuova Stazione') }}'"></h2>
<button @click="closeModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Codice') }} <span class="text-red-500">*</span></label>
<input type="text" x-model="form.code"
:disabled="isEditing"
:class="isEditing ? 'opacity-50 cursor-not-allowed font-mono' : 'font-mono uppercase'"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="ST-001"
maxlength="100">
<template x-if="isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Il codice non può essere modificato') }}</p>
</template>
<template x-if="!isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Identificativo univoco usato dal client tramite STATION_CODE') }}</p>
</template>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nome') }} <span class="text-red-500">*</span></label>
<input type="text" x-model="form.name"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Nome descrittivo della stazione') }}"
maxlength="255">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Postazione') }}</label>
<input type="text" x-model="form.location"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Es. Reparto A - Linea 2') }}"
maxlength="255">
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
<textarea x-model="form.notes" rows="3"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Note opzionali') }}"></textarea>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.active"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm font-medium text-[var(--text-primary)]">{{ _('Attiva') }}</span>
</label>
</div>
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeModal()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="saveStation()"
:disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
<span x-show="!saving" x-text="isEditing ? '{{ _('Salva Modifiche') }}' : '{{ _('Crea Stazione') }}'"></span>
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span>
</button>
</div>
</div>
</div>
<!-- Modal: Recipe Assignments -->
<div x-show="showAssignments" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeAssignmentsModal()">
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
<div x-show="showAssignments"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Ricette Assegnate') }}</h2>
<p class="text-xs text-[var(--text-secondary)] mt-0.5" x-text="assignmentStation ? assignmentStation.code + ' — ' + assignmentStation.name : ''"></p>
</div>
<button @click="closeAssignmentsModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="px-6 py-4 space-y-4">
<!-- Add recipe -->
<div class="flex items-end gap-2">
<div class="flex-1">
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
<select x-model="recipeToAdd"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="">{{ _('-- Seleziona ricetta --') }}</option>
<template x-for="r in unassignedRecipes" :key="r.id">
<option :value="r.id" x-text="r.code + ' — ' + r.name"></option>
</template>
</select>
</div>
<button @click="assignRecipe()"
:disabled="!recipeToAdd || saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
{{ _('Assegna') }}
</button>
</div>
<!-- Assigned list -->
<div>
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
</h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
<template x-for="r in assignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
<div>
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
</div>
<button @click="unassignRecipe(r.id)"
:disabled="saving"
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
:title="'{{ _('Rimuovi') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</template>
<template x-if="assignedRecipes.length === 0">
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
{{ _('Nessuna ricetta assegnata') }}
</div>
</template>
</div>
</div>
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeAssignmentsModal()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Chiudi') }}
</button>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div x-show="showDeleteConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false"></div>
<div x-show="showDeleteConfirm"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">{{ _('Conferma Eliminazione') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Sei sicuro di voler eliminare la stazione') }}
<strong x-text="deleteTarget?.code"></strong>?
<br><span class="text-xs text-red-600 dark:text-red-400">{{ _('Verranno rimosse anche tutte le assegnazioni di ricette.') }}</span>
</p>
<div class="flex justify-end gap-3">
<button @click="showDeleteConfirm = false"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="performDelete()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm bg-red-600 hover:bg-red-700 disabled:opacity-50 transition-colors">
{{ _('Elimina') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function stationManagement(initialStations, initialRecipes) {
return {
stations: initialStations || [],
allRecipes: initialRecipes || [],
search: '',
showModal: false,
showAssignments: false,
showDeleteConfirm: false,
isEditing: false,
editingId: null,
saving: false,
errorMsg: '',
deleteTarget: null,
assignmentStation: null,
assignedRecipes: [],
recipeToAdd: '',
form: {
code: '',
name: '',
location: '',
notes: '',
active: true,
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredStations() {
if (!this.search.trim()) return this.stations;
const q = this.search.toLowerCase();
return this.stations.filter(s =>
s.code.toLowerCase().includes(q) ||
s.name.toLowerCase().includes(q) ||
(s.location && s.location.toLowerCase().includes(q))
);
},
get unassignedRecipes() {
const assignedIds = new Set(this.assignedRecipes.map(r => r.id));
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', active: true };
this.showModal = true;
},
openEditModal(station) {
this.isEditing = true;
this.editingId = station.id;
this.errorMsg = '';
this.form = {
code: station.code,
name: station.name,
location: station.location || '',
notes: station.notes || '',
active: station.active,
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveStation() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
const data = {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
active: this.form.active,
};
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nel salvataggio") }}';
return;
}
const idx = this.stations.findIndex(s => s.id === this.editingId);
if (idx >= 0) this.stations[idx] = result;
} else {
if (!this.form.code || !this.form.name) {
this.errorMsg = '{{ _("Codice e nome sono obbligatori") }}';
return;
}
const data = {
code: this.form.code.trim(),
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
active: this.form.active,
};
const resp = await fetch('/admin/api/stations', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nella creazione") }}';
return;
}
this.stations.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
confirmDelete(station) {
this.deleteTarget = station;
this.showDeleteConfirm = true;
},
async performDelete() {
if (!this.deleteTarget) return;
this.saving = true;
try {
const resp = await fetch(`/admin/api/stations/${this.deleteTarget.id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
return;
}
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
this.showDeleteConfirm = false;
this.deleteTarget = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
},
async openAssignmentsModal(station) {
this.assignmentStation = station;
this.assignedRecipes = [];
this.recipeToAdd = '';
this.errorMsg = '';
this.showAssignments = true;
try {
const resp = await fetch(`/admin/api/stations/${station.id}/recipes`);
const data = await resp.json();
if (resp.ok && Array.isArray(data)) {
this.assignedRecipes = data;
} else {
this.errorMsg = (data && data.detail) || '{{ _("Errore nel caricamento delle ricette") }}';
}
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
}
},
closeAssignmentsModal() {
this.showAssignments = false;
this.assignmentStation = null;
this.assignedRecipes = [];
this.errorMsg = '';
},
async assignRecipe() {
if (!this.recipeToAdd || !this.assignmentStation) return;
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ recipe_id: parseInt(this.recipeToAdd, 10) }),
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
return;
}
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
this.recipeToAdd = '';
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
async unassignRecipe(recipeId) {
if (!this.assignmentStation) return;
this.saving = true;
this.errorMsg = '';
try {
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes/${recipeId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken },
});
if (!resp.ok) {
const result = await resp.json().catch(() => ({}));
this.errorMsg = result.detail || '{{ _("Errore nella rimozione") }}';
return;
}
this.assignedRecipes = this.assignedRecipes.filter(r => r.id !== recipeId);
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
};
}
</script>
{% endblock %}
@@ -0,0 +1,535 @@
{% extends "base.html" %}
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
{% block content %}
<script>window.__users = {{ users|tojson }};</script>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
x-data="userManagement(window.__users)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
</div>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
<!-- Search -->
<div class="relative w-full sm:w-80">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" x-model="search"
placeholder="{{ _('Cerca utente...') }}"
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] placeholder-[var(--text-secondary)]
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
</div>
<!-- New User Button -->
<button @click="openCreateModal()"
class="flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Nuovo Utente') }}
</button>
</div>
<!-- Users Table -->
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Username') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden sm:table-cell">{{ _('Nome Visualizzato') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Email') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Ruoli') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-color)]">
<template x-for="user in filteredUsers" :key="user.id">
<tr class="hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
@click="openEditModal(user)">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-semibold text-xs"
x-text="user.display_name ? user.display_name[0].toUpperCase() : user.username[0].toUpperCase()"></div>
<div>
<span class="text-sm font-medium text-[var(--text-primary)]" x-text="user.username"></span>
<template x-if="user.is_admin">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
</template>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden sm:table-cell" x-text="user.display_name"></td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="user.email || '-'"></td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
<template x-for="role in user.roles" :key="role">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="{
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist'
}"
x-text="role"></span>
</template>
<template x-if="!user.roles || user.roles.length === 0">
<span class="text-xs text-[var(--text-secondary)]">-</span>
</template>
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="user.active
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
x-text="user.active ? '{{ _('Attivo') }}' : '{{ _('Disattivato') }}'"></span>
</td>
<td class="px-4 py-3 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<button @click="openEditModal(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="'{{ _('Modifica') }}'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
: 'text-[var(--text-secondary)] hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'"
:title="user.active ? '{{ _('Disattiva') }}' : '{{ _('Riattiva') }}'">
<svg x-show="user.active" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
<svg x-show="!user.active" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Empty State -->
<template x-if="filteredUsers.length === 0">
<div class="px-6 py-12 text-center">
<svg class="w-12 h-12 mx-auto text-[var(--text-secondary)] opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<p class="mt-3 text-sm text-[var(--text-secondary)]">{{ _('Nessun utente trovato') }}</p>
</div>
</template>
</div>
<!-- Count -->
<div class="mt-3 text-xs text-[var(--text-secondary)]">
<span x-text="filteredUsers.length"></span> {{ _('utenti') }}
</div>
<!-- Modal: Create / Edit User -->
<div x-show="showModal" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeModal()">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
<!-- Modal Content -->
<div x-show="showModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
<h2 class="text-lg font-semibold text-[var(--text-primary)]"
x-text="isEditing ? '{{ _('Modifica Utente') }}' : '{{ _('Nuovo Utente') }}'"></h2>
<button @click="closeModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="px-6 py-4 space-y-4">
<!-- Username -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Username') }}</label>
<input type="text" x-model="form.username"
:disabled="isEditing"
:class="isEditing ? 'opacity-50 cursor-not-allowed' : ''"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Username') }}">
<template x-if="isEditing">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Il nome utente non può essere modificato') }}</p>
</template>
</div>
<!-- Display Name -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nome Visualizzato') }}</label>
<input type="text" x-model="form.display_name"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="{{ _('Nome Visualizzato') }}">
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Email') }}</label>
<input type="email" x-model="form.email"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="email@example.com">
</div>
<!-- Password -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
{{ _('Password') }}
<template x-if="isEditing">
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('lascia vuoto per non modificare') }})</span>
</template>
</label>
<input type="password" x-model="form.password"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
:placeholder="isEditing ? '{{ _('Nuova password (opzionale)') }}' : '{{ _('Password') }}'"
minlength="6">
</div>
<!-- Roles -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
<div class="flex flex-wrap gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.roles" value="Maker"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">Maker</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.roles" value="MeasurementTec"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">MeasurementTec</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.roles" value="Metrologist"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
</label>
</div>
</div>
<!-- Admin + Preferences Row -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- Is Admin -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.is_admin"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm font-medium text-[var(--text-primary)]">{{ _('Amministratore') }}</span>
</label>
</div>
<!-- Language -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Lingua') }}</label>
<select x-model="form.language_pref"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="it">{{ _('Italiano') }}</option>
<option value="en">{{ _('English') }}</option>
</select>
</div>
<!-- Theme -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Tema') }}</label>
<select x-model="form.theme_pref"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
<option value="light">{{ _('Chiaro') }}</option>
<option value="dark">{{ _('Scuro') }}</option>
</select>
</div>
</div>
<!-- Error Message -->
<template x-if="errorMsg">
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
</div>
</template>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
<button @click="closeModal()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="saveUser()"
:disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
<span x-show="!saving" x-text="isEditing ? '{{ _('Salva Modifiche') }}' : '{{ _('Crea Utente') }}'"></span>
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span>
</button>
</div>
</div>
</div>
<!-- Confirm Toggle Modal -->
<div x-show="showToggleConfirm" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50" @click="showToggleConfirm = false"></div>
<div x-show="showToggleConfirm"
x-transition
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2"
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
<span x-text="toggleUser?.active
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
<strong x-text="toggleUser?.username"></strong>?
</p>
<div class="flex justify-end gap-3">
<button @click="showToggleConfirm = false"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
{{ _('Annulla') }}
</button>
<button @click="confirmToggle()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm disabled:opacity-50 transition-colors"
:class="toggleUser?.active ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'">
<span x-text="toggleUser?.active ? '{{ _('Disattiva') }}' : '{{ _('Riattiva') }}'"></span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function userManagement(initialUsers) {
return {
users: initialUsers || [],
search: '',
showModal: false,
showToggleConfirm: false,
isEditing: false,
editingUserId: null,
toggleUser: null,
saving: false,
errorMsg: '',
form: {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredUsers() {
if (!this.search.trim()) return this.users;
const q = this.search.toLowerCase();
return this.users.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
);
},
openCreateModal() {
this.isEditing = false;
this.editingUserId = null;
this.errorMsg = '';
this.form = {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
};
this.showModal = true;
},
openEditModal(user) {
this.isEditing = true;
this.editingUserId = user.id;
this.errorMsg = '';
this.form = {
username: user.username,
display_name: user.display_name || '',
email: user.email || '',
password: '',
roles: [...(user.roles || [])],
is_admin: user.is_admin || false,
language_pref: user.language_pref || 'it',
theme_pref: user.theme_pref || 'light'
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveUser() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
// Update user
const data = {
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nel salvataggio';
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.editingUserId);
if (idx >= 0) this.users[idx] = result;
// Change password if provided
if (this.form.password) {
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.form.password })
});
if (!pwResp.ok) {
const pwResult = await pwResp.json();
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
return;
}
}
} else {
// Create user
if (!this.form.username || !this.form.display_name || !this.form.password) {
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
return;
}
const data = {
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nella creazione';
return;
}
this.users.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
},
async confirmToggle() {
if (!this.toggleUser) return;
this.saving = true;
try {
const newActive = !this.toggleUser.active;
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ active: newActive })
});
const result = await resp.json();
if (!resp.ok) {
alert(result.detail || 'Errore');
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
if (idx >= 0) this.users[idx] = result;
this.showToggleConfirm = false;
this.toggleUser = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
}
};
}
</script>
{% endblock %}
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Login — TieMeasureFlow{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- Card -->
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
<!-- Logo -->
<div class="text-center mb-8">
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
alt="TieMeasureFlow Logo"
class="mx-auto h-16 w-auto mb-4"
onerror="this.style.display='none'">
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
TieMeasureFlow
</h2>
<p class="text-sm text-slate-600 dark:text-slate-400">
{{ _('Accedi al sistema') }}
</p>
</div>
<!-- Login Form -->
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Username -->
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Username') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text"
id="username"
name="username"
required
autofocus
placeholder="{{ _('Username') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Password') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password"
id="password"
name="password"
required
placeholder="{{ _('Password') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
{{ _('Accedi') }}
</button>
</div>
</form>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ _('Hai dimenticato la password?') }}
<br>
{{ _('Contatta l\'amministratore') }}
</p>
</div>
</div>
<!-- Footer -->
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
<p>TieMeasureFlow &copy; 2025 - {{ _('Sistema di misurazione industriale') }}</p>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
<!-- Page Header -->
<div class="mb-8">
<div class="flex items-center space-x-3">
<svg class="h-8 w-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<h1 class="text-3xl font-bold text-slate-900 dark:text-white">
{{ _('Profilo Utente') }}
</h1>
</div>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{{ _('Gestisci le tue informazioni e preferenze') }}
</p>
</div>
<!-- Profile Card -->
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl overflow-hidden">
<form method="POST" action="{{ url_for('auth.profile') }}" class="divide-y divide-slate-200 dark:divide-slate-700">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Account Information Section -->
<div class="p-6 sm:p-8">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
{{ _('Informazioni Account') }}
</h2>
<div class="space-y-6">
<!-- Username (readonly) -->
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Username') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text"
id="username"
value="{{ user.username if user else '' }}"
disabled
class="block w-full pl-10 pr-3 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-900 text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ _('Il nome utente non può essere modificato') }}
</p>
</div>
<!-- Display Name -->
<div>
<label for="display_name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Nome Visualizzato') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<input type="text"
id="display_name"
name="display_name"
value="{{ user.display_name if user else '' }}"
placeholder="{{ _('Nome da visualizzare nell\'interfaccia') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Roles (readonly badges) -->
{% if user and user.roles %}
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Ruoli') }}
</label>
<div class="flex flex-wrap gap-2">
{% for role in user.roles %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium
{% if role == 'Maker' %}
bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-200 border border-indigo-200 dark:border-indigo-800
{% elif role == 'MeasurementTec' %}
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
{% elif role == 'Metrologist' %}
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
{% else %}
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
{% endif %}">
<svg class="h-3 w-3 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{{ _(role) }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Preferences Section -->
<div class="p-6 sm:p-8">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
{{ _('Preferenze') }}
</h2>
<div class="space-y-6">
<!-- Language Preference -->
<div>
<label for="language_pref" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Lingua Preferita') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
</div>
<select id="language_pref"
name="language_pref"
class="block w-full pl-10 pr-10 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white transition-colors">
<option value="it" {% if user and user.language_pref == 'it' %}selected{% endif %}>{{ _('Italiano') }}</option>
<option value="en" {% if user and user.language_pref == 'en' %}selected{% endif %}>{{ _('English') }}</option>
</select>
</div>
</div>
<!-- Theme Preference -->
<div>
<label for="theme_pref" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Tema Preferito') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<select id="theme_pref"
name="theme_pref"
class="block w-full pl-10 pr-10 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white transition-colors">
<option value="light" {% if user and user.theme_pref == 'light' %}selected{% endif %}>{{ _('Chiaro') }}</option>
<option value="dark" {% if user and user.theme_pref == 'dark' %}selected{% endif %}>{{ _('Scuro') }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="px-6 py-4 sm:px-8 bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between">
<a href="/"
class="inline-flex items-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ _('Indietro') }}
</a>
<button type="submit"
class="inline-flex items-center px-6 py-2 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ _('Salva Modifiche') }}
</button>
</div>
</form>
</div>
<!-- Account Actions -->
<div class="mt-8 bg-white dark:bg-slate-800 shadow-lg rounded-xl overflow-hidden">
<div class="p-6 sm:p-8">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{{ _('Azioni Account') }}
</h2>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-slate-900 dark:text-white">{{ _('Esci dal sistema') }}</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ _('Termina la sessione corrente e torna alla schermata di login') }}
</p>
<div class="mt-3">
<a href="{{ url_for('auth.logout') }}"
class="inline-flex items-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg shadow-sm text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-slate-800 hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200">
{{ _('Logout') }}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="{{ current_language|default('it') }}"
:class="{ 'dark': $store.theme.dark }"
class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="TieMeasureFlow - Sistema di gestione misurazioni industriali">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}TieMeasureFlow{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/tmflow-icon.svg') }}">
<!-- Google Fonts: Inter + JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- TailwindCSS (compiled) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/tailwind.css') }}">
<!-- Theme CSS Variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
<!-- Alpine.js Theme Init (before Alpine loads) -->
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
<!-- Alpine.js App Wrapper -->
<div x-data class="min-h-full flex flex-col">
<!-- Navbar -->
{% include "components/navbar.html" %}
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="fixed top-16 right-4 z-50 flex flex-col gap-2 max-w-md w-full"
x-data
x-init="setTimeout(() => { $el.remove() }, 8000)">
{% for category, message in messages %}
<div x-data="{ show: true }"
x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-4"
class="flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border
{% if category == 'success' %}
bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200
{% elif category == 'error' %}
bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200
{% elif category == 'warning' %}
bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200
{% else %}
bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200
{% endif %}">
<!-- Icon -->
{% if category == 'success' %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif category == 'error' %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif category == 'warning' %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
{% else %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% endif %}
<span class="text-sm font-medium flex-1">{{ message }}</span>
<!-- Dismiss -->
<button @click="show = false" class="shrink-0 hover:opacity-70 transition-opacity touch-target">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content -->
<main class="flex-1">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic
</p>
</footer>
</div>
{% block extra_js %}{% endblock %}
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</body>
</html>
@@ -0,0 +1,159 @@
<!--
Barcode Scanner Modal Component
Camera-based barcode/QR code scanner
Include in pages where barcode scanning is needed
-->
<div x-data="barcodeScanner()"
@barcode-use.window="stopScan(); clear()"
x-init="$watch('scanning', val => console.log('Scanning:', val))"
x-cloak>
<!-- Trigger button (styled slot - customize per use case) -->
<slot name="trigger">
<button @click="startScan()"
type="button"
class="inline-flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors duration-200 shadow-sm hover:shadow-md">
<!-- Barcode icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
</svg>
<span>{{ _('Scansiona Barcode') }}</span>
</button>
</slot>
<!-- Modal overlay -->
<div x-show="scanning || result"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
style="display: none;">
<div @click.outside="scanning && !result ? null : (stopScan(), clear())"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full overflow-hidden">
<!-- Header -->
<div class="flex justify-between items-center px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
{{ _('Scansiona Barcode') }}
</h3>
</div>
<button @click="stopScan(); clear()"
type="button"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Content -->
<div class="p-6">
<!-- Camera preview container -->
<div x-show="scanning && !result"
class="relative rounded-xl overflow-hidden bg-slate-900 aspect-square mb-4">
<!-- Scanner element -->
<div id="barcode-reader" class="w-full h-full"></div>
<!-- Scanning indicator -->
<div class="absolute inset-0 pointer-events-none">
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-64 h-64 border-2 border-white/50 rounded-lg"></div>
</div>
</div>
<!-- Instruction overlay -->
<div class="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<p class="text-white text-sm text-center font-medium">
{{ _('Inquadra il codice a barre nel riquadro') }}
</p>
</div>
</div>
<!-- Success result -->
<div x-show="result"
x-transition
class="text-center py-8">
<!-- Success icon -->
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<p class="text-sm text-slate-600 dark:text-slate-400 mb-3">
{{ _('Codice rilevato') }}
</p>
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-lg p-4 mb-4">
<p class="font-mono text-xl font-bold text-primary break-all" x-text="result"></p>
</div>
</div>
<!-- Error message -->
<div x-show="error"
x-transition
class="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
</div>
</div>
<!-- Action buttons -->
<div class="flex gap-3">
<button x-show="result"
@click="$dispatch('barcode-use', { code: result }); stopScan(); clear()"
type="button"
class="flex-1 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md">
{{ _('Usa Codice') }}
</button>
<button x-show="scanning && !result"
@click="stopScan()"
type="button"
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
{{ _('Ferma') }}
</button>
<button x-show="!scanning || result"
@click="stopScan(); clear()"
type="button"
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
{{ _('Chiudi') }}
</button>
</div>
</div>
</div>
</div>
<script>
window.BARCODE_I18N = {
scanner_lib_not_loaded: "{{ _('Libreria scanner non caricata') }}",
no_camera_available: "{{ _('Nessuna fotocamera disponibile') }}",
camera_access_error: "{{ _('Impossibile accedere alla fotocamera') }}"
};
</script>
</div>
@@ -0,0 +1,63 @@
<!--
Caliper Status Indicator - Passive HID Monitor
Shows USB caliper reading feedback without Web Serial.
Flashes green when a caliper reading is detected via burst timing.
-->
<div x-data="caliperStatus()"
class="inline-block">
<!-- Passive indicator (no connect/disconnect button needed) -->
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-300"
:class="active
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700 shadow-md shadow-green-100 dark:shadow-green-900/30'
: 'bg-[var(--bg-card)] border-[var(--border-color)]'"
:title="readingCount > 0
? '{{ _('Letture calibro') }}: ' + readingCount
: '{{ _('Calibro USB') }}'">
<!-- Status dot -->
<span class="relative flex h-2.5 w-2.5">
<!-- Ping animation when active -->
<span x-show="active"
x-transition
class="absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75 animate-ping"></span>
<!-- Static dot -->
<span class="relative inline-flex rounded-full h-2.5 w-2.5 transition-colors duration-300"
:class="active
? 'bg-green-500'
: readingCount > 0
? 'bg-green-400'
: 'bg-slate-400 dark:bg-slate-500'
"></span>
</span>
<!-- Caliper icon -->
<svg class="w-4 h-4 transition-colors duration-300"
:class="active ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<!-- Label -->
<span class="text-xs font-medium transition-colors duration-300"
:class="active ? 'text-green-700 dark:text-green-300' : 'text-slate-600 dark:text-slate-400'"
x-text="active ? '{{ _('Lettura calibro') }}' : '{{ _('Calibro USB') }}'"></span>
<!-- Last reading value (shows when active or when there's a recent reading) -->
<span x-show="lastReading !== null"
x-transition
class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded transition-colors duration-300"
:class="active
? 'text-green-700 dark:text-green-200 bg-green-100 dark:bg-green-800/40'
: 'text-primary bg-blue-50 dark:bg-blue-900/30'"
x-text="lastReading?.toFixed(3) + ' mm'"></span>
<!-- Reading counter badge -->
<span x-show="readingCount > 0 && !active"
x-transition
class="text-[10px] font-mono text-slate-400 dark:text-slate-500"
x-text="'(' + readingCount + ')'"></span>
</div>
</div>
@@ -0,0 +1,76 @@
{# Measurement Feedback Component
Mostra feedback visivo in tempo reale della misurazione corrente.
Variabili Alpine.js richieste nel parent component:
- currentValue: float | null - valore corrente misurato
- nominal: float - valore nominale
- utl: float - Upper Tolerance Limit
- uwl: float - Upper Warning Limit
- lwl: float - Lower Warning Limit
- ltl: float - Lower Tolerance Limit
- unit: string - unità di misura (es. "mm")
- passFailStatus: computed - 'pass' | 'warning' | 'fail'
- deviation: computed - scostamento dal nominale
- progressWidth: computed - larghezza barra percentuale
#}
<div class="measurement-feedback" x-show="currentValue !== null">
<!-- Status bar colorata -->
<div class="h-3 rounded-full overflow-hidden bg-slate-200 dark:bg-slate-600">
<div class="h-full rounded-full transition-all duration-300"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"
:style="'width: ' + progressWidth + '%'">
</div>
</div>
<!-- Status text + deviation -->
<div class="flex justify-between items-center mt-2">
<div class="flex items-center gap-2">
<!-- Icona stato - CONFORME -->
<template x-if="passFailStatus === 'pass'">
<span class="flex items-center gap-1 text-measure-pass font-semibold text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
</svg>
{{ _('CONFORME') }}
</span>
</template>
<!-- Icona stato - ATTENZIONE -->
<template x-if="passFailStatus === 'warning'">
<span class="flex items-center gap-1 text-measure-warning font-semibold text-sm">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/>
</svg>
{{ _('ATTENZIONE') }}
</span>
</template>
<!-- Icona stato - NON CONFORME -->
<template x-if="passFailStatus === 'fail'">
<span class="flex items-center gap-1 text-measure-fail font-semibold text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('NON CONFORME') }}
</span>
</template>
</div>
<!-- Deviation con segno -->
<span class="font-mono text-sm font-medium"
:class="{
'text-measure-pass': passFailStatus === 'pass',
'text-measure-warning': passFailStatus === 'warning',
'text-measure-fail': passFailStatus === 'fail'
}"
x-text="deviation !== null ?
(deviation >= 0 ? '+' : '') + deviation.toFixed(3) + ' ' + unit : ''">
</span>
</div>
</div>
@@ -0,0 +1,307 @@
<!-- TieMeasureFlow Navbar -->
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
x-data="{ mobileOpen: false, userDropdown: false }">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- Left: Logo -->
<div class="flex items-center gap-3">
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
{% if company_logo %}
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
{% else %}
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
alt="TieMeasureFlow"
class="h-8 w-auto">
{% endif %}
</a>
</div>
<!-- Center: Navigation Links (Desktop) -->
{% if current_user %}
<div class="hidden md:flex items-center gap-1">
{# MeasurementTec: Misure #}
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('measure.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Clipboard Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
<span>{{ _('Misure') }}</span>
</a>
{% endif %}
{# Maker: Ricette #}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('maker.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Edit/Pencil Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
<span>{{ _('Ricette') }}</span>
</a>
{% endif %}
{# Metrologist: Statistiche #}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Chart Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span>{{ _('Statistiche') }}</span>
</a>
{% endif %}
{# Admin: Utenti #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.user_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Users Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<span>{{ _('Utenti') }}</span>
</a>
<a href="{{ url_for('admin.station_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.station_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Station / Workstation Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
<span>{{ _('Stazioni') }}</span>
</a>
{% endif %}
</div>
{% endif %}
<!-- Right: Controls -->
<div class="flex items-center gap-2">
<!-- Language Toggle -->
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
<a href="{{ url_for('set_language', lang='it') }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'it' %}
bg-primary text-white
{% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}">
IT
</a>
<a href="{{ url_for('set_language', lang='en') }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'en' %}
bg-primary text-white
{% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}">
EN
</a>
</div>
<!-- Theme Toggle -->
<button @click="$store.theme.toggle()"
class="p-2 rounded-lg text-[var(--text-secondary)] hover:text-primary
hover:bg-[var(--bg-secondary)] transition-colors duration-200"
:title="$store.theme.dark ? 'Tema chiaro' : 'Tema scuro'">
<!-- Sun (shown in dark mode) -->
<svg x-show="$store.theme.dark" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<!-- Moon (shown in light mode) -->
<svg x-show="!$store.theme.dark" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
<!-- User Menu (when logged in) -->
{% if current_user %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open"
class="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<!-- Avatar -->
<div class="w-7 h-7 rounded-full bg-primary/10 border border-primary/20
flex items-center justify-center text-primary font-semibold text-xs">
{{ current_user.get('display_name', current_user.get('username', '?'))[0]|upper }}
</div>
<span class="hidden sm:block text-sm font-medium text-[var(--text-primary)] max-w-[120px] truncate">
{{ current_user.get('display_name', current_user.get('username', '')) }}
</span>
<svg class="w-3.5 h-3.5 text-[var(--text-secondary)] transition-transform duration-200"
:class="{ 'rotate-180': open }"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- User Dropdown -->
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-52 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
shadow-lg py-1 z-50">
<!-- User Info Header -->
<div class="px-4 py-2.5 border-b border-[var(--border-color)]">
<p class="text-sm font-semibold text-[var(--text-primary)] truncate">
{{ current_user.get('display_name', current_user.get('username', '')) }}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
{{ current_user.get('roles', [])|join(', ') }}
</p>
</div>
<a href="{{ url_for('auth.profile') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
{{ _('Profilo') }}
</a>
<div class="border-t border-[var(--border-color)] my-1"></div>
<a href="{{ url_for('auth.logout') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
{{ _('Logout') }}
</a>
</div>
</div>
{% endif %}
<!-- Mobile Hamburger (when logged in) -->
{% if current_user %}
<button @click="mobileOpen = !mobileOpen"
class="md:hidden p-2 rounded-lg text-[var(--text-secondary)]
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<svg x-show="!mobileOpen" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg x-show="mobileOpen" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{% endif %}
</div>
</div>
</div>
<!-- Mobile Menu -->
{% if current_user %}
<div x-show="mobileOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
x-cloak
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
<div class="px-4 py-3 space-y-1">
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Misure') }}
</a>
{% endif %}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{{ _('Ricette') }}
</a>
{% endif %}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
{{ _('Statistiche') }}
</a>
{% endif %}
{# Admin: Utenti + Stazioni #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
{{ _('Utenti') }}
</a>
<a href="{{ url_for('admin.station_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
{{ _('Stazioni') }}
</a>
{% endif %}
</div>
</div>
{% endif %}
</nav>
@@ -0,0 +1,32 @@
{# Next Measurement Component
Indicatore della prossima misura da effettuare.
Variabili Alpine.js richieste nel parent component:
- nextSubtask: object | null - prossimo subtask da misurare
- marker_number: int
- description: string
- nominal: float
- unit: string
#}
<div x-show="nextSubtask" class="mt-4 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-2 text-sm text-steel dark:text-steel-light">
<!-- Arrow right icon -->
<svg class="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
</svg>
<span class="font-medium">{{ _('Prossima misura') }}:</span>
<span class="font-semibold text-[var(--text-primary)]">
#<span x-text="nextSubtask?.marker_number"></span>
<span x-text="nextSubtask?.description" class="ml-1"></span>
</span>
</div>
<!-- Nominal preview -->
<div class="mt-1 ml-6 text-xs text-steel dark:text-steel-light">
<span>Nom: </span>
<span class="font-mono font-medium" x-text="nextSubtask?.nominal?.toFixed(3)"></span>
<span class="ml-1" x-text="nextSubtask?.unit || 'mm'"></span>
</div>
</div>
@@ -0,0 +1,121 @@
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
<div
x-data="numpad()"
@keydown.window="handleKeydown($event)"
class="numpad-container w-full max-w-sm mx-auto"
>
<!-- Value Display -->
<div class="value-display mb-3 p-4 bg-white dark:bg-slate-700 rounded-lg border-2 border-slate-200 dark:border-slate-600 shadow-sm">
<div class="flex items-baseline justify-center">
<span
x-text="displayValue || '0'"
class="font-mono text-3xl font-bold text-slate-900 dark:text-white"
></span>
<span
x-text="unit"
class="ml-2 text-sm font-medium text-steel dark:text-steel-light"
></span>
</div>
</div>
<!-- Keypad Grid 4x4 -->
<div class="grid grid-cols-4 gap-2">
<!-- Row 1: 7 8 9 ⌫ -->
<button
@click="addDigit('7')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>7</button>
<button
@click="addDigit('8')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>8</button>
<button
@click="addDigit('9')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>9</button>
<button
@click="backspace()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
></button>
<!-- Row 2: 4 5 6 C -->
<button
@click="addDigit('4')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>4</button>
<button
@click="addDigit('5')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>5</button>
<button
@click="addDigit('6')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>6</button>
<button
@click="clearAll()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-measure-fail hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>C</button>
<!-- Row 3: 1 2 3 +/ -->
<button
@click="addDigit('1')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>1</button>
<button
@click="addDigit('2')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>2</button>
<button
@click="addDigit('3')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>3</button>
<button
@click="toggleSign()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>+/</button>
<!-- Row 4: 0 . (spacer) ✓ -->
<button
@click="addDigit('0')"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>0</button>
<button
@click="addDecimal()"
type="button"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
>.</button>
<!-- Spacer -->
<div></div>
<button
@click="confirm()"
type="button"
:disabled="!hasValue"
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
></button>
</div>
</div>
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="mt-20 p-8 bg-[var(--bg-card)] rounded-xl shadow-lg text-center border border-red-200 dark:border-red-800">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-red-50 dark:bg-red-900/30 mb-6">
<svg class="w-8 h-8 text-measure-fail" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</div>
<h1 class="text-2xl font-bold text-measure-fail mb-4">
{{ _('Stazione non configurata') }}
</h1>
<p class="mb-4 text-[var(--text-primary)]">
{{ _('Questo client non ha impostato la variabile di ambiente STATION_CODE.') }}
</p>
<p class="mb-6 text-sm text-[var(--text-secondary)]">
{{ _('Contattare il responsabile IT: il file .env del container deve contenere STATION_CODE con il codice della stazione assegnata.') }}
</p>
{% if error %}
<pre class="text-xs text-left text-[var(--text-secondary)] bg-[var(--bg-secondary)]
p-3 rounded-lg border border-[var(--border-color)] overflow-auto">{{ error }}</pre>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,632 @@
{% extends "base.html" %}
{% block title %}
{% if recipe %}
{{ recipe.code }} — {{ _('Modifica Ricetta') }}
{% else %}
{{ _('Nuova Ricetta') }}
{% endif %}
— TieMeasureFlow
{% endblock %}
{% block extra_head %}
<style>
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 0.75rem;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.drop-zone.drag-over {
border-color: var(--color-primary);
background-color: rgba(37, 99, 235, 0.05);
}
.dark .drop-zone.drag-over {
background-color: rgba(59, 130, 246, 0.08);
}
</style>
{% endblock %}
{% block content %}
{# ——— Determine mode flags ——— #}
{% set is_new = (recipe is none) %}
{% set recipe_id = recipe.id if recipe else None %}
{% set current_version = recipe.current_version if recipe and recipe.current_version else None %}
{% set versions = recipe.versions if recipe and recipe.versions else [] %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
x-data="recipeEditor()"
x-cloak>
<!-- ============================================================
Breadcrumb
============================================================ -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Ricette') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{% if is_new %}
{{ _('Nuova Ricetta') }}
{% else %}
{{ recipe.code }}
{% endif %}
</li>
</ol>
</nav>
<!-- ============================================================
Header + Action Buttons
============================================================ -->
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div>
<h1 class="text-3xl font-bold text-[var(--text-primary)] mb-1">
{% if is_new %}
{{ _('Nuova Ricetta') }}
{% else %}
<span class="font-mono text-primary">{{ recipe.code }}</span>
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
(v<span x-text="currentVersion"></span>)
</span>
{% endif %}
</h1>
{% if not is_new %}
<p class="text-sm text-[var(--text-secondary)]" x-text="name"></p>
{% else %}
<p class="text-sm text-[var(--text-secondary)]">
{{ _('Compila i dati della ricetta') }}
</p>
{% endif %}
</div>
<!-- Top action buttons -->
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<button @click="saveRecipe()"
:disabled="saving || !name.trim() || (isNew && !code.trim())"
class="btn btn-primary gap-1.5">
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<span x-text="saving ? '{{ _('Salvataggio...') }}' : '{{ _('Salva') }}'"></span>
</button>
{% if not is_new %}
<a href="{{ url_for('maker.recipe_preview', recipe_id=recipe_id) }}"
class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</a>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe_id) }}"
class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Task') }}
</a>
{% endif %}
<a href="{{ url_for('maker.recipe_list') }}"
class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('Annulla') }}
</a>
</div>
</div>
</div>
<!-- ============================================================
Error Banner
============================================================ -->
<div x-show="errorMessage"
x-transition
class="mb-6 flex items-center gap-3 px-4 py-3 rounded-lg
bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800
text-red-800 dark:text-red-200 text-sm">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="errorMessage"></span>
<button @click="errorMessage = ''" class="ml-auto shrink-0 hover:opacity-70">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- ============================================================
Success Banner
============================================================ -->
<div x-show="saved"
x-transition
x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })"
class="mb-6 flex items-center gap-3 px-4 py-3 rounded-lg
bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800
text-emerald-800 dark:text-emerald-200 text-sm">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ _('Ricetta salvata con successo') }}</span>
</div>
<!-- ============================================================
1. Card: Metadati Ricetta
============================================================ -->
<div class="tmf-card mb-6">
<div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Metadati Ricetta') }}
</div>
<div class="tmf-card-body">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<!-- Codice -->
<div>
<label class="tmf-label" for="recipe-code">
{{ _('Codice') }} <span class="text-red-500">*</span>
</label>
<input id="recipe-code"
type="text"
x-model="code"
class="tmf-input font-mono tracking-wide"
:disabled="!isNew"
:class="{ 'opacity-60 cursor-not-allowed': !isNew }"
placeholder="{{ _('Es. COUPLING-256') }}"
required>
<p x-show="!isNew" class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Il codice non puo essere modificato dopo la creazione') }}
</p>
</div>
<!-- Nome -->
<div>
<label class="tmf-label" for="recipe-name">
{{ _('Nome') }} <span class="text-red-500">*</span>
</label>
<input id="recipe-name"
type="text"
x-model="name"
class="tmf-input"
placeholder="{{ _('Es. Coupling Assembly 256') }}"
required>
</div>
<!-- Descrizione -->
<div class="sm:col-span-2">
<label class="tmf-label" for="recipe-description">
{{ _('Descrizione') }}
</label>
<textarea id="recipe-description"
x-model="description"
class="tmf-input"
rows="3"
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
</div>
</div>
</div>
</div>
<!-- ============================================================
2. Card: Immagine Anteprima (thumbnail ricetta)
============================================================ -->
<div class="tmf-card mb-6">
<div class="tmf-card-header flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ _('Immagine Anteprima') }}
</div>
<span x-show="currentFilePath" class="badge badge-pass text-xs">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Immagine caricata') }}
</span>
</div>
<div class="tmf-card-body space-y-4">
<!-- Current image preview -->
<template x-if="currentFilePath">
<div class="space-y-3">
<div class="relative rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<img x-bind:src="'/maker/api/files/' + currentFilePath"
class="block mx-auto max-h-64 object-contain p-2"
:class="{ 'opacity-40': uploadingFile }"
loading="lazy">
<!-- Upload overlay on image -->
<div x-show="uploadingFile"
class="absolute inset-0 flex flex-col items-center justify-center bg-white/60 dark:bg-black/50">
<svg class="w-8 h-8 animate-spin text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<span class="text-sm font-medium text-[var(--text-primary)] mt-2">{{ _('Caricamento in corso...') }}</span>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 flex-wrap">
<label class="btn btn-secondary text-sm gap-1.5 cursor-pointer"
:class="{ 'opacity-50 pointer-events-none': uploadingFile }">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
{{ _('Sostituisci') }}
<input type="file"
accept="image/*,application/pdf"
class="hidden"
:disabled="uploadingFile"
@change="uploadFile($event.target.files[0]); $event.target.value = ''">
</label>
<button @click.stop="currentFilePath = ''"
type="button"
:disabled="uploadingFile"
class="btn btn-secondary text-sm gap-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{{ _('Rimuovi') }}
</button>
</div>
</div>
</template>
<!-- Upload area (only when no image) -->
<template x-if="!currentFilePath">
<div class="drop-zone p-6 text-center cursor-pointer relative"
:class="{ 'drag-over': dragOver }"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop($event)"
@click="$refs.fileInput.click()">
<input type="file"
x-ref="fileInput"
@change="uploadFile($event.target.files[0]); $event.target.value = ''"
accept="image/*,application/pdf"
class="hidden">
<!-- Uploading spinner -->
<div x-show="uploadingFile" class="flex flex-col items-center gap-3 py-4">
<svg class="w-8 h-8 animate-spin text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<span class="text-sm text-[var(--text-secondary)]">{{ _('Caricamento in corso...') }}</span>
</div>
<!-- Upload prompt -->
<div x-show="!uploadingFile" class="flex flex-col items-center gap-3 py-4">
<div class="w-12 h-12 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-primary" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
</div>
<div class="text-center">
<span class="inline-block px-4 py-2 rounded-lg bg-primary text-white text-sm font-semibold mb-2">
{{ _('Carica Immagine') }}
</span>
<p class="text-xs text-[var(--text-muted)]">
{{ _('Trascina qui oppure clicca. Formati: PNG, JPG, WebP, PDF (max 50MB)') }}
</p>
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Usata come thumbnail nella lista ricette') }}
</p>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- ============================================================
3. Card: Versioning (solo in modifica)
============================================================ -->
{% if not is_new %}
<div class="tmf-card mb-6">
<div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Versioning') }}
</div>
<div class="tmf-card-body space-y-4">
<!-- Current version info -->
<div class="flex items-center gap-3 flex-wrap">
<span class="text-sm text-[var(--text-secondary)]">
{{ _('Versione corrente:') }}
</span>
<span class="badge badge-neutral font-mono font-semibold"
x-text="'v' + currentVersion">
</span>
<template x-if="versions.length > 1">
<a href="{{ url_for('maker.version_history', recipe_id=recipe_id) }}"
class="text-xs text-primary hover:underline">
{{ _('Vedi cronologia') }} (<span x-text="versions.length"></span> {{ _('versioni') }})
</a>
</template>
</div>
<!-- Auto-version notice -->
<div class="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<svg class="w-5 h-5 text-blue-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-xs text-blue-700 dark:text-blue-300">
{{ _('Se modifichi questa ricetta verra creata automaticamente la versione') }}
<strong x-text="'v' + (currentVersion + 1)"></strong>.
{{ _('Le misure esistenti resteranno associate alla versione corrente.') }}
</p>
</div>
<!-- Change notes -->
<div>
<label class="tmf-label" for="change-notes">
{{ _('Motivo della modifica') }}
</label>
<input id="change-notes"
type="text"
x-model="changeNotes"
class="tmf-input"
placeholder="{{ _('Es. Aggiornate tolleranze foro centrale...') }}">
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Opzionale. Verra registrato nella cronologia versioni.') }}
</p>
</div>
</div>
</div>
{% endif %}
<!-- ============================================================
3. Footer Action Buttons
============================================================ -->
<div class="flex flex-col sm:flex-row items-center gap-3 pt-4 pb-8">
<button @click="saveRecipe()"
:disabled="saving || !name.trim() || (isNew && !code.trim())"
class="btn btn-primary gap-2 w-full sm:w-auto justify-center">
<svg x-show="!saving" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="saving" class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<span x-text="saving ? '{{ _('Salvataggio...') }}' : '{{ _('Salva Ricetta') }}'"></span>
</button>
<a href="{{ url_for('maker.recipe_list') }}"
class="btn btn-secondary gap-2 w-full sm:w-auto justify-center">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('Annulla') }}
</a>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function recipeEditor() {
return {
// ---- Mode ----
isNew: {{ 'true' if recipe is none else 'false' }},
recipeId: {{ recipe.id|tojson if recipe else 'null' }},
// ---- Form data ----
code: {{ (recipe.code if recipe else '')|tojson }},
name: {{ (recipe.name if recipe else '')|tojson }},
description: {{ (recipe.description if recipe and recipe.description else '')|tojson }},
changeNotes: '',
// ---- Save state ----
saving: false,
saved: false,
errorMessage: '',
// ---- Versioning ----
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- File upload (preview image) ----
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
uploadingFile: false,
dragOver: false,
// ============================================================
// Save Recipe
// ============================================================
async saveRecipe() {
this.saving = true;
this.errorMessage = '';
this.saved = false;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const payload = {
code: this.code.trim(),
name: this.name.trim(),
description: this.description.trim(),
};
// Include image_path for preview thumbnail
if (this.currentFilePath) {
payload.image_path = this.currentFilePath;
}
// Include change notes if editing
if (!this.isNew && this.changeNotes.trim()) {
payload.change_notes = this.changeNotes.trim();
}
const url = this.isNew
? '/maker/api/recipes'
: '/maker/api/recipes/' + this.recipeId;
const method = this.isNew ? 'POST' : 'PUT';
try {
const resp = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken || ''
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (data.error) {
var detail = data.detail || data.error || {{ _("Errore nel salvataggio")|tojson }};
this.errorMessage = (typeof detail === 'string') ? detail : JSON.stringify(detail);
this.saving = false;
return;
}
if (this.isNew) {
// Redirect to task editor after creating a new recipe
window.location.href = '/maker/recipes/' + data.id + '/tasks';
return;
}
// Update local state on successful edit
this.saved = true;
this.changeNotes = '';
// Update version info if server returned it
if (data.current_version) {
this.currentVersion = data.current_version.version_number;
}
// Refresh versions list if available
if (data.versions) {
this.versions = data.versions;
}
} catch (err) {
console.error('Save error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
// ============================================================
// Navigation
// ============================================================
goToPreview() {
if (this.recipeId) {
window.location.href = '/maker/recipes/' + this.recipeId + '/preview';
}
},
goToTasks() {
if (this.recipeId) {
window.location.href = '/maker/recipes/' + this.recipeId + '/tasks';
}
},
// ============================================================
// File Upload (thumbnail)
// ============================================================
handleDrop(event) {
this.dragOver = false;
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
this.uploadFile(files[0]);
}
},
async uploadFile(file) {
if (!file) return;
// Validate file type
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
if (!allowed.includes(file.type)) {
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG, GIF, WebP o PDF.")|tojson }};
return;
}
// Validate size (50MB)
if (file.size > 50 * 1024 * 1024) {
this.errorMessage = {{ _("File troppo grande. Massimo 50MB.")|tojson }};
return;
}
this.uploadingFile = true;
this.errorMessage = '';
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
try {
const formData = new FormData();
formData.append('file', file);
if (this.recipeId) {
formData.append('recipe_id', this.recipeId);
}
const resp = await fetch('/maker/api/upload', {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken || '' },
body: formData
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nel caricamento del file")|tojson }};
this.uploadingFile = false;
return;
}
this.currentFilePath = data.file_path;
} catch (err) {
console.error('Upload error:', err);
this.errorMessage = {{ _("Errore di connessione durante l'upload")|tojson }};
}
this.uploadingFile = false;
}
};
}
</script>
{% endblock %}
@@ -0,0 +1,396 @@
{% extends "base.html" %}
{% block title %}{{ _('Gestione Ricette') }} — TieMeasureFlow{% endblock %}
{% block content %}
<script>window.__recipeListData = {{ recipes|tojson }};</script>
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl"
x-data="{
recipes: window.__recipeListData,
search: '{{ search or '' }}',
filter: 'all',
deleteModal: false,
deleteTarget: null,
get filteredRecipes() {
let filtered = this.recipes;
// Apply search filter
if (this.search.trim()) {
const term = this.search.toLowerCase().trim();
filtered = filtered.filter(r =>
r.name.toLowerCase().includes(term) ||
r.code.toLowerCase().includes(term) ||
(r.description && r.description.toLowerCase().includes(term))
);
}
// Apply status filter
if (this.filter === 'active') {
filtered = filtered.filter(r => r.active);
} else if (this.filter === 'inactive') {
filtered = filtered.filter(r => !r.active);
}
return filtered;
},
async deleteRecipe(id) {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
try {
const resp = await fetch(`/maker/api/recipes/${id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken || '' }
});
if (resp.ok) {
this.recipes = this.recipes.filter(r => r.id !== id);
this.deleteModal = false;
this.deleteTarget = null;
} else {
const data = await resp.json();
alert(data.error || '{{ _('Errore durante eliminazione') }}');
}
} catch (err) {
console.error(err);
alert('{{ _('Errore di connessione') }}');
}
},
openDeleteModal(recipe) {
this.deleteTarget = recipe;
this.deleteModal = true;
}
}">
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
{{ _('Dashboard') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Ricette') }}
</li>
</ol>
</nav>
<!-- Header Section -->
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div>
<h1 class="text-3xl font-bold text-[var(--text-primary)] mb-1">
{{ _('Gestione Ricette') }}
</h1>
<p class="text-sm text-[var(--text-secondary)]">
{{ _('Crea e gestisci le ricette di misura') }}
</p>
</div>
<a href="{{ url_for('maker.recipe_new') }}"
class="btn btn-primary gap-2 shrink-0 w-full sm:w-auto justify-center">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Nuova Ricetta') }}
</a>
</div>
</div>
<!-- Filters -->
<div class="tmf-card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-3">
<!-- Search Input -->
<div class="flex-1">
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<input type="text"
x-model="search"
class="tmf-input pl-10"
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
</div>
</div>
<!-- Status Filter -->
<div class="sm:w-48">
<select x-model="filter" class="tmf-input">
<option value="all">{{ _('Tutti') }}</option>
<option value="active">{{ _('Attive') }}</option>
<option value="inactive">{{ _('Disattivate') }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Results Count -->
<div class="mb-5">
<p class="text-sm text-[var(--text-secondary)]">
<span x-text="filteredRecipes.length"></span>
<span x-text="filteredRecipes.length === 1 ? '{{ _('ricetta trovata') }}' : '{{ _('ricette trovate') }}'"></span>
</p>
</div>
<!-- Recipe Cards -->
<div class="space-y-4" x-show="filteredRecipes.length > 0">
<template x-for="recipe in filteredRecipes" :key="recipe.id">
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
<div class="p-5 sm:p-6">
<!-- Card Header -->
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
<!-- Thumbnail -->
<template x-if="recipe.image_path">
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0">
<img :src="'/maker/api/files/' + recipe.image_path"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.parentElement.style.display='none'">
</div>
</template>
<!-- Left: Recipe Info -->
<div class="flex-1 min-w-0">
<!-- Badges Row -->
<div class="flex items-center gap-2 mb-3 flex-wrap">
<!-- Code Badge -->
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-semibold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800 font-mono tracking-wide"
x-text="recipe.code">
</span>
<!-- Version Badge -->
<template x-if="recipe.current_version">
<span class="badge badge-neutral font-mono"
x-text="'v' + recipe.current_version.version_number">
</span>
</template>
<!-- Status Badge -->
<span class="badge"
:class="recipe.active ? 'badge-pass' : 'badge-fail'"
x-text="recipe.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'">
</span>
</div>
<!-- Recipe Name -->
<h3 class="text-xl font-bold text-[var(--text-primary)] mb-2"
x-text="recipe.name">
</h3>
<!-- Description -->
<template x-if="recipe.description">
<p class="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-2 mb-3"
x-text="recipe.description">
</p>
</template>
<!-- Meta Information -->
<div class="flex items-center gap-4 flex-wrap text-xs text-[var(--text-secondary)]">
<!-- Task Count -->
<template x-if="recipe.current_version && recipe.current_version.task_count">
<span class="inline-flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span x-text="recipe.current_version.task_count"></span>
<span>{{ _('task') }}</span>
</span>
</template>
<!-- Updated Date -->
<template x-if="recipe.current_version && recipe.current_version.created_at">
<span class="inline-flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ _('Aggiornata') }}</span>
<span class="font-medium" x-text="new Date(recipe.current_version.created_at).toLocaleDateString('{{ current_language or 'it' }}')"></span>
</span>
</template>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-wrap pt-4 border-t border-[var(--border-color)]">
<!-- Modifica -->
<a :href="'/maker/recipes/' + recipe.id + '/edit'"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{{ _('Modifica') }}
</a>
<!-- Task -->
<a :href="'/maker/recipes/' + recipe.id + '/tasks'"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Task') }}
</a>
<!-- Anteprima -->
<a :href="'/maker/recipes/' + recipe.id + '/preview'"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</a>
<!-- Versioni -->
<a :href="'/maker/recipes/' + recipe.id + '/versions'"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Versioni') }}
</a>
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Elimina -->
<button @click="openDeleteModal(recipe)"
class="btn btn-danger gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{{ _('Elimina') }}
</button>
</div>
</div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="filteredRecipes.length === 0"
x-cloak
class="text-center py-16">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full
bg-[var(--bg-secondary)] mb-5">
<svg class="w-10 h-10 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
<span x-show="search.trim() || filter !== 'all'">
{{ _('Nessuna ricetta trovata') }}
</span>
<span x-show="!search.trim() && filter === 'all'">
{{ _('Nessuna ricetta disponibile') }}
</span>
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto mb-6">
<span x-show="search.trim() || filter !== 'all'">
{{ _('Prova a modificare i filtri di ricerca') }}
</span>
<span x-show="!search.trim() && filter === 'all'">
{{ _('Inizia creando la tua prima ricetta di misura') }}
</span>
</p>
<a href="{{ url_for('maker.recipe_new') }}"
x-show="!search.trim() && filter === 'all'"
class="btn btn-primary gap-2 inline-flex">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Crea Prima Ricetta') }}
</a>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="deleteModal"
x-cloak
@keydown.escape.window="deleteModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<!-- Backdrop -->
<div x-show="deleteModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="deleteModal = false"
class="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity">
</div>
<!-- Modal Content -->
<div class="flex min-h-full items-center justify-center p-4">
<div x-show="deleteModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="tmf-card max-w-lg w-full relative">
<div class="p-6">
<!-- Icon -->
<div class="mx-auto flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<!-- Title -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] text-center mb-2">
{{ _('Conferma Eliminazione') }}
</h3>
<!-- Message -->
<template x-if="deleteTarget">
<div>
<p class="text-sm text-[var(--text-secondary)] text-center mb-4">
{{ _('Sei sicuro di voler disattivare la ricetta') }}
<span class="font-semibold text-[var(--text-primary)]" x-text="deleteTarget.name"></span>?
</p>
<p class="text-xs text-[var(--text-muted)] text-center mb-6">
{{ _('Le misure esistenti non verranno eliminate') }}
</p>
</div>
</template>
<!-- Actions -->
<div class="flex flex-col sm:flex-row gap-3">
<button @click="deleteModal = false"
class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="deleteRecipe(deleteTarget.id)"
class="btn btn-danger flex-1 justify-center">
{{ _('Elimina') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,457 @@
{% extends "base.html" %}
{% block title %}{{ recipe.code }} — {{ _('Anteprima Ricetta') }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* Annotation viewer container */
.annotation-container {
position: relative;
width: 100%;
min-height: 300px;
background: var(--bg-secondary);
border-radius: 0.5rem;
overflow: hidden;
}
.annotation-container canvas {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
/* Tolerance bar gradient */
.tolerance-bar-bg {
background: linear-gradient(to right,
rgba(220, 38, 38, 0.15) 0%,
rgba(217, 119, 6, 0.15) 15%,
rgba(5, 150, 105, 0.15) 30%,
rgba(5, 150, 105, 0.15) 70%,
rgba(217, 119, 6, 0.15) 85%,
rgba(220, 38, 38, 0.15) 100%
);
}
</style>
{% endblock %}
{% block content %}
<script>
window.__taskAnnotations = {};
{% for task in recipe.tasks %}
{% if task.annotations_json %}
window.__taskAnnotations[{{ task.id }}] = {{ task.annotations_json|tojson }};
{% endif %}
{% endfor %}
</script>
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
x-data="recipePreview()"
x-cloak>
{# ================================================================
BREADCRUMB
================================================================ #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Ricette') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li>
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="hover:text-primary transition-colors">
{{ recipe.code }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Anteprima') }}
</li>
</ol>
</nav>
{# ================================================================
HEADER
================================================================ #}
<div class="mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div class="flex items-center gap-3 flex-wrap">
{# Preview badge #}
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider
bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300
border border-amber-300 dark:border-amber-700">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">
<span class="font-mono text-primary">{{ recipe.code }}</span>
<span class="text-[var(--text-muted)] text-lg font-normal ml-1">
&mdash; {{ recipe.name }}
</span>
</h1>
{% if recipe.current_version %}
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
{{ _('Versione') }} <span class="font-mono font-semibold">v{{ recipe.current_version.version_number }}</span>
</p>
{% endif %}
</div>
</div>
{# Action buttons #}
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-1.5 text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Torna a Ricetta') }}
</a>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-1.5 text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{{ _('Modifica Task') }}
</a>
</div>
</div>
</div>
{# ================================================================
PREVIEW BANNER
================================================================ #}
<div class="mb-6 flex items-start gap-3 px-4 py-3 rounded-lg
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
<svg class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
{{ _('Modalita Anteprima') }}
</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-0.5">
{{ _('Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura.') }}
</p>
</div>
</div>
{# ================================================================
RECIPE DESCRIPTION (if present)
================================================================ #}
{% if recipe.description %}
<div class="tmf-card mb-6">
<div class="tmf-card-body">
<p class="text-sm text-[var(--text-secondary)] leading-relaxed">{{ recipe.description }}</p>
</div>
</div>
{% endif %}
{# ================================================================
TASKS LOOP
================================================================ #}
{% if recipe.tasks %}
{% for task in recipe.tasks %}
<div class="tmf-card mb-6">
{# ---- Task Header ---- #}
<div class="tmf-card-header flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800
text-sm font-bold">
{{ loop.index }}
</span>
<div>
<h2 class="font-semibold text-[var(--text-primary)]">{{ task.title }}</h2>
{% if task.directive or task.description %}
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
{{ task.directive or task.description }}
</p>
{% endif %}
</div>
</div>
{% if task.subtasks %}
<span class="badge badge-neutral text-xs font-mono">
{{ task.subtasks|length }} {{ _('misure') }}
</span>
{% endif %}
</div>
<div class="tmf-card-body space-y-5">
{# ---- Technical Drawing / Annotations ---- #}
{% if task.file_path and task.file_type == 'image' %}
<div>
<div class="flex items-center gap-2 mb-2 text-xs font-medium text-[var(--text-secondary)]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ _('Disegno Tecnico') }}
</div>
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/maker/api/files/{{ task.file_path }}';
annotations = window.__taskAnnotations[{{ task.id }}] || null;
$nextTick(() => init());
">
<canvas x-ref="annotationCanvas"></canvas>
</div>
</div>
{% elif task.file_path and task.file_type == 'pdf' %}
<div class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<svg class="w-10 h-10 text-red-400 shrink-0" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<div class="flex-1">
<p class="text-sm font-medium text-[var(--text-primary)]">{{ _('Documento PDF allegato') }}</p>
<p class="text-xs text-[var(--text-muted)]">{{ task.file_path }}</p>
</div>
<a href="/maker/api/files/{{ task.file_path }}"
target="_blank"
class="btn btn-secondary text-xs gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{{ _('Apri PDF') }}
</a>
</div>
{% else %}
<div class="flex items-center justify-center p-6 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<div class="text-center">
<svg class="w-10 h-10 mx-auto text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
</div>
</div>
{% endif %}
{# ---- Subtask List ---- #}
{% if task.subtasks %}
<div>
<div class="flex items-center gap-2 mb-3 text-xs font-medium text-[var(--text-secondary)]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
{{ _('Punti di Misura') }}
</div>
<div class="space-y-3">
{% for subtask in task.subtasks %}
<div class="p-4 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
hover:border-primary/30 transition-colors">
{# Subtask header: marker badge + description #}
<div class="flex items-start gap-3 mb-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full shrink-0
bg-primary text-white font-bold text-sm shadow-sm">
{{ subtask.marker_number }}
</span>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm text-[var(--text-primary)]">{{ subtask.description }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-[var(--text-muted)]">
{% if subtask.measurement_type %}
<span>{{ subtask.measurement_type }}</span>
<span>&middot;</span>
{% endif %}
<span>{{ subtask.unit or 'mm' }}</span>
</div>
</div>
</div>
{# Tolerance parameters grid #}
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
{# Nominal #}
<div class="col-span-2 sm:col-span-1 flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-primary-50 dark:bg-primary-900/20
border border-primary-100 dark:border-primary-800">
<span class="text-[10px] font-medium text-primary-600 dark:text-primary-400 uppercase tracking-wider">
{{ _('Nominale') }}
</span>
<span class="measure-value text-sm font-bold text-primary">
{% if subtask.nominal is not none %}{{ "%.3f"|format(subtask.nominal) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# LTL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-red-50/50 dark:bg-red-900/10">
<span class="text-[10px] font-medium text-measure-fail uppercase tracking-wider">LTL</span>
<span class="measure-value text-xs font-semibold text-measure-fail">
{% if subtask.ltl is not none %}{{ "%.3f"|format(subtask.ltl) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# LWL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-[10px] font-medium text-measure-warning uppercase tracking-wider">LWL</span>
<span class="measure-value text-xs font-semibold text-measure-warning">
{% if subtask.lwl is not none %}{{ "%.3f"|format(subtask.lwl) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# UWL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-[10px] font-medium text-measure-warning uppercase tracking-wider">UWL</span>
<span class="measure-value text-xs font-semibold text-measure-warning">
{% if subtask.uwl is not none %}{{ "%.3f"|format(subtask.uwl) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# UTL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-red-50/50 dark:bg-red-900/10">
<span class="text-[10px] font-medium text-measure-fail uppercase tracking-wider">UTL</span>
<span class="measure-value text-xs font-semibold text-measure-fail">
{% if subtask.utl is not none %}{{ "%.3f"|format(subtask.utl) }}{% else %}&mdash;{% endif %}
</span>
</div>
</div>
{# Visual tolerance bar #}
{% if subtask.ltl is not none and subtask.utl is not none %}
<div class="mt-2">
<div class="tolerance-bar-bg h-2 rounded-full relative overflow-hidden border border-[var(--border-color)]">
{# Nominal center line #}
<div class="absolute top-0 bottom-0 w-px bg-primary/40" style="left: 50%;"></div>
</div>
<div class="flex justify-between mt-0.5 text-[9px] font-mono text-[var(--text-muted)]">
<span>{{ "%.2f"|format(subtask.ltl) }}</span>
<span>{{ "%.2f"|format(subtask.nominal or 0) }}</span>
<span>{{ "%.2f"|format(subtask.utl) }}</span>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center py-4">
<p class="text-sm text-[var(--text-muted)]">{{ _('Nessun punto di misura definito per questo task') }}</p>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
{# ---- Empty state: no tasks ---- #}
<div class="tmf-card">
<div class="p-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-[var(--bg-secondary)] mb-4">
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
{{ _('Nessun task definito') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Questa ricetta non contiene ancora task di misurazione.') }}
</p>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
class="btn btn-primary gap-2 inline-flex">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
{{ _('Aggiungi Task') }}
</a>
</div>
</div>
{% endif %}
{# ================================================================
FOOTER NAVIGATION
================================================================ #}
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pt-6 pb-8">
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-2 w-full sm:w-auto justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Torna a Ricetta') }}
</a>
<div class="flex items-center gap-2 w-full sm:w-auto">
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
class="btn btn-primary gap-2 flex-1 sm:flex-initial justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{{ _('Modifica Task') }}
</a>
<a href="{{ url_for('maker.version_history', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-2 flex-1 sm:flex-initial justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Storico Versioni') }}
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script>
/**
* Recipe Preview - Minimal Alpine.js component
* Read-only preview mode, no editing capabilities.
*/
function recipePreview() {
return {
recipe: {{ recipe|tojson }},
tasks: {{ recipe.tasks|tojson if recipe.tasks else '[]' }},
init() {
// Nothing to do - this is a read-only preview.
// annotation-viewer.js components are initialized inline via x-init.
}
};
}
</script>
{% endblock %}
@@ -0,0 +1,537 @@
{% extends "base.html" %}
{% block title %}{{ _('Disegno Task') }} #{{ (task.order_index or 0) + 1 }} — {{ recipe.code }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* Annotation toolbar button */
.anno-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
gap: 0.25rem;
}
.anno-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-muted);
}
.anno-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.anno-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Canvas container — use custom class name to avoid conflict with
Fabric.js internal .canvas-container wrapper div */
.anno-canvas-wrap {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
min-height: 400px;
}
.anno-canvas-wrap canvas {
display: block;
}
/* Fabric.js internal wrapper also needs display:block on canvases */
.canvas-container canvas {
display: block;
}
</style>
{% endblock %}
{% block content %}
<script>
window.__taskData = {{ task|tojson }};
window.__i18n_taskDrawing = {
saving: {{ _("Salvataggio...")|tojson }},
saveAnnotations: {{ _("Salva Annotazioni")|tojson }},
saveError: {{ _("Errore nel salvataggio delle annotazioni")|tojson }},
saveSuccess: {{ _("Annotazioni salvate con successo")|tojson }},
connectionError: {{ _("Errore di connessione al server")|tojson }}
};
</script>
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
x-data="taskDrawing()"
x-cloak>
<!-- ============================================================
Breadcrumb
============================================================ -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Ricette') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li>
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="hover:text-primary transition-colors">
{{ recipe.code }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
class="hover:text-primary transition-colors">
{{ _('Task') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Disegno Task') }} #{{ (task.order_index or 0) + 1 }}
</li>
</ol>
</nav>
<!-- ============================================================
Header
============================================================ -->
<div class="mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-1">
{{ _('Annotazioni Disegno') }}
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
Task #{{ (task.order_index or 0) + 1 }}
</span>
</h1>
<p class="text-sm text-[var(--text-secondary)]">{{ task.title or '' }}</p>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<button @click="saveAnnotations()"
:disabled="saving"
class="btn btn-primary gap-1.5">
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<span x-text="saving ? window.__i18n_taskDrawing.saving : window.__i18n_taskDrawing.saveAnnotations"></span>
</button>
<a :href="'{{ url_for('maker.task_editor', recipe_id=recipe.id) }}' + '?_=' + Date.now()"
class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
</svg>
{{ _('Torna ai Task') }}
</a>
</div>
</div>
</div>
<!-- ============================================================
Error/Success Banners
============================================================ -->
<div x-show="errorMessage"
x-transition
class="mb-4 flex items-center gap-3 px-4 py-3 rounded-lg
bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800
text-red-800 dark:text-red-200 text-sm">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="errorMessage"></span>
<button @click="errorMessage = ''" class="ml-auto shrink-0 hover:opacity-70">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div x-show="successMessage"
x-transition
x-init="$watch('successMessage', v => { if (v) setTimeout(() => successMessage = '', 4000) })"
class="mb-4 flex items-center gap-3 px-4 py-3 rounded-lg
bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800
text-emerald-800 dark:text-emerald-200 text-sm">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="successMessage"></span>
</div>
<!-- ============================================================
Annotation Toolbar
============================================================ -->
<div class="tmf-card mb-4">
<div class="p-3 space-y-2">
<!-- Row 1: Drawing Tools -->
<div class="flex flex-wrap items-center gap-1.5">
<!-- Select Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'select' }"
@click="setAnnoTool('select')"
title="{{ _('Seleziona') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 15l-2 5L9 9l11 4-5 2z"/>
</svg>
<span class="hidden sm:inline">{{ _('Seleziona') }}</span>
</button>
<!-- Marker Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'marker' }"
@click="setAnnoTool('marker')"
title="{{ _('Marker numerato') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="hidden sm:inline">{{ _('Marker') }} #</span>
</button>
<!-- Arrow Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'arrow' }"
@click="setAnnoTool('arrow')"
title="{{ _('Freccia') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
<span class="hidden sm:inline">{{ _('Freccia') }}</span>
</button>
<!-- Rectangle Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'rect' }"
@click="setAnnoTool('rect')"
title="{{ _('Rettangolo') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
</svg>
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
</button>
<!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
<!-- Delete selected -->
<button type="button"
class="anno-btn text-red-500 hover:text-red-600"
@click="deleteAnnotation()"
:disabled="!annoSelected"
title="{{ _('Elimina selezionato') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
{{ _('Elimina') }}
</button>
<!-- Clear All -->
<button type="button"
class="anno-btn text-red-500 hover:text-red-600"
@click="if (confirm('{{ _('Eliminare tutte le annotazioni?') }}')) clearAllAnnotations()"
title="{{ _('Cancella tutto') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 13h6m2 9H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('Cancella tutto') }}
</button>
</div>
<!-- Row 2: Color, Stroke Width, Line Style -->
<div class="flex flex-wrap items-center gap-1.5 pt-1 border-t border-[var(--border-color)]">
<!-- Color picker -->
<span class="text-xs text-[var(--text-muted)] mr-1">{{ _('Colore') }}:</span>
<template x-for="c in colorOptions" :key="c.value">
<button type="button"
class="w-6 h-6 rounded-full border-2 transition-all"
:style="'background-color: ' + c.value"
:class="propsDisabled ? 'border-[var(--border-color)] opacity-30 cursor-not-allowed' : (annoColor === c.value ? 'border-[var(--text-primary)] scale-110 ring-2 ring-offset-1 ring-[var(--color-primary)]' : 'border-[var(--border-color)] hover:scale-105')"
:title="c.label"
:disabled="propsDisabled"
@click="setAnnoColor(c.value)">
</button>
</template>
<!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1.5"></div>
<!-- Stroke width -->
<span class="text-xs text-[var(--text-muted)] mr-1">{{ _('Spessore') }}:</span>
<template x-for="sw in strokeOptions" :key="sw">
<button type="button"
class="anno-btn"
:class="{ 'active': annoStrokeWidth === sw }"
:disabled="propsDisabled"
@click="setAnnoStrokeWidth(sw)"
:title="sw + 'px'">
<div class="flex items-center justify-center w-4 h-4">
<div class="rounded-full bg-current" :style="'width: ' + (sw + 2) + 'px; height: ' + (sw + 2) + 'px'"></div>
</div>
</button>
</template>
<!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1.5"></div>
<!-- Line style -->
<span class="text-xs text-[var(--text-muted)] mr-1">{{ _('Linea') }}:</span>
<template x-for="ls in lineDashOptions" :key="ls.icon">
<button type="button"
class="anno-btn"
:class="{ 'active': JSON.stringify(annoLineDash) === JSON.stringify(ls.value) }"
:disabled="propsDisabled"
@click="setAnnoLineDash(ls.value)"
:title="ls.label">
<div class="flex items-center justify-center w-5 h-4">
<template x-if="ls.icon === 'solid'">
<div class="w-full h-0.5 bg-current"></div>
</template>
<template x-if="ls.icon === 'dashed'">
<div class="w-full h-0.5 flex gap-0.5">
<div class="flex-1 bg-current"></div>
<div class="flex-1 bg-current"></div>
<div class="flex-1 bg-current"></div>
</div>
</template>
<template x-if="ls.icon === 'dotted'">
<div class="w-full h-0.5 flex gap-1 justify-center">
<div class="w-1 h-1 rounded-full bg-current"></div>
<div class="w-1 h-1 rounded-full bg-current"></div>
<div class="w-1 h-1 rounded-full bg-current"></div>
<div class="w-1 h-1 rounded-full bg-current"></div>
</div>
</template>
</div>
</button>
</template>
</div>
</div>
</div>
<!-- ============================================================
Canvas Area
============================================================ -->
<div class="tmf-card">
<div class="tmf-card-body p-0">
<div x-data="annotationEditor()"
class="anno-canvas-wrap"
id="annotationCanvasContainer">
<canvas id="annotationCanvas" x-ref="fabricCanvas"></canvas>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}?v=25"></script>
<script>
function taskDrawing() {
const taskData = window.__taskData || {};
return {
taskId: taskData.id || null,
recipeId: {{ recipe.id|tojson }},
// Annotation state
annoTool: 'select',
annoSelected: false,
annoSelectedType: null,
annotationsJson: taskData.annotations_json || null,
// Color and stroke width
annoColor: '#2563EB',
annoStrokeWidth: 2,
colorOptions: [
{ value: '#2563EB', label: {{ _('Blu')|tojson }} },
{ value: '#DC2626', label: {{ _('Rosso')|tojson }} },
{ value: '#16A34A', label: {{ _('Verde')|tojson }} },
{ value: '#F59E0B', label: {{ _('Arancio')|tojson }} },
{ value: '#8B5CF6', label: {{ _('Viola')|tojson }} },
{ value: '#000000', label: {{ _('Nero')|tojson }} },
],
strokeOptions: [1, 2, 3, 4],
annoLineDash: [],
lineDashOptions: [
{ value: [], label: {{ _('Continua')|tojson }}, icon: 'solid' },
{ value: [8, 4], label: {{ _('Tratteggiata')|tojson }}, icon: 'dashed' },
{ value: [3, 3], label: {{ _('Punteggiata')|tojson }}, icon: 'dotted' },
],
// Save state
saving: false,
errorMessage: '',
successMessage: '',
get propsDisabled() {
var isDrawingTool = this.annoTool === 'marker' || this.annoTool === 'arrow' || this.annoTool === 'rect';
return !isDrawingTool && !this.annoSelected;
},
init() {
// Listen for annotation selection events from annotation-editor.js
window.addEventListener('annotation-selected', (e) => {
this.annoSelected = e.detail.selected;
this.annoSelectedType = e.detail.objectType || null;
});
// Listen for annotation data changes
window.addEventListener('annotations-changed', (e) => {
this.annotationsJson = e.detail.json;
});
// Listen for mode changes from annotation editor (auto-select after placing)
window.addEventListener('anno-mode-changed', (e) => {
if (e.detail && e.detail.mode) {
this.annoTool = e.detail.mode;
}
});
// Load the image into the annotation editor
if (taskData.file_path) {
this.$nextTick(() => {
window.dispatchEvent(new CustomEvent('image-loaded', {
detail: {
path: taskData.file_path,
annotations: taskData.annotations_json || null
}
}));
});
}
},
// ---- Save annotations to task ----
async saveAnnotations() {
this.saving = true;
this.errorMessage = '';
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const payload = {};
if (this.annotationsJson) {
payload.annotations_json = (typeof this.annotationsJson === 'string')
? JSON.parse(this.annotationsJson)
: this.annotationsJson;
}
try {
const resp = await fetch(`/maker/api/tasks/${this.taskId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || window.__i18n_taskDrawing.saveError;
} else {
this.successMessage = window.__i18n_taskDrawing.saveSuccess;
// Store updated annotations in sessionStorage so task_editor
// can pick them up and refresh the preview immediately.
try {
sessionStorage.setItem('taskDrawingUpdated', JSON.stringify({
taskId: this.taskId,
annotations_json: this.annotationsJson
}));
} catch (_e) { /* sessionStorage may be unavailable */ }
}
} catch (err) {
console.error('saveAnnotations error:', err);
this.errorMessage = window.__i18n_taskDrawing.connectionError;
}
this.saving = false;
},
// ---- Annotation Tools ----
setAnnoTool(tool) {
this.annoTool = tool;
window.dispatchEvent(new CustomEvent('anno-tool-change', {
detail: { tool }
}));
},
deleteAnnotation() {
window.dispatchEvent(new CustomEvent('anno-delete-selected'));
},
clearAllAnnotations() {
window.dispatchEvent(new CustomEvent('anno-clear-all'));
},
setAnnoColor(color) {
this.annoColor = color;
window.dispatchEvent(new CustomEvent('anno-color-change', {
detail: { color }
}));
},
setAnnoStrokeWidth(width) {
this.annoStrokeWidth = width;
window.dispatchEvent(new CustomEvent('anno-stroke-width-change', {
detail: { width }
}));
},
setAnnoLineDash(dash) {
this.annoLineDash = dash;
window.dispatchEvent(new CustomEvent('anno-line-dash-change', {
detail: { dash }
}));
}
};
}
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,409 @@
{% extends "base.html" %}
{% block title %}{{ recipe.code }} — {{ _('Storico Versioni') }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* Timeline vertical connector */
.timeline-connector {
position: absolute;
left: 1.25rem;
top: 2.5rem;
bottom: 0;
width: 2px;
background: var(--border-color);
}
.timeline-connector::before {
content: '';
position: absolute;
top: -4px;
left: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-color);
}
/* Timeline dot */
.timeline-dot {
position: absolute;
left: 0.5rem;
top: 0.875rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
z-index: 1;
border: 2px solid var(--bg-primary);
box-shadow: 0 0 0 2px var(--border-color);
}
.timeline-dot.current {
background: var(--color-primary);
color: #fff;
box-shadow: 0 0 0 2px var(--color-primary), 0 0 8px rgba(37, 99, 235, 0.3);
}
.timeline-dot.past {
background: var(--bg-secondary);
color: var(--text-muted);
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
x-data="versionHistory()"
x-init="loadMeasurementCounts()"
x-cloak>
{# ================================================================
BREADCRUMB
================================================================ #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Ricette') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li>
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="hover:text-primary transition-colors">
{{ recipe.code }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Storico Versioni') }}
</li>
</ol>
</nav>
{# ================================================================
HEADER
================================================================ #}
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div>
<h1 class="text-3xl font-bold text-[var(--text-primary)] mb-1">
<span class="font-mono text-primary">{{ recipe.code }}</span>
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
&mdash; {{ recipe.name }}
</span>
</h1>
<div class="flex items-center gap-3 mt-1.5">
<p class="text-sm text-[var(--text-secondary)]">
{{ _('Storico Versioni') }}
</p>
{% if recipe.current_version %}
<span class="badge badge-pass font-mono text-xs">
{{ _('Corrente:') }} v{{ recipe.current_version.version_number }}
</span>
{% endif %}
</div>
</div>
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-1.5 shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Torna a Ricetta') }}
</a>
</div>
</div>
{# ================================================================
VERSION TIMELINE
================================================================ #}
<template x-if="versions.length > 0">
<div class="relative pl-12">
{# Vertical connector line #}
<div class="absolute left-5 top-6 bottom-6 w-0.5 bg-[var(--border-color)]"></div>
<template x-for="(version, idx) in sortedVersions" :key="version.id">
<div class="relative mb-6 last:mb-0">
{# Timeline dot #}
<div class="absolute -left-7 top-4 w-6 h-6 rounded-full flex items-center justify-center
text-[10px] font-bold z-10 border-2"
:class="version.version_number === currentVersionNumber
? 'bg-primary text-white border-primary shadow-md shadow-primary/30'
: 'bg-[var(--bg-card)] text-[var(--text-muted)] border-[var(--border-color)]'"
x-text="'v' + version.version_number">
</div>
{# Version card #}
<div class="tmf-card transition-all duration-200"
:class="version.version_number === currentVersionNumber ? 'border-primary/40 shadow-md' : ''">
<div class="p-5">
{# Card header row #}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
<div class="flex items-center gap-2.5 flex-wrap">
{# Version badge #}
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-bold font-mono"
:class="version.version_number === currentVersionNumber
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-800'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]'"
x-text="'v' + version.version_number">
</span>
{# Current badge #}
<template x-if="version.version_number === currentVersionNumber">
<span class="badge badge-pass text-xs">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Corrente') }}
</span>
</template>
{# Measurement count badge #}
<template x-if="measurementCounts[version.id] !== undefined">
<span class="badge text-xs"
:class="measurementCounts[version.id] > 0 ? 'badge-pass' : 'badge-neutral'">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<span x-text="measurementCounts[version.id]"></span>
{{ _('misurazioni') }}
</span>
</template>
<template x-if="measurementCounts[version.id] === undefined && loadingCounts">
<span class="badge badge-neutral text-xs">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
{{ _('Caricamento...') }}
</span>
</template>
</div>
{# View button #}
<a :href="'/maker/recipes/' + recipeId + '/edit?version=' + version.id"
class="btn btn-secondary text-xs gap-1.5 shrink-0">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Visualizza') }}
</a>
</div>
{# Version details #}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{# Created at #}
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span x-text="formatDate(version.created_at)"></span>
</div>
{# Created by #}
<template x-if="version.created_by_user">
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<span x-text="version.created_by_user.display_name || version.created_by_user.username"></span>
</div>
</template>
{# Task count (if available) #}
<template x-if="version.tasks && version.tasks.length > 0">
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span x-text="version.tasks.length + ' task'"></span>
</div>
</template>
</div>
{# Change notes #}
<template x-if="version.change_notes">
<div class="mt-3 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>
<p class="text-sm text-[var(--text-secondary)] leading-relaxed" x-text="version.change_notes"></p>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
{# ================================================================
EMPTY STATE
================================================================ #}
<template x-if="versions.length === 0">
<div class="tmf-card">
<div class="p-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-[var(--bg-secondary)] mb-4">
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
{{ _('Nessuna versione trovata') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
{{ _('Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio.') }}
</p>
</div>
</div>
</template>
{# ================================================================
FOOTER
================================================================ #}
<div class="flex items-center justify-between gap-3 pt-6 pb-8">
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Torna a Ricetta') }}
</a>
<a href="{{ url_for('maker.recipe_preview', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</a>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
/**
* Version History - Alpine.js component
* Loads and displays recipe version timeline with AJAX measurement counts.
*/
function versionHistory() {
return {
recipeId: {{ recipe.id|tojson }},
currentVersionNumber: {{ (recipe.current_version.version_number if recipe.current_version else 0)|tojson }},
versions: {{ versions|tojson }},
measurementCounts: {},
loadingCounts: true,
/**
* Sorted versions: most recent first.
*/
get sortedVersions() {
return [...this.versions].sort((a, b) => b.version_number - a.version_number);
},
/**
* Load measurement counts for all versions via AJAX.
* Calls the proxy API endpoint for each version.
*/
async loadMeasurementCounts() {
this.loadingCounts = true;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const promises = this.versions.map(async (version) => {
try {
const resp = await fetch(
'/maker/api/recipes/' + this.recipeId + '/versions/' + version.version_number + '/measurement-count',
{
method: 'GET',
headers: {
'X-CSRFToken': csrfToken
}
}
);
if (resp.ok) {
const data = await resp.json();
this.measurementCounts[version.id] = data.measurement_count || 0;
} else {
this.measurementCounts[version.id] = 0;
}
} catch (err) {
console.error('Error loading measurement count for version', version.version_number, err);
this.measurementCounts[version.id] = 0;
}
});
await Promise.all(promises);
this.loadingCounts = false;
},
/**
* Format ISO date string to a localized display format.
* Example output: "15 Gen 2026, 14:30"
* @param {string} isoString - ISO 8601 date string
* @returns {string} Formatted date
*/
formatDate(isoString) {
if (!isoString) return '—';
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return isoString;
const months = [
'Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu',
'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'
];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return day + ' ' + month + ' ' + year + ', ' + hours + ':' + minutes;
} catch (e) {
return isoString;
}
}
};
}
</script>
{% endblock %}
@@ -0,0 +1,335 @@
{% extends "base.html" %}
{% block title %}{{ _('Seleziona Ricetta') }} — TieMeasureFlow{% endblock %}
{% block content %}
<script>window.__selectRecipeData = {{ recipes|tojson }};</script>
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
x-data="{
recipes: window.__selectRecipeData,
search: '{{ auto_recipe_code }}',
lot_number: '{{ auto_lot }}',
serial_number: '{{ auto_serial }}',
barcodeModal: false,
barcodeInput: '',
barcodeLoading: false,
barcodeError: '',
get filteredRecipes() {
if (!this.search) return this.recipes;
const q = this.search.toLowerCase();
return this.recipes.filter(r =>
(r.name || '').toLowerCase().includes(q) ||
(r.code || '').toLowerCase().includes(q) ||
(r.description || '').toLowerCase().includes(q)
);
},
buildTaskUrl(recipeId) {
let url = '/measure/tasks/' + recipeId + '?';
const params = [];
if (this.lot_number) params.push('lot_number=' + encodeURIComponent(this.lot_number));
if (this.serial_number) params.push('serial_number=' + encodeURIComponent(this.serial_number));
return url + params.join('&');
},
async lookupBarcode() {
if (!this.barcodeInput.trim()) return;
this.barcodeLoading = true;
this.barcodeError = '';
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
const resp = await fetch('/measure/lookup-barcode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken || ''
},
body: JSON.stringify({ code: this.barcodeInput.trim() })
});
const data = await resp.json();
if (data.error) {
this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}';
} else {
this.barcodeModal = false;
this.barcodeInput = '';
window.location.href = this.buildTaskUrl(data.id);
}
} catch (e) {
this.barcodeError = '{{ _("Errore di connessione") }}';
} finally {
this.barcodeLoading = false;
}
}
}">
<!-- Page Header -->
<div class="mb-8">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center space-x-3">
<div class="p-2 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
<svg class="h-7 w-7 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-[var(--text-primary)]">
{{ _('Seleziona Ricetta') }}
</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">
{{ _('Scegli la ricetta di misura da eseguire') }}
</p>
<p class="mt-1 text-sm text-steel-500 dark:text-steel-400">
{{ _('Stazione') }}: <span class="font-mono font-bold">{{ station_code }}</span>
</p>
</div>
</div>
<!-- Barcode Button -->
<button @click="barcodeModal = true"
class="btn btn-secondary gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4h2v16H3V4zm4 0h1v16H7V4zm3 0h2v16h-2V4zm4 0h3v16h-3V4zm5 0h2v16h-2V4z"/>
</svg>
{{ _('Scansiona Barcode') }}
</button>
</div>
</div>
<!-- Search + Traceability Bar -->
<div class="tmf-card mb-6">
<div class="p-4 sm:p-5">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Search Input -->
<div class="sm:col-span-2">
<label class="tmf-label">
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
{{ _('Cerca ricetta') }}
</label>
<input type="text"
x-model="search"
placeholder="{{ _('Nome, codice o descrizione...') }}"
class="tmf-input">
</div>
<!-- Lot Number -->
<div>
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
<input type="text"
x-model="lot_number"
placeholder="{{ _('Opzionale') }}"
class="tmf-input">
</div>
<!-- Serial Number -->
<div>
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
<input type="text"
x-model="serial_number"
placeholder="{{ _('Opzionale') }}"
class="tmf-input">
</div>
</div>
</div>
</div>
<!-- Results Count -->
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-[var(--text-secondary)]">
<span x-text="filteredRecipes.length"></span> {{ _('ricette trovate') }}
</p>
</div>
<!-- Recipe Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<template x-for="recipe in filteredRecipes" :key="recipe.id">
<div class="tmf-card flex flex-col hover:border-primary/30 transition-all duration-200 group">
<!-- Card Header -->
<div class="p-5 flex-1">
<!-- Code Badge + Version -->
<div class="flex items-center justify-between mb-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800 font-mono tracking-wide"
x-text="recipe.code">
</span>
<span class="badge badge-neutral text-xs"
x-show="recipe.current_version || recipe.version"
x-text="'v' + (recipe.current_version ? recipe.current_version.version_number : (recipe.version || ''))">
</span>
</div>
<!-- Recipe Name -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug"
x-text="recipe.name">
</h3>
<!-- Description -->
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
</p>
<!-- Meta Info -->
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
<template x-if="recipe.task_count != null">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<span x-text="recipe.task_count + ' task'"></span>
</span>
</template>
<template x-if="recipe.updated_at">
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="new Date(recipe.updated_at).toLocaleDateString('it-IT')"></span>
</span>
</template>
</div>
</div>
<!-- Card Footer -->
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
rounded-b-xl">
<a :href="buildTaskUrl(recipe.id)"
class="btn btn-primary w-full justify-center text-sm font-semibold
group-hover:shadow-md transition-shadow duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Seleziona') }}
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="filteredRecipes.length === 0" x-cloak
class="text-center py-16">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-[var(--bg-secondary)] mb-4">
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
{{ _('Nessuna ricetta trovata') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
<span x-show="search">
{{ _('Nessun risultato per') }} "<span x-text="search" class="font-medium"></span>".
{{ _('Prova con un termine diverso.') }}
</span>
<span x-show="!search">
{{ _('Non ci sono ricette disponibili al momento.') }}
</span>
</p>
</div>
<!-- Barcode Modal -->
<div x-show="barcodeModal" x-cloak
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="barcodeModal = false">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="barcodeModal = false"></div>
<!-- Modal Content -->
<div class="relative w-full max-w-md bg-[var(--bg-card)] rounded-xl shadow-2xl
border border-[var(--border-color)]"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.stop>
<!-- Modal Header -->
<div class="flex items-center justify-between p-5 border-b border-[var(--border-color)]">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4h2v16H3V4zm4 0h1v16H7V4zm3 0h2v16h-2V4zm4 0h3v16h-3V4zm5 0h2v16h-2V4z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">
{{ _('Scansiona Barcode') }}
</h3>
</div>
<button @click="barcodeModal = false"
class="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)]
hover:bg-[var(--bg-secondary)] transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="p-5">
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Inserisci o scansiona il codice della ricetta per selezionarla automaticamente.') }}
</p>
<div class="space-y-4">
<div>
<label class="tmf-label">{{ _('Codice Ricetta') }}</label>
<input type="text"
x-model="barcodeInput"
@keydown.enter.prevent="lookupBarcode()"
x-ref="barcodeField"
x-init="$watch('barcodeModal', val => { if (val) setTimeout(() => $refs.barcodeField.focus(), 100) })"
placeholder="{{ _('Es. REC-001') }}"
class="tmf-input font-mono text-lg tracking-wider"
:disabled="barcodeLoading">
</div>
<!-- Error Message -->
<div x-show="barcodeError" x-cloak
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20
border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="barcodeError"></span>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end gap-3 p-5 border-t border-[var(--border-color)]
bg-[var(--bg-secondary)] rounded-b-xl">
<button @click="barcodeModal = false; barcodeInput = ''; barcodeError = ''"
class="btn btn-secondary">
{{ _('Annulla') }}
</button>
<button @click="lookupBarcode()"
:disabled="!barcodeInput.trim() || barcodeLoading"
class="btn btn-primary">
<svg x-show="barcodeLoading" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span x-show="!barcodeLoading">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</span>
{{ _('Cerca') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,312 @@
{% extends "base.html" %}
{% block title %}{{ _('Riepilogo') }} - {{ recipe.name }}{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('measure.select_recipe') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Misure') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li>
<a href="{{ url_for('measure.task_list', recipe_id=recipe.id) }}"
class="hover:text-primary transition-colors">
{{ recipe.name }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Riepilogo') }}
</li>
</ol>
</nav>
<!-- Recipe Info Card -->
<div class="tmf-card mb-6">
<div class="tmf-card-header">
<h1 class="text-2xl font-bold" style="color: var(--text-primary);">{{ _('Misurazioni Complete') }}</h1>
</div>
<div class="tmf-card-body">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Codice') }}</p>
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ recipe.code }}</p>
</div>
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Nome') }}</p>
<p class="font-medium" style="color: var(--text-primary);">{{ recipe.name }}</p>
</div>
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Versione') }}</p>
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ recipe.current_version.version_number if recipe.current_version else recipe.version }}</p>
</div>
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Lotto') }}</p>
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ lot_number or '-' }}</p>
</div>
</div>
{% if serial_number %}
<div class="mt-3 pt-3" style="border-top: 1px solid var(--border-color);">
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Seriale') }}</p>
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ serial_number }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Statistics Cards -->
{% set total_count = measurements|length %}
{% set pass_count = measurements|selectattr('pass_fail', 'equalto', 'pass')|list|length %}
{% set warning_count = measurements|selectattr('pass_fail', 'equalto', 'warning')|list|length %}
{% set fail_count = measurements|selectattr('pass_fail', 'equalto', 'fail')|list|length %}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total -->
<div class="tmf-card">
<div class="tmf-card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Totale') }}</p>
<p class="text-3xl font-bold" style="color: var(--text-primary);">{{ total_count }}</p>
</div>
<div class="p-3 rounded-lg" style="background-color: var(--bg-secondary);">
<svg class="h-8 w-8" style="color: var(--text-primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Pass -->
<div class="tmf-card">
<div class="tmf-card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Conformi') }}</p>
<p class="text-3xl font-bold text-measure-pass dark:text-green-400">{{ pass_count }}</p>
</div>
<div class="p-3 rounded-lg bg-green-100 dark:bg-green-900/30">
<svg class="h-8 w-8 text-measure-pass dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Warning -->
<div class="tmf-card">
<div class="tmf-card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Attenzione') }}</p>
<p class="text-3xl font-bold text-measure-warning dark:text-amber-400">{{ warning_count }}</p>
</div>
<div class="p-3 rounded-lg bg-amber-100 dark:bg-amber-900/30">
<svg class="h-8 w-8 text-measure-warning dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Fail -->
<div class="tmf-card">
<div class="tmf-card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Non Conformi') }}</p>
<p class="text-3xl font-bold text-measure-fail dark:text-red-400">{{ fail_count }}</p>
</div>
<div class="p-3 rounded-lg bg-red-100 dark:bg-red-900/30">
<svg class="h-8 w-8 text-measure-fail dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Measurements Table -->
<div class="tmf-card mb-6">
<div class="tmf-card-header flex items-center justify-between">
<h2 class="text-xl font-bold" style="color: var(--text-primary);">{{ _('Dettaglio Misurazioni') }}</h2>
<button
onclick="(function(){ var e = csvExport(); e.exportMeasurements(window.measurementData.measurements, 'misure_' + window.measurementData.recipeCode + '.csv'); })()"
class="btn btn-secondary flex items-center gap-2">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span>{{ _('Esporta CSV') }}</span>
</button>
</div>
<div class="tmf-card-body overflow-x-auto">
<table class="tmf-table">
<thead>
<tr>
<th class="text-center">{{ _('Data/Ora') }}</th>
<th>{{ _('Task') }}</th>
<th>{{ _('Descrizione') }}</th>
<th class="text-right">{{ _('Nominale') }}</th>
<th class="text-right">{{ _('Valore') }}</th>
<th class="text-right">{{ _('Deviazione') }}</th>
<th class="text-center">{{ _('Esito') }}</th>
<th class="text-center">{{ _('Metodo') }}</th>
</tr>
</thead>
<tbody>
{% for m in measurements|sort(attribute='task_info.order_index,subtask.marker_number') %}
<tr>
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
</td>
<td style="color: var(--text-secondary);" class="text-sm">
{{ m.task_info.title if m.task_info else '-' }}
</td>
<td style="color: var(--text-primary);">
{{ m.subtask.description }}
</td>
<td class="text-right measure-value">
{{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }}
</td>
<td class="text-right measure-value font-semibold" style="color: var(--text-primary);">
{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}
</td>
<td class="text-right measure-value {% if m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
{{ "%+.3f"|format(m.deviation) }}
</td>
<td class="text-center">
{% if m.pass_fail == 'pass' %}
<span class="badge badge-pass">{{ _('Pass') }}</span>
{% elif m.pass_fail == 'warning' %}
<span class="badge badge-warning">{{ _('Warning') }}</span>
{% elif m.pass_fail == 'fail' %}
<span class="badge badge-fail">{{ _('Fail') }}</span>
{% else %}
<span class="badge badge-neutral">-</span>
{% endif %}
</td>
<td class="text-center">
{% if m.input_method == 'manual' %}
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
</svg>
<span class="text-sm">{{ _('Manuale') }}</span>
</div>
{% elif m.input_method == 'usb_caliper' %}
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
</svg>
<span class="text-sm">{{ _('Calibro') }}</span>
</div>
{% else %}
<span class="text-sm" style="color: var(--text-muted);">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Navigation Footer -->
<div class="flex flex-col sm:flex-row gap-3 justify-between items-center">
<div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<a href="{{ url_for('measure.select_recipe') }}"
class="btn btn-secondary w-full sm:w-auto flex items-center justify-center gap-2">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
</svg>
<span>{{ _('Seleziona altra ricetta') }}</span>
</a>
<a href="{{ url_for('measure.task_list', recipe_id=recipe.id) }}"
class="btn btn-secondary w-full sm:w-auto flex items-center justify-center gap-2">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span>{{ _('Ripeti misurazioni') }}</span>
</a>
</div>
{% if current_user.get('roles') and 'Metrologist' in current_user.get('roles', []) %}
<a href="{{ url_for('statistics.dashboard') }}"
class="btn btn-primary w-full sm:w-auto flex items-center justify-center gap-2">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span>{{ _('Statistiche') }}</span>
</a>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/csv-export.js') }}"></script>
<script>
window.CSV_I18N = {
subtask_id: "{{ _('Subtask ID') }}",
subtask_name: "{{ _('Nome Sottotask') }}",
measured_value: "{{ _('Valore Misurato') }}",
unit: "{{ _('Unita') }}",
nominal_value: "{{ _('Valore Nominale') }}",
tolerance_plus: "{{ _('Tolleranza +') }}",
tolerance_minus: "{{ _('Tolleranza -') }}",
deviation: "{{ _('Scarto') }}",
result: "{{ _('Esito') }}",
lot_number: "{{ _('Numero Lotto') }}",
serial_number: "{{ _('Numero Seriale') }}",
input_method: "{{ _('Metodo Input') }}",
measurement_date: "{{ _('Data Misurazione') }}",
operator: "{{ _('Operatore') }}",
task_summary_title: "{{ _('RIEPILOGO ESECUZIONE TASK') }}",
task_id: "{{ _('Task ID') }}",
task_name: "{{ _('Nome Task') }}",
recipe: "{{ _('Ricetta') }}",
start_date: "{{ _('Data Inizio') }}",
end_date: "{{ _('Data Fine') }}",
status: "{{ _('Stato') }}",
lot: "{{ _('Lotto') }}",
serial: "{{ _('Seriale') }}",
statistics: "{{ _('STATISTICHE') }}",
total_measurements: "{{ _('Totale Misure') }}",
passed: "{{ _('Passate') }}",
failed: "{{ _('Fallite') }}",
pass_rate: "{{ _('Percentuale Successo') }}",
measurement_details: "{{ _('DETTAGLIO MISURE') }}"
};
</script>
<script>
// Pass data to csv-export.js
window.measurementData = {
recipeName: {{ recipe.name|tojson }},
recipeCode: {{ recipe.code|tojson }},
version: {{ (recipe.current_version.version_number if recipe.current_version else recipe.version)|tojson }},
lotNumber: {{ (lot_number or '')|tojson }},
serialNumber: {{ (serial_number or '')|tojson }},
measurements: {{ measurements|tojson }}
};
</script>
{% endblock %}
@@ -0,0 +1,708 @@
{% extends "base.html" %}
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* Annotation viewer container */
.annotation-container {
position: relative;
width: 100%;
height: 100%;
background: var(--bg-secondary);
overflow: hidden;
}
.annotation-container canvas {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
/* Tolerance bar gradient background */
.tolerance-bar-bg {
background: linear-gradient(to right,
rgba(220, 38, 38, 0.15) 0%,
rgba(217, 119, 6, 0.15) 15%,
rgba(5, 150, 105, 0.15) 30%,
rgba(5, 150, 105, 0.15) 70%,
rgba(217, 119, 6, 0.15) 85%,
rgba(220, 38, 38, 0.15) 100%
);
}
/* Pulse animation for active marker */
@keyframes markerPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
}
.marker-active-pulse {
animation: markerPulse 2s ease-in-out infinite;
}
/* Left sidebar scrollbar */
.sidebar-markers::-webkit-scrollbar { width: 3px; }
.sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
/* Right panel scrollbar */
.right-panel::-webkit-scrollbar { width: 4px; }
.right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
</style>
{% endblock %}
{% block content %}
<script>
window.__taskData = {{ task|tojson }};
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
window.__allTaskIds = {{ all_task_ids|tojson }};
</script>
<div class="h-screen flex flex-col overflow-hidden"
x-data="taskExecute()"
x-init="init()"
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
{# ================================================================
HEADER — Compact task info bar
================================================================ #}
<div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
<div class="px-3 py-2 flex items-center gap-3">
{# Back button #}
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
{# Task badge + title #}
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800">
Task {{ (task.order_index or 0) + 1 }}
</span>
<h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
{{ task.title or _('Task di misurazione') }}
</h1>
</div>
{# Lot + Serial badges #}
<div class="shrink-0 flex items-center gap-2">
{% if lot_number %}
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
text-amber-800 dark:text-amber-200">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<span class="font-mono font-semibold">{{ lot_number }}</span>
</div>
{% endif %}
{% if serial_number %}
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
text-indigo-800 dark:text-indigo-200">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
<span class="font-mono font-semibold">{{ serial_number }}</span>
</div>
{% endif %}
{# Caliper status #}
{% include "components/caliper_status.html" %}
</div>
</div>
</div>
{# ================================================================
MAIN — 3-column layout
================================================================ #}
<div class="flex-1 flex overflow-hidden">
{# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical)
────────────────────────────────────────────── #}
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
<template x-for="(st, idx) in subtasks" :key="st.id">
<button @click="goToSubtask(idx)"
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
:class="idx === currentIndex
? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
: 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
{# Marker circle #}
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
:class="idx === currentIndex
? 'bg-primary text-white shadow-md marker-active-pulse'
: getMeasurementStatus(st.id) === 'pass'
? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
: getMeasurementStatus(st.id) === 'fail'
? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
: getMeasurementStatus(st.id) === 'warning'
? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
x-text="st.marker_number"></span>
{# Status icon below circle #}
<template x-if="getMeasurementStatus(st.id) === 'pass'">
<svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'fail'">
<svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="getMeasurementStatus(st.id) === 'warning'">
<svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</template>
</button>
</template>
</div>
{# ──────────────────────────────────────────────
CENTER — Image area
────────────────────────────────────────────── #}
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
{# Main image area #}
<div class="flex-1 overflow-hidden relative">
{# Option A: Subtask has its own image → plain <img> #}
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
<img :src="'/measure/api/files/' + currentSubtaskImage"
alt="{{ _('Immagine dettaglio misura') }}"
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
</div>
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
<div x-show="showTaskImage" class="absolute inset-0">
{% if task.file_path %}
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/measure/api/files/{{ task.file_path }}';
annotations = window.__taskAnnotations;
$nextTick(() => init());
"
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
<canvas x-ref="annotationCanvas"
@click="handleClick($event)"
class="cursor-pointer"></canvas>
</div>
{% endif %}
</div>
{# Option C: No image → placeholder #}
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
</div>
</div>
</div>
</div>
{# ──────────────────────────────────────────────
RIGHT PANEL — Info + tolerances + numpad
────────────────────────────────────────────── #}
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
{# ---- Subtask header ---- #}
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
bg-primary text-white font-bold text-sm shadow marker-active-pulse"
x-text="currentSubtask?.marker_number"></span>
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
<span class="text-[11px] text-[var(--text-muted)]"
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
</div>
<span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
</span>
</div>
</div>
{# ---- Tolerance parameters (compact grid) ---- #}
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
{# Nominal row #}
<div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
<span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
<span class="font-mono font-bold text-primary text-sm"
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
</div>
{# 2x2 tolerance grid #}
<div class="grid grid-cols-2 gap-1 text-[11px]">
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
<span class="text-measure-fail font-medium">UTL</span>
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
<span class="text-measure-fail font-medium">LTL</span>
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-measure-warning font-medium">UWL</span>
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
</div>
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-measure-warning font-medium">LWL</span>
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
</div>
</div>
</div>
{# ---- Tolerance bar (compact) ---- #}
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
<div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
{# Zone labels #}
<div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
<span x-text="currentSubtask?.utl?.toFixed(2)"></span>
</div>
{# Center line #}
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
{# Value needle #}
<div x-show="currentValue !== null"
x-transition
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"
:style="'left: calc(' + progressWidth + '% - 2px)'">
<div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
:class="{
'bg-measure-pass': passFailStatus === 'pass',
'bg-measure-warning': passFailStatus === 'warning',
'bg-measure-fail': passFailStatus === 'fail'
}"></div>
</div>
</div>
</div>
{# ---- Already measured indicator ---- #}
<div x-show="isMeasured(currentSubtask?.id)"
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span>{{ _('Registrata') }}:</span>
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
<span x-text="currentSubtask?.unit || 'mm'"></span>
</div>
</div>
{# ---- Measurement feedback ---- #}
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
get utl() { return currentSubtask?.utl || 0; },
get uwl() { return currentSubtask?.uwl || 0; },
get lwl() { return currentSubtask?.lwl || 0; },
get ltl() { return currentSubtask?.ltl || 0; },
get unit() { return currentSubtask?.unit || 'mm'; } }">
{% include "components/measurement_feedback.html" %}
</div>
{# ---- Numpad ---- #}
<div class="px-3 py-2 relative flex-1">
{# Saving overlay #}
<div x-show="saving"
x-transition
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
<div class="flex items-center gap-2 text-primary font-medium text-sm">
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{{ _('Salvataggio...') }}
</div>
</div>
{% include "components/numpad.html" %}
</div>
{# ---- Next measurement indicator ---- #}
<div class="px-3 pb-2">
{% include "components/next_measurement.html" %}
</div>
{# ---- Error message ---- #}
<div x-show="errorMessage"
x-transition
x-cloak
class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
<div class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="errorMessage"></span>
</div>
</div>
</div>
</div>
{# ================================================================
FOOTER — Progress bar + navigation
================================================================ #}
<div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
<div class="px-3 py-2 flex items-center gap-3">
{# Left: Back #}
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<span class="hidden sm:inline">{{ _('Task') }}</span>
</a>
{# Center: Progress bar #}
<div class="flex-1 flex items-center gap-2">
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
</span>
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 ease-out"
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
:style="'width: ' + progressPercent + '%'"></div>
</div>
<span class="text-xs font-bold shrink-0"
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
x-text="Math.round(progressPercent) + '%'"></span>
</div>
{# Right: Summary button #}
<button x-show="isComplete"
x-transition
@click="goToSummary()"
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Riepilogo') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</div>
</div>
{# ================================================================
COMPLETION OVERLAY
================================================================ #}
<div x-show="showCompletionOverlay"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center"
x-transition:enter="transition ease-out duration-300 delay-100"
x-transition:enter-start="opacity-0 scale-90"
x-transition:enter-end="opacity-100 scale-100">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('Misurazioni Complete') }}</h3>
<p class="text-sm text-[var(--text-secondary)] mb-2">
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
</p>
<div class="flex justify-center gap-4 mb-5">
<div class="text-center">
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Conformi') }}</div>
</div>
<div class="text-center" x-show="warningCount > 0">
<div class="text-xl font-bold font-mono text-measure-warning" x-text="warningCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Attenzione') }}</div>
</div>
<div class="text-center" x-show="failCount > 0">
<div class="text-xl font-bold font-mono text-measure-fail" x-text="failCount"></div>
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Non Conf.') }}</div>
</div>
</div>
<button @click="goToSummary()"
class="btn btn-primary w-full justify-center gap-2">
{{ _('Vai al Riepilogo') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
<script>
/**
* Task Execute - Main Alpine.js component
* Manages measurement workflow state, save logic, and navigation.
* Layout: 3-column (marker sidebar | image center | info+numpad right)
*/
function taskExecute() {
return {
// ---- Data from server ----
task: window.__taskData,
subtasks: window.__taskSubtasks,
lotNumber: '{{ lot_number or '' }}',
serialNumber: '{{ serial_number or '' }}',
// ---- Measurement state ----
currentIndex: 0,
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
saving: false,
errorMessage: '',
showCompletionOverlay: false,
// ---- Value from numpad / caliper ----
currentValue: null,
// ---- Image switching logic ----
get currentSubtaskImage() {
return this.currentSubtask?.image_path || null;
},
get showSubtaskImage() {
return !!this.currentSubtaskImage;
},
get showTaskImage() {
return !this.currentSubtaskImage && !!this.task.file_path;
},
// ---- Computed properties ----
get currentSubtask() {
return this.subtasks[this.currentIndex] || null;
},
get nextSubtask() {
return this.subtasks[this.currentIndex + 1] || null;
},
get totalSubtasks() {
return this.subtasks.length;
},
get completedCount() {
return this.measurements.length;
},
get isComplete() {
return this.completedCount >= this.totalSubtasks;
},
get progressPercent() {
return this.totalSubtasks > 0
? (this.completedCount / this.totalSubtasks * 100)
: 0;
},
// ---- Pass/fail logic ----
get passFailStatus() {
if (this.currentValue === null || !this.currentSubtask) return null;
const v = this.currentValue;
const s = this.currentSubtask;
if (s.utl != null && v > s.utl) return 'fail';
if (s.ltl != null && v < s.ltl) return 'fail';
if (s.uwl != null && v > s.uwl) return 'warning';
if (s.lwl != null && v < s.lwl) return 'warning';
return 'pass';
},
get deviation() {
if (this.currentValue === null || !this.currentSubtask) return null;
return this.currentValue - (this.currentSubtask.nominal || 0);
},
get progressWidth() {
if (!this.currentSubtask || this.currentValue === null) return 50;
const s = this.currentSubtask;
if (s.utl == null || s.ltl == null) return 50;
const range = s.utl - s.ltl;
if (range <= 0) return 50;
const pos = (this.currentValue - s.ltl) / range * 100;
return Math.max(0, Math.min(100, pos));
},
// ---- Summary counts ----
get passCount() {
return this.measurements.filter(m => m.pass_fail === 'pass').length;
},
get warningCount() {
return this.measurements.filter(m => m.pass_fail === 'warning').length;
},
get failCount() {
return this.measurements.filter(m => m.pass_fail === 'fail').length;
},
// ---- Init ----
init() {
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
},
// ---- Check if a subtask has been measured ----
isMeasured(subtaskId) {
return this.measurements.some(m => m.subtask_id === subtaskId);
},
getMeasuredValue(subtaskId) {
const m = this.measurements.find(m => m.subtask_id === subtaskId);
return m ? m.value : null;
},
getMeasurementStatus(subtaskId) {
const m = this.measurements.find(m => m.subtask_id === subtaskId);
return m ? m.pass_fail : null;
},
// ---- Handle numpad confirm ----
async handleMeasurement(value, inputMethod) {
if (!this.currentSubtask || this.saving) return;
this.currentValue = value;
this.errorMessage = '';
const pf = this.passFailStatus;
const dev = this.deviation;
this.saving = true;
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const response = await fetch('{{ url_for("measure.save_measurement") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({
subtask_id: this.currentSubtask.id,
version_id: this.task.version_id,
value: value,
lot_number: this.lotNumber,
serial_number: this.serialNumber,
input_method: inputMethod || 'manual',
}),
});
const result = await response.json();
if (!response.ok || result.error) {
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
this.saving = false;
return;
}
// Record measurement locally
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
if (existingIdx !== -1) {
this.measurements.splice(existingIdx, 1, mEntry);
} else {
this.measurements.push(mEntry);
}
this.saving = false;
// Pause to show result feedback
await new Promise(r => setTimeout(r, 1000));
// Check if all done
if (this.completedCount >= this.totalSubtasks) {
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
} else {
this.showCompletionOverlay = true;
}
return;
}
this.advanceToNext();
} catch (err) {
console.error('Save measurement error:', err);
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
this.saving = false;
}
},
// ---- Advance to next unmeasured subtask ----
advanceToNext() {
this.currentValue = null;
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i;
return;
}
}
for (let i = 0; i < this.currentIndex; i++) {
if (!this.isMeasured(this.subtasks[i].id)) {
this.currentIndex = i;
return;
}
}
},
// ---- Navigation ----
goToSubtask(index) {
if (index >= 0 && index < this.totalSubtasks) {
this.currentIndex = index;
this.currentValue = null;
this.errorMessage = '';
}
},
goToSubtaskByMarker(markerNumber) {
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
if (idx !== -1) {
this.goToSubtask(idx);
}
},
// ---- Go to summary ----
goToSummary() {
const recipeId = this.task.recipe_id || 0;
window.location.href = '{{ url_for("measure.task_complete", recipe_id=0) }}'.replace('/0', '/' + recipeId) +
'?version_id=' + encodeURIComponent(this.task.version_id || '');
},
};
}
</script>
{% endblock %}
@@ -0,0 +1,247 @@
{% extends "base.html" %}
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('measure.select_recipe') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Misure') }}
</a>
</li>
<li>
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ recipe.name }}
</li>
</ol>
</nav>
<!-- Recipe Info Card -->
<div class="tmf-card mb-8">
<div class="p-5 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<!-- Left: Recipe Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<!-- Code Badge -->
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-semibold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800 font-mono tracking-wide">
{{ recipe.code }}
</span>
<!-- Version Badge -->
{% if recipe.current_version or recipe.version %}
<span class="badge badge-neutral">
v{{ recipe.current_version.version_number if recipe.current_version else recipe.version }}
</span>
{% endif %}
</div>
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-2">
{{ recipe.name }}
</h1>
{% if recipe.description %}
<p class="text-sm text-[var(--text-secondary)] leading-relaxed max-w-2xl">
{{ recipe.description }}
</p>
{% endif %}
</div>
<!-- Right: Traceability Badges -->
<div class="flex flex-col gap-2 sm:items-end shrink-0">
{% if lot_number %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
text-amber-800 dark:text-amber-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<span class="font-medium">{{ _('Lotto') }}:</span>
<span class="font-mono font-semibold">{{ lot_number }}</span>
</div>
{% endif %}
{% if serial_number %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
text-indigo-800 dark:text-indigo-200">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
<span class="font-medium">{{ _('Seriale') }}:</span>
<span class="font-mono font-semibold">{{ serial_number }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Task Count Header -->
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{{ _('Task da eseguire') }}
</h2>
<span class="badge badge-neutral">
{{ tasks|length }} task
</span>
</div>
<!-- Task Cards -->
{% if tasks %}
<div class="space-y-4">
{% for task in tasks %}
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
<div class="p-5 sm:p-6">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Task Number Circle -->
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
text-primary-700 dark:text-primary-300 font-bold text-lg">
{{ loop.index }}
</div>
<!-- Task Info -->
<div class="flex-1 min-w-0">
<!-- Task Header -->
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
</span>
</div>
<!-- Task Title -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
</h3>
<!-- Directive -->
{% if task.directive or task.description %}
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
{{ task.directive or task.description }}
</p>
{% endif %}
<!-- Meta Row -->
<div class="flex items-center gap-4 flex-wrap">
<!-- Subtask Count -->
{% if task.subtask_count is defined or task.subtasks %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<span class="font-medium">
{% if task.subtask_count is defined %}
{{ task.subtask_count }}
{% elif task.subtasks %}
{{ task.subtasks|length }}
{% endif %}
{{ _('misurazioni') }}
</span>
</span>
{% endif %}
<!-- File Attachment -->
{% if task.file_path %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
{% if task.file_path.endswith('.pdf') %}
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{% endif %}
<span class="font-medium">{{ _('Allegato') }}</span>
</span>
{% endif %}
</div>
</div>
<!-- Action Button -->
<div class="shrink-0 sm:ml-4">
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
group-hover:shadow-md transition-shadow duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Inizia Misure') }}
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-[var(--bg-secondary)] mb-4">
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
{{ _('Nessun task disponibile') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
{{ _('Questa ricetta non ha ancora task definiti.') }}
</p>
</div>
{% endif %}
<!-- Bottom Navigation -->
<div class="mt-8 flex items-center justify-between">
<a href="{{ url_for('measure.select_recipe') }}"
class="btn btn-secondary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Seleziona altra ricetta') }}
</a>
{% if tasks %}
<div class="text-sm text-[var(--text-secondary)]">
{{ tasks|length }} task &middot;
{% set total_subs = namespace(count=0) %}
{% for t in tasks %}
{% if t.subtask_count is defined %}
{% set total_subs.count = total_subs.count + t.subtask_count %}
{% elif t.subtasks %}
{% set total_subs.count = total_subs.count + t.subtasks|length %}
{% endif %}
{% endfor %}
{% if total_subs.count > 0 %}
{{ total_subs.count }} {{ _('misurazioni totali') }}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,465 @@
{% extends "base.html" %}
{% block title %}{{ _("Statistiche SPC") }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<!-- Plotly.js CDN -->
<script src="https://cdn.plot.ly/plotly-2.32.0.min.js" charset="utf-8"></script>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-6"
x-data="spcDashboard()"
x-init="init()">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Statistiche SPC") }}</h1>
<p class="text-sm text-steel mt-1">{{ _("Analisi statistica di processo per le misurazioni") }}</p>
</div>
<!-- Filtri -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-4 mb-6 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Ricetta -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Ricetta") }}</label>
<select x-model="selectedRecipeId"
@change="onRecipeChange()"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">{{ _("Seleziona ricetta...") }}</option>
{% for r in recipes %}
<option value="{{ r.id }}">{{ r.code }} — {{ r.name }}</option>
{% endfor %}
</select>
</div>
<!-- Subtask -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
<select x-model="selectedSubtaskId"
:disabled="subtasks.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutti i punti") }}</option>
<template x-for="st in subtasks" :key="st.id">
<option :value="st.id" x-text="'#' + st.marker_number + ' — ' + st.description"></option>
</template>
</select>
</div>
<!-- Date range -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Dal") }}</label>
<input type="date" x-model="dateFrom"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Al") }}</label>
<input type="date" x-model="dateTo"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
</div>
</div>
<!-- Apply button + Download -->
<div class="mt-4 flex justify-between items-center">
<!-- Download buttons (visible only when data is loaded) -->
<div class="flex gap-2" x-show="hasData" x-cloak>
<button @click="downloadReport('spc')"
:disabled="!selectedSubtaskId || downloadingReport"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'spc'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'spc'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report SPC") }}
</button>
<button @click="downloadReport('measurements')"
:disabled="downloadingReport"
class="px-4 py-2 bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="downloadingReport === 'measurements'">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'measurements'">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _("Report Misurazioni") }}
</button>
</div>
<div x-show="!hasData"></div>
<button @click="loadData()"
:disabled="!selectedRecipeId || loading"
class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="loading">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
{{ _("Applica filtri") }}
</button>
</div>
<!-- Inline report error (shown below buttons row) -->
<div x-show="reportError" x-cloak
class="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
x-text="reportError"
x-transition>
</div>
</div>
<!-- Nessuna ricetta selezionata -->
<template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel">
<svg class="mx-auto h-16 w-16 text-steel-light mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<p class="text-lg font-medium">{{ _("Seleziona una ricetta per iniziare") }}</p>
<p class="text-sm mt-1">{{ _("Scegli una ricetta dal filtro e premi Applica filtri") }}</p>
</div>
</template>
<!-- Contenuto dashboard -->
<template x-if="hasData">
<div>
<!-- Row 1: Riepilogo + Capability -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Riepilogo Pass/Fail -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Riepilogo") }}</h2>
<template x-if="summary">
<div>
<div class="text-3xl font-bold font-mono text-[var(--text-primary)] mb-3" x-text="summary.total + ' misure'"></div>
<div class="grid grid-cols-3 gap-3">
<!-- Pass -->
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="summary.pass_rate + '%'"></div>
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("Pass") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.pass_count"></div>
</div>
<!-- Warning -->
<div class="text-center p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20">
<div class="text-2xl font-bold font-mono text-amber-600" x-text="summary.warning_rate + '%'"></div>
<div class="text-xs text-amber-700 dark:text-amber-400 mt-1">{{ _("Warning") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.warning_count"></div>
</div>
<!-- Fail -->
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
<div class="text-2xl font-bold font-mono text-red-600" x-text="summary.fail_rate + '%'"></div>
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("Fail") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.fail_count"></div>
</div>
</div>
</div>
</template>
</div>
<!-- Capability -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Indici di Capability") }}</h2>
<template x-if="capability && capability.cp != null">
<div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="text-center">
<div class="text-xs text-steel">Cp</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cp)" x-text="capability.cp"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Cpk</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cpk)" x-text="capability.cpk"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Pp</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.pp)" x-text="capability.pp"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Ppk</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.ppk)" x-text="capability.ppk"></div>
</div>
</div>
<!-- Gauge -->
<div id="cpk-gauge"></div>
<!-- Stats -->
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-steel">
<div>
<span class="font-medium">n:</span>
<span class="font-mono" x-text="capability.n"></span>
</div>
<div>
<span class="font-medium">x&#772;:</span>
<span class="font-mono" x-text="capability.mean"></span>
</div>
<div>
<span class="font-medium">&sigma;:</span>
<span class="font-mono" x-text="capability.std_dev"></span>
</div>
</div>
</div>
</template>
<template x-if="!capability || capability.cp == null">
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
</template>
</div>
</div>
<!-- Row 2: Carta di Controllo -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
<div id="control-chart" style="min-height: 350px;"></div>
</div>
<!-- Row 3: Istogramma -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
<div id="histogram-chart" style="min-height: 350px;"></div>
</div>
</div>
</template>
<!-- Error message -->
<template x-if="errorMessage">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300 text-sm">
<span x-text="errorMessage"></span>
</div>
</template>
</div>
{% endblock %}
{% block extra_js %}
<script>
// i18n bridge for spc-charts.js
window.SPC_I18N = {
noData: "{{ _('Nessun dato disponibile') }}",
inControl: "{{ _('In controllo') }}",
outOfControl: "{{ _('Fuori controllo') }}",
controlChart: "{{ _('Carta di Controllo') }}",
measureNum: "{{ _('Misura #') }}",
value: "{{ _('Valore') }}",
frequency: "{{ _('Frequenza') }}",
normalCurve: "{{ _('Curva normale') }}",
histogram: "{{ _('Istogramma') }}",
indicesNotAvailable: "{{ _('Indici non disponibili') }}",
};
</script>
<script src="{{ url_for('static', filename='js/spc-charts.js') }}"></script>
<script>
function spcDashboard() {
return {
// Filter state
selectedRecipeId: '',
selectedSubtaskId: '',
dateFrom: '',
dateTo: '',
subtasks: [],
// Data state
summary: null,
capability: null,
controlChart: null,
histogram: null,
// UI state
loading: false,
hasData: false,
errorMessage: '',
reportError: '',
downloadingReport: false,
init() {
// Nothing to pre-load
},
async onRecipeChange() {
this.selectedSubtaskId = '';
this.subtasks = [];
this.hasData = false;
this.summary = null;
this.capability = null;
this.controlChart = null;
this.histogram = null;
if (!this.selectedRecipeId) return;
// Load subtasks for dropdown
try {
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
const data = await resp.json();
if (Array.isArray(data)) {
this.subtasks = data;
}
} catch (e) {
console.error('Error loading subtasks:', e);
}
},
async loadData() {
if (!this.selectedRecipeId) return;
this.loading = true;
this.errorMessage = '';
this.hasData = false;
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
try {
// Parallel fetch of all endpoints
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
this.selectedSubtaskId
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
this.selectedSubtaskId
? fetch('/statistics/api/control-chart?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
this.selectedSubtaskId
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
]);
// Summary (always available)
if (summaryResp.status === 'fulfilled' && !summaryResp.value?.error) {
this.summary = summaryResp.value;
} else {
this.errorMessage = (summaryResp.value?.detail) || '{{ _("Errore nel caricamento dei dati") }}';
this.loading = false;
return;
}
// Capability
if (capabilityResp.status === 'fulfilled' && capabilityResp.value && !capabilityResp.value?.error) {
this.capability = capabilityResp.value;
} else {
this.capability = null;
}
// Control chart
if (controlResp.status === 'fulfilled' && controlResp.value && !controlResp.value?.error) {
this.controlChart = controlResp.value;
} else {
this.controlChart = null;
}
// Histogram
if (histogramResp.status === 'fulfilled' && histogramResp.value && !histogramResp.value?.error) {
this.histogram = histogramResp.value;
} else {
this.histogram = null;
}
this.hasData = true;
// Render charts after DOM update
this.$nextTick(() => {
this.renderCharts();
});
} catch (e) {
console.error('Error loading SPC data:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
} finally {
this.loading = false;
}
},
renderCharts() {
// Control chart
if (this.controlChart && this.selectedSubtaskId) {
renderControlChart('control-chart', this.controlChart);
} else {
const el = document.getElementById('control-chart');
if (el) el.innerHTML = '<p class="text-center text-steel py-12">{{ _("Seleziona un punto di misura per la carta di controllo") }}</p>';
}
// Histogram
if (this.histogram && this.selectedSubtaskId) {
const tol = this.capability ? {
utl: this.capability.utl,
ltl: this.capability.ltl,
nominal: this.capability.nominal,
} : null;
renderHistogram('histogram-chart', this.histogram, tol);
} else {
const el = document.getElementById('histogram-chart');
if (el) el.innerHTML = '<p class="text-center text-steel py-12">' + {{ _("Seleziona un punto di misura per l'istogramma")|tojson }} + '</p>';
}
// Capability gauge
if (this.capability && this.capability.cpk != null) {
renderCapabilityGauge('cpk-gauge', this.capability);
}
},
capabilityColor(value) {
if (value == null) return 'text-steel';
if (value >= 1.33) return 'text-emerald-600';
if (value >= 1.0) return 'text-amber-600';
return 'text-red-600';
},
async downloadReport(type) {
this.reportError = '';
this.downloadingReport = type;
try {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url);
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
console.error('Report download failed (' + resp.status + '):', msg, errData);
this.reportError = msg;
return;
}
// Check Content-Type: server may return JSON error with 200 status
const contentType = resp.headers.get('Content-Type') || '';
if (!contentType.includes('application/pdf')) {
const errData = await resp.json().catch(() => ({}));
const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
console.error('Report returned non-PDF content-type "' + contentType + '":', msg, errData);
this.reportError = msg;
return;
}
// Download the PDF
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = type === 'spc'
? 'spc_report_' + this.selectedRecipeId + '.pdf'
: 'measurements_report_' + this.selectedRecipeId + '.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (e) {
console.error('Error downloading report:', e);
this.reportError = '{{ _("Errore di connessione al server") }}';
} finally {
this.downloadingReport = false;
}
},
};
}
</script>
{% endblock %}
+104
View File
@@ -0,0 +1,104 @@
"""Shared test fixtures for Flask client tests.
Creates a Flask test client and mocks the API client (requests to FastAPI server).
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Ensure the client package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app import create_app
# All blueprint modules that import api_client at module level.
# We must patch at the *use-site* (blueprint namespace) so the local name
# ``api_client`` in each module points to the mock, not the real singleton.
_API_CLIENT_PATCH_TARGETS = [
"blueprints.auth.api_client",
"blueprints.maker.api_client",
"blueprints.measure.api_client",
"blueprints.statistics.api_client",
]
@pytest.fixture
def flask_app():
"""Create a Flask application configured for testing."""
app = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["SECRET_KEY"] = "test-secret-key"
# Provide default template variables that are normally set inside
# specific Jinja2 blocks and therefore not visible to sibling blocks.
@app.context_processor
def _inject_template_defaults():
return {
"versions": [],
"current_version": None,
}
return app
@pytest.fixture
def client(flask_app):
"""Yield a Flask test client."""
with flask_app.test_client() as test_client:
yield test_client
@pytest.fixture
def logged_in_client(flask_app):
"""Yield a Flask test client with a logged-in session."""
with flask_app.test_client() as test_client:
with test_client.session_transaction() as sess:
sess["api_key"] = "test-api-key-12345"
sess["user"] = {
"id": 1,
"username": "testuser",
"display_name": "Test User",
"roles": ["Maker", "MeasurementTec", "Metrologist"],
"is_admin": True,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 1
sess["language"] = "en"
sess["theme"] = "light"
yield test_client
@pytest.fixture
def mock_api_client():
"""Patch the api_client singleton used in blueprints.
Patches every blueprint module that does
``from services.api_client import api_client`` so the local name
in each module resolves to the same MagicMock instance.
Yields the mocked APIClient instance so tests can configure responses:
mock_api_client.get.return_value = {"items": [...], "total": 1}
mock_api_client.post.return_value = {"user": {...}, "api_key": "..."}
"""
mock = MagicMock()
# Default: all methods return empty success dict
mock.get.return_value = {}
mock.post.return_value = {}
mock.put.return_value = {}
mock.delete.return_value = {}
# Stack patches for every blueprint that imports api_client
patchers = [patch(target, mock) for target in _API_CLIENT_PATCH_TARGETS]
for p in patchers:
p.start()
yield mock
for p in patchers:
p.stop()
@@ -0,0 +1,134 @@
"""Tests for the admin Stations management UI and proxy endpoints."""
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def mock_admin_api():
"""Patch api_client used by the admin blueprint."""
mock = MagicMock()
with patch("blueprints.admin.api_client", mock):
yield mock
def test_station_list_page_requires_admin(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
assert b"Gestione Stazioni" in resp.data or b"stations" in resp.data.lower()
def test_station_list_page_calls_correct_endpoints(logged_in_client, mock_admin_api):
mock_admin_api.get.side_effect = [
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
"notes": None, "active": True, "created_by": 1, "created_at": "2026-04-25T10:00:00"}],
[{"id": 1, "code": "REC-001", "name": "Test Recipe", "active": True}],
]
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
calls = [c.args[0] for c in mock_admin_api.get.call_args_list]
assert "/api/stations" in calls
assert "/api/recipes" in calls
def test_create_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"id": 5, "code": "ST-005", "name": "Nuova",
"location": None, "notes": None, "active": True,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.post(
"/admin/api/stations",
json={"code": "ST-005", "name": "Nuova", "active": True},
)
assert resp.status_code == 201
body = resp.get_json()
assert body["code"] == "ST-005"
mock_admin_api.post.assert_called_once_with(
"/api/stations",
data={"code": "ST-005", "name": "Nuova", "active": True},
)
def test_create_station_propagates_error(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"error": True, "status_code": 409, "detail": "code already exists",
}
resp = logged_in_client.post(
"/admin/api/stations", json={"code": "ST-001", "name": "x"},
)
assert resp.status_code == 409
assert resp.get_json()["detail"] == "code already exists"
def test_update_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {
"id": 3, "code": "ST-003", "name": "Aggiornata",
"location": "Reparto B", "notes": None, "active": False,
"created_by": 1, "created_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.put(
"/admin/api/stations/3",
json={"name": "Aggiornata", "location": "Reparto B", "active": False},
)
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with(
"/api/stations/3",
data={"name": "Aggiornata", "location": "Reparto B", "active": False},
)
def test_delete_station_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {}
resp = logged_in_client.delete("/admin/api/stations/7")
assert resp.status_code == 200
assert resp.get_json() == {"deleted": True}
mock_admin_api.delete.assert_called_once_with("/api/stations/7")
def test_assign_recipe_proxy(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {
"id": 1, "station_id": 2, "recipe_id": 10,
"assigned_by": 1, "assigned_at": "2026-04-25T10:00:00",
}
resp = logged_in_client.post(
"/admin/api/stations/2/recipes", json={"recipe_id": 10},
)
assert resp.status_code == 201
mock_admin_api.post.assert_called_once_with(
"/api/stations/2/recipes", data={"recipe_id": 10},
)
def test_unassign_recipe_proxy(logged_in_client, mock_admin_api):
mock_admin_api.delete.return_value = {}
resp = logged_in_client.delete("/admin/api/stations/2/recipes/10")
assert resp.status_code == 200
mock_admin_api.delete.assert_called_once_with("/api/stations/2/recipes/10")
def test_list_station_recipes_proxy(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = [
{"id": 1, "code": "REC-001", "name": "R1", "active": True},
]
resp = logged_in_client.get("/admin/api/stations/2/recipes")
assert resp.status_code == 200
assert resp.get_json() == [{"id": 1, "code": "REC-001", "name": "R1", "active": True}]
mock_admin_api.get.assert_called_once_with("/api/stations/2/recipes")
def test_non_admin_cannot_access(client, mock_admin_api):
"""Non-admin user gets redirected away from station management."""
with client.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 2,
"username": "operator",
"roles": ["MeasurementTec"],
"is_admin": False,
"active": True,
}
sess["user_id"] = 2
resp = client.get("/admin/stations", follow_redirects=False)
assert resp.status_code in (301, 302)
@@ -0,0 +1,70 @@
"""Tests that APIClient forwards the real tablet IP via X-Forwarded-For."""
from unittest.mock import patch
from services.api_client import api_client
def _last_call_headers(mock):
"""Return the headers kwarg from the most recent requests.* call."""
return mock.call_args.kwargs["headers"]
def test_get_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "203.0.113.10"}
):
with patch("services.api_client.requests.get") as mock_get:
mock_get.return_value.ok = True
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {}
api_client.get("/api/health")
headers = _last_call_headers(mock_get)
assert headers["X-Forwarded-For"] == "203.0.113.10"
assert headers["X-Real-IP"] == "203.0.113.10"
def test_post_json_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "198.51.100.42"}
):
with patch("services.api_client.requests.post") as mock_post:
mock_post.return_value.ok = True
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
api_client.post("/api/foo", data={"x": 1})
headers = _last_call_headers(mock_post)
assert headers["X-Forwarded-For"] == "198.51.100.42"
def test_post_with_files_forwards_real_client_ip(flask_app):
with flask_app.test_request_context(
"/", environ_base={"REMOTE_ADDR": "198.51.100.77"}
):
with patch("services.api_client.requests.post") as mock_post:
mock_post.return_value.ok = True
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
api_client.post(
"/api/files/upload", data={}, files={"file": ("x.txt", b"data")}
)
headers = _last_call_headers(mock_post)
assert headers["X-Forwarded-For"] == "198.51.100.77"
assert headers["X-Real-IP"] == "198.51.100.77"
def test_real_client_ip_returns_none_outside_request_context(flask_app):
"""Background workers without a Flask request must NOT inject a fake IP."""
from services.api_client import APIClient
with flask_app.app_context():
assert APIClient._real_client_ip() is None
def test_proxy_fix_is_installed(flask_app):
"""ProxyFix must wrap wsgi_app so REMOTE_ADDR is rewritten in production."""
from werkzeug.middleware.proxy_fix import ProxyFix
assert isinstance(flask_app.wsgi_app, ProxyFix)
@@ -0,0 +1,15 @@
"""Tests for the station-related helpers in api_client."""
from unittest.mock import patch
from services.api_client import APIClient
def test_get_station_recipes_calls_correct_endpoint():
client = APIClient()
with patch.object(client, "get") as mock_get:
mock_get.return_value = [{"id": 1, "code": "R1", "name": "R1", "active": True}]
result = client.get_station_recipes("ST-001")
# The helper must call the underlying .get() with the exact endpoint path.
called_args, called_kwargs = mock_get.call_args
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
+130
View File
@@ -0,0 +1,130 @@
"""Tests for auth blueprint (client/blueprints/auth.py).
Covers login GET/POST, logout, unauthenticated redirect, and language/theme switch.
"""
import pytest
from unittest.mock import patch
class TestLoginPage:
"""GET /auth/login tests."""
def test_login_page_renders(self, client):
"""Login page renders successfully with 200 status."""
resp = client.get("/auth/login")
assert resp.status_code == 200
assert b"login" in resp.data.lower() or b"Login" in resp.data
def test_login_page_redirects_when_already_logged_in(self, logged_in_client):
"""Already authenticated user is redirected away from login."""
resp = logged_in_client.get("/auth/login")
assert resp.status_code == 302
class TestLoginPost:
"""POST /auth/login tests."""
def test_login_post_success_redirects(self, client, mock_api_client):
"""Successful login POST sets session and redirects."""
mock_api_client.post.return_value = {
"api_key": "new-api-key-abc",
"user": {
"id": 1,
"username": "testuser",
"display_name": "Test User",
"roles": ["MeasurementTec"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
},
}
# Mock settings call made after login
mock_api_client.get.return_value = {}
resp = client.post(
"/auth/login",
data={"username": "testuser", "password": "pass123"},
follow_redirects=False,
)
assert resp.status_code == 302
mock_api_client.post.assert_called_once()
# Verify session was populated
with client.session_transaction() as sess:
assert sess.get("api_key") == "new-api-key-abc"
assert sess["user"]["username"] == "testuser"
def test_login_post_error_shows_error(self, client, mock_api_client):
"""Failed login returns the login page with error flash."""
mock_api_client.post.return_value = {
"error": True,
"detail": "Credenziali non valide",
}
resp = client.post(
"/auth/login",
data={"username": "bad", "password": "wrong"},
follow_redirects=True,
)
assert resp.status_code == 200
# Error message should appear in the response (flashed)
assert b"Credenziali non valide" in resp.data or b"login" in resp.data.lower()
def test_login_post_empty_fields(self, client, mock_api_client):
"""Login with empty username/password shows validation error."""
resp = client.post(
"/auth/login",
data={"username": "", "password": ""},
follow_redirects=True,
)
assert resp.status_code == 200
# Should render login again (not crash)
class TestLogout:
"""GET/POST /auth/logout tests."""
def test_logout_clears_session(self, logged_in_client, mock_api_client):
"""Logout clears session and redirects to login."""
mock_api_client.post.return_value = {}
resp = logged_in_client.get("/auth/logout", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
# Session should be cleared
with logged_in_client.session_transaction() as sess:
assert "api_key" not in sess
assert "user" not in sess
class TestUnauthenticatedRedirect:
"""Protected routes redirect unauthenticated users."""
def test_profile_requires_login(self, client):
"""Profile page redirects to login when not authenticated."""
resp = client.get("/auth/profile", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestLanguageSwitch:
"""Language and theme switching via app routes."""
def test_set_language_stores_in_session(self, client):
"""Setting language updates session and redirects back."""
resp = client.get("/set-language/en", follow_redirects=False)
assert resp.status_code == 302
with client.session_transaction() as sess:
assert sess.get("language") == "en"
def test_set_language_invalid_ignored(self, client):
"""Setting an unsupported language does not crash and still redirects."""
resp = client.get("/set-language/xx", follow_redirects=False)
assert resp.status_code == 302
with client.session_transaction() as sess:
# Invalid language should NOT be stored
assert sess.get("language") != "xx"
@@ -0,0 +1,17 @@
"""Tests that STATION_CODE is loaded from env and exposed on the client Config class."""
import importlib
import os
def test_station_code_read_from_env(monkeypatch):
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
importlib.reload(config)
assert config.Config.STATION_CODE == "ST-TEST"
def test_station_code_defaults_to_none_when_missing(monkeypatch):
monkeypatch.delenv("STATION_CODE", raising=False)
import config
importlib.reload(config)
assert config.Config.STATION_CODE is None
@@ -0,0 +1,89 @@
"""Tests for maker blueprint (client/blueprints/maker.py).
Covers recipe list, recipe creation page, role enforcement, and recipe edit.
"""
import pytest
class TestRecipeList:
"""GET /maker/recipes tests."""
def test_recipe_list_renders(self, logged_in_client, mock_api_client):
"""Recipe list page renders for Maker role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/maker/recipes")
assert resp.status_code == 200
def test_recipe_list_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/maker/recipes", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestRecipeNew:
"""GET /maker/recipes/new tests."""
def test_recipe_new_renders(self, logged_in_client, mock_api_client):
"""Recipe creation page renders for Maker role."""
resp = logged_in_client.get("/maker/recipes/new")
assert resp.status_code == 200
class TestRoleRequired:
"""Maker role enforcement tests."""
def test_requires_maker_role(self, flask_app):
"""User without Maker role gets 403 on maker endpoints."""
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 2,
"username": "tec_only",
"display_name": "Tec Only",
"roles": ["MeasurementTec"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 2
sess["language"] = "en"
sess["theme"] = "light"
resp = c.get("/maker/recipes")
assert resp.status_code == 403
class TestRecipeEdit:
"""GET /maker/recipes/<id>/edit tests."""
def test_recipe_edit_renders(self, logged_in_client, mock_api_client):
"""Recipe edit page renders with recipe data."""
# First call: recipe detail, second call: versions list
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
[{"version_number": 1, "is_current": True}],
]
resp = logged_in_client.get("/maker/recipes/1/edit")
assert resp.status_code == 200
def test_recipe_edit_not_found(self, logged_in_client, mock_api_client):
"""Recipe edit with API error redirects to recipe list."""
mock_api_client.get.return_value = {
"error": True,
"detail": "Not found",
}
resp = logged_in_client.get("/maker/recipes/999/edit", follow_redirects=False)
assert resp.status_code == 302
assert "/maker/recipes" in resp.headers["Location"]
@@ -0,0 +1,94 @@
"""Tests for measure blueprint (client/blueprints/measure.py).
Covers recipe selection, task list, login requirement, and measurement submission.
"""
import pytest
class TestSelectRecipe:
"""GET /measure/select tests."""
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
def test_select_recipe_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/measure/select", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestTaskList:
"""GET /measure/tasks/<recipe_id> tests."""
def test_task_list_renders(self, logged_in_client, mock_api_client):
"""Task list page renders with recipe and task data."""
# First call: recipe details (dict), second call: tasks list.
# The route calls tasks_resp.get("error") so the mock must return
# a dict (not a bare list) to avoid AttributeError.
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
{
"items": [
{"id": 1, "title": "Task 1", "order_index": 0},
{"id": 2, "title": "Task 2", "order_index": 1},
],
},
]
resp = logged_in_client.get("/measure/tasks/1")
assert resp.status_code == 200
class TestSaveMeasurement:
"""POST /measure/save-measurement tests."""
def test_save_measurement_proxy(self, logged_in_client, mock_api_client):
"""Save measurement forwards data to API and returns 201."""
mock_api_client.post.return_value = {
"id": 1,
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
}
resp = logged_in_client.post(
"/measure/save-measurement",
json={
"subtask_id": 10,
"task_id": 5,
"value": 9.95,
"pass_fail": "pass",
"deviation": 0.05,
},
content_type="application/json",
)
assert resp.status_code == 201
data = resp.get_json()
assert data["id"] == 1
assert data["pass_fail"] == "pass"
def test_save_measurement_missing_fields(self, logged_in_client, mock_api_client):
"""Missing required fields return 400."""
resp = logged_in_client.post(
"/measure/save-measurement",
json={"subtask_id": 10}, # missing task_id and value
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert data["error"] is True
@@ -0,0 +1,37 @@
"""Verify that /measure/select reads STATION_CODE and filters recipes via the server."""
import importlib
from unittest.mock import patch, MagicMock
def _reload_measure(monkeypatch, station_code=None):
"""Reload config and measure module under the given STATION_CODE env."""
if station_code is None:
monkeypatch.delenv("STATION_CODE", raising=False)
else:
monkeypatch.setenv("STATION_CODE", station_code)
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
def test_select_recipe_calls_station_endpoint(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code="ST-TEST")
from blueprints import measure as measure_bp_mod
with patch.object(measure_bp_mod, "api_client") as mock_api:
mock_api.get_station_recipes.return_value = [
{"id": 1, "code": "R1", "name": "Recipe 1", "active": True},
]
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 200
mock_api.get_station_recipes.assert_called_once()
args, kwargs = mock_api.get_station_recipes.call_args
assert args[0] == "ST-TEST" or kwargs.get("station_code") == "ST-TEST"
def test_select_recipe_without_station_code_shows_error(logged_in_client, monkeypatch):
_reload_measure(monkeypatch, station_code=None)
resp = logged_in_client.get("/measure/select")
assert resp.status_code == 503
body = resp.data.lower()
assert b"station_code" in body or b"stazione" in body
@@ -0,0 +1,88 @@
"""Tests for statistics blueprint (client/blueprints/statistics.py).
Covers dashboard rendering, role enforcement, and SPC/summary API proxies.
"""
import pytest
class TestDashboard:
"""GET /statistics/dashboard tests."""
def test_dashboard_renders(self, logged_in_client, mock_api_client):
"""Dashboard renders for Metrologist role."""
mock_api_client.get.return_value = {
"items": [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
],
"total": 1,
"pages": 1,
}
resp = logged_in_client.get("/statistics/dashboard")
assert resp.status_code == 200
def test_dashboard_requires_login(self, client):
"""Unauthenticated user is redirected to login."""
resp = client.get("/statistics/dashboard", follow_redirects=False)
assert resp.status_code == 302
assert "/auth/login" in resp.headers["Location"]
class TestRoleRequired:
"""Metrologist role enforcement tests."""
def test_requires_metrologist_role(self, flask_app):
"""User without Metrologist role gets 403 on statistics endpoints."""
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {
"id": 3,
"username": "maker_only",
"display_name": "Maker Only",
"roles": ["Maker"],
"is_admin": False,
"language_pref": "en",
"theme_pref": "light",
"active": True,
}
sess["user_id"] = 3
sess["language"] = "en"
sess["theme"] = "light"
resp = c.get("/statistics/dashboard")
assert resp.status_code == 403
class TestSPCProxy:
"""SPC data proxy endpoint tests."""
def test_summary_proxy(self, logged_in_client, mock_api_client):
"""Summary API proxy returns JSON from backend."""
mock_api_client.get.return_value = {
"total": 100,
"pass_count": 90,
"fail_count": 5,
"warning_count": 5,
}
resp = logged_in_client.get(
"/statistics/api/summary?recipe_id=1"
)
assert resp.status_code == 200
data = resp.get_json()
assert data["total"] == 100
def test_capability_proxy(self, logged_in_client, mock_api_client):
"""Capability API proxy returns JSON from backend."""
mock_api_client.get.return_value = {
"cp": 1.33,
"cpk": 1.20,
}
resp = logged_in_client.get(
"/statistics/api/capability?recipe_id=1&subtask_id=1"
)
assert resp.status_code == 200
data = resp.get_json()
assert "cp" in data
@@ -0,0 +1,5 @@
[python: *.py]
[python: blueprints/**.py]
[python: services/**.py]
[jinja2: templates/**.html]
encoding = utf-8
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python
"""Verify i18n setup for TieMeasureFlow."""
import json
from pathlib import Path
def check_file(path: Path, description: str) -> bool:
"""Check if a file exists and report."""
exists = path.exists()
status = "[OK]" if exists else "[FAIL]"
print(f"{status} {description}: {path}")
return exists
def check_json_valid(path: Path) -> bool:
"""Check if JSON file is valid."""
try:
with open(path, 'r', encoding='utf-8') as f:
json.load(f)
return True
except Exception as e:
print(f" ERROR: {e}")
return False
def main():
"""Verify i18n setup."""
print("=== TieMeasureFlow i18n Verification ===\n")
root = Path(__file__).parent
all_good = True
# Check Flask-Babel files
print("Flask-Babel (Server-side):")
files = [
(root / "translations/babel.cfg", "Babel config"),
(root / "translations/it/LC_MESSAGES/messages.po", "Italian .po"),
(root / "translations/it/LC_MESSAGES/messages.mo", "Italian .mo"),
(root / "translations/en/LC_MESSAGES/messages.po", "English .po"),
(root / "translations/en/LC_MESSAGES/messages.mo", "English .mo"),
]
for path, desc in files:
if not check_file(path, desc):
all_good = False
# Count messages in .po files
if (root / "translations/it/LC_MESSAGES/messages.po").exists():
with open(root / "translations/it/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
content = f.read()
msgid_count = content.count('msgid "') - 1 # Exclude header
print(f" Italian: {msgid_count} messages")
if (root / "translations/en/LC_MESSAGES/messages.po").exists():
with open(root / "translations/en/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
content = f.read()
msgid_count = content.count('msgid "') - 1
print(f" English: {msgid_count} messages")
# Check Alpine.js i18n files
print("\nAlpine.js i18n (Client-side):")
json_files = [
(root / "static/js/locales/it.json", "Italian locale"),
(root / "static/js/locales/en.json", "English locale"),
]
for path, desc in json_files:
if check_file(path, desc):
if not check_json_valid(path):
all_good = False
else:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
keys = count_keys(data)
print(f" {keys} total keys")
# Check app.py integration
print("\nApp Integration:")
app_py = root / "app.py"
if check_file(app_py, "app.py"):
with open(app_py, 'r', encoding='utf-8') as f:
content = f.read()
checks = [
("from flask_babel import Babel", "Flask-Babel imported"),
("def get_locale()", "get_locale() function"),
("Babel(app, locale_selector=get_locale)", "Babel initialized"),
("def set_language(lang)", "set_language endpoint"),
]
for check_str, check_desc in checks:
found = check_str in content
status = "[OK]" if found else "[FAIL]"
print(f" {status} {check_desc}")
if not found:
all_good = False
# Summary
print("\n" + "="*40)
if all_good:
print("[OK] i18n setup verified successfully!")
print("\nNext steps:")
print("1. Use _() in Python code and templates")
print("2. Use $t() in Alpine.js components")
print("3. Test language switching: /set-language/en or /set-language/it")
else:
print("[FAIL] Some checks failed - review errors above")
return 1
return 0
def count_keys(obj, depth=0):
"""Recursively count keys in nested dict."""
if not isinstance(obj, dict):
return 0
count = len(obj)
for value in obj.values():
if isinstance(value, dict):
count += count_keys(value, depth + 1)
return count
if __name__ == '__main__':
exit(main())