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:
Adriano
2026-02-07 08:40:58 +01:00
parent edd4580a5a
commit a386986c17
19 changed files with 3905 additions and 16 deletions
+20 -1
View File
@@ -1,7 +1,7 @@
"""Authentication blueprint - login, logout, profile."""
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 services.api_client import api_client
@@ -20,6 +20,25 @@ def login_required(f):
return decorated
def role_required(*roles):
"""Decorator to require one of the specified roles.
Usage:
@role_required("MeasurementTec", "Metrologist")
def my_view(): ...
"""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
user_roles = user.get("roles", [])
if not any(r in user_roles for r in roles):
abort(403)
return f(*args, **kwargs)
return decorated
return decorator
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page and form handler."""
+252 -15
View File
@@ -1,36 +1,273 @@
"""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__)
# ---------------------------------------------------------------------------
# Route: Recipe selection
# ---------------------------------------------------------------------------
@measure_bp.route("/select")
@login_required
@role_required("MeasurementTec")
def select_recipe():
"""Recipe selection page."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("measure/select_recipe.html")
"""Recipe selection page with search and barcode support."""
# Load recipes from API
resp = api_client.get("/api/recipes", params={"per_page": 100})
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>")
@login_required
@role_required("MeasurementTec")
def task_list(recipe_id: int):
"""Task list for selected recipe."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("measure/task_list.html", recipe_id=recipe_id)
# Persist lot/serial from query params into session
lot_number = request.args.get(
"lot_number", session.get("lot_number", ""),
)
serial_number = request.args.get(
"serial_number", session.get("serial_number", ""),
)
if lot_number:
session["lot_number"] = lot_number
if serial_number:
session["serial_number"] = serial_number
# Load recipe details
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(
_("Ricetta non trovata: %(detail)s",
detail=recipe_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
# Load tasks for this recipe
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if tasks_resp.get("error"):
flash(
_("Errore nel caricamento dei task: %(detail)s",
detail=tasks_resp.get("detail", "")),
"error",
)
tasks = []
else:
tasks = tasks_resp if isinstance(tasks_resp, list) else tasks_resp.get("items", [])
return render_template(
"measure/task_list.html",
recipe=recipe_resp,
tasks=tasks,
lot_number=lot_number,
serial_number=serial_number,
)
# ---------------------------------------------------------------------------
# Route: Task execution (measurement input)
# ---------------------------------------------------------------------------
@measure_bp.route("/execute/<int:task_id>")
@login_required
@role_required("MeasurementTec")
def task_execute(task_id: int):
"""Execute measurements for a task."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("measure/task_execute.html", task_id=task_id)
# Load task + subtasks
task_resp = api_client.get(f"/api/tasks/{task_id}")
if task_resp.get("error"):
flash(
_("Task non trovato: %(detail)s",
detail=task_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
lot_number = session.get("lot_number", "")
serial_number = session.get("serial_number", "")
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>")
@login_required
@role_required("MeasurementTec")
def task_complete(recipe_id: int):
"""Task completion summary."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("measure/task_complete.html", recipe_id=recipe_id)
"""Task completion summary with all measurements."""
# Retrieve version_id from query params
version_id = request.args.get("version_id")
# Load recipe for context
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if recipe_resp.get("error"):
flash(
_("Ricetta non trovata: %(detail)s",
detail=recipe_resp.get("detail", "")),
"error",
)
return redirect(url_for("measure.select_recipe"))
# Load 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
+55
View File
@@ -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));
}
+273
View File
@@ -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();
}
}
};
}
+132
View File
@@ -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: '' });
}
};
}
+142
View File
@@ -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();
}
};
}
+268
View File
@@ -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;
}
}
};
}
+189
View File
@@ -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>
+121
View File
@@ -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>
+331
View File
@@ -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 %}
+275
View File
@@ -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 %}
+750
View File
@@ -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 %}
+247
View File
@@ -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 &middot;
{% set total_subs = namespace(count=0) %}
{% for t in tasks %}
{% if t.subtask_count is defined %}
{% set total_subs.count = total_subs.count + t.subtask_count %}
{% elif t.subtasks %}
{% set total_subs.count = total_subs.count + t.subtasks|length %}
{% endif %}
{% endfor %}
{% if total_subs.count > 0 %}
{{ total_subs.count }} {{ _('misurazioni totali') }}
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -245,3 +245,260 @@ msgstr "Measurement Technician"
msgid "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"
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"