842ceca007
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
665 lines
31 KiB
HTML
665 lines
31 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ _("Statistiche SPC") }} — TieMeasureFlow{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<!-- Plotly.js CDN -->
|
|
<script src="{{ url_for('static', filename='vendor/plotly-2.32.0.min.js') }}" charset="utf-8"></script>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="tmf-page"
|
|
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>
|
|
|
|
<!-- Sessione di produzione (#58) -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
|
|
<select x-model="selectedRunId"
|
|
:disabled="runs.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="">{{ _("Tutte le sessioni") }}</option>
|
|
<template x-for="r in runs" :key="r.id">
|
|
<option :value="r.id" x-text="runLabel(r)"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Subtask -->
|
|
<div>
|
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
|
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
|
|
<select x-model="selectedSubtaskId"
|
|
@change="loadData()"
|
|
: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 + Download -->
|
|
<div class="mt-4 flex justify-between items-center">
|
|
<!-- Download buttons (visible only when data is loaded) -->
|
|
<div class="flex gap-2" x-show="hasData" x-cloak>
|
|
<button @click="downloadReport('spc')"
|
|
:disabled="!selectedSubtaskId || downloadingReport"
|
|
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 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="downloadingReport === 'spc'">
|
|
<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>
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'spc'">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
{{ _("Report SPC") }}
|
|
</button>
|
|
<button @click="downloadReport('measurements')"
|
|
:disabled="downloadingReport"
|
|
class="px-4 py-2 bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
|
|
<template x-if="downloadingReport === 'measurements'">
|
|
<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>
|
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'measurements'">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
</svg>
|
|
{{ _("Report Misurazioni") }}
|
|
</button>
|
|
</div>
|
|
<div x-show="!hasData"></div>
|
|
|
|
<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>
|
|
<!-- Inline report error (shown below buttons row) -->
|
|
<div x-show="reportError" x-cloak
|
|
class="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
|
|
x-text="reportError"
|
|
x-transition>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
|
|
<div id="run-task-results" x-show="selectedRunId" x-cloak
|
|
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
|
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
|
|
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
|
|
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
|
|
<template x-if="taskResults && !taskResultsLoading">
|
|
<div>
|
|
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
|
|
<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="taskResults.counts.ok"></div>
|
|
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
|
|
</div>
|
|
<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="taskResults.counts.nok"></div>
|
|
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
|
|
</div>
|
|
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
|
|
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
|
|
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
|
|
</div>
|
|
</div>
|
|
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
|
|
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
|
|
<th class="py-2 pr-3">{{ _("Ora") }}</th>
|
|
<th class="py-2 pr-3">{{ _("Task") }}</th>
|
|
<th class="py-2 pr-3">{{ _("Esito") }}</th>
|
|
<th class="py-2 pr-3">{{ _("Note") }}</th>
|
|
<th class="py-2 pr-3">{{ _("Campi") }}</th>
|
|
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
|
|
<th class="py-2">{{ _("Operatore") }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="r in taskResults.results" :key="r.id">
|
|
<tr class="border-b border-[var(--border-color)] align-top">
|
|
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
|
|
<td class="py-2 pr-3" x-text="r.task_title"></td>
|
|
<td class="py-2 pr-3">
|
|
<span class="px-2 py-0.5 rounded text-xs font-semibold"
|
|
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
|
|
x-text="outcomeLabel(r.outcome)"></span>
|
|
</td>
|
|
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
|
|
<td class="py-2 pr-3">
|
|
<template x-for="v in r.values" :key="v.key">
|
|
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
|
|
</template>
|
|
</td>
|
|
<td class="py-2 pr-3">
|
|
<template x-for="a in r.attachments" :key="a.path">
|
|
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
|
|
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
|
|
</template>
|
|
</td>
|
|
<td class="py-2" x-text="r.user_name"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</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>
|
|
<!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
|
|
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
|
|
<div>
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-xs text-steel text-left">
|
|
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
|
|
<th class="py-1 font-medium text-right">n</th>
|
|
<th class="py-1 font-medium text-right">Cp</th>
|
|
<th class="py-1 font-medium text-right">Cpk</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="row in capabilityAll" :key="row.subtask.id">
|
|
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
|
|
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
|
|
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
|
|
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
|
|
<td class="py-1.5 text-right font-mono font-bold"
|
|
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
|
|
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
|
|
<td class="py-1.5 text-right font-mono font-bold"
|
|
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
|
|
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
|
|
</div>
|
|
</template>
|
|
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
|
|
<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 -->
|
|
<!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
|
|
<div x-show="loadedSubtaskId" 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 x-show="loadedSubtaskId" 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: '',
|
|
selectedRunId: '', // #58
|
|
runs: [], // #58 sessioni della ricetta
|
|
dateFrom: '',
|
|
dateTo: '',
|
|
subtasks: [],
|
|
|
|
// Data state
|
|
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
|
|
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
|
|
summary: null,
|
|
capability: null,
|
|
controlChart: null,
|
|
histogram: null,
|
|
|
|
// UI state
|
|
loading: false,
|
|
hasData: false,
|
|
errorMessage: '',
|
|
reportError: '',
|
|
downloadingReport: false,
|
|
|
|
// #58: esiti dei task della sessione scelta
|
|
taskResults: null,
|
|
taskResultsLoading: false,
|
|
taskResultsError: '',
|
|
|
|
init() {
|
|
// #58: il pannello esiti si ricarica a ogni cambio di sessione
|
|
this.$watch('selectedRunId', () => this.loadTaskResults());
|
|
},
|
|
|
|
async loadTaskResults() {
|
|
this.taskResults = null;
|
|
this.taskResultsError = '';
|
|
const runId = this.selectedRunId;
|
|
if (!runId) return;
|
|
this.taskResultsLoading = true;
|
|
try {
|
|
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
|
|
const data = await resp.json();
|
|
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
|
|
if (runId !== this.selectedRunId) return;
|
|
if (!resp.ok || data.error) {
|
|
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
|
|
} else {
|
|
this.taskResults = data;
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading task results:', e);
|
|
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
|
|
} finally {
|
|
if (runId === this.selectedRunId) this.taskResultsLoading = false;
|
|
}
|
|
},
|
|
|
|
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
|
|
resultTime(iso) {
|
|
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
|
},
|
|
|
|
outcomeLabel(o) {
|
|
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
|
|
},
|
|
|
|
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
|
|
attachmentUrl(path) {
|
|
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
|
|
},
|
|
|
|
async onRecipeChange() {
|
|
this.selectedSubtaskId = '';
|
|
this.loadedSubtaskId = ''; // #100
|
|
this.capabilityAll = []; // #100
|
|
this.selectedRunId = ''; // #58
|
|
this.runs = [];
|
|
this.subtasks = [];
|
|
this.hasData = false;
|
|
this.summary = null;
|
|
this.capability = null;
|
|
this.controlChart = null;
|
|
this.histogram = null;
|
|
|
|
if (!this.selectedRecipeId) return;
|
|
|
|
// #58: sessioni della ricetta per il filtro «Sessione»
|
|
try {
|
|
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
|
|
const rdata = await rresp.json();
|
|
if (Array.isArray(rdata)) this.runs = rdata;
|
|
} catch (e) {
|
|
console.error('Error loading runs:', e);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
},
|
|
|
|
// #58: etichetta sessione = data, stazione, operatore, lotto
|
|
runLabel(r) {
|
|
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
|
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
|
|
},
|
|
|
|
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.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
|
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
|
|
|
|
try {
|
|
// Parallel fetch of all endpoints
|
|
const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = 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),
|
|
// #100: senza punto scelto, capability di ogni punto della ricetta
|
|
requestedSubtaskId
|
|
? Promise.resolve([])
|
|
: Promise.all(this.subtasks.map(st => {
|
|
const p = new URLSearchParams(params.toString());
|
|
p.set('subtask_id', st.id);
|
|
return fetch('/statistics/api/capability?' + p.toString())
|
|
.then(r => r.json())
|
|
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
|
|
.catch(() => ({ subtask: st, data: 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;
|
|
}
|
|
|
|
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
|
|
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
|
|
this.loadedSubtaskId = requestedSubtaskId;
|
|
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.loadedSubtaskId) { // #100
|
|
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.loadedSubtaskId) { // #100
|
|
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")|tojson }} + '</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';
|
|
},
|
|
|
|
async downloadReport(type) {
|
|
this.reportError = '';
|
|
this.downloadingReport = type;
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.set('recipe_id', this.selectedRecipeId);
|
|
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
|
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
|
|
|
const url = '/statistics/api/report-' + type + '?' + params.toString();
|
|
const resp = await fetch(url);
|
|
|
|
if (!resp.ok) {
|
|
const errData = await resp.json().catch(() => ({}));
|
|
const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
|
|
console.error('Report download failed (' + resp.status + '):', msg, errData);
|
|
this.reportError = msg;
|
|
return;
|
|
}
|
|
|
|
// Check Content-Type: server may return JSON error with 200 status
|
|
const contentType = resp.headers.get('Content-Type') || '';
|
|
if (!contentType.includes('application/pdf')) {
|
|
const errData = await resp.json().catch(() => ({}));
|
|
const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
|
|
console.error('Report returned non-PDF content-type "' + contentType + '":', msg, errData);
|
|
this.reportError = msg;
|
|
return;
|
|
}
|
|
|
|
// Download the PDF
|
|
const blob = await resp.blob();
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = type === 'spc'
|
|
? 'spc_report_' + this.selectedRecipeId + '.pdf'
|
|
: 'measurements_report_' + this.selectedRecipeId + '.pdf';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(a.href);
|
|
} catch (e) {
|
|
console.error('Error downloading report:', e);
|
|
this.reportError = '{{ _("Errore di connessione al server") }}';
|
|
} finally {
|
|
this.downloadingReport = false;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|