From a386986c17c9aeef177a49f05a5549e929d63911 Mon Sep 17 00:00:00 2001 From: Adriano Date: Sat, 7 Feb 2026 08:40:58 +0100 Subject: [PATCH] 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 --- client/blueprints/auth.py | 21 +- client/blueprints/measure.py | 267 ++++++- client/static/css/numpad.css | 55 ++ client/static/js/annotation-viewer.js | 273 +++++++ client/static/js/barcode.js | 132 +++ client/static/js/caliper.js | 142 ++++ client/static/js/csv-export.js | 268 +++++++ client/static/js/numpad.js | 189 +++++ .../templates/components/barcode_scanner.html | 151 ++++ .../templates/components/caliper_status.html | 77 ++ .../components/measurement_feedback.html | 76 ++ .../components/next_measurement.html | 32 + client/templates/components/numpad.html | 121 +++ client/templates/measure/select_recipe.html | 331 ++++++++ client/templates/measure/task_complete.html | 275 +++++++ client/templates/measure/task_execute.html | 750 ++++++++++++++++++ client/templates/measure/task_list.html | 247 ++++++ .../translations/en/LC_MESSAGES/messages.po | 257 ++++++ .../translations/it/LC_MESSAGES/messages.po | 257 ++++++ 19 files changed, 3905 insertions(+), 16 deletions(-) create mode 100644 client/static/css/numpad.css create mode 100644 client/static/js/annotation-viewer.js create mode 100644 client/static/js/barcode.js create mode 100644 client/static/js/caliper.js create mode 100644 client/static/js/csv-export.js create mode 100644 client/static/js/numpad.js create mode 100644 client/templates/components/barcode_scanner.html create mode 100644 client/templates/components/caliper_status.html create mode 100644 client/templates/components/measurement_feedback.html create mode 100644 client/templates/components/next_measurement.html create mode 100644 client/templates/components/numpad.html create mode 100644 client/templates/measure/select_recipe.html create mode 100644 client/templates/measure/task_complete.html create mode 100644 client/templates/measure/task_execute.html create mode 100644 client/templates/measure/task_list.html diff --git a/client/blueprints/auth.py b/client/blueprints/auth.py index 0e023d7..8a7a1d8 100644 --- a/client/blueprints/auth.py +++ b/client/blueprints/auth.py @@ -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.""" diff --git a/client/blueprints/measure.py b/client/blueprints/measure.py index 3e20465..8f65a82 100644 --- a/client/blueprints/measure.py +++ b/client/blueprints/measure.py @@ -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/") +@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/") +@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/") +@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 diff --git a/client/static/css/numpad.css b/client/static/css/numpad.css new file mode 100644 index 0000000..5330952 --- /dev/null +++ b/client/static/css/numpad.css @@ -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)); +} diff --git a/client/static/js/annotation-viewer.js b/client/static/js/annotation-viewer.js new file mode 100644 index 0000000..c6a29a0 --- /dev/null +++ b/client/static/js/annotation-viewer.js @@ -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: + *
+ * + *
+ */ + +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(); + } + } + }; +} diff --git a/client/static/js/barcode.js b/client/static/js/barcode.js new file mode 100644 index 0000000..bd193ac --- /dev/null +++ b/client/static/js/barcode.js @@ -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: '' }); + } + }; +} diff --git a/client/static/js/caliper.js b/client/static/js/caliper.js new file mode 100644 index 0000000..23fda57 --- /dev/null +++ b/client/static/js/caliper.js @@ -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(); + } + }; +} diff --git a/client/static/js/csv-export.js b/client/static/js/csv-export.js new file mode 100644 index 0000000..7a9d021 --- /dev/null +++ b/client/static/js/csv-export.js @@ -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; + } + } + }; +} diff --git a/client/static/js/numpad.js b/client/static/js/numpad.js new file mode 100644 index 0000000..a278c40 --- /dev/null +++ b/client/static/js/numpad.js @@ -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(); + } + } + }; +} diff --git a/client/templates/components/barcode_scanner.html b/client/templates/components/barcode_scanner.html new file mode 100644 index 0000000..e22cea6 --- /dev/null +++ b/client/templates/components/barcode_scanner.html @@ -0,0 +1,151 @@ + +
+ + + + + + + + +
diff --git a/client/templates/components/caliper_status.html b/client/templates/components/caliper_status.html new file mode 100644 index 0000000..2248b0c --- /dev/null +++ b/client/templates/components/caliper_status.html @@ -0,0 +1,77 @@ + +
+ + + +
diff --git a/client/templates/components/measurement_feedback.html b/client/templates/components/measurement_feedback.html new file mode 100644 index 0000000..beec5d6 --- /dev/null +++ b/client/templates/components/measurement_feedback.html @@ -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 +#} + +
+ +
+
+
+
+ + +
+
+ + + + + + + + +
+ + + + +
+
diff --git a/client/templates/components/next_measurement.html b/client/templates/components/next_measurement.html new file mode 100644 index 0000000..1c2a607 --- /dev/null +++ b/client/templates/components/next_measurement.html @@ -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 +#} + +
+
+ + + + + {{ _('Prossima misura') }}: + + # + + +
+ + +
+ Nom: + + +
+
diff --git a/client/templates/components/numpad.html b/client/templates/components/numpad.html new file mode 100644 index 0000000..0c43304 --- /dev/null +++ b/client/templates/components/numpad.html @@ -0,0 +1,121 @@ + +
+ +
+
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
diff --git a/client/templates/measure/select_recipe.html b/client/templates/measure/select_recipe.html new file mode 100644 index 0000000..7aced02 --- /dev/null +++ b/client/templates/measure/select_recipe.html @@ -0,0 +1,331 @@ +{% extends "base.html" %} +{% block title %}{{ _('Seleziona Ricetta') }} — TieMeasureFlow{% endblock %} + +{% block content %} +
+ + +
+
+
+
+ + + +
+
+

+ {{ _('Seleziona Ricetta') }} +

+

+ {{ _('Scegli la ricetta di misura da eseguire') }} +

+
+
+ + + +
+
+ + +
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + +
+

+ {{ _('ricette trovate') }} +

+
+ + +
+ +
+ + +
+
+ + + +
+

+ {{ _('Nessuna ricetta trovata') }} +

+

+ + {{ _('Nessun risultato per') }} "". + {{ _('Prova con un termine diverso.') }} + + + {{ _('Non ci sono ricette disponibili al momento.') }} + +

+
+ + +
+ +
+ + +
+ + +
+
+
+ + + +
+

+ {{ _('Scansiona Barcode') }} +

+
+ +
+ + +
+

+ {{ _('Inserisci o scansiona il codice della ricetta per selezionarla automaticamente.') }} +

+ +
+
+ + +
+ + +
+ + + + +
+
+
+ + +
+ + +
+
+
+ +
+{% endblock %} diff --git a/client/templates/measure/task_complete.html b/client/templates/measure/task_complete.html new file mode 100644 index 0000000..81ab3f4 --- /dev/null +++ b/client/templates/measure/task_complete.html @@ -0,0 +1,275 @@ +{% extends "base.html" %} + +{% block title %}{{ _('Riepilogo') }} - {{ recipe.name }}{% endblock %} + +{% block content %} +
+ + + + +
+
+

{{ _('Misurazioni Complete') }}

+
+
+
+
+

{{ _('Codice') }}

+

{{ recipe.code }}

+
+
+

{{ _('Nome') }}

+

{{ recipe.name }}

+
+
+

{{ _('Versione') }}

+

{{ recipe.current_version or recipe.version }}

+
+
+

{{ _('Lotto') }}

+

{{ lot_number or '-' }}

+
+
+ {% if serial_number %} +
+

{{ _('Seriale') }}

+

{{ serial_number }}

+
+ {% endif %} +
+
+ + + {% 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 %} + +
+ +
+
+
+
+

{{ _('Totale') }}

+

{{ total_count }}

+
+
+ + + +
+
+
+
+ + +
+
+
+
+

{{ _('Conformi') }}

+

{{ pass_count }}

+
+
+ + + +
+
+
+
+ + +
+
+
+
+

{{ _('Attenzione') }}

+

{{ warning_count }}

+
+
+ + + +
+
+
+
+ + +
+
+
+
+

{{ _('Non Conformi') }}

+

{{ fail_count }}

+
+
+ + + +
+
+
+
+
+ + +
+
+

{{ _('Dettaglio Misurazioni') }}

+ +
+
+ + + + + + + + + + + + + + {% for m in measurements|sort(attribute='subtask.marker_number') %} + + + + + + + + + + {% endfor %} + +
#{{ _('Descrizione') }}{{ _('Nominale') }}{{ _('Valore') }}{{ _('Deviazione') }}{{ _('Esito') }}{{ _('Metodo') }}
+ {{ m.subtask.marker_number }} + + {{ m.subtask.description }} + + {{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }} + + {{ "%.3f"|format(m.value) }} {{ m.subtask.unit }} + + {{ "%+.3f"|format(m.deviation) }} + + {% if m.pass_fail == 'pass' %} + {{ _('Pass') }} + {% elif m.pass_fail == 'warning' %} + {{ _('Warning') }} + {% elif m.pass_fail == 'fail' %} + {{ _('Fail') }} + {% else %} + - + {% endif %} + + {% if m.method == 'manual' %} +
+ + + + {{ _('Manuale') }} +
+ {% elif m.method == 'caliper' %} +
+ + + + {{ _('Calibro') }} +
+ {% else %} + - + {% endif %} +
+
+
+ + +
+ + {% if current_user.get('roles') and 'Metrologist' in current_user.get('roles', []) %} + + + + + {{ _('Statistiche') }} + + {% endif %} +
+
+{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/client/templates/measure/task_execute.html b/client/templates/measure/task_execute.html new file mode 100644 index 0000000..200d7df --- /dev/null +++ b/client/templates/measure/task_execute.html @@ -0,0 +1,750 @@ +{% extends "base.html" %} +{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ + {# ================================================================ + HEADER — Task info + traceability + ================================================================ #} +
+
+
+ + {# Left: Task title and directive #} +
+ {# Breadcrumb line #} +
+ + + + + {{ _('Task') }} + + + + + + Task {{ (task.order_index or 0) + 1 }} + +
+ +

+ {{ task.title or _('Task di misurazione') }} +

+ + {% if task.directive or task.description %} +

+ {{ task.directive or task.description }} +

+ {% endif %} +
+ + {# Right: Traceability + Caliper #} +
+ {# Lot badge #} + {% if lot_number %} +
+ + + + {{ lot_number }} +
+ {% endif %} + + {# Serial badge #} + {% if serial_number %} +
+ + + + {{ serial_number }} +
+ {% endif %} + + {# Caliper status #} + {% include "components/caliper_status.html" %} +
+
+
+
+ + {# ================================================================ + MAIN CONTENT — Split layout + ================================================================ #} +
+
+ + {# ────────────────────────────────────────────── + LEFT COLUMN — Annotation Viewer (60%) + ────────────────────────────────────────────── #} +
+ + {# Image viewer card #} +
+
+ + + + + {{ _('Disegno Tecnico') }} + + +
+ +
+ {% if task.file_path and task.file_type == 'image' %} + {# Image with annotation overlay #} +
+ +
+ + {% elif task.file_path and task.file_type == 'pdf' %} + {# PDF placeholder #} +
+
+ + + +

{{ _('Visualizzatore PDF') }}

+

{{ _('In fase di sviluppo') }}

+ + + + + {{ _('Apri PDF') }} + +
+
+ + {% else %} + {# No file placeholder #} +
+
+ + + +

{{ _('Nessuna immagine allegata') }}

+

{{ _('Segui le istruzioni nella direttiva') }}

+
+
+ {% endif %} +
+
+ + {# Marker quick-nav strip (horizontal scrollable) #} +
+ +
+
+ + {# ────────────────────────────────────────────── + RIGHT COLUMN — Measurement Panel (40%) + ────────────────────────────────────────────── #} +
+ + {# ---- Subtask Info Card ---- #} +
+
+ + {# Marker header #} +
+
+ +
+

+ +
+
+ + {# Counter badge #} + + / + +
+ + {# Tolerance parameters table #} +
+ {# Nominal #} +
+ {{ _('Nominale') }} + +
+ + {# UTL #} +
+ UTL + +
+ + {# LTL #} +
+ LTL + +
+ + {# UWL #} +
+ UWL + +
+ + {# LWL #} +
+ LWL + +
+
+ + {# Already measured indicator #} +
+
+ + + + {{ _('Misurazione registrata') }}: + + +
+
+
+
+ + {# ---- Tolerance Visualization Bar ---- #} +
+
+
+ {# Zone labels #} +
+ + + +
+ + {# Nominal center line #} +
+ + {# Value indicator (needle) #} +
+ {# Pip on top #} +
+
+
+
+
+ + {# ---- Measurement Feedback ---- #} +
+ {% include "components/measurement_feedback.html" %} +
+ + {# ---- Numpad ---- #} +
+
+ {# Saving overlay #} +
+
+ + + + + {{ _('Salvataggio...') }} +
+
+ + {% include "components/numpad.html" %} +
+
+ + {# ---- Next Measurement Indicator ---- #} +
+ {% include "components/next_measurement.html" %} +
+ + {# ---- Error message ---- #} +
+
+ + + + +
+
+
+ +
+
+ + {# ================================================================ + FOOTER — Progress bar + navigation + ================================================================ #} +
+
+
+ + {# Left: Back button #} + + + + + + + + {# Center: Progress #} +
+
+ {# Text counter #} + + / + + + {# Progress bar #} +
+
+
+ + {# Percentage #} + +
+
+ + {# Right: Complete / next task button #} + +
+
+
+ + {# ================================================================ + COMPLETION OVERLAY + ================================================================ #} +
+
+ + {# Success icon #} +
+ + + +
+ +

{{ _('Misurazioni Complete') }}

+

+ {{ _('Tutte le') }} {{ _('misurazioni sono state registrate.') }} +

+ + {# Summary counts #} +
+
+
+
{{ _('Conformi') }}
+
+
+
+
{{ _('Attenzione') }}
+
+
+
+
{{ _('Non Conf.') }}
+
+
+ + +
+
+ +
+{% endblock %} + +{% block extra_js %} + + + + + +{% endblock %} diff --git a/client/templates/measure/task_list.html b/client/templates/measure/task_list.html new file mode 100644 index 0000000..d230377 --- /dev/null +++ b/client/templates/measure/task_list.html @@ -0,0 +1,247 @@ +{% extends "base.html" %} +{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %} + +{% block content %} +
+ + + + + +
+
+
+ +
+
+ + + {{ recipe.code }} + + + + {% if recipe.current_version or recipe.version %} + + v{{ recipe.current_version or recipe.version }} + + {% endif %} +
+ +

+ {{ recipe.name }} +

+ + {% if recipe.description %} +

+ {{ recipe.description }} +

+ {% endif %} +
+ + +
+ {% if lot_number %} +
+ + + + {{ _('Lotto') }}: + {{ lot_number }} +
+ {% endif %} + + {% if serial_number %} +
+ + + + {{ _('Seriale') }}: + {{ serial_number }} +
+ {% endif %} +
+
+
+
+ + +
+

+ + + + {{ _('Task da eseguire') }} +

+ + {{ tasks|length }} task + +
+ + + {% if tasks %} +
+ {% for task in tasks %} +
+
+
+ + +
+ {{ loop.index }} +
+ + +
+ +
+ + Task {{ loop.index }} {{ _('di') }} {{ tasks|length }} + +
+ + +

+ {{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }} +

+ + + {% if task.directive or task.description %} +

+ {{ task.directive or task.description }} +

+ {% endif %} + + +
+ + {% if task.subtask_count is defined or task.subtasks %} + + + + + + {% if task.subtask_count is defined %} + {{ task.subtask_count }} + {% elif task.subtasks %} + {{ task.subtasks|length }} + {% endif %} + {{ _('misurazioni') }} + + + {% endif %} + + + {% if task.file_path %} + + {% if task.file_path.endswith('.pdf') %} + + + + {% else %} + + + + {% endif %} + {{ _('Allegato') }} + + {% endif %} +
+
+ + + +
+
+
+ {% endfor %} +
+ + {% else %} + +
+
+ + + +
+

+ {{ _('Nessun task disponibile') }} +

+

+ {{ _('Questa ricetta non ha ancora task definiti.') }} +

+
+ {% endif %} + + +
+ + + + + {{ _('Seleziona altra ricetta') }} + + + {% if tasks %} +
+ {{ tasks|length }} task · + {% set total_subs = namespace(count=0) %} + {% for t in tasks %} + {% if t.subtask_count is defined %} + {% set total_subs.count = total_subs.count + t.subtask_count %} + {% elif t.subtasks %} + {% set total_subs.count = total_subs.count + t.subtasks|length %} + {% endif %} + {% endfor %} + {% if total_subs.count > 0 %} + {{ total_subs.count }} {{ _('misurazioni totali') }} + {% endif %} +
+ {% endif %} +
+ +
+{% endblock %} diff --git a/client/translations/en/LC_MESSAGES/messages.po b/client/translations/en/LC_MESSAGES/messages.po index 411dd75..1df06f8 100644 --- a/client/translations/en/LC_MESSAGES/messages.po +++ b/client/translations/en/LC_MESSAGES/messages.po @@ -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" diff --git a/client/translations/it/LC_MESSAGES/messages.po b/client/translations/it/LC_MESSAGES/messages.po index 9d0d9af..b2ba572 100644 --- a/client/translations/it/LC_MESSAGES/messages.po +++ b/client/translations/it/LC_MESSAGES/messages.po @@ -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"