feat: FASE 5b/6.1+6.2 - SPC Backend + Dashboard Metrologist (Plotly.js)

Aggiunge servizio SPC con calcoli Cp/Cpk/Pp/Ppk, carta di controllo (UCL/LCL),
istogramma con curva normale. Router FastAPI con 5 endpoint statistics, blueprint
Flask con proxy AJAX, dashboard interattiva Alpine.js + Plotly.js con filtri per
ricetta/subtask/date, riepilogo pass/fail, gauge Cpk e i18n IT/EN completo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 15:00:05 +01:00
parent e1f4ee73d0
commit bcd807e57d
14 changed files with 1567 additions and 242 deletions
+1
View File
@@ -259,6 +259,7 @@ def save_measurement():
"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"),
"input_method": data.get("input_method", "manual"),
}
resp = api_client.post("/api/measurements", data=payload)
+93 -30
View File
@@ -1,44 +1,107 @@
"""Metrologist blueprint - SPC statistics and dashboards."""
from flask import Blueprint, render_template, session, redirect, url_for
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
from flask import Blueprint, jsonify, render_template, request
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from services.api_client import api_client
statistics_bp = Blueprint("statistics", __name__)
# ============================================================================
# PAGINE
# ============================================================================
@statistics_bp.route("/dashboard")
@login_required
@role_required("Metrologist")
def dashboard():
"""SPC dashboard overview."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("statistics/dashboard.html")
"""SPC dashboard overview — loads recipes for filter dropdown."""
resp = api_client.get("/api/recipes", params={"per_page": 100})
if isinstance(resp, dict) and resp.get("error"):
recipes = []
else:
recipes = resp.get("items", []) if isinstance(resp, dict) else []
return render_template("statistics/dashboard.html", recipes=recipes)
@statistics_bp.route("/control-chart")
def control_chart():
"""X-bar / R control chart."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("statistics/control_chart.html")
# ============================================================================
# PROXY API AJAX → FastAPI
# ============================================================================
def _proxy_statistics(endpoint: str):
"""Forward query params to a FastAPI statistics endpoint."""
params = {}
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number", "n_bins",
]:
val = request.args.get(key)
if val is not None and val != "":
params[key] = val
resp = api_client.get(f"/api/statistics/{endpoint}", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/histogram")
def histogram():
"""Histogram with normal curve."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("statistics/histogram.html")
@statistics_bp.route("/api/summary")
@login_required
@role_required("Metrologist")
def api_summary():
"""Proxy: summary pass/fail/warning."""
return _proxy_statistics("summary")
@statistics_bp.route("/capability")
def capability():
"""Cp/Cpk/Pp/Ppk capability gauge."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("statistics/capability.html")
@statistics_bp.route("/api/capability")
@login_required
@role_required("Metrologist")
def api_capability():
"""Proxy: capability indices."""
return _proxy_statistics("capability")
@statistics_bp.route("/trend")
def trend():
"""Temporal trends and period comparison."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("statistics/trend.html")
@statistics_bp.route("/api/control-chart")
@login_required
@role_required("Metrologist")
def api_control_chart():
"""Proxy: control chart data."""
return _proxy_statistics("control-chart")
@statistics_bp.route("/api/histogram")
@login_required
@role_required("Metrologist")
def api_histogram():
"""Proxy: histogram data."""
return _proxy_statistics("histogram")
@statistics_bp.route("/api/subtasks")
@login_required
@role_required("Metrologist")
def api_subtasks():
"""Proxy: get subtasks for recipe filter."""
params = {}
recipe_id = request.args.get("recipe_id")
if recipe_id:
params["recipe_id"] = recipe_id
version_id = request.args.get("version_id")
if version_id:
params["version_id"] = version_id
resp = api_client.get("/api/statistics/subtasks", params=params)
if isinstance(resp, dict) and resp.get("error"):
return jsonify({"error": True, "detail": resp.get("detail", "")}), resp.get("status_code", 500)
# Response is a list
if isinstance(resp, list):
return jsonify(resp)
return jsonify(resp)
+31 -128
View File
@@ -1,142 +1,45 @@
/**
* Caliper Connection Manager
* Web Serial API integration for USB digital calipers
* Supports standard SPC/Digimatic protocol
* Caliper Status - Passive HID Monitor
* USB digital calipers act as keyboard emulators (HID),
* sending digits + Enter as keystrokes. This component
* monitors numpad-confirm events with inputMethod === 'usb_caliper'
* and provides visual feedback (no Web Serial needed).
*/
function caliperConnection() {
function caliperStatus() {
return {
connected: false,
status: 'disconnected', // disconnected, connecting, connected, error
lastReading: null,
port: null,
reader: null,
readController: null,
active: false, // Flash active (for animation)
lastReading: null, // Last value read from caliper
readingCount: 0, // Session reading counter
_flashTimeout: null,
_onConfirm: null,
get isSupported() {
return 'serial' in navigator;
init() {
// Listen for numpad confirmations with usb_caliper method
this._onConfirm = (e) => {
if (e.detail && e.detail.inputMethod === 'usb_caliper') {
this.registerReading(e.detail.value);
}
};
window.addEventListener('numpad-confirm', this._onConfirm);
},
async connect() {
if (!this.isSupported) {
this.status = 'error';
console.error('Web Serial API not supported');
return;
}
registerReading(value) {
this.lastReading = value;
this.readingCount++;
this.active = true;
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);
}
clearTimeout(this._flashTimeout);
this._flashTimeout = setTimeout(() => {
this.active = false;
}, 1500);
},
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();
if (this._onConfirm) {
window.removeEventListener('numpad-confirm', this._onConfirm);
}
clearTimeout(this._flashTimeout);
}
};
}
+30 -1
View File
@@ -13,6 +13,10 @@ function numpad() {
maxIntDigits: 6, // Maximum integer digits
maxDecDigits: 6, // Maximum decimal digits
// HID burst detection (USB caliper vs manual typing)
_lastKeyTime: 0, // Timestamp of last keystroke
_burstCount: 0, // Consecutive fast keystrokes
/**
* Get the display value with sign
*/
@@ -115,11 +119,16 @@ function numpad() {
const val = this.numericValue;
// Determine input method: 3+ fast keystrokes = USB caliper burst
const inputMethod = this._burstCount >= 3 ? 'usb_caliper' : 'manual';
// Dispatch custom event for parent component to handle
this.$dispatch('numpad-confirm', { value: val });
this.$dispatch('numpad-confirm', { value: val, inputMethod: inputMethod });
// Reset after confirmation
this.clearAll();
this._burstCount = 0;
this._lastKeyTime = 0;
},
/**
@@ -157,11 +166,13 @@ function numpad() {
// Number keys
if (e.key >= '0' && e.key <= '9') {
e.preventDefault();
this._trackBurst();
this.addDigit(e.key);
}
// Decimal point (both . and ,)
else if (e.key === '.' || e.key === ',') {
e.preventDefault();
this._trackBurst();
this.addDecimal();
}
// Backspace
@@ -184,6 +195,24 @@ function numpad() {
e.preventDefault();
this.toggleSign();
}
},
/**
* Track keystroke timing for HID burst detection.
* USB calipers send digits in rapid succession (<80ms apart).
* Human typing is much slower (>100ms between keys).
*/
_trackBurst() {
const now = performance.now();
const gap = now - this._lastKeyTime;
if (this._lastKeyTime > 0 && gap < 80) {
this._burstCount++;
} else {
this._burstCount = 1;
}
this._lastKeyTime = now;
}
};
}
+298
View File
@@ -0,0 +1,298 @@
/**
* SPC Charts — Plotly.js helper functions for Metrologist dashboard.
*
* Requires Plotly.js loaded via CDN before this script.
* Requires window.SPC_I18N object set in the template for translations.
*
* Brand colors:
* Primary: #2563EB
* Pass: #10b981
* Warning: #f59e0b
* Fail: #ef4444
*/
// i18n helper — falls back to Italian defaults
const _t = (key) => (window.SPC_I18N && window.SPC_I18N[key]) || key;
const SPC_COLORS = {
primary: '#2563EB',
primaryLight: '#3B82F6',
pass: '#10b981',
warning: '#f59e0b',
fail: '#ef4444',
steel: '#64748B',
steelLight: '#94A3B8',
nominal: '#8b5cf6',
ucl_lcl: '#ef4444',
uwl_lwl: '#f59e0b',
utl_ltl: '#dc2626',
bg: '#ffffff',
gridColor: '#e2e8f0',
textColor: '#334155',
};
const PLOTLY_LAYOUT_DEFAULTS = {
font: { family: 'Inter, system-ui, sans-serif', color: SPC_COLORS.textColor },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
margin: { t: 40, r: 30, b: 50, l: 60 },
xaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
yaxis: { gridcolor: SPC_COLORS.gridColor, zeroline: false },
};
const PLOTLY_CONFIG = {
responsive: true,
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false,
};
/**
* Render a control chart (X-bar) with tolerance and control limits.
*
* @param {string} containerId - DOM element ID for the chart.
* @param {Object} data - ControlChartData from API.
*/
function renderControlChart(containerId, data) {
if (!data || !data.values || data.values.length === 0) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
return;
}
const n = data.values.length;
const xLabels = data.timestamps.map((t, i) => i + 1);
// Separate in-control and out-of-control points
const oocSet = new Set(data.out_of_control || []);
const inControlX = [], inControlY = [];
const oocX = [], oocY = [];
for (let i = 0; i < n; i++) {
if (oocSet.has(i)) {
oocX.push(xLabels[i]);
oocY.push(data.values[i]);
} else {
inControlX.push(xLabels[i]);
inControlY.push(data.values[i]);
}
}
const traces = [
// In-control points
{
x: inControlX, y: inControlY,
mode: 'markers',
type: 'scatter',
name: _t('inControl'),
marker: { color: SPC_COLORS.primary, size: 6 },
},
// Out-of-control points
{
x: oocX, y: oocY,
mode: 'markers',
type: 'scatter',
name: _t('outOfControl'),
marker: { color: SPC_COLORS.fail, size: 9, symbol: 'diamond' },
},
// Line connecting all points
{
x: xLabels, y: data.values,
mode: 'lines',
type: 'scatter',
name: '',
line: { color: SPC_COLORS.steelLight, width: 1 },
showlegend: false,
},
];
// Helper to add horizontal line
const shapes = [];
function addLine(yVal, color, dash) {
if (yVal == null) return;
shapes.push({
type: 'line', xref: 'paper', x0: 0, x1: 1, y0: yVal, y1: yVal,
line: { color, width: 1.5, dash },
});
}
// Mean line
addLine(data.mean, SPC_COLORS.primary, 'solid');
// UCL / LCL
addLine(data.ucl, SPC_COLORS.ucl_lcl, 'dash');
addLine(data.lcl, SPC_COLORS.ucl_lcl, 'dash');
// UTL / LTL
addLine(data.utl, SPC_COLORS.utl_ltl, 'dot');
addLine(data.ltl, SPC_COLORS.utl_ltl, 'dot');
// UWL / LWL
addLine(data.uwl, SPC_COLORS.uwl_lwl, 'dashdot');
addLine(data.lwl, SPC_COLORS.uwl_lwl, 'dashdot');
// Nominal
addLine(data.nominal, SPC_COLORS.nominal, 'longdash');
// Build annotations for limit labels
const annotations = [];
function addLabel(yVal, text, color) {
if (yVal == null) return;
annotations.push({
x: 1, xref: 'paper', xanchor: 'left',
y: yVal, yanchor: 'middle',
text: text,
showarrow: false,
font: { size: 10, color },
});
}
addLabel(data.mean, 'X\u0304', SPC_COLORS.primary);
addLabel(data.ucl, 'UCL', SPC_COLORS.ucl_lcl);
addLabel(data.lcl, 'LCL', SPC_COLORS.ucl_lcl);
addLabel(data.utl, 'UTL', SPC_COLORS.utl_ltl);
addLabel(data.ltl, 'LTL', SPC_COLORS.utl_ltl);
addLabel(data.uwl, 'UWL', SPC_COLORS.uwl_lwl);
addLabel(data.lwl, 'LWL', SPC_COLORS.uwl_lwl);
addLabel(data.nominal, 'NOM', SPC_COLORS.nominal);
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
title: { text: _t('controlChart'), font: { size: 14 } },
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('measureNum') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('value') },
shapes,
annotations,
showlegend: true,
legend: { orientation: 'h', y: -0.15 },
margin: { ...PLOTLY_LAYOUT_DEFAULTS.margin, r: 50 },
};
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
}
/**
* Render histogram with normal curve overlay.
*
* @param {string} containerId - DOM element ID.
* @param {Object} data - HistogramData from API.
* @param {Object} [tol] - Optional tolerance limits {utl, ltl, nominal}.
*/
function renderHistogram(containerId, data, tol) {
if (!data || !data.bins || data.bins.length === 0 || data.n === 0) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-12">' + _t('noData') + '</p>';
return;
}
// Bin centers for bar chart
const binCenters = [];
const binWidth = data.bins[1] - data.bins[0];
for (let i = 0; i < data.counts.length; i++) {
binCenters.push((data.bins[i] + data.bins[i + 1]) / 2);
}
const traces = [
// Histogram bars
{
x: binCenters, y: data.counts,
type: 'bar',
name: _t('frequency'),
marker: { color: SPC_COLORS.primaryLight, opacity: 0.7 },
width: binWidth * 0.9,
},
];
// Normal curve overlay
if (data.normal_x && data.normal_x.length > 0) {
traces.push({
x: data.normal_x, y: data.normal_y,
type: 'scatter',
mode: 'lines',
name: _t('normalCurve'),
line: { color: SPC_COLORS.fail, width: 2 },
});
}
const shapes = [];
function addVertLine(xVal, color, dash) {
if (xVal == null) return;
shapes.push({
type: 'line', yref: 'paper', y0: 0, y1: 1, x0: xVal, x1: xVal,
line: { color, width: 1.5, dash },
});
}
if (tol) {
addVertLine(tol.utl, SPC_COLORS.utl_ltl, 'dot');
addVertLine(tol.ltl, SPC_COLORS.utl_ltl, 'dot');
addVertLine(tol.nominal, SPC_COLORS.nominal, 'longdash');
}
// Mean line
addVertLine(data.mean, SPC_COLORS.primary, 'dash');
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
title: { text: _t('histogram'), font: { size: 14 } },
xaxis: { ...PLOTLY_LAYOUT_DEFAULTS.xaxis, title: _t('value') },
yaxis: { ...PLOTLY_LAYOUT_DEFAULTS.yaxis, title: _t('frequency') },
shapes,
bargap: 0.05,
showlegend: true,
legend: { orientation: 'h', y: -0.15 },
};
Plotly.newPlot(containerId, traces, layout, PLOTLY_CONFIG);
}
/**
* Render a capability gauge (horizontal bar indicator for Cpk).
*
* @param {string} containerId - DOM element ID.
* @param {Object} data - CapabilityData from API.
*/
function renderCapabilityGauge(containerId, data) {
if (!data || data.cpk == null) {
document.getElementById(containerId).innerHTML =
'<p class="text-center text-steel py-8">' + _t('indicesNotAvailable') + '</p>';
return;
}
const cpk = data.cpk;
// Color zones: <1.0 = fail, 1.0-1.33 = warning, >=1.33 = pass
let barColor = SPC_COLORS.fail;
if (cpk >= 1.33) barColor = SPC_COLORS.pass;
else if (cpk >= 1.0) barColor = SPC_COLORS.warning;
const trace = {
type: 'indicator',
mode: 'gauge+number',
value: cpk,
title: { text: 'Cpk', font: { size: 14 } },
number: { font: { family: 'JetBrains Mono, monospace', size: 28 } },
gauge: {
axis: { range: [0, 2.5], tickwidth: 1 },
bar: { color: barColor, thickness: 0.6 },
bgcolor: '#f1f5f9',
borderwidth: 0,
steps: [
{ range: [0, 1.0], color: '#fecaca' },
{ range: [1.0, 1.33], color: '#fef3c7' },
{ range: [1.33, 2.5], color: '#d1fae5' },
],
threshold: {
line: { color: SPC_COLORS.primary, width: 3 },
thickness: 0.75,
value: cpk,
},
},
};
const layout = {
...PLOTLY_LAYOUT_DEFAULTS,
height: 200,
margin: { t: 30, r: 20, b: 10, l: 20 },
};
Plotly.newPlot(containerId, [trace], layout, { ...PLOTLY_CONFIG, displayModeBar: false });
}
+45 -59
View File
@@ -1,77 +1,63 @@
<!--
Caliper Status Indicator
Compact component for navbar or header
Shows connection status and last reading
Caliper Status Indicator - Passive HID Monitor
Shows USB caliper reading feedback without Web Serial.
Flashes green when a caliper reading is detected via burst timing.
-->
<div x-data="caliperConnection()"
x-init="$watch('connected', value => console.log('Caliper connected:', value))"
<div x-data="caliperStatus()"
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'
}">
<!-- Passive indicator (no connect/disconnect button needed) -->
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-300"
:class="active
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700 shadow-md shadow-green-100 dark:shadow-green-900/30'
: 'bg-[var(--bg-card)] border-[var(--border-color)]'"
:title="readingCount > 0
? '{{ _('Letture calibro') }}: ' + readingCount
: '{{ _('Calibro USB') }}'">
<!-- Status dot with animation -->
<!-- Status dot -->
<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>
<!-- Ping animation when active -->
<span x-show="active"
x-transition
class="absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75 animate-ping"></span>
<!-- Static dot -->
<span class="relative inline-flex rounded-full h-2.5 w-2.5"
:class="{
'bg-green-500': status === 'connected',
'bg-slate-400': status === 'disconnected',
'bg-amber-400': status === 'connecting',
'bg-red-500': status === 'error'
}"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 transition-colors duration-300"
:class="active
? 'bg-green-500'
: readingCount > 0
? 'bg-green-400'
: 'bg-slate-400 dark:bg-slate-500'
"></span>
</span>
<!-- Caliper icon -->
<svg class="w-4 h-4 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 class="w-4 h-4 transition-colors duration-300"
:class="active ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400'"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<!-- 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>
<!-- Label -->
<span class="text-xs font-medium transition-colors duration-300"
:class="active ? 'text-green-700 dark:text-green-300' : 'text-slate-600 dark:text-slate-400'"
x-text="active ? '{{ _('Lettura calibro') }}' : '{{ _('Calibro USB') }}'"></span>
<!-- Last reading (when connected) -->
<span x-show="connected && lastReading !== null"
<!-- Last reading value (shows when active or when there's a recent reading) -->
<span x-show="lastReading !== null"
x-transition
class="font-mono text-xs font-semibold text-primary px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 rounded"
class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded transition-colors duration-300"
:class="active
? 'text-green-700 dark:text-green-200 bg-green-100 dark:bg-green-800/40'
: 'text-primary bg-blue-50 dark:bg-blue-900/30'"
x-text="lastReading?.toFixed(3) + ' mm'"></span>
<!-- 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>
<!-- Reading counter badge -->
<span x-show="readingCount > 0 && !active"
x-transition
class="text-[10px] font-mono text-slate-400 dark:text-slate-500"
x-text="'(' + readingCount + ')'"></span>
</div>
</div>
+4 -10
View File
@@ -55,9 +55,8 @@
<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)">
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
{# ================================================================
HEADER — Task info + traceability
@@ -628,7 +627,7 @@ function taskExecute() {
},
// ---- Handle numpad confirm ----
async handleMeasurement(value) {
async handleMeasurement(value, inputMethod) {
if (!this.currentSubtask || this.saving) return;
this.currentValue = value;
@@ -657,6 +656,7 @@ function taskExecute() {
deviation: dev,
lot_number: this.lotNumber,
serial_number: this.serialNumber,
input_method: inputMethod || 'manual',
}),
});
@@ -732,12 +732,6 @@ function taskExecute() {
}
},
// ---- 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;
+374
View File
@@ -0,0 +1,374 @@
{% extends "base.html" %}
{% block title %}{{ _("Statistiche SPC") }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<!-- Plotly.js CDN -->
<script src="https://cdn.plot.ly/plotly-2.32.0.min.js" charset="utf-8"></script>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-6"
x-data="spcDashboard()"
x-init="init()">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Statistiche SPC") }}</h1>
<p class="text-sm text-steel mt-1">{{ _("Analisi statistica di processo per le misurazioni") }}</p>
</div>
<!-- Filtri -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-4 mb-6 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Ricetta -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Ricetta") }}</label>
<select x-model="selectedRecipeId"
@change="onRecipeChange()"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">{{ _("Seleziona ricetta...") }}</option>
{% for r in recipes %}
<option value="{{ r.id }}">{{ r.code }} — {{ r.name }}</option>
{% endfor %}
</select>
</div>
<!-- Subtask -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
<select x-model="selectedSubtaskId"
:disabled="subtasks.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutti i punti") }}</option>
<template x-for="st in subtasks" :key="st.id">
<option :value="st.id" x-text="'#' + st.marker_number + ' — ' + st.description"></option>
</template>
</select>
</div>
<!-- Date range -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Dal") }}</label>
<input type="date" x-model="dateFrom"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Al") }}</label>
<input type="date" x-model="dateTo"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
</div>
</div>
<!-- Apply button -->
<div class="mt-4 flex justify-end">
<button @click="loadData()"
:disabled="!selectedRecipeId || loading"
class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
<template x-if="loading">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</template>
{{ _("Applica filtri") }}
</button>
</div>
</div>
<!-- Nessuna ricetta selezionata -->
<template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel">
<svg class="mx-auto h-16 w-16 text-steel-light mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<p class="text-lg font-medium">{{ _("Seleziona una ricetta per iniziare") }}</p>
<p class="text-sm mt-1">{{ _("Scegli una ricetta dal filtro e premi Applica filtri") }}</p>
</div>
</template>
<!-- Contenuto dashboard -->
<template x-if="hasData">
<div>
<!-- Row 1: Riepilogo + Capability -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Riepilogo Pass/Fail -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Riepilogo") }}</h2>
<template x-if="summary">
<div>
<div class="text-3xl font-bold font-mono text-[var(--text-primary)] mb-3" x-text="summary.total + ' misure'"></div>
<div class="grid grid-cols-3 gap-3">
<!-- Pass -->
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="summary.pass_rate + '%'"></div>
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("Pass") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.pass_count"></div>
</div>
<!-- Warning -->
<div class="text-center p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20">
<div class="text-2xl font-bold font-mono text-amber-600" x-text="summary.warning_rate + '%'"></div>
<div class="text-xs text-amber-700 dark:text-amber-400 mt-1">{{ _("Warning") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.warning_count"></div>
</div>
<!-- Fail -->
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
<div class="text-2xl font-bold font-mono text-red-600" x-text="summary.fail_rate + '%'"></div>
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("Fail") }}</div>
<div class="text-xs text-steel font-mono" x-text="summary.fail_count"></div>
</div>
</div>
</div>
</template>
</div>
<!-- Capability -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Indici di Capability") }}</h2>
<template x-if="capability && capability.cp != null">
<div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="text-center">
<div class="text-xs text-steel">Cp</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cp)" x-text="capability.cp"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Cpk</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cpk)" x-text="capability.cpk"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Pp</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.pp)" x-text="capability.pp"></div>
</div>
<div class="text-center">
<div class="text-xs text-steel">Ppk</div>
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.ppk)" x-text="capability.ppk"></div>
</div>
</div>
<!-- Gauge -->
<div id="cpk-gauge"></div>
<!-- Stats -->
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-steel">
<div>
<span class="font-medium">n:</span>
<span class="font-mono" x-text="capability.n"></span>
</div>
<div>
<span class="font-medium">x&#772;:</span>
<span class="font-mono" x-text="capability.mean"></span>
</div>
<div>
<span class="font-medium">&sigma;:</span>
<span class="font-mono" x-text="capability.std_dev"></span>
</div>
</div>
</div>
</template>
<template x-if="!capability || capability.cp == null">
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
</template>
</div>
</div>
<!-- Row 2: Carta di Controllo -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
<div id="control-chart" style="min-height: 350px;"></div>
</div>
<!-- Row 3: Istogramma -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
<div id="histogram-chart" style="min-height: 350px;"></div>
</div>
</div>
</template>
<!-- Error message -->
<template x-if="errorMessage">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300 text-sm">
<span x-text="errorMessage"></span>
</div>
</template>
</div>
{% endblock %}
{% block extra_js %}
<script>
// i18n bridge for spc-charts.js
window.SPC_I18N = {
noData: "{{ _('Nessun dato disponibile') }}",
inControl: "{{ _('In controllo') }}",
outOfControl: "{{ _('Fuori controllo') }}",
controlChart: "{{ _('Carta di Controllo') }}",
measureNum: "{{ _('Misura #') }}",
value: "{{ _('Valore') }}",
frequency: "{{ _('Frequenza') }}",
normalCurve: "{{ _('Curva normale') }}",
histogram: "{{ _('Istogramma') }}",
indicesNotAvailable: "{{ _('Indici non disponibili') }}",
};
</script>
<script src="{{ url_for('static', filename='js/spc-charts.js') }}"></script>
<script>
function spcDashboard() {
return {
// Filter state
selectedRecipeId: '',
selectedSubtaskId: '',
dateFrom: '',
dateTo: '',
subtasks: [],
// Data state
summary: null,
capability: null,
controlChart: null,
histogram: null,
// UI state
loading: false,
hasData: false,
errorMessage: '',
init() {
// Nothing to pre-load
},
async onRecipeChange() {
this.selectedSubtaskId = '';
this.subtasks = [];
this.hasData = false;
this.summary = null;
this.capability = null;
this.controlChart = null;
this.histogram = null;
if (!this.selectedRecipeId) return;
// Load subtasks for dropdown
try {
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
const data = await resp.json();
if (Array.isArray(data)) {
this.subtasks = data;
}
} catch (e) {
console.error('Error loading subtasks:', e);
}
},
async loadData() {
if (!this.selectedRecipeId) return;
this.loading = true;
this.errorMessage = '';
this.hasData = false;
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
try {
// Parallel fetch of all endpoints
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
this.selectedSubtaskId
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
this.selectedSubtaskId
? fetch('/statistics/api/control-chart?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
this.selectedSubtaskId
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
: Promise.resolve(null),
]);
// Summary (always available)
if (summaryResp.status === 'fulfilled' && !summaryResp.value?.error) {
this.summary = summaryResp.value;
} else {
this.errorMessage = (summaryResp.value?.detail) || '{{ _("Errore nel caricamento dei dati") }}';
this.loading = false;
return;
}
// Capability
if (capabilityResp.status === 'fulfilled' && capabilityResp.value && !capabilityResp.value?.error) {
this.capability = capabilityResp.value;
} else {
this.capability = null;
}
// Control chart
if (controlResp.status === 'fulfilled' && controlResp.value && !controlResp.value?.error) {
this.controlChart = controlResp.value;
} else {
this.controlChart = null;
}
// Histogram
if (histogramResp.status === 'fulfilled' && histogramResp.value && !histogramResp.value?.error) {
this.histogram = histogramResp.value;
} else {
this.histogram = null;
}
this.hasData = true;
// Render charts after DOM update
this.$nextTick(() => {
this.renderCharts();
});
} catch (e) {
console.error('Error loading SPC data:', e);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
} finally {
this.loading = false;
}
},
renderCharts() {
// Control chart
if (this.controlChart && this.selectedSubtaskId) {
renderControlChart('control-chart', this.controlChart);
} else {
const el = document.getElementById('control-chart');
if (el) el.innerHTML = '<p class="text-center text-steel py-12">{{ _("Seleziona un punto di misura per la carta di controllo") }}</p>';
}
// Histogram
if (this.histogram && this.selectedSubtaskId) {
const tol = this.capability ? {
utl: this.capability.utl,
ltl: this.capability.ltl,
nominal: this.capability.nominal,
} : null;
renderHistogram('histogram-chart', this.histogram, tol);
} else {
const el = document.getElementById('histogram-chart');
if (el) el.innerHTML = '<p class="text-center text-steel py-12">{{ _("Seleziona un punto di misura per l\'istogramma") }}</p>';
}
// Capability gauge
if (this.capability && this.capability.cpk != null) {
renderCapabilityGauge('cpk-gauge', this.capability);
}
},
capabilityColor(value) {
if (value == null) return 'text-steel';
if (value >= 1.33) return 'text-emerald-600';
if (value >= 1.0) return 'text-amber-600';
return 'text-red-600';
},
};
}
</script>
{% endblock %}
+96 -7
View File
@@ -489,15 +489,15 @@ msgstr "WARNING"
msgid "NON CONFORME"
msgstr "NON-CONFORMING"
# Caliper Status
msgid "Connessione..."
msgstr "Connecting..."
# Caliper Status (Passive HID Monitor)
msgid "Calibro USB"
msgstr "USB Caliper"
msgid "Errore calibro"
msgstr "Caliper Error"
msgid "Lettura calibro"
msgstr "Caliper reading"
msgid "Browser non supporta Web Serial API"
msgstr "Browser does not support Web Serial API"
msgid "Letture calibro"
msgstr "Caliper readings"
# Recipe Selection Additional
msgid "Errore di connessione"
@@ -949,3 +949,92 @@ msgstr "Error loading recipe: %(error)s"
msgid "Errore nel caricamento delle versioni: %(error)s"
msgstr "Error loading versions: %(error)s"
# SPC Statistics Dashboard
msgid "Statistiche SPC"
msgstr "SPC Statistics"
msgid "Analisi statistica di processo per le misurazioni"
msgstr "Statistical process analysis for measurements"
msgid "Seleziona ricetta..."
msgstr "Select recipe..."
msgid "Punto di misura"
msgstr "Measurement point"
msgid "Tutti i punti"
msgstr "All points"
msgid "Dal"
msgstr "From"
msgid "Al"
msgstr "To"
msgid "Applica filtri"
msgstr "Apply filters"
msgid "Seleziona una ricetta per iniziare"
msgstr "Select a recipe to start"
msgid "Scegli una ricetta dal filtro e premi Applica filtri"
msgstr "Choose a recipe from the filter and press Apply filters"
msgid "Riepilogo"
msgstr "Summary"
msgid "Pass"
msgstr "Pass"
msgid "Warning"
msgstr "Warning"
msgid "Fail"
msgstr "Fail"
msgid "Indici di Capability"
msgstr "Capability Indices"
msgid "Seleziona un punto di misura per calcolare gli indici"
msgstr "Select a measurement point to calculate indices"
msgid "Carta di Controllo"
msgstr "Control Chart"
msgid "Istogramma"
msgstr "Histogram"
msgid "Seleziona un punto di misura per la carta di controllo"
msgstr "Select a measurement point for the control chart"
msgid "Seleziona un punto di misura per l'istogramma"
msgstr "Select a measurement point for the histogram"
msgid "Errore nel caricamento dei dati"
msgstr "Error loading data"
# SPC Charts JS i18n
msgid "Nessun dato disponibile"
msgstr "No data available"
msgid "In controllo"
msgstr "In control"
msgid "Fuori controllo"
msgstr "Out of control"
msgid "Misura #"
msgstr "Measurement #"
msgid "Valore"
msgstr "Value"
msgid "Frequenza"
msgstr "Frequency"
msgid "Curva normale"
msgstr "Normal curve"
msgid "Indici non disponibili"
msgstr "Indices not available"
+96 -7
View File
@@ -489,15 +489,15 @@ msgstr "ATTENZIONE"
msgid "NON CONFORME"
msgstr "NON CONFORME"
# Caliper Status
msgid "Connessione..."
msgstr "Connessione..."
# Caliper Status (Passive HID Monitor)
msgid "Calibro USB"
msgstr "Calibro USB"
msgid "Errore calibro"
msgstr "Errore calibro"
msgid "Lettura calibro"
msgstr "Lettura calibro"
msgid "Browser non supporta Web Serial API"
msgstr "Browser non supporta Web Serial API"
msgid "Letture calibro"
msgstr "Letture calibro"
# Recipe Selection Additional
msgid "Errore di connessione"
@@ -949,3 +949,92 @@ msgstr "Errore nel caricamento della ricetta: %(error)s"
msgid "Errore nel caricamento delle versioni: %(error)s"
msgstr "Errore nel caricamento delle versioni: %(error)s"
# SPC Statistics Dashboard
msgid "Statistiche SPC"
msgstr "Statistiche SPC"
msgid "Analisi statistica di processo per le misurazioni"
msgstr "Analisi statistica di processo per le misurazioni"
msgid "Seleziona ricetta..."
msgstr "Seleziona ricetta..."
msgid "Punto di misura"
msgstr "Punto di misura"
msgid "Tutti i punti"
msgstr "Tutti i punti"
msgid "Dal"
msgstr "Dal"
msgid "Al"
msgstr "Al"
msgid "Applica filtri"
msgstr "Applica filtri"
msgid "Seleziona una ricetta per iniziare"
msgstr "Seleziona una ricetta per iniziare"
msgid "Scegli una ricetta dal filtro e premi Applica filtri"
msgstr "Scegli una ricetta dal filtro e premi Applica filtri"
msgid "Riepilogo"
msgstr "Riepilogo"
msgid "Pass"
msgstr "Pass"
msgid "Warning"
msgstr "Warning"
msgid "Fail"
msgstr "Fail"
msgid "Indici di Capability"
msgstr "Indici di Capability"
msgid "Seleziona un punto di misura per calcolare gli indici"
msgstr "Seleziona un punto di misura per calcolare gli indici"
msgid "Carta di Controllo"
msgstr "Carta di Controllo"
msgid "Istogramma"
msgstr "Istogramma"
msgid "Seleziona un punto di misura per la carta di controllo"
msgstr "Seleziona un punto di misura per la carta di controllo"
msgid "Seleziona un punto di misura per l'istogramma"
msgstr "Seleziona un punto di misura per l'istogramma"
msgid "Errore nel caricamento dei dati"
msgstr "Errore nel caricamento dei dati"
# SPC Charts JS i18n
msgid "Nessun dato disponibile"
msgstr "Nessun dato disponibile"
msgid "In controllo"
msgstr "In controllo"
msgid "Fuori controllo"
msgstr "Fuori controllo"
msgid "Misura #"
msgstr "Misura #"
msgid "Valore"
msgstr "Valore"
msgid "Frequenza"
msgstr "Frequenza"
msgid "Curva normale"
msgstr "Curva normale"
msgid "Indici non disponibili"
msgstr "Indici non disponibili"