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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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̄:</span>
|
||||
<span class="font-mono" x-text="capability.mean"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">σ:</span>
|
||||
<span class="font-mono" x-text="capability.std_dev"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!capability || capability.cp == null">
|
||||
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Carta di Controllo -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
||||
<div id="control-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Istogramma -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error message -->
|
||||
<template x-if="errorMessage">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300 text-sm">
|
||||
<span x-text="errorMessage"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// i18n bridge for spc-charts.js
|
||||
window.SPC_I18N = {
|
||||
noData: "{{ _('Nessun dato disponibile') }}",
|
||||
inControl: "{{ _('In controllo') }}",
|
||||
outOfControl: "{{ _('Fuori controllo') }}",
|
||||
controlChart: "{{ _('Carta di Controllo') }}",
|
||||
measureNum: "{{ _('Misura #') }}",
|
||||
value: "{{ _('Valore') }}",
|
||||
frequency: "{{ _('Frequenza') }}",
|
||||
normalCurve: "{{ _('Curva normale') }}",
|
||||
histogram: "{{ _('Istogramma') }}",
|
||||
indicesNotAvailable: "{{ _('Indici non disponibili') }}",
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/spc-charts.js') }}"></script>
|
||||
<script>
|
||||
function spcDashboard() {
|
||||
return {
|
||||
// Filter state
|
||||
selectedRecipeId: '',
|
||||
selectedSubtaskId: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
subtasks: [],
|
||||
|
||||
// Data state
|
||||
summary: null,
|
||||
capability: null,
|
||||
controlChart: null,
|
||||
histogram: null,
|
||||
|
||||
// UI state
|
||||
loading: false,
|
||||
hasData: false,
|
||||
errorMessage: '',
|
||||
|
||||
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 %}
|
||||
Reference in New Issue
Block a user