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:
@@ -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()"]
|
||||
@@ -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
|
||||
@@ -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 " 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('"', """)
|
||||
)
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 © 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 %}
|
||||
@@ -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> — 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">
|
||||
— {{ 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>·</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 %}—{% 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 %}—{% 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 %}—{% 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 %}—{% 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 %}—{% 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">
|
||||
— {{ 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 ·
|
||||
{% 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̄:</span>
|
||||
<span class="font-mono" x-text="capability.mean"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">σ:</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 %}
|
||||
@@ -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}]
|
||||
@@ -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
@@ -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())
|
||||
Reference in New Issue
Block a user