feat: FASE 3 - Flusso MeasurementTec (selezione ricetta, esecuzione misure, riepilogo)
Implementazione completa del flusso operativo per il ruolo MeasurementTec:
Blueprint measure.py:
- select_recipe: selezione ricetta con ricerca e barcode
- task_list: lista task con conteggi subtask e allegati
- task_execute: esecuzione misure con numpad, calibro USB, feedback real-time
- task_complete: riepilogo con statistiche pass/fail e export CSV
- API AJAX: lookup-barcode, save-traceability, save-measurement
- Autorizzazione role_required("MeasurementTec") su tutte le route
Componenti riutilizzabili:
- numpad.html/js/css: tastierino numerico touch-friendly con keyboard support
- caliper_status.html + caliper.js: integrazione calibro USB via Web Serial API
- barcode_scanner.html + barcode.js: scansione barcode con html5-qrcode
- measurement_feedback.html: feedback visivo pass/warning/fail in tempo reale
- next_measurement.html: indicatore prossima misurazione
- annotation-viewer.js: visualizzatore canvas con marker su disegni tecnici
- csv-export.js: export CSV con locale italiano (delimitatore ;, decimale ,)
Sicurezza:
- Decoratore role_required(*roles) per autorizzazione basata su ruoli
- CSRF token su tutti i POST AJAX
- |tojson per prevenire XSS su annotations_json
- Validazione input lato client e server
i18n: 23+ nuove chiavi tradotte IT/EN per tutti i template FASE 3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"""Authentication blueprint - login, logout, profile."""
|
"""Authentication blueprint - login, logout, profile."""
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
@@ -20,6 +20,25 @@ def login_required(f):
|
|||||||
return decorated
|
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"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
|
|||||||
+252
-15
@@ -1,36 +1,273 @@
|
|||||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
from flask import Blueprint, render_template, session, redirect, url_for
|
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, role_required
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
measure_bp = Blueprint("measure", __name__)
|
measure_bp = Blueprint("measure", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route: Recipe selection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@measure_bp.route("/select")
|
@measure_bp.route("/select")
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
def select_recipe():
|
def select_recipe():
|
||||||
"""Recipe selection page."""
|
"""Recipe selection page with search and barcode support."""
|
||||||
if "user" not in session:
|
# Load recipes from API
|
||||||
return redirect(url_for("auth.login"))
|
resp = api_client.get("/api/recipes", params={"per_page": 100})
|
||||||
return render_template("measure/select_recipe.html")
|
if 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,
|
||||||
|
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>")
|
@measure_bp.route("/tasks/<int:recipe_id>")
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
def task_list(recipe_id: int):
|
def task_list(recipe_id: int):
|
||||||
"""Task list for selected recipe."""
|
"""Task list for selected recipe."""
|
||||||
if "user" not in session:
|
# Persist lot/serial from query params into session
|
||||||
return redirect(url_for("auth.login"))
|
lot_number = request.args.get(
|
||||||
return render_template("measure/task_list.html", recipe_id=recipe_id)
|
"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 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>")
|
@measure_bp.route("/execute/<int:task_id>")
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
def task_execute(task_id: int):
|
def task_execute(task_id: int):
|
||||||
"""Execute measurements for a task."""
|
"""Execute measurements for a task."""
|
||||||
if "user" not in session:
|
# Load task + subtasks
|
||||||
return redirect(url_for("auth.login"))
|
task_resp = api_client.get(f"/api/tasks/{task_id}")
|
||||||
return render_template("measure/task_execute.html", task_id=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", "")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"measure/task_execute.html",
|
||||||
|
task=task_resp,
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route: Task completion summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@measure_bp.route("/complete/<int:recipe_id>")
|
@measure_bp.route("/complete/<int:recipe_id>")
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
def task_complete(recipe_id: int):
|
def task_complete(recipe_id: int):
|
||||||
"""Task completion summary."""
|
"""Task completion summary with all measurements."""
|
||||||
if "user" not in session:
|
# Retrieve version_id from query params
|
||||||
return redirect(url_for("auth.login"))
|
version_id = request.args.get("version_id")
|
||||||
return render_template("measure/task_complete.html", recipe_id=recipe_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 measurements if version_id provided
|
||||||
|
measurements = []
|
||||||
|
if version_id:
|
||||||
|
meas_resp = api_client.get(
|
||||||
|
"/api/measurements",
|
||||||
|
params={"version_id": version_id},
|
||||||
|
)
|
||||||
|
if not meas_resp.get("error"):
|
||||||
|
measurements = (
|
||||||
|
meas_resp if isinstance(meas_resp, list)
|
||||||
|
else meas_resp.get("items", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
task_id = data.get("task_id")
|
||||||
|
value = data.get("value")
|
||||||
|
|
||||||
|
if subtask_id is None or task_id is None or value is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Dati mancanti: subtask_id, task_id e value sono obbligatori"),
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Build payload for the FastAPI backend
|
||||||
|
payload = {
|
||||||
|
"subtask_id": subtask_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"value": value,
|
||||||
|
"pass_fail": data.get("pass_fail", ""),
|
||||||
|
"deviation": data.get("deviation"),
|
||||||
|
"lot_number": data.get("lot_number", session.get("lot_number", "")),
|
||||||
|
"serial_number": data.get("serial_number", session.get("serial_number", "")),
|
||||||
|
"measured_by": session.get("user_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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,273 @@
|
|||||||
|
/**
|
||||||
|
* 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 con scaling
|
||||||
|
*/
|
||||||
|
loadImage() {
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
// Calculate scale to fit container
|
||||||
|
const container = this.canvas.parentElement;
|
||||||
|
if (!container) {
|
||||||
|
console.error('AnnotationViewer: parent container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
const containerHeight = container.clientHeight || 500; // fallback height
|
||||||
|
|
||||||
|
this.scale = Math.min(
|
||||||
|
containerWidth / img.width,
|
||||||
|
containerHeight / img.height,
|
||||||
|
1 // non ingrandire oltre dimensione originale
|
||||||
|
);
|
||||||
|
|
||||||
|
this.canvas.width = img.width * this.scale;
|
||||||
|
this.canvas.height = img.height * this.scale;
|
||||||
|
|
||||||
|
// Draw image
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.imageLoaded = true;
|
||||||
|
|
||||||
|
// Draw annotations on top
|
||||||
|
this.drawAnnotations();
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
console.error('AnnotationViewer: failed to load image:', this.imageUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = this.imageUrl;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disegna tutti i markers sulle annotazioni
|
||||||
|
*/
|
||||||
|
drawAnnotations() {
|
||||||
|
if (!this.annotations || !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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Barcode Scanner Manager
|
||||||
|
* Integration with html5-qrcode library
|
||||||
|
* Supports QR codes, barcodes, and camera scanning
|
||||||
|
*/
|
||||||
|
|
||||||
|
function barcodeScanner() {
|
||||||
|
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 = 'Scanner library not loaded';
|
||||||
|
this.scanning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get available cameras
|
||||||
|
const devices = await Html5Qrcode.getCameras();
|
||||||
|
|
||||||
|
if (!devices || devices.length === 0) {
|
||||||
|
this.error = 'Nessuna fotocamera disponibile';
|
||||||
|
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 = 'Impossibile accedere alla fotocamera';
|
||||||
|
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,142 @@
|
|||||||
|
/**
|
||||||
|
* Caliper Connection Manager
|
||||||
|
* Web Serial API integration for USB digital calipers
|
||||||
|
* Supports standard SPC/Digimatic protocol
|
||||||
|
*/
|
||||||
|
|
||||||
|
function caliperConnection() {
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
status: 'disconnected', // disconnected, connecting, connected, error
|
||||||
|
lastReading: null,
|
||||||
|
port: null,
|
||||||
|
reader: null,
|
||||||
|
readController: null,
|
||||||
|
|
||||||
|
get isSupported() {
|
||||||
|
return 'serial' in navigator;
|
||||||
|
},
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (!this.isSupported) {
|
||||||
|
this.status = 'error';
|
||||||
|
console.error('Web Serial API not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.status = 'connecting';
|
||||||
|
|
||||||
|
// Request port selection
|
||||||
|
this.port = await navigator.serial.requestPort();
|
||||||
|
|
||||||
|
// Open port with standard caliper settings
|
||||||
|
await this.port.open({
|
||||||
|
baudRate: 9600,
|
||||||
|
dataBits: 8,
|
||||||
|
stopBits: 1,
|
||||||
|
parity: 'none',
|
||||||
|
flowControl: 'none'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connected = true;
|
||||||
|
this.status = 'connected';
|
||||||
|
|
||||||
|
// Start reading loop
|
||||||
|
this.readLoop();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.status = 'error';
|
||||||
|
this.connected = false;
|
||||||
|
console.error('Caliper connection error:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async readLoop() {
|
||||||
|
if (!this.port || !this.port.readable) return;
|
||||||
|
|
||||||
|
const reader = this.port.readable.getReader();
|
||||||
|
this.reader = reader;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
console.log('Reader closed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.parseData(value);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'NetworkError') {
|
||||||
|
console.error('Read error:', err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseData(data) {
|
||||||
|
try {
|
||||||
|
// Decode buffer to text
|
||||||
|
const text = new TextDecoder().decode(data);
|
||||||
|
|
||||||
|
// Standard digital caliper protocol patterns
|
||||||
|
// Format: typically " XX.XXX mm\r\n" or similar
|
||||||
|
// Try multiple patterns for compatibility
|
||||||
|
|
||||||
|
// Pattern 1: Standard with units
|
||||||
|
let match = text.match(/([+-]?\s*\d+\.?\d*)\s*mm/i);
|
||||||
|
|
||||||
|
// Pattern 2: Just number with optional sign
|
||||||
|
if (!match) {
|
||||||
|
match = text.match(/([+-]?\s*\d+\.?\d*)/);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
// Clean whitespace and parse
|
||||||
|
const valueStr = match[1].replace(/\s+/g, '');
|
||||||
|
const value = parseFloat(valueStr);
|
||||||
|
|
||||||
|
if (!isNaN(value)) {
|
||||||
|
this.lastReading = value;
|
||||||
|
|
||||||
|
// Dispatch event for other components
|
||||||
|
this.$dispatch('caliper-reading', {
|
||||||
|
value: value,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Parse error:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
try {
|
||||||
|
// Cancel reader
|
||||||
|
if (this.reader) {
|
||||||
|
await this.reader.cancel();
|
||||||
|
this.reader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close port
|
||||||
|
if (this.port) {
|
||||||
|
await this.port.close();
|
||||||
|
this.port = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Disconnect error:', err);
|
||||||
|
} finally {
|
||||||
|
this.connected = false;
|
||||||
|
this.status = 'disconnected';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cleanup on component destroy
|
||||||
|
destroy() {
|
||||||
|
this.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* CSV Export Utility
|
||||||
|
* Client-side CSV generation and download with Italian locale support
|
||||||
|
* Uses Blob API for efficient file generation
|
||||||
|
*/
|
||||||
|
|
||||||
|
function csvExport() {
|
||||||
|
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',
|
||||||
|
'Subtask ID',
|
||||||
|
'Nome Sottotask',
|
||||||
|
'Valore Misurato',
|
||||||
|
'Unità',
|
||||||
|
'Valore Nominale',
|
||||||
|
'Tolleranza +',
|
||||||
|
'Tolleranza -',
|
||||||
|
'Scarto',
|
||||||
|
'Esito',
|
||||||
|
'Numero Lotto',
|
||||||
|
'Numero Seriale',
|
||||||
|
'Metodo Input',
|
||||||
|
'Data Misurazione',
|
||||||
|
'Operatore'
|
||||||
|
];
|
||||||
|
|
||||||
|
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 = 'RIEPILOGO ESECUZIONE TASK\n\n';
|
||||||
|
|
||||||
|
csv += `Task ID${this.delimiter}${task.id}\n`;
|
||||||
|
csv += `Nome Task${this.delimiter}${this.escapeCsvValue(task.name || '')}\n`;
|
||||||
|
csv += `Ricetta${this.delimiter}${this.escapeCsvValue(task.recipe_name || '')}\n`;
|
||||||
|
csv += `Operatore${this.delimiter}${this.escapeCsvValue(task.operator_name || '')}\n`;
|
||||||
|
csv += `Data Inizio${this.delimiter}${this.formatDateTime(task.started_at)}\n`;
|
||||||
|
csv += `Data Fine${this.delimiter}${this.formatDateTime(task.completed_at)}\n`;
|
||||||
|
csv += `Stato${this.delimiter}${task.status || ''}\n`;
|
||||||
|
csv += `Lotto${this.delimiter}${this.escapeCsvValue(task.lot_number || '')}\n`;
|
||||||
|
csv += `Seriale${this.delimiter}${this.escapeCsvValue(task.serial_number || '')}\n`;
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
csv += `\nSTATISTICHE\n`;
|
||||||
|
const stats = this.calculateStats(task.measurements);
|
||||||
|
csv += `Totale Misure${this.delimiter}${stats.total}\n`;
|
||||||
|
csv += `Passate${this.delimiter}${stats.passed}\n`;
|
||||||
|
csv += `Fallite${this.delimiter}${stats.failed}\n`;
|
||||||
|
csv += `Percentuale Successo${this.delimiter}${this.formatNumber(stats.passRate)}%\n`;
|
||||||
|
|
||||||
|
// Measurements detail
|
||||||
|
csv += `\nDETTAGLIO MISURE\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,189 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
// Dispatch custom event for parent component to handle
|
||||||
|
this.$dispatch('numpad-confirm', { value: val });
|
||||||
|
|
||||||
|
// Reset after confirmation
|
||||||
|
this.clearAll();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.addDigit(e.key);
|
||||||
|
}
|
||||||
|
// Decimal point (both . and ,)
|
||||||
|
else if (e.key === '.' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<!--
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!--
|
||||||
|
Caliper Status Indicator
|
||||||
|
Compact component for navbar or header
|
||||||
|
Shows connection status and last reading
|
||||||
|
-->
|
||||||
|
<div x-data="caliperConnection()"
|
||||||
|
x-init="$watch('connected', value => console.log('Caliper connected:', value))"
|
||||||
|
class="inline-block">
|
||||||
|
|
||||||
|
<!-- Compact indicator button -->
|
||||||
|
<button @click="connected ? disconnect() : connect()"
|
||||||
|
:disabled="!isSupported"
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-200 hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:class="{
|
||||||
|
'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700': status === 'disconnected',
|
||||||
|
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800': status === 'connected',
|
||||||
|
'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800': status === 'connecting',
|
||||||
|
'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800': status === 'error'
|
||||||
|
}">
|
||||||
|
|
||||||
|
<!-- Status dot with animation -->
|
||||||
|
<span class="relative flex h-2.5 w-2.5">
|
||||||
|
<!-- Ping animation for active states -->
|
||||||
|
<span x-show="status === 'connected' || status === 'connecting'"
|
||||||
|
class="absolute inline-flex h-full w-full rounded-full opacity-75 animate-ping"
|
||||||
|
:class="{
|
||||||
|
'bg-green-400': status === 'connected',
|
||||||
|
'bg-amber-400': status === 'connecting'
|
||||||
|
}"></span>
|
||||||
|
|
||||||
|
<!-- Static dot -->
|
||||||
|
<span class="relative inline-flex rounded-full h-2.5 w-2.5"
|
||||||
|
:class="{
|
||||||
|
'bg-green-500': status === 'connected',
|
||||||
|
'bg-slate-400': status === 'disconnected',
|
||||||
|
'bg-amber-400': status === 'connecting',
|
||||||
|
'bg-red-500': status === 'error'
|
||||||
|
}"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Caliper icon -->
|
||||||
|
<svg class="w-4 h-4 text-slate-600 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>
|
||||||
|
|
||||||
|
<!-- Status label -->
|
||||||
|
<span class="text-xs font-medium text-slate-700 dark:text-slate-300"
|
||||||
|
x-text="status === 'connected' ? '{{ _('Calibro') }}' :
|
||||||
|
status === 'connecting' ? '{{ _('Connessione...') }}' :
|
||||||
|
status === 'error' ? '{{ _('Errore calibro') }}' :
|
||||||
|
'{{ _('Calibro') }}'"></span>
|
||||||
|
|
||||||
|
<!-- Last reading (when connected) -->
|
||||||
|
<span x-show="connected && lastReading !== null"
|
||||||
|
x-transition
|
||||||
|
class="font-mono text-xs font-semibold text-primary px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 rounded"
|
||||||
|
x-text="lastReading?.toFixed(3) + ' mm'"></span>
|
||||||
|
|
||||||
|
<!-- Not supported warning icon -->
|
||||||
|
<template x-if="!isSupported">
|
||||||
|
<div class="flex items-center gap-1.5" x-data="{ showTooltip: false }">
|
||||||
|
<svg @mouseenter="showTooltip = true"
|
||||||
|
@mouseleave="showTooltip = false"
|
||||||
|
class="w-4 h-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<div x-show="showTooltip"
|
||||||
|
x-transition
|
||||||
|
class="absolute z-50 px-2 py-1 text-xs text-white bg-slate-900 rounded shadow-lg whitespace-nowrap -translate-y-8">
|
||||||
|
{{ _('Browser non supporta Web Serial API') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</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,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,331 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ _('Seleziona Ricetta') }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
|
||||||
|
x-data="{
|
||||||
|
recipes: {{ recipes|tojson }},
|
||||||
|
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>
|
||||||
|
</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.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,275 @@
|
|||||||
|
{% 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 or 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">#</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='subtask.marker_number') %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-center font-mono font-medium" style="color: var(--text-primary);">
|
||||||
|
{{ m.subtask.marker_number }}
|
||||||
|
</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.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.method == '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>
|
||||||
|
// Pass data to csv-export.js
|
||||||
|
window.measurementData = {
|
||||||
|
recipeName: {{ recipe.name|tojson }},
|
||||||
|
recipeCode: {{ recipe.code|tojson }},
|
||||||
|
version: {{ (recipe.current_version or recipe.version)|tojson }},
|
||||||
|
lotNumber: {{ (lot_number or '')|tojson }},
|
||||||
|
serialNumber: {{ (serial_number or '')|tojson }},
|
||||||
|
measurements: {{ measurements|tojson }}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,750 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
/* Annotation viewer container aspect ratio */
|
||||||
|
.annotation-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
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 indicator */
|
||||||
|
@keyframes markerPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 8px rgba(37, 99, 235, 0); }
|
||||||
|
}
|
||||||
|
.marker-active-pulse {
|
||||||
|
animation: markerPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth value transition */
|
||||||
|
.value-enter {
|
||||||
|
animation: valueSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes valueSlideIn {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col"
|
||||||
|
x-data="taskExecute()"
|
||||||
|
x-init="init()"
|
||||||
|
@numpad-confirm.window="handleMeasurement($event.detail.value)"
|
||||||
|
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
|
||||||
|
@caliper-reading.window="handleCaliperReading($event.detail.value)">
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
HEADER — Task info + traceability
|
||||||
|
================================================================ #}
|
||||||
|
<div class="bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
|
||||||
|
{# Left: Task title and directive #}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
{# Breadcrumb line #}
|
||||||
|
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
|
<a href="{{ url_for('measure.task_list', recipe_id=task.version_id or task.recipe_id or 0) }}"
|
||||||
|
class="text-xs text-[var(--text-muted)] hover:text-primary transition-colors inline-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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Task') }}
|
||||||
|
</a>
|
||||||
|
<svg class="w-3 h-3 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>
|
||||||
|
<span class="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-xl font-bold text-[var(--text-primary)] truncate">
|
||||||
|
{{ task.title or _('Task di misurazione') }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{% if task.directive or task.description %}
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] mt-0.5 line-clamp-2">
|
||||||
|
{{ task.directive or task.description }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Right: Traceability + Caliper #}
|
||||||
|
<div class="flex items-center gap-3 flex-wrap shrink-0">
|
||||||
|
{# Lot badge #}
|
||||||
|
{% if lot_number %}
|
||||||
|
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg 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.5 h-3.5 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 %}
|
||||||
|
|
||||||
|
{# Serial badge #}
|
||||||
|
{% if serial_number %}
|
||||||
|
<div class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg 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.5 h-3.5 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
MAIN CONTENT — Split layout
|
||||||
|
================================================================ #}
|
||||||
|
<div class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-5">
|
||||||
|
<div class="flex flex-col lg:flex-row gap-5 h-full">
|
||||||
|
|
||||||
|
{# ──────────────────────────────────────────────
|
||||||
|
LEFT COLUMN — Annotation Viewer (60%)
|
||||||
|
────────────────────────────────────────────── #}
|
||||||
|
<div class="lg:w-[60%] flex flex-col gap-4">
|
||||||
|
|
||||||
|
{# Image viewer card #}
|
||||||
|
<div class="tmf-card flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div class="tmf-card-header flex items-center justify-between">
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 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 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') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-[var(--text-muted)]"
|
||||||
|
x-text="'Marker #' + (currentSubtask?.marker_number || '-') + ' {{ _('attivo') }}'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tmf-card-body flex-1 p-0">
|
||||||
|
{% if task.file_path and task.file_type == 'image' %}
|
||||||
|
{# Image with annotation overlay #}
|
||||||
|
<div class="annotation-container"
|
||||||
|
x-data="annotationViewer()"
|
||||||
|
x-init="
|
||||||
|
imageUrl = '{{ url_for('static', filename='uploads/' ~ task.file_path) }}';
|
||||||
|
annotations = {{ task.annotations_json|default('null')|tojson }};
|
||||||
|
$nextTick(() => init());
|
||||||
|
"
|
||||||
|
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
|
||||||
|
<canvas x-ref="annotationCanvas"
|
||||||
|
@click="handleClick($event)"
|
||||||
|
class="cursor-pointer"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif task.file_path and task.file_type == 'pdf' %}
|
||||||
|
{# PDF placeholder #}
|
||||||
|
<div class="annotation-container flex items-center justify-center">
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-red-400 mb-3" 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>
|
||||||
|
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Visualizzatore PDF') }}</p>
|
||||||
|
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('In fase di sviluppo') }}</p>
|
||||||
|
<a href="{{ url_for('static', filename='uploads/' ~ task.file_path) }}"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-secondary mt-3 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="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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# No file placeholder #}
|
||||||
|
<div class="annotation-container flex items-center justify-center">
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3" 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-secondary)]">{{ _('Nessuna immagine allegata') }}</p>
|
||||||
|
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Segui le istruzioni nella direttiva') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Marker quick-nav strip (horizontal scrollable) #}
|
||||||
|
<div class="flex gap-2 overflow-x-auto pb-1 -mb-1 scrollbar-thin" x-show="subtasks.length > 1">
|
||||||
|
<template x-for="(st, idx) in subtasks" :key="st.id">
|
||||||
|
<button @click="goToSubtask(idx)"
|
||||||
|
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all duration-200"
|
||||||
|
:class="idx === currentIndex
|
||||||
|
? 'bg-primary text-white border-primary shadow-md marker-active-pulse'
|
||||||
|
: isMeasured(st.id)
|
||||||
|
? 'bg-measure-pass/10 text-measure-pass border-measure-pass/30 hover:bg-measure-pass/20'
|
||||||
|
: 'bg-[var(--bg-card)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-primary/50 hover:text-primary'
|
||||||
|
">
|
||||||
|
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold"
|
||||||
|
:class="idx === currentIndex
|
||||||
|
? 'bg-white/20'
|
||||||
|
: isMeasured(st.id)
|
||||||
|
? 'bg-measure-pass/20'
|
||||||
|
: 'bg-[var(--bg-secondary)]'
|
||||||
|
"
|
||||||
|
x-text="st.marker_number"></span>
|
||||||
|
<span x-text="st.description" class="truncate max-w-[100px]"></span>
|
||||||
|
{# Check icon if measured #}
|
||||||
|
<svg x-show="isMeasured(st.id)" class="w-3.5 h-3.5 text-measure-pass 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>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ──────────────────────────────────────────────
|
||||||
|
RIGHT COLUMN — Measurement Panel (40%)
|
||||||
|
────────────────────────────────────────────── #}
|
||||||
|
<div class="lg:w-[40%] flex flex-col gap-4">
|
||||||
|
|
||||||
|
{# ---- Subtask Info Card ---- #}
|
||||||
|
<div class="tmf-card" x-show="currentSubtask">
|
||||||
|
<div class="p-4 sm:p-5">
|
||||||
|
|
||||||
|
{# Marker header #}
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class="inline-flex items-center justify-center w-9 h-9 rounded-full
|
||||||
|
bg-primary text-white font-bold text-sm shadow-md marker-active-pulse"
|
||||||
|
x-text="currentSubtask?.marker_number"></span>
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold text-[var(--text-primary)] text-base leading-tight"
|
||||||
|
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
|
||||||
|
<span class="text-xs text-[var(--text-muted)]"
|
||||||
|
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Counter badge #}
|
||||||
|
<span class="badge badge-neutral font-mono text-xs">
|
||||||
|
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tolerance parameters table #}
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 text-sm mb-1">
|
||||||
|
{# Nominal #}
|
||||||
|
<div class="flex items-center justify-between col-span-2 py-1.5 px-3 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800">
|
||||||
|
<span class="text-xs font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
|
||||||
|
<span class="font-mono font-bold text-primary text-base"
|
||||||
|
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# UTL #}
|
||||||
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||||
|
<span class="text-xs text-measure-fail font-medium">UTL</span>
|
||||||
|
<span class="font-mono text-xs font-semibold text-measure-fail"
|
||||||
|
x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# LTL #}
|
||||||
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||||
|
<span class="text-xs text-measure-fail font-medium">LTL</span>
|
||||||
|
<span class="font-mono text-xs font-semibold text-measure-fail"
|
||||||
|
x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# UWL #}
|
||||||
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||||
|
<span class="text-xs text-measure-warning font-medium">UWL</span>
|
||||||
|
<span class="font-mono text-xs font-semibold text-measure-warning"
|
||||||
|
x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# LWL #}
|
||||||
|
<div class="flex items-center justify-between py-1 px-3 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||||
|
<span class="text-xs text-measure-warning font-medium">LWL</span>
|
||||||
|
<span class="font-mono text-xs font-semibold text-measure-warning"
|
||||||
|
x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Already measured indicator #}
|
||||||
|
<div x-show="isMeasured(currentSubtask?.id)"
|
||||||
|
class="mt-3 p-2.5 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-measure-pass font-medium">
|
||||||
|
<svg class="w-4 h-4" 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>{{ _('Misurazione registrata') }}:</span>
|
||||||
|
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
||||||
|
<span class="text-xs" x-text="currentSubtask?.unit || 'mm'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Tolerance Visualization Bar ---- #}
|
||||||
|
<div class="tmf-card" x-show="currentSubtask">
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="tolerance-bar-bg h-8 rounded-full relative overflow-hidden border border-[var(--border-color)]">
|
||||||
|
{# Zone labels #}
|
||||||
|
<div class="absolute inset-0 flex items-center justify-between px-2 text-[9px] 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>
|
||||||
|
|
||||||
|
{# Nominal center line #}
|
||||||
|
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
|
||||||
|
|
||||||
|
{# Value indicator (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)'">
|
||||||
|
{# Pip on top #}
|
||||||
|
<div class="absolute -top-1 left-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white dark:border-slate-800 shadow-md"
|
||||||
|
:class="{
|
||||||
|
'bg-measure-pass': passFailStatus === 'pass',
|
||||||
|
'bg-measure-warning': passFailStatus === 'warning',
|
||||||
|
'bg-measure-fail': passFailStatus === 'fail'
|
||||||
|
}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Measurement Feedback ---- #}
|
||||||
|
<div class="px-1" 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="tmf-card">
|
||||||
|
<div class="p-4 relative">
|
||||||
|
{# Saving overlay #}
|
||||||
|
<div x-show="saving"
|
||||||
|
x-transition
|
||||||
|
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 rounded-xl flex items-center justify-center backdrop-blur-sm">
|
||||||
|
<div class="flex items-center gap-3 text-primary font-medium">
|
||||||
|
<svg class="animate-spin w-5 h-5" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Next Measurement Indicator ---- #}
|
||||||
|
<div class="px-1">
|
||||||
|
{% include "components/next_measurement.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ---- Error message ---- #}
|
||||||
|
<div x-show="errorMessage"
|
||||||
|
x-transition
|
||||||
|
x-cloak
|
||||||
|
class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<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="errorMessage"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
FOOTER — Progress bar + navigation
|
||||||
|
================================================================ #}
|
||||||
|
<div class="sticky bottom-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-30">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
|
||||||
|
{# Left: Back button #}
|
||||||
|
<a href="{{ url_for('measure.task_list', recipe_id=task.version_id or task.recipe_id or 0) }}"
|
||||||
|
class="btn btn-secondary text-xs shrink-0 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 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">{{ _('Task') }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{# Center: Progress #}
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{# Text counter #}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{# Progress bar #}
|
||||||
|
<div class="flex-1 h-2.5 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>
|
||||||
|
|
||||||
|
{# Percentage #}
|
||||||
|
<span class="text-xs font-bold shrink-0"
|
||||||
|
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
|
||||||
|
x-text="Math.round(progressPercent) + '%'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Right: Complete / next task button #}
|
||||||
|
<button x-show="isComplete"
|
||||||
|
x-transition
|
||||||
|
@click="goToSummary()"
|
||||||
|
class="btn btn-primary text-xs shrink-0 gap-1.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>
|
||||||
|
</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">
|
||||||
|
|
||||||
|
{# Success icon #}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{# Summary counts #}
|
||||||
|
<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="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}"></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.
|
||||||
|
*/
|
||||||
|
function taskExecute() {
|
||||||
|
return {
|
||||||
|
// ---- Data from server ----
|
||||||
|
task: {{ task|tojson }},
|
||||||
|
subtasks: {{ task.subtasks|tojson if task.subtasks else '[]' }},
|
||||||
|
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,
|
||||||
|
|
||||||
|
// ---- 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() {
|
||||||
|
// Sort subtasks by order_index to ensure correct order
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Handle numpad confirm ----
|
||||||
|
async handleMeasurement(value) {
|
||||||
|
if (!this.currentSubtask || this.saving) return;
|
||||||
|
|
||||||
|
this.currentValue = value;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
// Compute pass/fail before saving
|
||||||
|
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,
|
||||||
|
task_id: this.task.id,
|
||||||
|
value: value,
|
||||||
|
pass_fail: pf,
|
||||||
|
deviation: dev,
|
||||||
|
lot_number: this.lotNumber,
|
||||||
|
serial_number: this.serialNumber,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
this.measurements.push({
|
||||||
|
subtask_id: this.currentSubtask.id,
|
||||||
|
value: value,
|
||||||
|
pass_fail: pf,
|
||||||
|
deviation: dev,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.saving = false;
|
||||||
|
|
||||||
|
// Check if all done
|
||||||
|
if (this.completedCount >= this.totalSubtasks) {
|
||||||
|
// Show completion overlay
|
||||||
|
this.showCompletionOverlay = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to next unmeasured subtask
|
||||||
|
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;
|
||||||
|
|
||||||
|
// First try the next sequential subtask
|
||||||
|
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
|
||||||
|
if (!this.isMeasured(this.subtasks[i].id)) {
|
||||||
|
this.currentIndex = i;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap around from beginning
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Caliper reading ----
|
||||||
|
handleCaliperReading(value) {
|
||||||
|
// Auto-fill value from USB caliper
|
||||||
|
this.currentValue = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Go to summary ----
|
||||||
|
goToSummary() {
|
||||||
|
const recipeId = this.task.version_id || 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 or 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 %}
|
||||||
@@ -245,3 +245,260 @@ msgstr "Measurement Technician"
|
|||||||
|
|
||||||
msgid "Metrologist"
|
msgid "Metrologist"
|
||||||
msgstr "Metrologist"
|
msgstr "Metrologist"
|
||||||
|
|
||||||
|
# Measure - Recipe Selection
|
||||||
|
msgid "Seleziona Ricetta"
|
||||||
|
msgstr "Select Recipe"
|
||||||
|
|
||||||
|
msgid "Scegli la ricetta di misura da eseguire"
|
||||||
|
msgstr "Choose the measurement recipe to execute"
|
||||||
|
|
||||||
|
msgid "Scansiona Barcode"
|
||||||
|
msgstr "Scan Barcode"
|
||||||
|
|
||||||
|
msgid "Cerca ricetta"
|
||||||
|
msgstr "Search recipe"
|
||||||
|
|
||||||
|
msgid "Nome, codice o descrizione..."
|
||||||
|
msgstr "Name, code or description..."
|
||||||
|
|
||||||
|
msgid "Numero Lotto"
|
||||||
|
msgstr "Lot Number"
|
||||||
|
|
||||||
|
msgid "Numero Seriale"
|
||||||
|
msgstr "Serial Number"
|
||||||
|
|
||||||
|
msgid "Opzionale"
|
||||||
|
msgstr "Optional"
|
||||||
|
|
||||||
|
msgid "ricette trovate"
|
||||||
|
msgstr "recipes found"
|
||||||
|
|
||||||
|
msgid "Nessuna descrizione disponibile"
|
||||||
|
msgstr "No description available"
|
||||||
|
|
||||||
|
msgid "Seleziona"
|
||||||
|
msgstr "Select"
|
||||||
|
|
||||||
|
msgid "Nessuna ricetta trovata"
|
||||||
|
msgstr "No recipe found"
|
||||||
|
|
||||||
|
msgid "Nessun risultato per"
|
||||||
|
msgstr "No results for"
|
||||||
|
|
||||||
|
msgid "Prova con un termine diverso."
|
||||||
|
msgstr "Try a different search term."
|
||||||
|
|
||||||
|
msgid "Non ci sono ricette disponibili al momento."
|
||||||
|
msgstr "No recipes available at the moment."
|
||||||
|
|
||||||
|
msgid "Inserisci o scansiona il codice della ricetta per selezionarla automaticamente."
|
||||||
|
msgstr "Enter or scan the recipe code to select it automatically."
|
||||||
|
|
||||||
|
msgid "Codice Ricetta"
|
||||||
|
msgstr "Recipe Code"
|
||||||
|
|
||||||
|
msgid "Es. REC-001"
|
||||||
|
msgstr "E.g. REC-001"
|
||||||
|
|
||||||
|
msgid "Cerca"
|
||||||
|
msgstr "Search"
|
||||||
|
|
||||||
|
# Measure - Task List
|
||||||
|
msgid "Lotto"
|
||||||
|
msgstr "Lot"
|
||||||
|
|
||||||
|
msgid "Seriale"
|
||||||
|
msgstr "Serial"
|
||||||
|
|
||||||
|
msgid "Task da eseguire"
|
||||||
|
msgstr "Tasks to execute"
|
||||||
|
|
||||||
|
msgid "Task"
|
||||||
|
msgstr "Task"
|
||||||
|
|
||||||
|
msgid "misurazioni"
|
||||||
|
msgstr "measurements"
|
||||||
|
|
||||||
|
msgid "Allegato"
|
||||||
|
msgstr "Attachment"
|
||||||
|
|
||||||
|
msgid "Inizia Misure"
|
||||||
|
msgstr "Start Measurements"
|
||||||
|
|
||||||
|
msgid "Nessun task disponibile"
|
||||||
|
msgstr "No tasks available"
|
||||||
|
|
||||||
|
msgid "Questa ricetta non ha ancora task definiti."
|
||||||
|
msgstr "This recipe has no tasks defined yet."
|
||||||
|
|
||||||
|
msgid "Seleziona altra ricetta"
|
||||||
|
msgstr "Select another recipe"
|
||||||
|
|
||||||
|
msgid "misurazioni totali"
|
||||||
|
msgstr "total measurements"
|
||||||
|
|
||||||
|
# Measure - Task Execute
|
||||||
|
msgid "Task di misurazione"
|
||||||
|
msgstr "Measurement task"
|
||||||
|
|
||||||
|
msgid "Disegno Tecnico"
|
||||||
|
msgstr "Technical Drawing"
|
||||||
|
|
||||||
|
msgid "attivo"
|
||||||
|
msgstr "active"
|
||||||
|
|
||||||
|
msgid "Visualizzatore PDF"
|
||||||
|
msgstr "PDF Viewer"
|
||||||
|
|
||||||
|
msgid "In fase di sviluppo"
|
||||||
|
msgstr "Under development"
|
||||||
|
|
||||||
|
msgid "Apri PDF"
|
||||||
|
msgstr "Open PDF"
|
||||||
|
|
||||||
|
msgid "Nessuna immagine allegata"
|
||||||
|
msgstr "No image attached"
|
||||||
|
|
||||||
|
msgid "Segui le istruzioni nella direttiva"
|
||||||
|
msgstr "Follow the instructions in the directive"
|
||||||
|
|
||||||
|
msgid "Misurazione"
|
||||||
|
msgstr "Measurement"
|
||||||
|
|
||||||
|
msgid "Nominale"
|
||||||
|
msgstr "Nominal"
|
||||||
|
|
||||||
|
msgid "Misura"
|
||||||
|
msgstr "Measure"
|
||||||
|
|
||||||
|
msgid "Misurazione registrata"
|
||||||
|
msgstr "Measurement recorded"
|
||||||
|
|
||||||
|
msgid "Salvataggio..."
|
||||||
|
msgstr "Saving..."
|
||||||
|
|
||||||
|
msgid "Riepilogo"
|
||||||
|
msgstr "Summary"
|
||||||
|
|
||||||
|
msgid "Misurazioni Complete"
|
||||||
|
msgstr "Measurements Complete"
|
||||||
|
|
||||||
|
msgid "Tutte le"
|
||||||
|
msgstr "All"
|
||||||
|
|
||||||
|
msgid "misurazioni sono state registrate."
|
||||||
|
msgstr "measurements have been recorded."
|
||||||
|
|
||||||
|
msgid "Conformi"
|
||||||
|
msgstr "Pass"
|
||||||
|
|
||||||
|
msgid "Non Conf."
|
||||||
|
msgstr "Fail"
|
||||||
|
|
||||||
|
msgid "Vai al Riepilogo"
|
||||||
|
msgstr "Go to Summary"
|
||||||
|
|
||||||
|
msgid "Errore nel salvataggio della misurazione"
|
||||||
|
msgstr "Error saving measurement"
|
||||||
|
|
||||||
|
msgid "Errore di rete. Riprovare."
|
||||||
|
msgstr "Network error. Please retry."
|
||||||
|
|
||||||
|
# Measure - API Errors
|
||||||
|
msgid "Errore nel caricamento delle ricette: %(detail)s"
|
||||||
|
msgstr "Error loading recipes: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Ricetta non trovata: %(detail)s"
|
||||||
|
msgstr "Recipe not found: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Errore nel caricamento dei task: %(detail)s"
|
||||||
|
msgstr "Error loading tasks: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Task non trovato: %(detail)s"
|
||||||
|
msgstr "Task not found: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Codice non fornito"
|
||||||
|
msgstr "Code not provided"
|
||||||
|
|
||||||
|
msgid "Ricetta non trovata"
|
||||||
|
msgstr "Recipe not found"
|
||||||
|
|
||||||
|
msgid "Dati mancanti: subtask_id, task_id e value sono obbligatori"
|
||||||
|
msgstr "Missing data: subtask_id, task_id and value are required"
|
||||||
|
|
||||||
|
msgid "Errore nel salvataggio"
|
||||||
|
msgstr "Error saving"
|
||||||
|
|
||||||
|
# Task Complete Page
|
||||||
|
msgid "Codice"
|
||||||
|
msgstr "Code"
|
||||||
|
|
||||||
|
msgid "Nome"
|
||||||
|
msgstr "Name"
|
||||||
|
|
||||||
|
msgid "Versione"
|
||||||
|
msgstr "Version"
|
||||||
|
|
||||||
|
msgid "Totale"
|
||||||
|
msgstr "Total"
|
||||||
|
|
||||||
|
msgid "Dettaglio Misurazioni"
|
||||||
|
msgstr "Measurement Details"
|
||||||
|
|
||||||
|
msgid "Esporta CSV"
|
||||||
|
msgstr "Export CSV"
|
||||||
|
|
||||||
|
msgid "Descrizione"
|
||||||
|
msgstr "Description"
|
||||||
|
|
||||||
|
msgid "Deviazione"
|
||||||
|
msgstr "Deviation"
|
||||||
|
|
||||||
|
msgid "Esito"
|
||||||
|
msgstr "Result"
|
||||||
|
|
||||||
|
msgid "Metodo"
|
||||||
|
msgstr "Method"
|
||||||
|
|
||||||
|
msgid "Pass"
|
||||||
|
msgstr "Pass"
|
||||||
|
|
||||||
|
msgid "Warning"
|
||||||
|
msgstr "Warning"
|
||||||
|
|
||||||
|
msgid "Fail"
|
||||||
|
msgstr "Fail"
|
||||||
|
|
||||||
|
msgid "Manuale"
|
||||||
|
msgstr "Manual"
|
||||||
|
|
||||||
|
msgid "Calibro"
|
||||||
|
msgstr "Caliper"
|
||||||
|
|
||||||
|
msgid "Ripeti misurazioni"
|
||||||
|
msgstr "Repeat Measurements"
|
||||||
|
|
||||||
|
# Measurement Feedback
|
||||||
|
msgid "CONFORME"
|
||||||
|
msgstr "CONFORMING"
|
||||||
|
|
||||||
|
msgid "ATTENZIONE"
|
||||||
|
msgstr "WARNING"
|
||||||
|
|
||||||
|
msgid "NON CONFORME"
|
||||||
|
msgstr "NON-CONFORMING"
|
||||||
|
|
||||||
|
# Caliper Status
|
||||||
|
msgid "Connessione..."
|
||||||
|
msgstr "Connecting..."
|
||||||
|
|
||||||
|
msgid "Errore calibro"
|
||||||
|
msgstr "Caliper Error"
|
||||||
|
|
||||||
|
msgid "Browser non supporta Web Serial API"
|
||||||
|
msgstr "Browser does not support Web Serial API"
|
||||||
|
|
||||||
|
# Recipe Selection Additional
|
||||||
|
msgid "Errore di connessione"
|
||||||
|
msgstr "Connection Error"
|
||||||
|
|||||||
@@ -245,3 +245,260 @@ msgstr "Tecnico di Misura"
|
|||||||
|
|
||||||
msgid "Metrologist"
|
msgid "Metrologist"
|
||||||
msgstr "Metrologo"
|
msgstr "Metrologo"
|
||||||
|
|
||||||
|
# Measure - Recipe Selection
|
||||||
|
msgid "Seleziona Ricetta"
|
||||||
|
msgstr "Seleziona Ricetta"
|
||||||
|
|
||||||
|
msgid "Scegli la ricetta di misura da eseguire"
|
||||||
|
msgstr "Scegli la ricetta di misura da eseguire"
|
||||||
|
|
||||||
|
msgid "Scansiona Barcode"
|
||||||
|
msgstr "Scansiona Barcode"
|
||||||
|
|
||||||
|
msgid "Cerca ricetta"
|
||||||
|
msgstr "Cerca ricetta"
|
||||||
|
|
||||||
|
msgid "Nome, codice o descrizione..."
|
||||||
|
msgstr "Nome, codice o descrizione..."
|
||||||
|
|
||||||
|
msgid "Numero Lotto"
|
||||||
|
msgstr "Numero Lotto"
|
||||||
|
|
||||||
|
msgid "Numero Seriale"
|
||||||
|
msgstr "Numero Seriale"
|
||||||
|
|
||||||
|
msgid "Opzionale"
|
||||||
|
msgstr "Opzionale"
|
||||||
|
|
||||||
|
msgid "ricette trovate"
|
||||||
|
msgstr "ricette trovate"
|
||||||
|
|
||||||
|
msgid "Nessuna descrizione disponibile"
|
||||||
|
msgstr "Nessuna descrizione disponibile"
|
||||||
|
|
||||||
|
msgid "Seleziona"
|
||||||
|
msgstr "Seleziona"
|
||||||
|
|
||||||
|
msgid "Nessuna ricetta trovata"
|
||||||
|
msgstr "Nessuna ricetta trovata"
|
||||||
|
|
||||||
|
msgid "Nessun risultato per"
|
||||||
|
msgstr "Nessun risultato per"
|
||||||
|
|
||||||
|
msgid "Prova con un termine diverso."
|
||||||
|
msgstr "Prova con un termine diverso."
|
||||||
|
|
||||||
|
msgid "Non ci sono ricette disponibili al momento."
|
||||||
|
msgstr "Non ci sono ricette disponibili al momento."
|
||||||
|
|
||||||
|
msgid "Inserisci o scansiona il codice della ricetta per selezionarla automaticamente."
|
||||||
|
msgstr "Inserisci o scansiona il codice della ricetta per selezionarla automaticamente."
|
||||||
|
|
||||||
|
msgid "Codice Ricetta"
|
||||||
|
msgstr "Codice Ricetta"
|
||||||
|
|
||||||
|
msgid "Es. REC-001"
|
||||||
|
msgstr "Es. REC-001"
|
||||||
|
|
||||||
|
msgid "Cerca"
|
||||||
|
msgstr "Cerca"
|
||||||
|
|
||||||
|
# Measure - Task List
|
||||||
|
msgid "Lotto"
|
||||||
|
msgstr "Lotto"
|
||||||
|
|
||||||
|
msgid "Seriale"
|
||||||
|
msgstr "Seriale"
|
||||||
|
|
||||||
|
msgid "Task da eseguire"
|
||||||
|
msgstr "Task da eseguire"
|
||||||
|
|
||||||
|
msgid "Task"
|
||||||
|
msgstr "Task"
|
||||||
|
|
||||||
|
msgid "misurazioni"
|
||||||
|
msgstr "misurazioni"
|
||||||
|
|
||||||
|
msgid "Allegato"
|
||||||
|
msgstr "Allegato"
|
||||||
|
|
||||||
|
msgid "Inizia Misure"
|
||||||
|
msgstr "Inizia Misure"
|
||||||
|
|
||||||
|
msgid "Nessun task disponibile"
|
||||||
|
msgstr "Nessun task disponibile"
|
||||||
|
|
||||||
|
msgid "Questa ricetta non ha ancora task definiti."
|
||||||
|
msgstr "Questa ricetta non ha ancora task definiti."
|
||||||
|
|
||||||
|
msgid "Seleziona altra ricetta"
|
||||||
|
msgstr "Seleziona altra ricetta"
|
||||||
|
|
||||||
|
msgid "misurazioni totali"
|
||||||
|
msgstr "misurazioni totali"
|
||||||
|
|
||||||
|
# Measure - Task Execute
|
||||||
|
msgid "Task di misurazione"
|
||||||
|
msgstr "Task di misurazione"
|
||||||
|
|
||||||
|
msgid "Disegno Tecnico"
|
||||||
|
msgstr "Disegno Tecnico"
|
||||||
|
|
||||||
|
msgid "attivo"
|
||||||
|
msgstr "attivo"
|
||||||
|
|
||||||
|
msgid "Visualizzatore PDF"
|
||||||
|
msgstr "Visualizzatore PDF"
|
||||||
|
|
||||||
|
msgid "In fase di sviluppo"
|
||||||
|
msgstr "In fase di sviluppo"
|
||||||
|
|
||||||
|
msgid "Apri PDF"
|
||||||
|
msgstr "Apri PDF"
|
||||||
|
|
||||||
|
msgid "Nessuna immagine allegata"
|
||||||
|
msgstr "Nessuna immagine allegata"
|
||||||
|
|
||||||
|
msgid "Segui le istruzioni nella direttiva"
|
||||||
|
msgstr "Segui le istruzioni nella direttiva"
|
||||||
|
|
||||||
|
msgid "Misurazione"
|
||||||
|
msgstr "Misurazione"
|
||||||
|
|
||||||
|
msgid "Nominale"
|
||||||
|
msgstr "Nominale"
|
||||||
|
|
||||||
|
msgid "Misura"
|
||||||
|
msgstr "Misura"
|
||||||
|
|
||||||
|
msgid "Misurazione registrata"
|
||||||
|
msgstr "Misurazione registrata"
|
||||||
|
|
||||||
|
msgid "Salvataggio..."
|
||||||
|
msgstr "Salvataggio..."
|
||||||
|
|
||||||
|
msgid "Riepilogo"
|
||||||
|
msgstr "Riepilogo"
|
||||||
|
|
||||||
|
msgid "Misurazioni Complete"
|
||||||
|
msgstr "Misurazioni Complete"
|
||||||
|
|
||||||
|
msgid "Tutte le"
|
||||||
|
msgstr "Tutte le"
|
||||||
|
|
||||||
|
msgid "misurazioni sono state registrate."
|
||||||
|
msgstr "misurazioni sono state registrate."
|
||||||
|
|
||||||
|
msgid "Conformi"
|
||||||
|
msgstr "Conformi"
|
||||||
|
|
||||||
|
msgid "Non Conf."
|
||||||
|
msgstr "Non Conf."
|
||||||
|
|
||||||
|
msgid "Vai al Riepilogo"
|
||||||
|
msgstr "Vai al Riepilogo"
|
||||||
|
|
||||||
|
msgid "Errore nel salvataggio della misurazione"
|
||||||
|
msgstr "Errore nel salvataggio della misurazione"
|
||||||
|
|
||||||
|
msgid "Errore di rete. Riprovare."
|
||||||
|
msgstr "Errore di rete. Riprovare."
|
||||||
|
|
||||||
|
# Measure - API Errors
|
||||||
|
msgid "Errore nel caricamento delle ricette: %(detail)s"
|
||||||
|
msgstr "Errore nel caricamento delle ricette: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Ricetta non trovata: %(detail)s"
|
||||||
|
msgstr "Ricetta non trovata: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Errore nel caricamento dei task: %(detail)s"
|
||||||
|
msgstr "Errore nel caricamento dei task: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Task non trovato: %(detail)s"
|
||||||
|
msgstr "Task non trovato: %(detail)s"
|
||||||
|
|
||||||
|
msgid "Codice non fornito"
|
||||||
|
msgstr "Codice non fornito"
|
||||||
|
|
||||||
|
msgid "Ricetta non trovata"
|
||||||
|
msgstr "Ricetta non trovata"
|
||||||
|
|
||||||
|
msgid "Dati mancanti: subtask_id, task_id e value sono obbligatori"
|
||||||
|
msgstr "Dati mancanti: subtask_id, task_id e value sono obbligatori"
|
||||||
|
|
||||||
|
msgid "Errore nel salvataggio"
|
||||||
|
msgstr "Errore nel salvataggio"
|
||||||
|
|
||||||
|
# Task Complete Page
|
||||||
|
msgid "Codice"
|
||||||
|
msgstr "Codice"
|
||||||
|
|
||||||
|
msgid "Nome"
|
||||||
|
msgstr "Nome"
|
||||||
|
|
||||||
|
msgid "Versione"
|
||||||
|
msgstr "Versione"
|
||||||
|
|
||||||
|
msgid "Totale"
|
||||||
|
msgstr "Totale"
|
||||||
|
|
||||||
|
msgid "Dettaglio Misurazioni"
|
||||||
|
msgstr "Dettaglio Misurazioni"
|
||||||
|
|
||||||
|
msgid "Esporta CSV"
|
||||||
|
msgstr "Esporta CSV"
|
||||||
|
|
||||||
|
msgid "Descrizione"
|
||||||
|
msgstr "Descrizione"
|
||||||
|
|
||||||
|
msgid "Deviazione"
|
||||||
|
msgstr "Deviazione"
|
||||||
|
|
||||||
|
msgid "Esito"
|
||||||
|
msgstr "Esito"
|
||||||
|
|
||||||
|
msgid "Metodo"
|
||||||
|
msgstr "Metodo"
|
||||||
|
|
||||||
|
msgid "Pass"
|
||||||
|
msgstr "Pass"
|
||||||
|
|
||||||
|
msgid "Warning"
|
||||||
|
msgstr "Warning"
|
||||||
|
|
||||||
|
msgid "Fail"
|
||||||
|
msgstr "Fail"
|
||||||
|
|
||||||
|
msgid "Manuale"
|
||||||
|
msgstr "Manuale"
|
||||||
|
|
||||||
|
msgid "Calibro"
|
||||||
|
msgstr "Calibro"
|
||||||
|
|
||||||
|
msgid "Ripeti misurazioni"
|
||||||
|
msgstr "Ripeti misurazioni"
|
||||||
|
|
||||||
|
# Measurement Feedback
|
||||||
|
msgid "CONFORME"
|
||||||
|
msgstr "CONFORME"
|
||||||
|
|
||||||
|
msgid "ATTENZIONE"
|
||||||
|
msgstr "ATTENZIONE"
|
||||||
|
|
||||||
|
msgid "NON CONFORME"
|
||||||
|
msgstr "NON CONFORME"
|
||||||
|
|
||||||
|
# Caliper Status
|
||||||
|
msgid "Connessione..."
|
||||||
|
msgstr "Connessione..."
|
||||||
|
|
||||||
|
msgid "Errore calibro"
|
||||||
|
msgstr "Errore calibro"
|
||||||
|
|
||||||
|
msgid "Browser non supporta Web Serial API"
|
||||||
|
msgstr "Browser non supporta Web Serial API"
|
||||||
|
|
||||||
|
# Recipe Selection Additional
|
||||||
|
msgid "Errore di connessione"
|
||||||
|
msgstr "Errore di connessione"
|
||||||
|
|||||||
Reference in New Issue
Block a user